simt-dev 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/README.md +44 -15
  3. package/bin/simt.mjs +19 -6
  4. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  5. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  6. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  7. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  8. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  9. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  10. package/docs/decisions/README.md +44 -0
  11. package/package.json +1 -1
  12. package/rules/mdc/preview-postflight.mdc +5 -6
  13. package/rules/stacks/android.md +7 -8
  14. package/rules/stacks/cloudflare-workers.md +1 -1
  15. package/rules/stacks/container.md +2 -0
  16. package/rules/stacks/jvm.md +1 -1
  17. package/rules/stacks/react-native.md +3 -5
  18. package/rules/stacks/react-web.md +9 -9
  19. package/src/agenthooks/decide.mjs +101 -10
  20. package/src/agenthooks/policy.mjs +81 -5
  21. package/src/agenthooks/shell.mjs +523 -0
  22. package/src/argv.mjs +54 -9
  23. package/src/commands/agenthook.mjs +25 -3
  24. package/src/commands/ci.mjs +28 -3
  25. package/src/commands/completions.mjs +27 -0
  26. package/src/commands/decide.mjs +14 -1
  27. package/src/commands/doctor.mjs +367 -0
  28. package/src/commands/explain.mjs +52 -0
  29. package/src/commands/help.mjs +66 -17
  30. package/src/commands/hooks.mjs +208 -60
  31. package/src/commands/ignore.mjs +152 -0
  32. package/src/commands/index.mjs +54 -100
  33. package/src/commands/init.mjs +82 -12
  34. package/src/commands/lease.mjs +136 -0
  35. package/src/commands/lessons.mjs +12 -0
  36. package/src/commands/mcp.mjs +2 -2
  37. package/src/commands/pack.mjs +9 -0
  38. package/src/commands/plan.mjs +13 -1
  39. package/src/commands/plugin.mjs +16 -1
  40. package/src/commands/prbody.mjs +45 -0
  41. package/src/commands/repoindex.mjs +3 -0
  42. package/src/commands/rules.mjs +145 -105
  43. package/src/commands/shared.mjs +36 -5
  44. package/src/commands/skills.mjs +293 -205
  45. package/src/commands/skillsinstall.mjs +100 -30
  46. package/src/commands/slice.mjs +353 -0
  47. package/src/commands/status.mjs +62 -0
  48. package/src/commands/sync.mjs +110 -15
  49. package/src/commands/undo.mjs +106 -0
  50. package/src/commands/upgrade.mjs +70 -8
  51. package/src/completions.mjs +191 -0
  52. package/src/diff.mjs +254 -0
  53. package/src/explain.mjs +258 -0
  54. package/src/federation/config.mjs +44 -0
  55. package/src/graph/load.mjs +14 -5
  56. package/src/hooks/dispatcher.mjs +110 -0
  57. package/src/hooks/gates.mjs +14 -3
  58. package/src/hooks/resolve.mjs +14 -0
  59. package/src/ignore.mjs +133 -0
  60. package/src/index/persist.mjs +66 -9
  61. package/src/journal.mjs +326 -0
  62. package/src/lease.mjs +113 -0
  63. package/src/mcp/server.mjs +15 -1
  64. package/src/mcp/tools.mjs +296 -256
  65. package/src/memory/decide.mjs +6 -1
  66. package/src/memory/lessons.mjs +12 -0
  67. package/src/output/index.mjs +82 -12
  68. package/src/package-version.mjs +49 -1
  69. package/src/packs/manage.mjs +13 -4
  70. package/src/plan/index.mjs +6 -0
  71. package/src/plugins.mjs +27 -6
  72. package/src/prbody.mjs +251 -0
  73. package/src/projection/manifest.mjs +13 -2
  74. package/src/projection/shared.mjs +4 -1
  75. package/src/secrets.mjs +231 -0
  76. package/src/status.mjs +152 -0
@@ -51,6 +51,232 @@ export function listFlag(value) {
51
51
  .filter(Boolean);
52
52
  }
53
53
 
54
+ // --- cores: one computation for the CLI and the MCP tools (Q15) ----------------
55
+ //
56
+ // `skills_brief`, `skills_find`, `skills_show` and `skills_why` in the MCP
57
+ // surface used to rebuild these answers from the same modules with different
58
+ // loops, and the copies had already drifted: the budget loop inlined a deferred
59
+ // dependency on one surface and not the other, and the payloads disagreed on
60
+ // field names (`resolved`/`selected`, `reasons`/`why`, `signals`). The commands
61
+ // below render these facts for terminals; the tools return them as JSON. The
62
+ // CLI keeps its field names; the tools adopt them.
63
+ //
64
+ // A core throws when it cannot answer: the message names the problem, `remedy`
65
+ // names the next command, and `exitCode` is the exit the CLI leaves with. The
66
+ // MCP handler turns the same throw into an error result, so both surfaces
67
+ // report the same failure for the same cause.
68
+ function skillsFailure(message, remedy, exitCode) {
69
+ throw Object.assign(new Error(message), { remedy, exitCode });
70
+ }
71
+
72
+ async function skillsRegistry(args) {
73
+ const { registry, errors, ok } = await loadRegistry(null, args);
74
+ if (!ok) skillsFailure(errors[0], WORKSPACE_REMEDY, EXIT.REGISTRY);
75
+ return registry;
76
+ }
77
+
78
+ function findSkill(registry, name, verb) {
79
+ if (!name) skillsFailure(`${verb} needs a skill name`, `usage: ${CLI} skills ${verb} <name>`, EXIT.USAGE);
80
+ const skill = registry.skills.find((s) => s.name === name);
81
+ if (!skill) {
82
+ const near = nearMatches(name, registry.skills.map((s) => s.name));
83
+ skillsFailure(
84
+ `no skill named "${name}"${near.length ? ` (did you mean ${near.join(', ')}?)` : ''}`,
85
+ `run: ${CLI} skills find <query>`,
86
+ EXIT.USAGE,
87
+ );
88
+ }
89
+ return skill;
90
+ }
91
+
92
+ /** The JSON-safe skill facts both surfaces spread: absent lists read as empty, not missing. */
93
+ function skillFacts(skill) {
94
+ return {
95
+ ...skill,
96
+ capabilities: skill.capabilities ?? [],
97
+ stack: skill.stack ?? [],
98
+ keywords: skill.keywords ?? [],
99
+ requires: skill.requires ?? [],
100
+ variants: skill.variants ?? [],
101
+ aliasOf: skill.aliasOf ?? null,
102
+ variantOf: skill.variantOf ?? null,
103
+ };
104
+ }
105
+
106
+ /** Tier 1: the selection the task resolves to, with the text packet's display sets. */
107
+ export async function skillsBriefFacts(root, args = {}) {
108
+ const registry = await skillsRegistry(args);
109
+ const repo = await fingerprint(root);
110
+ const { note: workspace, root: workspaceRoot } = await announceWorkspace(args);
111
+ const repoStack = repo.signals;
112
+ const paths = args.paths ? parsePathList(args.paths) : await dirtyPaths(root);
113
+ const taskTokens = tokenize(args.task ?? '');
114
+ const forced = listFlag(args.skill);
115
+ const budget = args.budget ?? DEFAULT_BUDGET;
116
+ const task = args.task ?? null;
117
+ const repoFacts = { root, stack: repoStack, facts: repo.facts };
118
+
119
+ // A brief with no task and no paths resolves on stack alone, which produces a
120
+ // plausible-looking list the agent may act on. It warns rather than silently
121
+ // answering; under `--json` the warning is a field, not prose on stdout.
122
+ if (!args.task && paths.length === 0 && forced.length === 0) {
123
+ const warning =
124
+ 'no --task and no staged files, so selection can only use the repo signals. ' +
125
+ 'pass --task "<what you are doing>" or stage files so the diff drives selection, ' +
126
+ 'or pass --all to take everything for this stack.';
127
+ if (args.all) {
128
+ const usable = registry.skills.filter((s) => stackCompatible(s, repoStack));
129
+ return {
130
+ kind: 'all',
131
+ repoStack,
132
+ inline: usable.filter((s) => !s.deferred),
133
+ deferred: usable.filter((s) => s.deferred),
134
+ warning,
135
+ };
136
+ }
137
+ return {
138
+ kind: 'brief',
139
+ repo: repoFacts,
140
+ workspace,
141
+ task,
142
+ budget,
143
+ inlineBytes: 0,
144
+ resolved: [],
145
+ deferred: [],
146
+ excluded: [],
147
+ shown: [],
148
+ shownDeferred: [],
149
+ shownUsed: 0,
150
+ pointerOnly: false,
151
+ warning,
152
+ };
153
+ }
154
+
155
+ const context = { repoStack, taskTokens, paths };
156
+ const { selected, excluded } = rankSkills(registry.skills, context, { forced });
157
+
158
+ // Hard dependencies are pulled in even when they did not score, and even past
159
+ // budget: a skill that is useless without its dependency is worse than a large
160
+ // context, because it silently misleads.
161
+ const expanded = closure(selected, registry.skills);
162
+ const { picked, deferred, used: inlineBytes } = selectWithinBudget(expanded, {
163
+ budget,
164
+ all: args.all === true,
165
+ });
166
+ const closureNames = new Set(expanded.map((e) => e.skill.name));
167
+
168
+ const plain = (entry) => ({
169
+ name: entry.skill.name,
170
+ bytes: entry.skill.bytes,
171
+ summary: entry.skill.summary,
172
+ why: [...entry.reasons, ...[...entry.via].map((v) => `requires:${v}`)],
173
+ });
174
+ // A body the packet cannot paste costs nothing. Charging the catalogue size
175
+ // is how a brief with no workspace reports "budget capped" and then prints
176
+ // the same remedy once per skill.
177
+ const readable = new Set();
178
+ for (const entry of expanded) {
179
+ if (await bodyReadable(entry.skill, workspaceRoot, root)) readable.add(entry.skill.name);
180
+ }
181
+ const pointerOnly = expanded.length > 0 && readable.size === 0;
182
+
183
+ let shown = picked;
184
+ let shownDeferred = deferred;
185
+ let shownUsed = inlineBytes;
186
+ if (pointerOnly) {
187
+ const priced = expanded.map((e) =>
188
+ e.skill.deferred ? e : { ...e, skill: { ...e.skill, bytes: 0 } },
189
+ );
190
+ const again = selectWithinBudget(priced, { budget, all: args.all === true });
191
+ shown = again.picked;
192
+ shownDeferred = again.deferred;
193
+ shownUsed = again.used;
194
+ }
195
+
196
+ return {
197
+ kind: 'brief',
198
+ repo: repoFacts,
199
+ workspace,
200
+ workspaceRoot,
201
+ task,
202
+ budget,
203
+ inlineBytes,
204
+ resolved: picked.map(plain),
205
+ deferred: deferred.map((e) => ({
206
+ name: e.skill.name,
207
+ bytes: e.skill.bytes,
208
+ reason: e.skill.deferred ? 'marked deferred' : 'over budget',
209
+ })),
210
+ excluded: excluded.filter((e) => !closureNames.has(e.skill.name)).map((e) => ({ name: e.skill.name, reason: e.reason })),
211
+ shown,
212
+ shownDeferred,
213
+ shownUsed,
214
+ pointerOnly,
215
+ excludedEntries: excluded.filter((e) => !closureNames.has(e.skill.name)),
216
+ };
217
+ }
218
+
219
+ /** Raw search across capability, keyword and summary. */
220
+ export async function skillsFindFacts(query, args = {}) {
221
+ const q = String(query ?? '').trim();
222
+ if (!q) skillsFailure('find needs a query', `usage: ${CLI} skills find <query>`, EXIT.USAGE);
223
+ const registry = await skillsRegistry(args);
224
+ const tokens = tokenize(q);
225
+ const results = registry.skills
226
+ .map((skill) => ({ skill, ...scoreSkill(skill, { repoStack: [], taskTokens: tokens, paths: [] }) }))
227
+ .filter((r) => selectionVerdict(r.evidence, r.reasons) === null)
228
+ .sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name));
229
+ return {
230
+ query: q,
231
+ results: results.map((r) => ({ name: r.skill.name, score: r.score, summary: r.skill.summary, reasons: r.reasons })),
232
+ };
233
+ }
234
+
235
+ /** Tier 2: one skill body and the metadata needed to judge it. */
236
+ export async function skillsShowFacts(root, name, args = {}) {
237
+ const registry = await skillsRegistry(args);
238
+ const skill = findSkill(registry, name, 'show');
239
+ const { note: workspace, root: workspaceRoot } = await announceWorkspace(args);
240
+ // The repo is a location for a declared source too, so `show` reads a body the
241
+ // caller already has instead of reporting the body missing.
242
+ const body = await sourcePath(skill, workspaceRoot, root);
243
+ const bodyFrom = body === null ? 'none' : body.startsWith(`${root}/`) ? 'repo' : 'workspace';
244
+ return {
245
+ skill: skillFacts(skill),
246
+ workspace,
247
+ body,
248
+ bodyFrom,
249
+ bodyText: await readBody(skill, workspaceRoot, root),
250
+ };
251
+ }
252
+
253
+ /** The signal chain for one skill in one repository. */
254
+ export async function skillsWhyFacts(root, name, args = {}) {
255
+ const registry = await skillsRegistry(args);
256
+ const skill = findSkill(registry, name, 'why');
257
+ const repo = await fingerprint(root);
258
+ const paths = args.paths ? parsePathList(args.paths) : await dirtyPaths(root);
259
+ const tokens = tokenize(args.task ?? '');
260
+ const { score, reasons } = scoreSkill(skill, { repoStack: repo.signals, taskTokens: tokens, paths });
261
+ const compatible = stackCompatible(skill, repo.signals);
262
+ const ws = await resolveWorkspace(args);
263
+ return {
264
+ skill: skill.name,
265
+ score,
266
+ compatible,
267
+ gate: compatible ? 'pass' : 'fail',
268
+ repo: { root, stack: repo.stack, facts: repo.facts, evidence: repo.evidence },
269
+ workspace: { root: ws.root ?? null, source: ws.source, detail: ws.detail },
270
+ paths: paths.slice(0, 8),
271
+ taskTokens: tokens,
272
+ signals: reasons,
273
+ variants: (skill.variants ?? []).map((v) => ({ id: v.id, stack: v.stack, bytes: v.bytes })),
274
+ aliasOf: skill.aliasOf ?? null,
275
+ variantOf: skill.variantOf ?? null,
276
+ requires: skill.requires ?? [],
277
+ };
278
+ }
279
+
54
280
  /** Compact stack label: the tags that actually discriminate, not all of them. */
55
281
  function stackLabel(tags = []) {
56
282
  const DISCRIMINATING = [
@@ -142,11 +368,8 @@ function repoLine(repo) {
142
368
  }
143
369
 
144
370
  /** `--all` with no other signal: everything this stack can use, deferred excluded. */
145
- async function emitAll(registry, repoStack, flags, warning) {
146
- const repo = new Set(repoStack);
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);
371
+ async function emitAllFacts(facts, flags) {
372
+ const { inline, deferred, repoStack, warning } = facts;
150
373
 
151
374
  if (isJson(flags)) {
152
375
  emitJson(
@@ -179,22 +402,18 @@ async function emitAll(registry, repoStack, flags, warning) {
179
402
  // --- find ---------------------------------------------------------------------
180
403
 
181
404
  export async function findCommand(args, flags) {
182
- const query = args.join(' ').trim();
183
- if (!query) return fail('find needs a query', EXIT.USAGE, `usage: ${CLI} skills find <query>`);
184
-
185
- const { registry, errors, ok } = await loadRegistry(null, flags);
186
- if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
187
-
188
- const tokens = tokenize(query);
405
+ let facts;
406
+ try {
407
+ facts = await skillsFindFacts(args.join(' '), flags);
408
+ } catch (error) {
409
+ return fail(error.message, error.exitCode ?? EXIT.FAIL, error.remedy);
410
+ }
411
+ const { query, results } = facts;
189
412
  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
413
 
195
414
  if (isJson(flags)) {
196
415
  emitJson(
197
- { ok: true, query, workspace, results: results.map((r) => ({ name: r.skill.name, score: r.score, reasons: r.reasons })) },
416
+ { ok: true, query, workspace, results },
198
417
  { maxBytes: flags.maxBytes ?? 0 },
199
418
  );
200
419
  return EXIT.OK;
@@ -212,7 +431,7 @@ export async function findCommand(args, flags) {
212
431
  '',
213
432
  ...results.map(
214
433
  (r) =>
215
- `${r.skill.name.padEnd(26)} ${kb(r.skill.bytes).padStart(7)} ${r.reasons.join(' ')}`,
434
+ `${r.name.padEnd(26)} ${kb(r.bytes).padStart(7)} ${r.reasons.join(' ')}`,
216
435
  ),
217
436
  ].join('\n'),
218
437
  { maxBytes: flags.maxBytes ?? 0 },
@@ -221,128 +440,44 @@ export async function findCommand(args, flags) {
221
440
  }
222
441
 
223
442
  // --- brief: tier 1 ------------------------------------------------------------
224
-
225
443
  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
444
  const root = flags.root ?? process.cwd();
230
- const repo = await fingerprint(root);
231
- const { note: workspace, root: workspaceRoot } = await announceWorkspace(flags);
232
- // stack ∪ facts: a skill may declare either, so the gate must see both.
233
- const repoStack = repo.signals;
234
- const paths = flags.paths ? parsePathList(flags.paths) : await dirtyPaths(root);
235
- const taskTokens = tokenize(flags.task ?? '');
236
- const forced = listFlag(flags.skill);
237
- const budget = flags.budget ?? DEFAULT_BUDGET;
445
+ let facts;
446
+ try {
447
+ facts = await skillsBriefFacts(root, flags);
448
+ } catch (error) {
449
+ return fail(error.message, error.exitCode ?? EXIT.FAIL, error.remedy);
450
+ }
238
451
 
239
- // A brief with no task and no paths resolves on stack alone, which produces a
240
- // plausible-looking list the agent may act on. It is the most likely way this
241
- // tool gets misused, so it warns rather than silently answering.
242
- //
243
- // Under `--json` the warning is a FIELD, not prose on stdout. It used to be
244
- // emitted as text with no `isJson` check, so `brief --json` printed a paragraph
245
- // and then nothing a caller could parse - and with `--all` the text came first
246
- // and a second document followed, so stdout was neither one document nor none.
247
- if (!flags.task && paths.length === 0 && forced.length === 0) {
248
- const warning =
249
- 'no --task and no staged files, so selection can only use the repo signals. ' +
250
- 'pass --task "<what you are doing>" or stage files so the diff drives selection, ' +
251
- 'or pass --all to take everything for this stack.';
252
- if (isJson(flags)) {
253
- // `ok: true` because the command did what was asked; the answer being weak
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
- }
452
+ if (facts.kind === 'all') {
453
+ return emitAllFacts(facts, flags);
454
+ }
455
+
456
+ // `ok: true` because the command did what was asked; the answer being weak
457
+ // is what `warning` carries. Failing here would tell a caller the tool
458
+ // broke when in fact it is telling them their query was.
459
+ if (isJson(flags)) {
460
+ const { shown, shownDeferred, shownUsed, pointerOnly, excludedEntries, workspaceRoot, kind, ...doc } = facts;
461
+ emitJson({ ok: true, ...doc });
462
+ return EXIT.OK;
463
+ }
464
+
465
+ if (facts.warning) {
466
+ const repo = await fingerprint(root);
272
467
  emit(
273
468
  [
274
469
  `repo ${root.split('/').pop()} (${repoLine(repo)})`,
275
470
  '',
276
- warning,
471
+ facts.warning,
277
472
  ].join('\n'),
278
473
  { maxBytes: flags.maxBytes ?? 0 },
279
474
  );
280
- if (flags.all) return emitAll(registry, repoStack, flags, warning);
281
475
  return EXIT.OK;
282
476
  }
283
477
 
284
- const context = { repoStack, taskTokens, paths };
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));
478
+ const { shown, shownDeferred, shownUsed, pointerOnly, excludedEntries, workspace, workspaceRoot, budget } = facts;
307
479
  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
- }
480
+ const repo = await fingerprint(root);
346
481
 
347
482
  lines.push(
348
483
  `RESOLVED ${shown.length} skills, ${kb(shownUsed)}${flags.all || shownUsed <= budget ? '' : ' (budget capped)'} - repo ${root.split('/').pop()} (${repoLine(repo)})`,
@@ -358,7 +493,7 @@ export async function briefCommand(args, flags) {
358
493
  lines.push('', `DEFERRED ${shownDeferred.length} (marked deferred)`);
359
494
  for (const entry of shownDeferred) lines.push(pointerLine(entry));
360
495
  }
361
- const hidden = excluded.filter((e) => !closureNames.has(e.skill.name));
496
+ const hidden = excludedEntries;
362
497
  if (hidden.length > 0) {
363
498
  lines.push('', `NOT SELECTED ${hidden.length}`, '', ` notes: ${CLI} skills why <name> | ${CLI} skills show <name>`);
364
499
  }
@@ -398,7 +533,7 @@ export async function briefCommand(args, flags) {
398
533
  }
399
534
  }
400
535
 
401
- const notSelected = excluded.filter((e) => !closureNames.has(e.skill.name));
536
+ const notSelected = excludedEntries;
402
537
  if (notSelected.length > 0) {
403
538
  lines.push('', 'NOT SELECTED');
404
539
  for (const { skill, reason } of notSelected) {
@@ -415,31 +550,17 @@ export async function briefCommand(args, flags) {
415
550
  // --- show: tier 2 -------------------------------------------------------------
416
551
 
417
552
  export async function showCommand(args, flags) {
418
- const name = args[0];
419
- if (!name) return fail('show needs a skill name', EXIT.USAGE, `usage: ${CLI} skills show <name>`);
420
-
421
- const { registry, errors, ok } = await loadRegistry(null, flags);
422
- if (!ok) return fail(errors[0], EXIT.REGISTRY, WORKSPACE_REMEDY);
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
- );
553
+ const root = flags.root ?? process.cwd();
554
+ let facts;
555
+ try {
556
+ facts = await skillsShowFacts(root, args[0], flags);
557
+ } catch (error) {
558
+ return fail(error.message, error.exitCode ?? EXIT.FAIL, error.remedy);
432
559
  }
433
-
560
+ const { skill, workspace, body, bodyFrom, bodyText } = facts;
434
561
  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
562
  if (isJson(flags)) {
442
- emitJson({ ok: true, ...skill, workspace, body, bodyFrom }, { maxBytes: flags.maxBytes ?? 0 });
563
+ emitJson({ ok: true, ...skill, workspace, body, bodyFrom, bodyText }, { maxBytes: flags.maxBytes ?? 0 });
443
564
  return EXIT.OK;
444
565
  }
445
566
  emit(
@@ -458,7 +579,7 @@ export async function showCommand(args, flags) {
458
579
  `body ${body === null ? '(unavailable)' : `${bodyFrom} ${body}`}`,
459
580
  workspace,
460
581
  '',
461
- await readBody(skill, workspaceRoot, root),
582
+ bodyText,
462
583
  ]
463
584
  .filter((l) => l !== null)
464
585
  .join('\n'),
@@ -470,29 +591,13 @@ export async function showCommand(args, flags) {
470
591
  // --- why ----------------------------------------------------------------------
471
592
 
472
593
  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
594
  const root = flags.root ?? process.cwd();
490
- const repo = await fingerprint(root);
491
- const paths = flags.paths ? parsePathList(flags.paths) : await dirtyPaths(root);
492
- const tokens = tokenize(flags.task ?? '');
493
-
494
- const { score, reasons } = scoreSkill(skill, { repoStack: repo.signals, taskTokens: tokens, paths });
495
- const compatible = stackCompatible(skill, repo.signals);
595
+ let facts;
596
+ try {
597
+ facts = await skillsWhyFacts(root, args[0], flags);
598
+ } catch (error) {
599
+ return fail(error.message, error.exitCode ?? EXIT.FAIL, error.remedy);
600
+ }
496
601
 
497
602
  // Every other command honours `--json`; this one built its answer as text and
498
603
  // emitted it, so `--json` was silently ignored and a caller got prose it could
@@ -501,48 +606,31 @@ export async function whyCommand(args, flags) {
501
606
  // to scrape: a skill that can never be selected in this repo is a decision, not
502
607
  // a line of output.
503
608
  if (isJson(flags)) {
504
- const workspace = await resolveWorkspace(flags);
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 });
609
+ emitJson({ ok: true, command: 'skills why', ...facts }, { maxBytes: flags.maxBytes ?? 0 });
522
610
  return EXIT.OK;
523
611
  }
524
612
 
525
613
  const lines = [
526
- `${skill.name} - score ${score}`,
614
+ `${facts.skill} - score ${facts.score}`,
527
615
  '',
528
- `repo stack ${repo.stack.join(', ') || 'undetected'}`,
529
- `repo facts ${repo.facts.join(', ') || 'none'}`,
530
- `repo evidence ${repo.evidence.join(' ') || 'none'}`,
616
+ `repo stack ${facts.repo.stack.join(', ') || 'undetected'}`,
617
+ `repo facts ${facts.repo.facts.join(', ') || 'none'}`,
618
+ `repo evidence ${facts.repo.evidence.join(' ') || 'none'}`,
531
619
  await workspaceLine(flags),
532
- `paths seen ${paths.length > 0 ? paths.slice(0, 8).join(' ') : '(none - pass --paths or stage files)'}`,
533
- `task tokens ${tokens.length > 0 ? tokens.join(' ') : '(none - pass --task)'}`,
620
+ `paths seen ${facts.paths.length > 0 ? facts.paths.join(' ') : '(none - pass --paths or stage files)'}`,
621
+ `task tokens ${facts.taskTokens.length > 0 ? facts.taskTokens.join(' ') : '(none - pass --task)'}`,
534
622
  '',
535
- `gate ${compatible ? 'pass (stack overlaps the repo)' : 'FAIL (no stack overlap, so it can never be selected)'}`,
536
- `signals ${reasons.length > 0 ? reasons.join(' ') : '(none - this skill scores 0 for the current context)'}`,
623
+ `gate ${facts.compatible ? 'pass (stack overlaps the repo)' : 'FAIL (no stack overlap, so it can never be selected)'}`,
624
+ `signals ${facts.signals.length > 0 ? facts.signals.join(' ') : '(none - this skill scores 0 for the current context)'}`,
537
625
  ];
538
626
 
539
- if (skill.variants?.length > 0) {
627
+ if (facts.variants?.length > 0) {
540
628
  lines.push('', 'variants (one body per stack):');
541
- for (const v of skill.variants) lines.push(` ${v.id.padEnd(8)} ${v.stack.join('+').padEnd(30)} ${kb(v.bytes)}`);
629
+ for (const v of facts.variants) lines.push(` ${v.id.padEnd(8)} ${v.stack.join('+').padEnd(30)} ${kb(v.bytes)}`);
542
630
  }
543
- if (skill.aliasOf) lines.push('', `alias-of ${skill.aliasOf} (retired name, resolves to that skill)`);
544
- if (skill.variantOf) lines.push('', `variant-of ${skill.variantOf} (sibling group, one body per stack)`);
545
- if (skill.requires?.length > 0) lines.push(`requires ${skill.requires.join(', ')}`);
631
+ if (facts.aliasOf) lines.push('', `alias-of ${facts.aliasOf} (retired name, resolves to that skill)`);
632
+ if (facts.variantOf) lines.push('', `variant-of ${facts.variantOf} (sibling group, one body per stack)`);
633
+ if (facts.requires?.length > 0) lines.push(`requires ${facts.requires.join(', ')}`);
546
634
 
547
635
  emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
548
636
  return EXIT.OK;
@@ -668,7 +756,7 @@ async function bodyReadable(skill, workspaceRoot, root) {
668
756
  return Boolean(full && existsSync(full));
669
757
  }
670
758
 
671
- export async function readBody(skill, workspaceRoot, root) {
759
+ async function readBody(skill, workspaceRoot, root) {
672
760
  const full = await sourcePath(skill, workspaceRoot, root);
673
761
  if (!full) {
674
762
  return `(body unavailable: this installation has no workspace and ${root ?? 'this directory'} does not hold ${skill.name}; ${WORKSPACE_REMEDY})`;