simt-dev 0.6.3 → 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.
Files changed (137) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +2 -2
  3. package/agents/simt-agent.md +84 -0
  4. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  5. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  6. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  7. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  8. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  9. 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
  10. 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
  11. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  12. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  13. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  14. 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
  15. 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
  16. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  17. package/docs/decisions/README.md +9 -0
  18. package/package.json +4 -2
  19. package/rules/core.md +2 -2
  20. package/rules/mdc/test-gate.mdc +2 -0
  21. package/rules/stacks/android.md +3 -3
  22. package/rules/stacks/connectiq.md +2 -2
  23. package/rules/stacks/dart.md +1 -1
  24. package/rules/stacks/deno.md +1 -1
  25. package/rules/stacks/dotnet.md +1 -1
  26. package/rules/stacks/elixir.md +1 -1
  27. package/rules/stacks/go.md +1 -1
  28. package/rules/stacks/jvm.md +3 -2
  29. package/rules/stacks/node.md +2 -2
  30. package/rules/stacks/python.md +7 -6
  31. package/rules/stacks/ruby.md +1 -1
  32. package/rules/stacks/rust.md +1 -1
  33. package/rules/stacks/swift.md +1 -1
  34. package/rules/stacks/typescript.md +2 -2
  35. package/rules/stacks/uv.md +3 -2
  36. package/skills/simt-commands/SKILL.md +70 -0
  37. package/src/advice/advice.mjs +7 -3
  38. package/src/agenthooks/decide.mjs +141 -9
  39. package/src/agenthooks/opencode.mjs +72 -0
  40. package/src/agenthooks/prompt.mjs +6 -2
  41. package/src/agenthooks/render.mjs +186 -19
  42. package/src/agenthooks/shim.mjs +55 -0
  43. package/src/agents/render.mjs +277 -0
  44. package/src/argv.mjs +8 -0
  45. package/src/bench/ablate.mjs +31 -14
  46. package/src/bench/mine.mjs +48 -1
  47. package/src/bench/probes.mjs +5 -6
  48. package/src/bench/report.mjs +243 -80
  49. package/src/bench/run.mjs +12 -11
  50. package/src/bench/variants.mjs +112 -40
  51. package/src/brief/assemble.mjs +6 -0
  52. package/src/ci/workflow.mjs +16 -7
  53. package/src/commands/agent.mjs +206 -0
  54. package/src/commands/agenthook.mjs +109 -15
  55. package/src/commands/bench.mjs +2 -1
  56. package/src/commands/compare.mjs +8 -7
  57. package/src/commands/decide.mjs +6 -5
  58. package/src/commands/evidence.mjs +13 -3
  59. package/src/commands/gate.mjs +85 -19
  60. package/src/commands/graph.mjs +18 -5
  61. package/src/commands/help.mjs +35 -14
  62. package/src/commands/hooks.mjs +68 -25
  63. package/src/commands/impact.mjs +4 -2
  64. package/src/commands/index.mjs +17 -4
  65. package/src/commands/init.mjs +169 -37
  66. package/src/commands/lease.mjs +203 -35
  67. package/src/commands/lessons.mjs +65 -9
  68. package/src/commands/map.mjs +33 -18
  69. package/src/commands/plugin.mjs +1 -1
  70. package/src/commands/repoindex.mjs +1 -0
  71. package/src/commands/rules.mjs +92 -18
  72. package/src/commands/skills.mjs +60 -25
  73. package/src/commands/skillscompile.mjs +8 -7
  74. package/src/commands/skillsinstall.mjs +12 -0
  75. package/src/commands/slice.mjs +377 -18
  76. package/src/commands/sync.mjs +37 -22
  77. package/src/commands/tests.mjs +5 -2
  78. package/src/commands/undo.mjs +24 -16
  79. package/src/commands/upgrade.mjs +33 -1
  80. package/src/commands/why.mjs +10 -4
  81. package/src/compare/hooksstate.mjs +23 -1
  82. package/src/compare/learn.mjs +82 -57
  83. package/src/compare/parallel.mjs +50 -0
  84. package/src/compare/row.mjs +26 -1
  85. package/src/completions.mjs +5 -0
  86. package/src/decisions/dir.mjs +84 -0
  87. package/src/decisions/index.mjs +109 -11
  88. package/src/distill/distill.mjs +3 -1
  89. package/src/ecosystem/node-runner.mjs +144 -0
  90. package/src/explain.mjs +18 -6
  91. package/src/graph/load.mjs +38 -19
  92. package/src/hooks/capability.mjs +14 -1
  93. package/src/hooks/dispatcher.mjs +335 -13
  94. package/src/hooks/resolve.mjs +9 -3
  95. package/src/hooks/shim.mjs +12 -7
  96. package/src/identity.mjs +67 -14
  97. package/src/index/extract.mjs +192 -11
  98. package/src/index/persist.mjs +307 -47
  99. package/src/index/store.mjs +263 -60
  100. package/src/index/tests.mjs +6 -35
  101. package/src/journal.mjs +296 -77
  102. package/src/kernel/card.mjs +37 -12
  103. package/src/kernel/checks.mjs +44 -9
  104. package/src/kernel/variants.mjs +27 -16
  105. package/src/lease.mjs +97 -15
  106. package/src/ledger/index.mjs +91 -25
  107. package/src/mcp/resources.mjs +5 -3
  108. package/src/mcp/server.mjs +81 -22
  109. package/src/mcp/tools.mjs +45 -9
  110. package/src/memory/decide.mjs +98 -43
  111. package/src/memory/lessons.mjs +168 -33
  112. package/src/name.mjs +9 -0
  113. package/src/plan/index.mjs +31 -8
  114. package/src/plan/template.mjs +27 -5
  115. package/src/plugins.mjs +6 -2
  116. package/src/prbody.mjs +7 -5
  117. package/src/projection/baselines.mjs +1 -1
  118. package/src/projection/disclosure.mjs +2 -2
  119. package/src/projection/loaders.mjs +41 -1
  120. package/src/projection/lock.mjs +21 -14
  121. package/src/projection/manifest.mjs +69 -19
  122. package/src/projection/markers.mjs +43 -3
  123. package/src/projection/mdc.mjs +34 -7
  124. package/src/projection/project.mjs +162 -18
  125. package/src/projection/rules.mjs +54 -3
  126. package/src/projection/surfaces.mjs +9 -1
  127. package/src/repo-root.mjs +69 -0
  128. package/src/resolve/body.mjs +4 -0
  129. package/src/resolve/fingerprint.mjs +5 -0
  130. package/src/resolve/local.mjs +58 -0
  131. package/src/review/critic.mjs +4 -2
  132. package/src/secrets.mjs +165 -22
  133. package/src/state.mjs +76 -0
  134. package/src/status.mjs +9 -4
  135. package/src/toolwords.mjs +5 -0
  136. package/src/tsconfig.mjs +39 -99
  137. package/templates/plan/tracker.json +1 -1
@@ -20,7 +20,9 @@ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
20
20
  import { loadRules, select } from '../projection/project.mjs';
21
21
  import { loadMdcRules } from '../projection/mdc.mjs';
22
22
  import { fingerprint } from '../resolve/fingerprint.mjs';
23
- import { byId, citations, loadDecisions, redactEstate } from '../decisions/index.mjs';
23
+ import { byId, citations, loadDecisionsCached, redactEstate } from '../decisions/index.mjs';
24
+ import { DECISIONS_DIR } from '../decisions/index.mjs';
25
+ import { decisionsDir } from '../decisions/dir.mjs';
24
26
  import { nearMatches } from '../resolve/near.mjs';
25
27
  import { CLI } from '../name.mjs';
26
28
 
@@ -175,15 +177,36 @@ function rulesFailure(message, remedy, exitCode) {
175
177
  throw Object.assign(new Error(message), { remedy, exitCode });
176
178
  }
177
179
 
180
+ /**
181
+ * The merged decision index: the package's shipped records underlay the
182
+ * repository's own, which win on an id collision (review 0003 Q34).
183
+ *
184
+ * Before this, a consumer with its own ADR-001/002 read `rules why core` as
185
+ * settled by *their* ADR-001 while the package's ADR-003…009 — the ones the
186
+ * shipped rules actually cite — reported MISSING. The overlay keeps both
187
+ * visible: every decision carries which layer it came from, so a same-number
188
+ * collision reads as two records, never as one answer wearing the wrong title.
189
+ *
190
+ * @returns {{ok: boolean, decisions: object[], errors: string[], origins: Record<string,'repo'|'package'>}}
191
+ */
178
192
  async function loadDecisionsFor(root, all) {
179
- const localDir = root ? join(root, 'docs', 'decisions') : null;
180
- const hasLocal = localDir
181
- ? (await readdir(localDir).catch(() => [])).some((n) => /^ADR-\d{3,}-.*\.md$/i.test(n))
182
- : false;
183
- return loadDecisions({
184
- all,
185
- ...(hasLocal ? { dir: localDir } : {}),
186
- });
193
+ // Q55: one resolution (manifest key, then detection) and the shared cache,
194
+ // so `rules why` answers from the repository's own records without re-parsing
195
+ // every record on every call. No root means the package's own records.
196
+ const pkg = await loadDecisionsCached({ root, dir: DECISIONS_DIR, all });
197
+ if (!root) return { ...pkg, origins: Object.fromEntries(pkg.decisions.map((d) => [d.id, 'package'])) };
198
+ const relDir = await decisionsDir(root);
199
+ const absDir = join(root, relDir);
200
+ const hasLocal = (await readdir(absDir).catch(() => [])).some((n) => /^ADR-\d{3,}-.*\.md$/i.test(n));
201
+ if (!hasLocal) return { ...pkg, origins: Object.fromEntries(pkg.decisions.map((d) => [d.id, 'package'])) };
202
+ const local = await loadDecisionsCached({ root, dir: absDir, all });
203
+ const merged = new Map(pkg.decisions.map((d) => [d.id, d]));
204
+ const origins = Object.fromEntries(pkg.decisions.map((d) => [d.id, 'package']));
205
+ for (const d of local.decisions) {
206
+ merged.set(d.id, d);
207
+ origins[d.id] = 'repo';
208
+ }
209
+ return { ok: pkg.ok && local.ok, decisions: [...merged.values()], errors: [...pkg.errors, ...local.errors], origins };
187
210
  }
188
211
 
189
212
  /** The catalogue both surfaces answer from: every rule, its decisions, and the selection. */
@@ -217,20 +240,22 @@ export async function rulesWhyFacts(root, what, { all = false } = {}) {
217
240
  const { rules } = await load(root);
218
241
  if (/^ADR-\d{3,}$/i.test(what)) {
219
242
  const id = what.toUpperCase();
220
- const { ok, decisions, errors } = await loadDecisionsFor(root, all);
243
+ const { ok, decisions, errors, origins } = await loadDecisionsFor(root, all);
221
244
  if (!ok) {
222
245
  rulesFailure(errors[0], 'the decision records must be readable for provenance to work', EXIT.CONFIG);
223
246
  }
224
- const record = decisions.find((x) => x.id === id);
247
+ const record = decisions.find((x) => x.id === id) ?? null;
225
248
  // Was 'list them with: ls docs/decisions/' - a path relative to simt-dev's own
226
249
  // tree, which resolves to nothing in the repo the reader is standing in. The
227
- // remedy must be a command they can run.
228
- if (!record) rulesFailure(`no decision record ${id}`, `list the known decisions with: ${CLI} rules list`, EXIT.USAGE);
250
+ // remedy must be a command they can run (Q34: the decisions list, not the
251
+ // rules list - the reader asked about a decision, not a rule).
252
+ if (!record) rulesFailure(`no decision record ${id}`, `list the known decisions with: ${CLI} rules decisions`, EXIT.USAGE);
229
253
  const replacement = record.supersededBy ? decisions.find((x) => x.id === record.supersededBy) : null;
230
254
  return {
231
255
  direction: 'decision',
232
256
  id,
233
- adr: record,
257
+ adr: { ...record, origin: origins[id] ?? 'package' },
258
+ origins,
234
259
  supersededBy: replacement ? { id: replacement.id, title: replacement.title } : null,
235
260
  inForce: replacement
236
261
  ? { id: replacement.id, status: replacement.status, summary: replacement.summary }
@@ -247,7 +272,7 @@ export async function rulesWhyFacts(root, what, { all = false } = {}) {
247
272
  EXIT.USAGE,
248
273
  );
249
274
  }
250
- const { ok, decisions, errors } = await loadDecisionsFor(root, all);
275
+ const { ok, decisions, errors, origins } = await loadDecisionsFor(root, all);
251
276
  if (!ok) {
252
277
  rulesFailure(errors[0], 'the decision records must be readable for provenance to work', EXIT.CONFIG);
253
278
  }
@@ -261,9 +286,10 @@ export async function rulesWhyFacts(root, what, { all = false } = {}) {
261
286
  title: rule.title ?? null,
262
287
  source: rule.source,
263
288
  origin: rule.origin,
264
- adrs: cited.map((c) => index.get(c) ?? { id: c, missing: true }),
289
+ adrs: cited.map((c) => (index.has(c) ? { ...index.get(c), origin: origins[c] ?? 'package' } : { id: c, missing: true })),
265
290
  uncited: cited.length === 0,
266
291
  others: others.map((r) => r.id),
292
+ origins,
267
293
  };
268
294
  }
269
295
 
@@ -351,10 +377,11 @@ function ruleDirection(facts, flags) {
351
377
  lines.push('', `settled by ${facts.adrs.length} decision${facts.adrs.length === 1 ? '' : 's'}`);
352
378
  for (const d of facts.adrs) {
353
379
  if (d.missing) {
354
- lines.push(` ${d.id} MISSING - the rule cites it, but this installation has no record of it`);
380
+ lines.push(` ${d.id} MISSING - the rule cites it, but neither this repo nor this installation has a record of it`);
381
+ lines.push(` remedy: list the known decisions with: ${CLI} rules decisions`);
355
382
  continue;
356
383
  }
357
- lines.push(` ${d.id} ${d.title}`);
384
+ lines.push(` ${d.id} [${d.origin ?? 'package'}] ${d.title}`);
358
385
  lines.push(` ${d.status}`);
359
386
  if (d.supersededBy) {
360
387
  // Never print a reversed ruling as if it were current.
@@ -379,6 +406,7 @@ function adrDirection(facts, flags) {
379
406
  const lines = [
380
407
  `${d.id} - ${d.title}`,
381
408
  '',
409
+ `origin ${d.origin ?? 'package'}${d.origin === 'repo' ? ' (this repository overlays the shipped record of the same number)' : ''}`,
382
410
  `status ${d.status}`,
383
411
  `restates ${restates.length > 0 ? restates.join(', ') : '(no rule cites it)'}`,
384
412
  ];
@@ -462,4 +490,50 @@ function adrDirection(facts, flags) {
462
490
  // A superseded record is not a failure; it is the supersession rule working.
463
491
  // The caller asked a question and got a true answer.
464
492
  return EXIT.OK;
493
+ }
494
+
495
+ // --- decisions ------------------------------------------------------------------
496
+
497
+ /**
498
+ * `simt rules decisions`: every known decision record, both layers.
499
+ *
500
+ * The shipped records underlay the repository's own, which win on a number
501
+ * collision (review 0003 Q34). The column that matters is `origin`: a reader in
502
+ * a consumer repository must be able to tell the package's ADR-001 from their
503
+ * own, because they are two records that happen to share a number.
504
+ */
505
+ export async function decisionsCommand(args, flags) {
506
+ const root = flags.root ?? null;
507
+ const { ok, decisions, errors, origins } = await loadDecisionsFor(root, flags.all === true);
508
+ if (!ok) {
509
+ return fail(errors[0], EXIT.CONFIG, 'the decision records must be readable to list them');
510
+ }
511
+ const rows = [...decisions]
512
+ .sort((a, b) => Number(a.id.replace(/^ADR-/i, '')) - Number(b.id.replace(/^ADR-/i, '')))
513
+ .map((d) => ({
514
+ id: d.id,
515
+ title: d.title,
516
+ status: d.status,
517
+ origin: origins[d.id] ?? 'package',
518
+ supersededBy: d.supersededBy ?? null,
519
+ file: d.file,
520
+ }));
521
+
522
+ if (isJson(flags)) {
523
+ emitJson({ ok: true, command: 'rules decisions', root, decisions: rows });
524
+ return EXIT.OK;
525
+ }
526
+
527
+ const repo = rows.filter((r) => r.origin === 'repo').length;
528
+ const lines = [
529
+ `${rows.length} decisions${root && repo > 0 ? ` (${repo} from this repo overlay the shipped set)` : ''}`,
530
+ '',
531
+ 'id origin title',
532
+ ];
533
+ for (const r of rows) {
534
+ const head = r.status.length > 80 ? `${r.status.slice(0, 80)}...` : r.status;
535
+ lines.push(`${r.id.padEnd(8)} ${r.origin.padEnd(7)} ${r.title} — ${head}`);
536
+ }
537
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
538
+ return EXIT.OK;
465
539
  }
@@ -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';
@@ -75,18 +76,25 @@ async function skillsRegistry(args) {
75
76
  return registry;
76
77
  }
77
78
 
78
- function findSkill(registry, name, verb) {
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) {
79
86
  if (!name) skillsFailure(`${verb} needs a skill name`, `usage: ${CLI} skills ${verb} <name>`, EXIT.USAGE);
80
87
  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;
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
+ );
90
98
  }
91
99
 
92
100
  /** The JSON-safe skill facts both surfaces spread: absent lists read as empty, not missing. */
@@ -116,6 +124,10 @@ export async function skillsBriefFacts(root, args = {}) {
116
124
  const task = args.task ?? null;
117
125
  const repoFacts = { root, stack: repoStack, facts: repo.facts };
118
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
+
119
131
  // A brief with no task and no paths resolves on stack alone, which produces a
120
132
  // plausible-looking list the agent may act on. It warns rather than silently
121
133
  // answering; under `--json` the warning is a field, not prose on stdout.
@@ -125,7 +137,7 @@ export async function skillsBriefFacts(root, args = {}) {
125
137
  'pass --task "<what you are doing>" or stage files so the diff drives selection, ' +
126
138
  'or pass --all to take everything for this stack.';
127
139
  if (args.all) {
128
- const usable = registry.skills.filter((s) => stackCompatible(s, repoStack));
140
+ const usable = pool.filter((s) => stackCompatible(s, repoStack));
129
141
  return {
130
142
  kind: 'all',
131
143
  repoStack,
@@ -153,12 +165,12 @@ export async function skillsBriefFacts(root, args = {}) {
153
165
  }
154
166
 
155
167
  const context = { repoStack, taskTokens, paths };
156
- const { selected, excluded } = rankSkills(registry.skills, context, { forced });
168
+ const { selected, excluded } = rankSkills(pool, context, { forced });
157
169
 
158
170
  // Hard dependencies are pulled in even when they did not score, and even past
159
171
  // budget: a skill that is useless without its dependency is worse than a large
160
172
  // context, because it silently misleads.
161
- const expanded = closure(selected, registry.skills);
173
+ const expanded = closure(selected, pool);
162
174
  const { picked, deferred, used: inlineBytes } = selectWithinBudget(expanded, {
163
175
  budget,
164
176
  all: args.all === true,
@@ -216,31 +228,53 @@ export async function skillsBriefFacts(root, args = {}) {
216
228
  };
217
229
  }
218
230
 
219
- /** Raw search across capability, keyword and summary. */
220
- export async function skillsFindFacts(query, args = {}) {
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 = {}) {
221
240
  const q = String(query ?? '').trim();
222
241
  if (!q) skillsFailure('find needs a query', `usage: ${CLI} skills find <query>`, EXIT.USAGE);
223
242
  const registry = await skillsRegistry(args);
224
243
  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));
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));
229
253
  return {
230
254
  query: q,
231
- results: results.map((r) => ({ name: r.skill.name, score: r.score, summary: r.skill.summary, reasons: r.reasons })),
255
+ results: results.map((r) => ({ name: r.skill.name, score: r.score, bytes: r.skill.bytes, summary: r.skill.summary, reasons: r.reasons })),
232
256
  };
233
257
  }
234
258
 
235
259
  /** Tier 2: one skill body and the metadata needed to judge it. */
236
260
  export async function skillsShowFacts(root, name, args = {}) {
237
261
  const registry = await skillsRegistry(args);
238
- const skill = findSkill(registry, name, 'show');
262
+ const skill = await findSkill(registry, root, name, 'show');
239
263
  const { note: workspace, root: workspaceRoot } = await announceWorkspace(args);
240
264
  // The repo is a location for a declared source too, so `show` reads a body the
241
265
  // caller already has instead of reporting the body missing.
242
266
  const body = await sourcePath(skill, workspaceRoot, root);
243
- const bodyFrom = body === null ? 'none' : body.startsWith(`${root}/`) ? 'repo' : 'workspace';
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';
244
278
  return {
245
279
  skill: skillFacts(skill),
246
280
  workspace,
@@ -253,7 +287,7 @@ export async function skillsShowFacts(root, name, args = {}) {
253
287
  /** The signal chain for one skill in one repository. */
254
288
  export async function skillsWhyFacts(root, name, args = {}) {
255
289
  const registry = await skillsRegistry(args);
256
- const skill = findSkill(registry, name, 'why');
290
+ const skill = await findSkill(registry, root, name, 'why');
257
291
  const repo = await fingerprint(root);
258
292
  const paths = args.paths ? parsePathList(args.paths) : await dirtyPaths(root);
259
293
  const tokens = tokenize(args.task ?? '');
@@ -402,9 +436,10 @@ async function emitAllFacts(facts, flags) {
402
436
  // --- find ---------------------------------------------------------------------
403
437
 
404
438
  export async function findCommand(args, flags) {
439
+ const root = flags.root ?? process.cwd();
405
440
  let facts;
406
441
  try {
407
- facts = await skillsFindFacts(args.join(' '), flags);
442
+ facts = await skillsFindFacts(root, args.join(' '), flags);
408
443
  } catch (error) {
409
444
  return fail(error.message, error.exitCode ?? EXIT.FAIL, error.remedy);
410
445
  }
@@ -20,7 +20,8 @@ import { resolveVaultDir } from '../bench/mine.mjs';
20
20
  import { locateSource, sourceHeldLocally } from '../resolve/source.mjs';
21
21
  import { nearMatches } from '../resolve/near.mjs';
22
22
  import { resolveWorkspace, WORKSPACE_REMEDY } from '../workspace.mjs';
23
- import { CLI, STATE_DIR } from '../name.mjs';
23
+ import { CLI } from '../name.mjs';
24
+ import { LOCAL_DIR } from '../state.mjs';
24
25
 
25
26
  async function readSkillRawText(skill, workspaceRoot, root) {
26
27
  const canonical = skill.sources?.find((s) => s.role === 'canonical') ?? skill.sources?.[0];
@@ -44,7 +45,7 @@ async function readSkillRawText(skill, workspaceRoot, root) {
44
45
 
45
46
  async function loadGoldPathsByTask(root) {
46
47
  const map = new Map();
47
- const tasksDir = join(root, STATE_DIR, 'bench', 'tasks');
48
+ const tasksDir = join(root, '.simt', 'bench', 'tasks');
48
49
  const vaultRoot = resolveVaultDir(root);
49
50
  const entries = (await readdir(tasksDir).catch(() => [])).filter((f) => f.endsWith('.json')).sort();
50
51
  for (const entry of entries) {
@@ -172,26 +173,26 @@ async function runSingleSkillCompile(skill, registry, { root, workspaceRoot, fla
172
173
  triggerStats,
173
174
  });
174
175
 
175
- const outDir = join(root, STATE_DIR, 'compiled', 'skills', skill.name);
176
- const proposalPath = join(root, STATE_DIR, 'proposals', 'skills', `${skill.name}.json`);
176
+ const outDir = join(root, LOCAL_DIR, 'compiled', 'skills', skill.name);
177
+ const proposalPath = join(root, LOCAL_DIR, 'proposals', 'skills', `${skill.name}.json`);
177
178
  const writtenFiles = [];
178
179
 
179
180
  if (mode.apply) {
180
181
  await mkdir(outDir, { recursive: true });
181
182
  await writeFile(join(outDir, 'SKILL.md'), compiled.mainText, 'utf8');
182
- writtenFiles.push(`${STATE_DIR}/compiled/skills/${skill.name}/SKILL.md`);
183
+ writtenFiles.push(`${LOCAL_DIR}/compiled/skills/${skill.name}/SKILL.md`);
183
184
 
184
185
  for (const ref of compiled.references) {
185
186
  if (!ref.content) continue;
186
187
  const refFull = join(outDir, ref.path);
187
188
  await mkdir(dirname(refFull), { recursive: true });
188
189
  await writeFile(refFull, ref.content, 'utf8');
189
- writtenFiles.push(`${STATE_DIR}/compiled/skills/${skill.name}/${ref.path}`);
190
+ writtenFiles.push(`${LOCAL_DIR}/compiled/skills/${skill.name}/${ref.path}`);
190
191
  }
191
192
 
192
193
  await mkdir(dirname(proposalPath), { recursive: true });
193
194
  await writeFile(proposalPath, `${JSON.stringify(proposal, null, 2)}\n`, 'utf8');
194
- writtenFiles.push(`${STATE_DIR}/proposals/skills/${skill.name}.json`);
195
+ writtenFiles.push(`${LOCAL_DIR}/proposals/skills/${skill.name}.json`);
195
196
  }
196
197
 
197
198
  if (isJson(flags)) {
@@ -89,6 +89,18 @@ export async function installCommand(args, flags) {
89
89
 
90
90
  const found = await locateSource(pick.source, { workspaceRoot: ws.root, root });
91
91
  if (!found.path) {
92
+ // A repo-side candidate with the wrong size is a third reason, and it needs
93
+ // its own wording: the file is on the reader's disk, so "this repo does not
94
+ // hold it" would send them looking for a file that is already there. The
95
+ // variant path calls the same failure an impostor (chooseSource).
96
+ const impostor = found.candidates.find((c) => c.how === 'repo');
97
+ if (impostor) {
98
+ return fail(
99
+ `${skill.name}: ${root}/${pick.source.path} exists but is not the declared body (${pick.source.bytes} bytes declared)`,
100
+ EXIT.USAGE,
101
+ `run: ${CLI} graph lint --workspace <path> to see what the registry expects, or delete the local copy`,
102
+ );
103
+ }
92
104
  // Two reasons land here and they need different wording: no workspace at all,
93
105
  // or a workspace that should hold this body and does not. An install that
94
106
  // cannot read a body has failed, so this is never a degraded mode - the