simt-dev 0.3.1 → 0.5.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 (60) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/README.md +271 -62
  3. package/package.json +6 -2
  4. package/rules/core.md +1 -1
  5. package/src/advice/advice.mjs +229 -0
  6. package/src/agenthooks/policy.mjs +10 -2
  7. package/src/agenthooks/prompt.mjs +81 -0
  8. package/src/agenthooks/render.mjs +138 -17
  9. package/src/argv.mjs +28 -1
  10. package/src/bench/grade.mjs +3 -0
  11. package/src/bench/run.mjs +88 -54
  12. package/src/bench/tasks.mjs +38 -0
  13. package/src/bench/variants.mjs +456 -0
  14. package/src/brief/assemble.mjs +368 -0
  15. package/src/brief/kind.mjs +134 -0
  16. package/src/brief/packet.mjs +200 -0
  17. package/src/commands/agenthook.mjs +84 -5
  18. package/src/commands/bench.mjs +72 -15
  19. package/src/commands/brief.mjs +78 -0
  20. package/src/commands/compare.mjs +10 -2
  21. package/src/commands/decide.mjs +116 -0
  22. package/src/commands/distill.mjs +36 -0
  23. package/src/commands/help.mjs +62 -3
  24. package/src/commands/index.mjs +29 -1
  25. package/src/commands/lessons.mjs +127 -0
  26. package/src/commands/local.mjs +97 -0
  27. package/src/commands/map.mjs +42 -7
  28. package/src/commands/repoindex.mjs +4 -1
  29. package/src/commands/review.mjs +65 -0
  30. package/src/commands/rules.mjs +17 -2
  31. package/src/commands/skills.mjs +5 -50
  32. package/src/commands/skillscompile.mjs +255 -0
  33. package/src/commands/sync.mjs +15 -0
  34. package/src/commands/tests.mjs +2 -49
  35. package/src/commands/why.mjs +23 -25
  36. package/src/compare/learn.mjs +680 -0
  37. package/src/decisions/index.mjs +116 -65
  38. package/src/distill/distill.mjs +246 -0
  39. package/src/distill/engine.mjs +187 -0
  40. package/src/federation/client.mjs +188 -0
  41. package/src/federation/config.mjs +134 -0
  42. package/src/federation/query.mjs +48 -0
  43. package/src/graph/compile.mjs +475 -0
  44. package/src/index/gate.mjs +1 -13
  45. package/src/index/govern.mjs +23 -11
  46. package/src/index/map.mjs +251 -17
  47. package/src/index/persist.mjs +74 -7
  48. package/src/index/store.mjs +222 -8
  49. package/src/kernel/variants.mjs +353 -0
  50. package/src/ledger/index.mjs +36 -1
  51. package/src/local/client.mjs +482 -0
  52. package/src/local/config.mjs +269 -0
  53. package/src/mcp/tools.mjs +138 -1
  54. package/src/memory/decide.mjs +462 -0
  55. package/src/memory/lessons.mjs +858 -0
  56. package/src/projection/lock.mjs +39 -7
  57. package/src/projection/rules.mjs +32 -8
  58. package/src/resolve/body.mjs +46 -0
  59. package/src/review/critic.mjs +509 -0
  60. package/src/toolwords.mjs +6 -0
@@ -0,0 +1,116 @@
1
+ // `simt decide` — scaffold an ADR with the next free number, a Status: line,
2
+ // a rulings skeleton, and structured `simt-affects` + `simt-supersedes` frontmatter
3
+ // (plan 0003 R34).
4
+
5
+ import { join, resolve } from 'node:path';
6
+ import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
7
+ import { CLI } from '../name.mjs';
8
+ import { DECISIONS_DIR } from '../decisions/index.mjs';
9
+ import { lintDecisions, scaffoldDecision } from '../memory/decide.mjs';
10
+ import { parsePathList } from '../argv.mjs';
11
+
12
+ export async function decideCommand(args, flags) {
13
+ const root = flags.root ?? process.cwd();
14
+
15
+ if (flags.check === true) {
16
+ const decisionsDir = join(root, 'docs', 'decisions');
17
+ const allowHistorical = resolve(decisionsDir) === DECISIONS_DIR;
18
+ const res = await lintDecisions(decisionsDir, { allowHistorical });
19
+
20
+ if (isJson(flags)) {
21
+ emitJson({
22
+ ok: res.ok,
23
+ command: 'decide --check',
24
+ root,
25
+ findings: res.findings,
26
+ });
27
+ return res.ok ? EXIT.OK : EXIT.FAIL;
28
+ }
29
+
30
+ if (res.findings.length === 0) {
31
+ emit('decisions ok', { maxBytes: flags.maxBytes ?? 0 });
32
+ return EXIT.OK;
33
+ }
34
+
35
+ emit(
36
+ res.findings
37
+ .flatMap((f) => [
38
+ `${f.status.toUpperCase()} ${f.file} ${f.detail}`,
39
+ ...(f.remedy ? [` remedy: ${f.remedy}`] : []),
40
+ ])
41
+ .join('\n'),
42
+ { maxBytes: flags.maxBytes ?? 0 },
43
+ );
44
+ return EXIT.FAIL;
45
+ }
46
+
47
+ const mode = writeMode(flags);
48
+ const title = args.join(' ').trim() || flags.task || 'Untitled decision';
49
+ const parsedPaths = parsePathList(flags.paths);
50
+ const explicitPaths = flags.paths !== undefined ? parsedPaths : undefined;
51
+
52
+ const res = await scaffoldDecision(root, {
53
+ title,
54
+ affects: explicitPaths,
55
+ supersedes: flags.supersedes,
56
+ write: mode.apply,
57
+ });
58
+
59
+ if (!res.ok) {
60
+ return fail(
61
+ res.errors[0],
62
+ EXIT.USAGE,
63
+ `usage: ${CLI} decide "<title>" [--supersedes ADR-NNN:<ruling>] [--yes]`,
64
+ );
65
+ }
66
+
67
+ if (mode.apply) {
68
+ note(`wrote ${res.relPath}`);
69
+ } else if (flags.dryRun === true) {
70
+ note('dry-run no files written');
71
+ }
72
+
73
+ if (isJson(flags)) {
74
+ emitJson({
75
+ ok: true,
76
+ command: 'decide',
77
+ root,
78
+ id: res.id,
79
+ number: res.number,
80
+ title: res.title,
81
+ file: res.file,
82
+ path: res.relPath,
83
+ affects: res.affects,
84
+ supersedes: res.supersedes,
85
+ written: res.written,
86
+ dryRun: !mode.apply,
87
+ });
88
+ return EXIT.OK;
89
+ }
90
+
91
+ const supersedesLabel =
92
+ res.supersedes.length > 0
93
+ ? res.supersedes
94
+ .map((s) => (s.ruling !== null ? `${s.adr} ruling ${s.ruling} (${s.kind})` : s.adr))
95
+ .join('; ')
96
+ : '(none)';
97
+
98
+ const lines = [
99
+ `id ${res.id}`,
100
+ `title ${res.title}`,
101
+ `file ${res.relPath}`,
102
+ `affects ${res.affects.length > 0 ? res.affects.join(', ') : '(none)'}`,
103
+ `supersedes ${supersedesLabel}`,
104
+ '',
105
+ res.text.trimEnd(),
106
+ '',
107
+ mode.apply
108
+ ? `wrote ${res.relPath}`
109
+ : flags.dryRun === true
110
+ ? 'dry-run; nothing written'
111
+ : `would write ${res.relPath}; apply with: ${CLI} decide ${JSON.stringify(res.title)} --yes`,
112
+ ];
113
+
114
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
115
+ return EXIT.OK;
116
+ }
@@ -0,0 +1,36 @@
1
+ // `simt distill <skill|doc>` (plan 0003 R38).
2
+ //
3
+ // A budgeted summary with citations to source line ranges, written to
4
+ // `.simt-dev/distilled/` as a proposal a human merges. The deterministic engine
5
+ // is the default; a local model proposes only when the caller asks for it and the
6
+ // manifest names one (ADR-090, ADR-075). Model output never lands anywhere
7
+ // projected or committed text does — that is the direction this command cannot
8
+ // take (principle P2).
9
+
10
+ import { CLI } from '../name.mjs';
11
+ import { EXIT, emit, emitJson, isJson } from '../output/index.mjs';
12
+ import { distillTarget } from '../distill/distill.mjs';
13
+
14
+ export async function distillCommand(args, flags) {
15
+ const result = await distillTarget(args, flags);
16
+ if (typeof result === 'number') return result;
17
+
18
+ if (isJson(flags)) {
19
+ emitJson(result, { maxBytes: flags.maxBytes ?? 0 });
20
+ return EXIT.OK;
21
+ }
22
+
23
+ emit(
24
+ [
25
+ `source ${result.target.kind} ${result.target.source} (${result.target.bytes} bytes, ${result.target.lines} lines)`,
26
+ `digest ${result.digest}`,
27
+ `engine ${result.engine}`,
28
+ `budget ${result.budget} bytes`,
29
+ `citations ${result.citations}`,
30
+ ...(result.verified === true ? [] : result.faults.map((f) => `unverified ${f}`)),
31
+ `proposal ${result.proposal.path} (${result.proposal.state}, ${result.proposal.bytes} bytes)`,
32
+ ].join('\n'),
33
+ { maxBytes: flags.maxBytes ?? 0 },
34
+ );
35
+ return EXIT.OK;
36
+ }
@@ -16,6 +16,12 @@ ${CLI} upgrade move every consumer to this simt-dev SHA, as one s
16
16
  (dry-run unless --yes; moves a pin only - it never
17
17
  adopts or repairs a repo; never commits or pushes)
18
18
 
19
+ ${CLI} brief "<task>" one packet for a task: the kind, the commands, the
20
+ likely files, the tests and the decisions that govern
21
+ them, inside --budget bytes (default 2048; 0 is
22
+ unlimited). Says how many items it cut; --explain
23
+ says why each was kept or dropped
24
+
19
25
  ${CLI} skills index tier-0 catalog: every skill, one line each
20
26
  ${CLI} skills brief --task tier-1 pre-assembled brief <- the common case
21
27
  ${CLI} skills show <name> tier-2 body (respects --max-bytes)
@@ -28,6 +34,9 @@ ${CLI} skills eval [corpus] measure the resolver against a gold corpus
28
34
  ${CLI} skills install <name> pin a skill into the repo as a local override
29
35
  (dry-run unless --yes; a multi-variant skill needs
30
36
  --variant or --from rather than a guess)
37
+ ${CLI} skills compile [<name>] compile a skill to Agent Skills standard, flag bodies
38
+ over 5000 tokens with a split plan, and report per-skill
39
+ trigger recall and precision from R02 transcripts
31
40
 
32
41
  ${CLI} rules list the rule catalogue (--root marks what applies to a repo)
33
42
  ${CLI} rules show <id> one rule body
@@ -39,7 +48,8 @@ ${CLI} hooks install [--yes] install the shared dispatcher; report what it wou
39
48
  ${CLI} hooks status what each git event routes to, and whether it is wired
40
49
  ${CLI} hooks run <event> run one event's gates now, as git would
41
50
  (pre-commit, pre-push, post-merge, ...)
42
- ${CLI} agent-hook <event> the entry point a harness calls before a tool call
51
+ ${CLI} agent-hook <event> the entry point a harness calls: pre-tool-use decides,
52
+ prompt-submit delivers the packet as prompt context
43
53
  (reads one JSON payload on stdin and answers in that
44
54
  harness's dialect; exit 2 blocks; needs --harness)
45
55
  ${CLI} agent-hooks install write this repo's policies into the harnesses that
@@ -82,6 +92,32 @@ ${CLI} map [--task "<text>"] [--level outline|names|signatures]
82
92
  ${CLI} evidence the runs recorded against the tree on disk
83
93
  ${CLI} evidence check <file> a commit message that claims a run this tree does not
84
94
  have: a warning, or a block when gates.evidence is "block"
95
+ ${CLI} lessons list deterministic lesson candidates in .simt-dev/lessons.jsonl
96
+ and promoted lessons in docs/lessons/*.md
97
+ ${CLI} lessons review [<id>] promote a lesson candidate to docs/lessons/NNNN-<slug>.md
98
+ (dry-run unless --yes; writes Appendix A frontmatter)
99
+ ${CLI} decide [<title>] scaffold an ADR with the next free number, a Status: line,
100
+ a rulings skeleton, and structured simt-affects +
101
+ simt-supersedes frontmatter (dry-run unless --yes;
102
+ --check lints ADRs for prose-only partial supersessions)
103
+ ${CLI} local doctor probe configured local model endpoints (chat and embed)
104
+ (reads local in ${MANIFEST_FILE}; opens no socket when
105
+ unset; refuses non-loopback URLs without --allow-remote)
106
+ ${CLI} distill <skill|doc> a budgeted proposal summary of a skill body or doc,
107
+ every claim citing the source line range it rests on
108
+ (writes .simt-dev/distilled/<digest>.md; a proposal a
109
+ human merges - never projected or committed;
110
+ deterministic outline by default; --local proposes
111
+ from the configured chat model instead; the cache is
112
+ keyed by source digest and budget, --refresh regenerates)
113
+ ${CLI} review [--local] an ADR-008 verdict skeleton for the staged diff,
114
+ the brief listing everything that governs it (R25)
115
+ (deterministic without --local: the brief and its
116
+ governing ids only; --local proposes from the
117
+ configured chat model and writes
118
+ .simt-dev/reviews/<digest>.md — a proposal a human
119
+ reads, verified against the verdict vocabulary and
120
+ the brief's ids; advisory always, never a gate)
85
121
  ${CLI} shared plan what a workspace's repeated rules cost to carry
86
122
  once: the shared set, its bytes, and every path that would be written
87
123
  (read-only; touches nothing, not even your home directory)
@@ -117,6 +153,14 @@ ${CLI} bench ablate [--yes] leave-one-out ablation across core, profile and p
117
153
  ${CLI} bench probes [--yes] 12 synthetic rule-compliance probes with deterministic graders
118
154
  (dry-run unless --yes; grades git state, transcript and ledger,
119
155
  and reports compliance per arm)
156
+ ${CLI} bench variants [--yes] evaluate simt-variants rule slots on the canary suite and lock
157
+ winning variants in simt-dev.lock.json (dry-run unless --yes;
158
+ re-opens when model or harness changes)
159
+ ${CLI} bench critic precision and recall of the local critic over a labelled
160
+ set of historical diffs (.simt-dev/critic-labels.jsonl;
161
+ overrides --labels; labels are human-written verdicts
162
+ under ADR-008; the critic is advisory until precision
163
+ reaches 0.8 on 30 labelled diffs)
120
164
  ${CLI} mcp the read-only surface as MCP tools over stdio
121
165
  (--list prints it without reading stdin; init, sync,
122
166
  upgrade and the installers stay CLI-only by design)`;
@@ -124,7 +168,8 @@ ${CLI} mcp the read-only surface as MCP tools over stdio
124
168
  const FLAGS = `--json structured output (compact; one document, one line)
125
169
  --pretty indent --json for a human
126
170
  --max-bytes <n> hard ceiling on emitted bytes (0 = unlimited, default 0)
127
- --budget <n> context budget for skills brief (default 20480)
171
+ --budget <n> context budget for a brief (default 2048)
172
+ (skills brief keeps its own default of 20480)
128
173
  --plan emit a fetch plan instead of inlining skill bodies
129
174
  --skill <name> force a skill into the brief (repeatable)
130
175
  --task "<text>" the task text used for capability resolution
@@ -142,7 +187,8 @@ const FLAGS = `--json structured output (compact; one document,
142
187
  --frozen check: the lock must match this projection; an output
143
188
  change no input change explains fails the gate
144
189
  --explain sync: name each line dropped as already inferable, with
145
- the fact behind it
190
+ the fact behind it; brief: name every item and why it
191
+ was kept or cut, with its section's value
146
192
  --all ignore a cap: every ruling of a record, or the skills budget
147
193
  --variant <id> which body of a multi-variant skill to use
148
194
  --from <repo/path> install from a declared source by name
@@ -176,6 +222,19 @@ const FLAGS = `--json structured output (compact; one document,
176
222
  --compare <a> <b> bench report: compare two arms with deltas and p-values
177
223
  --test <cmd> plan done: the test command that closes the row
178
224
  --state <name> plan done: target state (default: Complete)
225
+ --level <name> map: detail level (outline, names, signatures)
226
+ --supersedes <spec> decide: superseded ADR or ruling (ADR-NNN or ADR-NNN:<ruling>[:<kind>]; repeatable)
227
+ --allow-remote local doctor / distill / review: allow non-loopback model
228
+ URLs (refused by default)
229
+ --embed index / map / brief: compute or query optional local vector embeddings
230
+ (off by default unless recall@10 gains at least 10 points)
231
+ --labels <path> bench critic: the labelled set of historical diffs to score
232
+ (default .simt-dev/critic-labels.jsonl; one human-written
233
+ {sha, verdict} pair per line)
234
+ --refresh distill / review: regenerate a proposal that is already cached
235
+ --local distill / review: propose the answer from the configured
236
+ local chat model (local.chat); off by default, which
237
+ uses the deterministic path instead
179
238
  --help this text
180
239
  --version the version, and the SHA it was built from`;
181
240
 
@@ -25,6 +25,7 @@ import {
25
25
  whyCommand as skillsWhy,
26
26
  evalCommand as skillsEval,
27
27
  } from './skills.mjs';
28
+ import { briefCommand } from './brief.mjs';
28
29
  import { syncCommand, checkCommand } from './sync.mjs';
29
30
  import { initCommand } from './init.mjs';
30
31
  import { ciCommand } from './ci.mjs';
@@ -34,6 +35,7 @@ import { compareCommand } from './compare.mjs';
34
35
  import { upgradeCommand } from './upgrade.mjs';
35
36
  import { mcpCommand } from './mcp.mjs';
36
37
  import { installCommand } from './skillsinstall.mjs';
38
+ import { compileCommand } from './skillscompile.mjs';
37
39
  import { listCommand as rulesList, showCommand as rulesShow, whyCommand as rulesWhy } from './rules.mjs';
38
40
  import { tokensCommand } from './tokens.mjs';
39
41
  import { harnessCommand, harnessLoadersCommand } from './harness.mjs';
@@ -43,6 +45,8 @@ import {
43
45
  benchReportCommand,
44
46
  benchAblateCommand,
45
47
  benchProbesCommand,
48
+ benchVariantsCommand,
49
+ benchCriticCommand,
46
50
  } from './bench.mjs';
47
51
  import { planCommand } from './plan.mjs';
48
52
  import { sharedPlanCommand, sharedInstallCommand, sharedWarning } from './shared.mjs';
@@ -53,7 +57,13 @@ import { testsCommand } from './tests.mjs';
53
57
  import { whyCommand } from './why.mjs';
54
58
  import { gateCommand } from './gate.mjs';
55
59
  import { impactCommand } from './impact.mjs';
60
+ import { lessonsListCommand, lessonsReviewCommand } from './lessons.mjs';
61
+ import { decideCommand } from './decide.mjs';
62
+ import { reviewCommand } from './review.mjs';
63
+ import { localDoctorCommand } from './local.mjs';
64
+ import { distillCommand } from './distill.mjs';
56
65
  import { parse } from '../argv.mjs';
66
+ import { CLI } from '../name.mjs';
57
67
  import { fingerprint } from '../resolve/fingerprint.mjs';
58
68
  import { resolveWorkspace } from '../workspace.mjs';
59
69
 
@@ -79,6 +89,8 @@ const GROUPS = new Map([
79
89
  // where", and `install` — the subcommand that touches a reader's home directory — is the
80
90
  // one that needs `--yes` before it does anything.
81
91
  ['shared', 'plan'],
92
+ ['lessons', 'list'],
93
+ ['local', 'doctor'],
82
94
  ]);
83
95
 
84
96
  /**
@@ -95,6 +107,7 @@ const GROUPS = new Map([
95
107
  * just correct: nothing in the map depends on the call.
96
108
  */
97
109
  export const IMPLEMENTED = {
110
+ brief: briefCommand,
98
111
  doctor: runDoctor,
99
112
  'graph lint': lintCommand,
100
113
  'skills index': skillsIndex,
@@ -104,6 +117,7 @@ export const IMPLEMENTED = {
104
117
  'skills why': skillsWhy,
105
118
  'skills eval': skillsEval,
106
119
  'skills install': installCommand,
120
+ 'skills compile': compileCommand,
107
121
  'rules list': rulesList,
108
122
  'rules show': rulesShow,
109
123
  'rules why': rulesWhy,
@@ -121,6 +135,14 @@ export const IMPLEMENTED = {
121
135
  why: (args, flags) => whyCommand(args, flags, COMMANDS),
122
136
  gate: gateCommand,
123
137
  impact: (args, flags) => impactCommand(args, flags, COMMANDS),
138
+ 'lessons list': lessonsListCommand,
139
+ 'lessons review': lessonsReviewCommand,
140
+ decide: decideCommand,
141
+ distill: distillCommand,
142
+ // R25 machinery needs the runnable command names for Affects command tokens,
143
+ // same wiring `why` and `impact` get through their lambdas.
144
+ review: (args, flags) => reviewCommand(args, flags, COMMANDS),
145
+ 'local doctor': localDoctorCommand,
124
146
  'evidence check': (args, flags) => evidenceCommand(['check', ...args], flags),
125
147
  init: initCommand,
126
148
  ci: ciCommand,
@@ -139,6 +161,12 @@ export const IMPLEMENTED = {
139
161
  'bench report': benchReportCommand,
140
162
  'bench ablate': benchAblateCommand,
141
163
  'bench probes': benchProbesCommand,
164
+ 'bench variants': benchVariantsCommand,
165
+ // plan 0003 R39. The critic's acceptance bench measures BLOCK-precision and
166
+ // recall over a labelled set; until it reports its floor, the critic entries
167
+ // stay advisory and no gate reads them. COMMANDS rides along so the brief
168
+ // resolves Affects command tokens the way `simt why` does.
169
+ 'bench critic': (args, flags) => benchCriticCommand(args, flags, COMMANDS),
142
170
  upgrade: upgradeCommand,
143
171
  mcp: mcpCommand,
144
172
  };
@@ -186,7 +214,7 @@ export async function dispatch(argv) {
186
214
  // simt-dev: they run this tool, not its source tree. The step number is the
187
215
  // stable handle; the file that documents it is an implementation detail.
188
216
  return fail(
189
- `\`simt-dev ${path}\` is declared but not implemented`,
217
+ `\`${CLI} ${path}\` is declared but not implemented`,
190
218
  EXIT.UNSUPPORTED,
191
219
  `implementation step ${step} is not built yet`,
192
220
  );
@@ -0,0 +1,127 @@
1
+ // `simt lessons [list|review]` — deterministic lesson candidates and promotion to
2
+ // `docs/lessons/NNNN-<slug>.md` (plan 0003 R32).
3
+
4
+ import { resolve } from 'node:path';
5
+
6
+ import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
7
+ import { CLI } from '../name.mjs';
8
+ import { parsePathList } from '../argv.mjs';
9
+ import {
10
+ LESSONS_DIR,
11
+ LESSONS_FILE,
12
+ captureFromLedger,
13
+ captureRevertedCommits,
14
+ listPromotedLessons,
15
+ promoteLesson,
16
+ readLessonCandidates,
17
+ } from '../memory/lessons.mjs';
18
+
19
+ export async function lessonsListCommand(args, flags) {
20
+ if (args.length > 0) {
21
+ return fail(`unknown lessons argument ${args[0]}`, EXIT.USAGE, `run: ${CLI} lessons`);
22
+ }
23
+ const root = resolve(flags.root ?? process.cwd());
24
+ await captureFromLedger(root);
25
+ await captureRevertedCommits(root);
26
+
27
+ const [{ candidates, errors }, promoted] = await Promise.all([
28
+ readLessonCandidates(root),
29
+ listPromotedLessons(root),
30
+ ]);
31
+
32
+ const payload = {
33
+ ok: errors.length === 0,
34
+ command: 'lessons list',
35
+ root,
36
+ ledgerFile: LESSONS_FILE,
37
+ lessonsDir: LESSONS_DIR,
38
+ candidateCount: candidates.length,
39
+ promotedCount: promoted.length,
40
+ candidates,
41
+ promoted,
42
+ errors,
43
+ };
44
+
45
+ if (isJson(flags)) {
46
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
47
+ return errors.length === 0 ? EXIT.OK : EXIT.FAIL;
48
+ }
49
+
50
+ const lines = [
51
+ `candidates ${candidates.length} (${LESSONS_FILE})`,
52
+ `promoted ${promoted.length} (${LESSONS_DIR})`,
53
+ ];
54
+
55
+ if (candidates.length > 0) {
56
+ lines.push('', 'CANDIDATES');
57
+ for (const c of candidates) {
58
+ const sigShort = c.signature.slice(0, 19);
59
+ const state = c.status === 'promoted' ? `promoted -> ${c.promotedTo}` : 'candidate';
60
+ lines.push(` ${c.id} ${c.kind.padEnd(14)} ${sigShort} ${state} ${c.summary}`);
61
+ }
62
+ }
63
+
64
+ if (promoted.length > 0) {
65
+ lines.push('', 'LESSONS');
66
+ for (const l of promoted) {
67
+ lines.push(` ${l.id.padEnd(28)} ${(l.signature ?? 'no-signature').slice(0, 19)} ${l.title}`);
68
+ }
69
+ }
70
+
71
+ for (const err of errors) lines.push(`error ${err}`);
72
+
73
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
74
+ return errors.length === 0 ? EXIT.OK : EXIT.FAIL;
75
+ }
76
+
77
+ export async function lessonsReviewCommand(args, flags) {
78
+ if (args.length > 1) {
79
+ return fail(`unknown lessons review argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} lessons review [<id>] [--yes]`);
80
+ }
81
+ const root = resolve(flags.root ?? process.cwd());
82
+ const mode = writeMode(flags);
83
+ const parsedPaths = parsePathList(flags.paths);
84
+ const paths = parsedPaths.length > 0 ? parsedPaths : undefined;
85
+ const title = flags.task ? String(flags.task) : undefined;
86
+
87
+ const result = await promoteLesson(root, {
88
+ id: args[0],
89
+ apply: mode.apply,
90
+ paths,
91
+ title,
92
+ });
93
+
94
+ if (!result.ok) {
95
+ return fail(result.error, EXIT.FAIL, `record a session failure-then-fix or run: ${CLI} lessons`);
96
+ }
97
+
98
+ const payload = {
99
+ ok: true,
100
+ command: 'lessons review',
101
+ root,
102
+ dryRun: mode.dryRun,
103
+ written: result.written,
104
+ path: result.path,
105
+ candidate: result.candidate,
106
+ markdown: result.markdown,
107
+ };
108
+
109
+ if (isJson(flags)) {
110
+ emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
111
+ return EXIT.OK;
112
+ }
113
+
114
+ const lines = [
115
+ `promote ${result.candidate.id} (${result.candidate.kind}) -> ${result.path}`,
116
+ `signature ${result.candidate.signature}`,
117
+ `mode ${mode.apply ? 'applied' : mode.dryRun ? 'dry-run (writes nothing)' : `preview (pass --yes to write ${result.path})`}`,
118
+ '',
119
+ result.markdown.trimEnd(),
120
+ ];
121
+
122
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
123
+ if (!mode.apply) {
124
+ note(`note dry-run nothing written; apply with: ${CLI} lessons review --yes`);
125
+ }
126
+ return EXIT.OK;
127
+ }
@@ -0,0 +1,97 @@
1
+ // `simt local doctor` — probe configured local model endpoints (plan 0003 R37).
2
+ //
3
+ // Reads `local: { chat: { url, model }, embed: { url, model } }` from `simt.json`.
4
+ // When `local` is unset, opens no socket and exits 0. Refuses non-loopback URLs
5
+ // without `--allow-remote` (ADR-075).
6
+
7
+ import { resolve } from 'node:path';
8
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
9
+ import { CLI, MANIFEST_FILE } from '../name.mjs';
10
+ import { probeLocal } from '../local/client.mjs';
11
+
12
+ function formatRoleLine(role, report) {
13
+ if (!report) return `${role.padEnd(8)} (unset)`;
14
+ if (report.status === 'refused-remote') {
15
+ return `${role.padEnd(8)} refused-remote ${report.model} @ ${report.url}`;
16
+ }
17
+ if (!report.ok) {
18
+ return `${role.padEnd(8)} ${report.status} ${report.model} @ ${report.url} (${report.error})`;
19
+ }
20
+ const extra =
21
+ role === 'embed' && Number.isInteger(report.dimensions)
22
+ ? ` dimensions=${report.dimensions}`
23
+ : '';
24
+ const listed =
25
+ report.modelListed === true
26
+ ? ' listed=yes'
27
+ : report.modelListed === false
28
+ ? ' listed=no'
29
+ : '';
30
+ return `${role.padEnd(8)} ok ${report.model} @ ${report.url}${extra}${listed}`;
31
+ }
32
+
33
+ function remedyFor(result) {
34
+ if (result.status === 'refused-remote') {
35
+ return `set a loopback URL (http://127.0.0.1:<port>/v1) in ${MANIFEST_FILE}, or run: ${CLI} local doctor --allow-remote`;
36
+ }
37
+ if (result.status === 'invalid-config' || result.status === 'invalid-manifest') {
38
+ return `fix the \`local\` configuration in ${MANIFEST_FILE}`;
39
+ }
40
+ if (result.status === 'unreachable') {
41
+ return `start the local model server at the URL in ${MANIFEST_FILE}, then rerun: ${CLI} local doctor`;
42
+ }
43
+ return null;
44
+ }
45
+
46
+ export async function localDoctorCommand(args, flags) {
47
+ const root = resolve(flags.root ?? args[0] ?? process.cwd());
48
+ const allowRemote = flags.allowRemote === true;
49
+ const result = await probeLocal(root, { allowRemote });
50
+
51
+ const code = result.ok
52
+ ? EXIT.OK
53
+ : result.status === 'refused-remote' ||
54
+ result.status === 'invalid-config' ||
55
+ result.status === 'invalid-manifest'
56
+ ? EXIT.CONFIG
57
+ : EXIT.FAIL;
58
+
59
+ const error = result.ok ? null : result.findings[0] ?? `local model probe failed (${result.status})`;
60
+ const remedy = result.ok ? null : remedyFor(result);
61
+
62
+ if (isJson(flags)) {
63
+ emitJson(
64
+ {
65
+ ok: code === EXIT.OK,
66
+ command: 'local doctor',
67
+ root,
68
+ configured: result.configured,
69
+ status: result.status,
70
+ allowRemote: result.allowRemote,
71
+ remoteBlocked: result.remoteBlocked,
72
+ socketsOpened: result.socketsOpened,
73
+ chat: result.chat,
74
+ embed: result.embed,
75
+ findings: result.findings,
76
+ ...(error ? { error, remedy, exitCode: code } : {}),
77
+ },
78
+ { maxBytes: flags.maxBytes ?? 0 },
79
+ );
80
+ return code;
81
+ }
82
+
83
+ const lines = [
84
+ `root ${root}`,
85
+ `local ${result.configured ? result.status : '(unset) optional adapter is off; no socket opened'}`,
86
+ formatRoleLine('chat', result.chat),
87
+ formatRoleLine('embed', result.embed),
88
+ `sockets ${result.socketsOpened}`,
89
+ ];
90
+
91
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
92
+
93
+ if (!result.ok) {
94
+ return fail(error, code, remedy);
95
+ }
96
+ return EXIT.OK;
97
+ }
@@ -5,8 +5,15 @@
5
5
 
6
6
  import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
7
7
  import { CLI } from '../name.mjs';
8
- import { readEdges, readFiles, readSymbols, reindex } from '../index/store.mjs';
9
- import { DEFAULT_BUDGET, formatRecall, measureRecall, rankFiles, renderMap } from '../index/map.mjs';
8
+ import { readEdges, readEmbeddings, readFiles, readFts, readSymbols, reindex, searchFtsBm25 } from '../index/store.mjs';
9
+ import {
10
+ DEFAULT_BUDGET,
11
+ embedTaskQuery,
12
+ formatRecall,
13
+ measureRecall,
14
+ rankFiles,
15
+ renderMap,
16
+ } from '../index/map.mjs';
10
17
 
11
18
  const LEVELS = new Set(['outline', 'names', 'signatures']);
12
19
 
@@ -25,7 +32,15 @@ export async function mapCommand(args, flags) {
25
32
  const loaded = await loadGraph(root);
26
33
  if (loaded.error) return loaded.error;
27
34
  const task = typeof flags.task === 'string' ? flags.task : '';
28
- const ranked = rankFiles({ ...loaded, task });
35
+ const bm25 = task.trim().length > 0 ? await searchFtsBm25(root, task, { fts: loaded.fts }) : [];
36
+ const queryVector =
37
+ flags.embed === true && loaded.embeddings.length > 0
38
+ ? await embedTaskQuery(root, task, {
39
+ embed: true,
40
+ allowRemote: flags.allowRemote === true,
41
+ })
42
+ : null;
43
+ const ranked = rankFiles({ ...loaded, bm25, queryVector, task });
29
44
  const rendered = renderMap(ranked, loaded.symbols, { level, budget });
30
45
  const payload = {
31
46
  ok: true,
@@ -57,7 +72,10 @@ export async function mapCommand(args, flags) {
57
72
  async function recall(root, flags) {
58
73
  let result;
59
74
  try {
60
- result = await measureRecall(root);
75
+ result = await measureRecall(root, {
76
+ embed: flags.embed === true,
77
+ allowRemote: flags.allowRemote === true,
78
+ });
61
79
  } catch (error) {
62
80
  return fail(`could not measure recall (${error.message})`, EXIT.UNSUPPORTED, `run ${CLI} index in a git repository`);
63
81
  }
@@ -70,9 +88,21 @@ async function recall(root, flags) {
70
88
  return EXIT.OK;
71
89
  }
72
90
 
73
- async function loadGraph(root) {
91
+ /**
92
+ * The files, symbols, edges, FTS rows and stored vector embeddings the index holds,
93
+ * building the index once when it is empty.
94
+ *
95
+ * `build: false` refuses that rebuild and answers with `cold: true` instead. The one
96
+ * caller that passes it is the prompt-submit hook (R29), and the reason is the bar that
97
+ * hook has to meet: a delivered packet that built an index would make a person's first
98
+ * prompt in a fresh clone wait on a walk of the whole repository, and a prompt that takes
99
+ * a minute to appear is how a hook gets uninstalled. The next prompt gets the packet, and
100
+ * a command a person typed at a terminal always builds what it needs.
101
+ */
102
+ export async function loadGraph(root, { build = true } = {}) {
74
103
  let files = await readFiles(root);
75
104
  if (files.length === 0) {
105
+ if (!build) return { files: [], symbols: [], edges: [], fts: [], embeddings: [], cold: true };
76
106
  try {
77
107
  await reindex(root);
78
108
  } catch (error) {
@@ -86,6 +116,11 @@ async function loadGraph(root) {
86
116
  }
87
117
  files = await readFiles(root);
88
118
  }
89
- const [symbols, edges] = await Promise.all([readSymbols(root), readEdges(root)]);
90
- return { files, symbols, edges };
119
+ const [symbols, edges, fts, embeddings] = await Promise.all([
120
+ readSymbols(root),
121
+ readEdges(root),
122
+ readFts(root),
123
+ readEmbeddings(root),
124
+ ]);
125
+ return { files, symbols, edges, fts, embeddings };
91
126
  }