simt-dev 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/CHANGELOG.md +83 -1
  2. package/README.md +46 -17
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -6
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  7. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  8. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  9. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  10. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  11. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  12. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  13. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  14. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  15. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  16. 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
  17. 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
  18. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  19. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  20. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  21. 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
  22. 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
  23. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  24. package/docs/decisions/README.md +53 -0
  25. package/package.json +4 -2
  26. package/rules/core.md +2 -2
  27. package/rules/mdc/preview-postflight.mdc +5 -6
  28. package/rules/mdc/test-gate.mdc +2 -0
  29. package/rules/stacks/android.md +10 -11
  30. package/rules/stacks/cloudflare-workers.md +1 -1
  31. package/rules/stacks/connectiq.md +2 -2
  32. package/rules/stacks/container.md +2 -0
  33. package/rules/stacks/dart.md +1 -1
  34. package/rules/stacks/deno.md +1 -1
  35. package/rules/stacks/dotnet.md +1 -1
  36. package/rules/stacks/elixir.md +1 -1
  37. package/rules/stacks/go.md +1 -1
  38. package/rules/stacks/jvm.md +4 -3
  39. package/rules/stacks/node.md +2 -2
  40. package/rules/stacks/python.md +7 -6
  41. package/rules/stacks/react-native.md +3 -5
  42. package/rules/stacks/react-web.md +9 -9
  43. package/rules/stacks/ruby.md +1 -1
  44. package/rules/stacks/rust.md +1 -1
  45. package/rules/stacks/swift.md +1 -1
  46. package/rules/stacks/typescript.md +2 -2
  47. package/rules/stacks/uv.md +3 -2
  48. package/skills/simt-commands/SKILL.md +70 -0
  49. package/src/advice/advice.mjs +7 -3
  50. package/src/agenthooks/decide.mjs +238 -15
  51. package/src/agenthooks/opencode.mjs +72 -0
  52. package/src/agenthooks/policy.mjs +81 -5
  53. package/src/agenthooks/prompt.mjs +6 -2
  54. package/src/agenthooks/render.mjs +186 -19
  55. package/src/agenthooks/shell.mjs +523 -0
  56. package/src/agenthooks/shim.mjs +55 -0
  57. package/src/agents/render.mjs +277 -0
  58. package/src/argv.mjs +62 -9
  59. package/src/bench/ablate.mjs +31 -14
  60. package/src/bench/mine.mjs +48 -1
  61. package/src/bench/probes.mjs +5 -6
  62. package/src/bench/report.mjs +243 -80
  63. package/src/bench/run.mjs +12 -11
  64. package/src/bench/variants.mjs +112 -40
  65. package/src/brief/assemble.mjs +6 -0
  66. package/src/ci/workflow.mjs +16 -7
  67. package/src/commands/agent.mjs +206 -0
  68. package/src/commands/agenthook.mjs +133 -17
  69. package/src/commands/bench.mjs +2 -1
  70. package/src/commands/ci.mjs +28 -3
  71. package/src/commands/compare.mjs +8 -7
  72. package/src/commands/completions.mjs +27 -0
  73. package/src/commands/decide.mjs +20 -6
  74. package/src/commands/doctor.mjs +367 -0
  75. package/src/commands/evidence.mjs +13 -3
  76. package/src/commands/explain.mjs +52 -0
  77. package/src/commands/gate.mjs +85 -19
  78. package/src/commands/graph.mjs +18 -5
  79. package/src/commands/help.mjs +96 -26
  80. package/src/commands/hooks.mjs +258 -67
  81. package/src/commands/ignore.mjs +152 -0
  82. package/src/commands/impact.mjs +4 -2
  83. package/src/commands/index.mjs +69 -102
  84. package/src/commands/init.mjs +239 -37
  85. package/src/commands/lease.mjs +304 -0
  86. package/src/commands/lessons.mjs +77 -9
  87. package/src/commands/map.mjs +33 -18
  88. package/src/commands/mcp.mjs +2 -2
  89. package/src/commands/pack.mjs +9 -0
  90. package/src/commands/plan.mjs +13 -1
  91. package/src/commands/plugin.mjs +17 -2
  92. package/src/commands/prbody.mjs +45 -0
  93. package/src/commands/repoindex.mjs +4 -0
  94. package/src/commands/rules.mjs +219 -105
  95. package/src/commands/shared.mjs +36 -5
  96. package/src/commands/skills.mjs +329 -206
  97. package/src/commands/skillscompile.mjs +8 -7
  98. package/src/commands/skillsinstall.mjs +112 -30
  99. package/src/commands/slice.mjs +712 -0
  100. package/src/commands/status.mjs +62 -0
  101. package/src/commands/sync.mjs +146 -36
  102. package/src/commands/tests.mjs +5 -2
  103. package/src/commands/undo.mjs +114 -0
  104. package/src/commands/upgrade.mjs +103 -9
  105. package/src/commands/why.mjs +10 -4
  106. package/src/compare/hooksstate.mjs +23 -1
  107. package/src/compare/learn.mjs +82 -57
  108. package/src/compare/parallel.mjs +50 -0
  109. package/src/compare/row.mjs +26 -1
  110. package/src/completions.mjs +196 -0
  111. package/src/decisions/dir.mjs +84 -0
  112. package/src/decisions/index.mjs +109 -11
  113. package/src/diff.mjs +254 -0
  114. package/src/distill/distill.mjs +3 -1
  115. package/src/ecosystem/node-runner.mjs +144 -0
  116. package/src/explain.mjs +270 -0
  117. package/src/federation/config.mjs +44 -0
  118. package/src/graph/load.mjs +52 -24
  119. package/src/hooks/capability.mjs +14 -1
  120. package/src/hooks/dispatcher.mjs +441 -9
  121. package/src/hooks/gates.mjs +14 -3
  122. package/src/hooks/resolve.mjs +23 -3
  123. package/src/hooks/shim.mjs +12 -7
  124. package/src/identity.mjs +67 -14
  125. package/src/ignore.mjs +133 -0
  126. package/src/index/extract.mjs +192 -11
  127. package/src/index/persist.mjs +372 -55
  128. package/src/index/store.mjs +263 -60
  129. package/src/index/tests.mjs +6 -35
  130. package/src/journal.mjs +545 -0
  131. package/src/kernel/card.mjs +37 -12
  132. package/src/kernel/checks.mjs +44 -9
  133. package/src/kernel/variants.mjs +27 -16
  134. package/src/lease.mjs +195 -0
  135. package/src/ledger/index.mjs +91 -25
  136. package/src/mcp/resources.mjs +5 -3
  137. package/src/mcp/server.mjs +86 -13
  138. package/src/mcp/tools.mjs +340 -264
  139. package/src/memory/decide.mjs +98 -38
  140. package/src/memory/lessons.mjs +180 -33
  141. package/src/name.mjs +9 -0
  142. package/src/output/index.mjs +82 -12
  143. package/src/package-version.mjs +49 -1
  144. package/src/packs/manage.mjs +13 -4
  145. package/src/plan/index.mjs +37 -8
  146. package/src/plan/template.mjs +27 -5
  147. package/src/plugins.mjs +33 -8
  148. package/src/prbody.mjs +253 -0
  149. package/src/projection/baselines.mjs +1 -1
  150. package/src/projection/disclosure.mjs +2 -2
  151. package/src/projection/loaders.mjs +41 -1
  152. package/src/projection/lock.mjs +21 -14
  153. package/src/projection/manifest.mjs +82 -21
  154. package/src/projection/markers.mjs +43 -3
  155. package/src/projection/mdc.mjs +34 -7
  156. package/src/projection/project.mjs +162 -18
  157. package/src/projection/rules.mjs +54 -3
  158. package/src/projection/shared.mjs +4 -1
  159. package/src/projection/surfaces.mjs +9 -1
  160. package/src/repo-root.mjs +69 -0
  161. package/src/resolve/body.mjs +4 -0
  162. package/src/resolve/fingerprint.mjs +5 -0
  163. package/src/resolve/local.mjs +58 -0
  164. package/src/review/critic.mjs +4 -2
  165. package/src/secrets.mjs +374 -0
  166. package/src/state.mjs +76 -0
  167. package/src/status.mjs +157 -0
  168. package/src/toolwords.mjs +5 -0
  169. package/src/tsconfig.mjs +39 -99
  170. package/templates/plan/tracker.json +1 -1
@@ -37,7 +37,7 @@ import { filterInferable, inferableDefect, inferableProofs } from '../kernel/inf
37
37
  import { checkDefect, foldChecks } from '../kernel/checks.mjs';
38
38
  import { foldVariants, readLockedVariants, variantsDefect } from '../kernel/variants.mjs';
39
39
  import { BASELINES } from './baselines.mjs';
40
- import { CLI } from '../name.mjs';
40
+ import { CLI, MANIFEST_FILE } from '../name.mjs';
41
41
  import { installedSkills } from './disclosure.mjs';
42
42
  import { PACK_DIR, resolvePacks } from '../packs.mjs';
43
43
  import { discoverPackages } from '../monorepo.mjs';
@@ -134,6 +134,10 @@ export async function provenanceInputs() {
134
134
  async function walk(dir, prefix = '') {
135
135
  const out = [];
136
136
  for (const entry of await readdir(dir, { withFileTypes: true })) {
137
+ // A dotfile is machine noise (.DS_Store on macOS), not rule content: the digest
138
+ // must be stable across checkouts, and a fresh worktree does not carry the
139
+ // author's Finder metadata. Rule files are .md/.mdc, never dotfiles.
140
+ if (entry.name.startsWith('.')) continue;
137
141
  const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
138
142
  if (entry.isDirectory()) out.push(...(await walk(join(dir, entry.name), rel)));
139
143
  else if (entry.isFile()) out.push(rel);
@@ -282,8 +286,11 @@ export async function mdcTargets(root, sha, manifest, flags) {
282
286
  // same `bodies` from the same call, so the region and the files cannot disagree.
283
287
  if (foldsRules(resolved) && !resolved.surfaces.some((s) => s.rule)) {
284
288
  if (bodies.length > 0) {
289
+ // An empty selection has no row to read the primary from; the document is the
290
+ // one the region lives in, which is the answer in every case this note fires.
291
+ const primary = resolved.surfaces[0]?.primary ?? 'AGENTS.md';
285
292
  notes.push(
286
- `every rule is folded into ${resolved.surfaces[0].primary}; add a surface with its own rule path for a file per rule`,
293
+ `every rule is folded into ${primary}; add a surface with its own rule path for a file per rule`,
287
294
  );
288
295
  }
289
296
  return { targets, problems, notes };
@@ -411,6 +418,7 @@ async function ruleBodies(root, manifest, flags) {
411
418
  const { body, problems: renderProblems } = renderMdc(
412
419
  rule,
413
420
  ruleValues(rule, declared.values[rule.id] ?? {}, resolved.surfaces),
421
+ signals,
414
422
  );
415
423
  for (const p of renderProblems) problems.push(`${rule.file}: ${p}`);
416
424
  if (renderProblems.length > 0) continue;
@@ -558,6 +566,16 @@ export async function desiredRegion(root, flags) {
558
566
  for (const x of filtered.removed) inferable.push({ rule: r.id, text: x.text, expr: x.expr });
559
567
  shipped.push({ ...r, body: folded.body });
560
568
  }
569
+ // A body that points the reader at a profile they cannot reach is a rule-text defect:
570
+ // the region renders, but the reference dangles. Kept out of `problems` because that
571
+ // array is the mdc list `sync` already reports, and one fault must not print twice.
572
+ const crossRefs = [];
573
+ const selectedIds = new Set(selected.map((r) => r.id));
574
+ for (const r of shipped) {
575
+ for (const p of crossRefProblems(r.body, all, selectedIds)) {
576
+ crossRefs.push(`${r.id}: ${p}`);
577
+ }
578
+ }
561
579
  const lockedNonDefault = variantsSelected.filter((v) => v.locked && v.winner !== v.baseline);
562
580
  if (lockedNonDefault.length > 0) {
563
581
  notes.push(
@@ -906,6 +924,7 @@ export async function desiredRegion(root, flags) {
906
924
  surfaces: resolved.ids,
907
925
  surfaceSource: resolved.source,
908
926
  problems,
927
+ crossRefs,
909
928
  fallback,
910
929
  notes,
911
930
  body: assembled.body,
@@ -1301,9 +1320,13 @@ export function byPriorityThenId(a, b) {
1301
1320
  * `Applies to` is already the first line of a stack profile once its H1 is gone,
1302
1321
  * so it lands directly under `## Stack: …` instead of under whatever section the
1303
1322
  * previous source ended on.
1323
+ *
1324
+ * The rule text names the manifest with a `{manifest}` token and the render
1325
+ * substitutes it, so a rename of the file is a change in one place (MANIFEST_FILE),
1326
+ * not a search across every rule body.
1304
1327
  */
1305
1328
  function frameSource(rule, body, kind) {
1306
- const text = demoteHeadings(body).trim();
1329
+ const text = demoteHeadings(body.replaceAll('{manifest}', MANIFEST_FILE)).trim();
1307
1330
  if (!text) return '';
1308
1331
  const title = sourceTitle(rule, kind);
1309
1332
  if (!title) return text;
@@ -1317,6 +1340,34 @@ function sourceTitle(rule, kind) {
1317
1340
  return `Stack: ${rule.id}`;
1318
1341
  }
1319
1342
 
1343
+ /**
1344
+ * A body may point the reader at another profile in two ways: naming a profile
1345
+ * that is selected alongside it, or giving the command to fetch one
1346
+ * (`simt rules show <id>`). A reference that is neither is one the reader of
1347
+ * the projected region cannot follow. Both citation forms are checked: the
1348
+ * backtick-quoted `<id> profile` in prose, and the `simt rules show <id>`
1349
+ * command itself - an id that form names must exist, or the command 404s.
1350
+ */
1351
+ function crossRefProblems(body, all, selectedIds) {
1352
+ const problems = [];
1353
+ const seen = new Set();
1354
+ for (const m of String(body).matchAll(/`([a-z0-9][a-z0-9-]*)`\s+profile|rules show ([a-z0-9][a-z0-9-]*)/g)) {
1355
+ const id = m[1] ?? m[2];
1356
+ if (seen.has(id)) continue;
1357
+ seen.add(id);
1358
+ if (!all.some((r) => r.id === id)) {
1359
+ problems.push(`cross-reference: body cites the \`${id}\` profile, which is not a declared rule`);
1360
+ continue;
1361
+ }
1362
+ if (!selectedIds.has(id) && !new RegExp(`${CLI} rules show ${id}`).test(body)) {
1363
+ problems.push(
1364
+ `cross-reference: body cites the \`${id}\` profile, which is neither co-selected nor reachable via \`${CLI} rules show ${id}\``,
1365
+ );
1366
+ }
1367
+ }
1368
+ return problems;
1369
+ }
1370
+
1320
1371
  /** `##` becomes `###`. A fence is left alone: a heading in an example is an example. */
1321
1372
  function demoteHeadings(text) {
1322
1373
  let fence = false;
@@ -307,7 +307,10 @@ export async function sharedState(path, rules, meta) {
307
307
  * user-level AGENTS.md is the most likely file in a reader's home directory to contain
308
308
  * something they wrote themselves. Only the bytes between the markers are ours.
309
309
  */
310
- function injectShared(before, rules, meta) {
310
+ // Exported so `shared install --diff` previews the same bytes the write applies:
311
+ // a diff rendered from a second implementation of this injection would be a
312
+ // second opinion about what "current" means (plan 0003 R50).
313
+ export function injectShared(before, rules, meta) {
311
314
  return inject(before, sharedBody(rules), meta, { compose: composeShared });
312
315
  }
313
316
 
@@ -327,12 +327,20 @@ export const DEFAULT_SURFACES = ['opencode'];
327
327
  * Cursor reader gets them as `.mdc`. Folding and per-rule files therefore coexist,
328
328
  * and `ruleTargets` already skips a surface that has no rule path.
329
329
  *
330
+ * An empty selection folds too: with no surface carrying a rule path, the region is
331
+ * the only channel a rule has, and the old answer (no surface folds, so nothing
332
+ * folds) wrote every process rule nowhere while `check` reported current (review
333
+ * 0003 Q27).
334
+ *
330
335
  * Asked in exactly two places that must agree: `desiredRegion` folds the bodies in,
331
336
  * and `mdcTargets` writes the files. Asking it twice is how ADR-020's fold decision
332
337
  * could drift between the writer and the gate, which is the step-6 failure.
333
338
  */
334
339
  export function foldsRules(resolved) {
335
- return resolved.surfaces.some((s) => s.foldRules === true);
340
+ if (resolved.surfaces.length === 0) return true;
341
+ // A surface with no rule path has no per-rule channel, so its rules must be in the
342
+ // region; `foldRules` is that fact recorded on the row.
343
+ return resolved.surfaces.some((s) => s.foldRules === true || !s.rule);
336
344
  }
337
345
 
338
346
  /**
@@ -0,0 +1,69 @@
1
+ // The repository root a working directory belongs to, for the hook path.
2
+ //
3
+ // A payload's `cwd` is the directory the harness happens to be in, and a harness
4
+ // happens to be in subdirectories: an agent that was told to work in `src/` edits
5
+ // from there, and every repository-relative comparison that resolves against the
6
+ // payload's cwd — the lease file, `.gitmodules`, the manifest — compares against
7
+ // the wrong root and silently stops matching (review 0003 Q16). The root this
8
+ // module answers is the one git itself would use, so a lease taken at the top
9
+ // level matches an edit made three directories down.
10
+ //
11
+ // The answer is synchronous because the caller is a hook on the hot path: one
12
+ // `git rev-parse` is cheaper than the repository walk the fallback would do in
13
+ // the common case, and the fallback only pays when git cannot answer at all —
14
+ // a directory that is not in a repository, where the walk is short by
15
+ // definition.
16
+
17
+ import { spawnSync } from 'node:child_process';
18
+ import { existsSync } from 'node:fs';
19
+ import { dirname, join, resolve } from 'node:path';
20
+
21
+ import { MANIFEST_FILE, STATE_DIR } from './name.mjs';
22
+
23
+ /**
24
+ * The top-level directory of the repository `cwd` is in, or `cwd` itself when it
25
+ * is in none.
26
+ *
27
+ * The fallback looks for the two facts that mark a directory as adopted by this
28
+ * tool — the state directory and the manifest — and walks up, because a hook
29
+ * fired from a subdirectory of an adopted repository must still find it: a
30
+ * repository with no `.git` at all (a worktree's parent, a fresh checkout
31
+ // before `git init` finishes) is the case the git call refuses and the walk
32
+ * answers.
33
+ */
34
+ export function repoRoot(cwd) {
35
+ const start = resolve(String(cwd ?? ''));
36
+ const result = spawnSync('git', ['-C', start, 'rev-parse', '--show-toplevel'], {
37
+ encoding: 'utf8',
38
+ timeout: 2000,
39
+ });
40
+ if (result.status === 0) {
41
+ const top = String(result.stdout ?? '').trim();
42
+ if (top !== '') return top;
43
+ }
44
+ let dir = start;
45
+ for (;;) {
46
+ if (existsSync(join(dir, STATE_DIR)) || existsSync(join(dir, MANIFEST_FILE))) return dir;
47
+ const parent = dirname(dir);
48
+ if (parent === dir) return start;
49
+ dir = parent;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Whether git on this repository compares paths case-insensitively, which is
55
+ * the one filesystem fact a path comparison must honour: on a case-insensitive
56
+ * volume `NB.ipynb` and `nb.ipynb` are the file, and a comparison that spells
57
+ * them differently is a comparison that misses.
58
+ *
59
+ * A git that cannot answer (no repository, no binary) answers `false`, which is
60
+ * the strict spelling: the two case variants are then treated as distinct, the
61
+ // way they are on the filesystem the comparison is being done for.
62
+ */
63
+ export function coreIgnoreCase(root) {
64
+ const result = spawnSync('git', ['-C', root, 'config', '--get', 'core.ignorecase'], {
65
+ encoding: 'utf8',
66
+ timeout: 1000,
67
+ });
68
+ return result.status === 0 && String(result.stdout ?? '').trim().toLowerCase() === 'true';
69
+ }
@@ -22,6 +22,10 @@ import { existsSync } from 'node:fs';
22
22
  import { locateSource, sourceHeldLocally } from './source.mjs';
23
23
 
24
24
  export async function sourcePath(skill, workspaceRoot, root) {
25
+ // A node synthesized from the repo's own skill directory already names its file.
26
+ // Asking the workspace for it would hunt for a body the registry never declared.
27
+ if (skill.localFile) return skill.localFile;
28
+
25
29
  const canonical = skill.sources?.find((s) => s.role === 'canonical') ?? skill.sources?.[0];
26
30
 
27
31
  if (canonical) {
@@ -180,6 +180,11 @@ const FACTS = [
180
180
  { name: 'has-mix', kind: 'file', paths: ['mix.exs'] },
181
181
  { name: 'has-deno-config', kind: 'file', paths: ['deno.json', 'deno.jsonc'] },
182
182
  { name: 'has-bun-lock', kind: 'file', paths: ['bun.lock', 'bun.lockb'] },
183
+ // Q31: one fact per Node lockfile, so the runner resolver, the card, the gate
184
+ // seeds and CI all read the same committed evidence instead of assuming npm.
185
+ { name: 'has-pnpm-lock', kind: 'file', paths: ['pnpm-lock.yaml'] },
186
+ { name: 'has-yarn-lock', kind: 'file', paths: ['yarn.lock'] },
187
+ { name: 'has-package-lock', kind: 'file', paths: ['package-lock.json'] },
183
188
  // A glob walk skips dot directories, and `.devcontainer` is one. Stat the path.
184
189
  { name: 'has-devcontainer', kind: 'path', paths: ['.devcontainer/devcontainer.json'] },
185
190
  ];
@@ -0,0 +1,58 @@
1
+ // Repo-local skills as resolver nodes.
2
+ //
3
+ // The registry is what simt-dev ships; the skill directories a repo holds are what
4
+ // that repo owns. After plan 0005 S2 the registry is small and generic, so a skill
5
+ // that lives only in a repo was invisible to `skills find` and `skills brief`, and
6
+ // the NOT SELECTED block points a reader at `skills show` by name - a remedy that
7
+ // 404s for a name the registry does not declare.
8
+ //
9
+ // Presence in the repo's skill directory is the eligibility signal: a skill the repo
10
+ // installed is about the repo, and the stack it belongs to is the stack the repo has.
11
+ // The node's `stack` is therefore the repo's own signals, which makes the stack gate
12
+ // a self-overlap that always passes, and the stack score reads "more this repo's"
13
+ // rather than "compatible with a stranger".
14
+
15
+ import { stat } from 'node:fs/promises';
16
+
17
+ import { BODY_BYTE_BUDGET } from '../graph/compile.mjs';
18
+ import { installedSkills } from '../projection/disclosure.mjs';
19
+
20
+ /**
21
+ * The repo's own skills, as nodes the resolver can rank.
22
+ *
23
+ * Names the registry declares are excluded: the registry entry is canonical and the
24
+ * local file is its installed copy, so keeping both would rank one skill twice.
25
+ *
26
+ * @param {string} root the repository
27
+ * @param {object} registry the loaded registry
28
+ * @param {string[]} repoStack the repo's signals (stack ∪ facts)
29
+ * @returns {Promise<object[]>} resolver-ready nodes, name-sorted
30
+ */
31
+ export async function localSkillNodes(root, registry, repoStack) {
32
+ const installed = await installedSkills(root);
33
+ const known = new Set(registry.skills.map((s) => s.name));
34
+ const nodes = [];
35
+ for (const { name, trigger, file } of installed) {
36
+ if (known.has(name)) continue;
37
+ let size;
38
+ try {
39
+ size = (await stat(file)).size;
40
+ } catch (error) {
41
+ if (error.code !== 'ENOENT') throw error;
42
+ continue; // gone between listing and measuring
43
+ }
44
+ nodes.push({
45
+ name,
46
+ summary: trigger,
47
+ keywords: [],
48
+ capabilities: [],
49
+ stack: [...repoStack],
50
+ paths: [],
51
+ bytes: size,
52
+ deferred: size > BODY_BYTE_BUDGET,
53
+ localFile: file,
54
+ });
55
+ }
56
+ nodes.sort((a, b) => a.name.localeCompare(b.name));
57
+ return nodes;
58
+ }
@@ -32,9 +32,11 @@ import { digestOf, promptHash, proposalFrontmatter } from '../distill/engine.mjs
32
32
 
33
33
  const exec = promisify(execFile);
34
34
 
35
- // Locked on-disk beneath `.simt`, like `.simt/distilled` (R38) — a cache of
35
+ import { LOCAL_DIR } from '../state.mjs';
36
+
37
+ // Locked on-disk beneath machine-local state, like the distilled cache (R38) —
36
38
  // proposals keyed by content digest, never projected and never committed.
37
- const REVIEW_DIR = '.simt/reviews';
39
+ const REVIEW_DIR = join(LOCAL_DIR, 'reviews');
38
40
  const ENGINE_REVIEW_LOCAL = 'review-local';
39
41
 
40
42
  /** The diff brief's byte cap; `--budget` overrides it, `0` means unlimited. */