@mmnto/cli 1.123.0 → 1.124.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 (46) hide show
  1. package/dist/commands/extract-shared.d.ts.map +1 -1
  2. package/dist/commands/extract-shared.js +2 -6
  3. package/dist/commands/extract-shared.js.map +1 -1
  4. package/dist/commands/extract.test.js +19 -1
  5. package/dist/commands/extract.test.js.map +1 -1
  6. package/dist/commands/review-learn.d.ts +3 -0
  7. package/dist/commands/review-learn.d.ts.map +1 -1
  8. package/dist/commands/review-learn.js +4 -7
  9. package/dist/commands/review-learn.js.map +1 -1
  10. package/dist/commands/review-learn.test.js +19 -2
  11. package/dist/commands/review-learn.test.js.map +1 -1
  12. package/dist/commands/shield-learn.test.js +27 -5
  13. package/dist/commands/shield-learn.test.js.map +1 -1
  14. package/dist/commands/shield-retrieve.test.d.ts +2 -0
  15. package/dist/commands/shield-retrieve.test.d.ts.map +1 -0
  16. package/dist/commands/shield-retrieve.test.js +91 -0
  17. package/dist/commands/shield-retrieve.test.js.map +1 -0
  18. package/dist/commands/shield-templates.d.ts +7 -0
  19. package/dist/commands/shield-templates.d.ts.map +1 -1
  20. package/dist/commands/shield-templates.js +7 -0
  21. package/dist/commands/shield-templates.js.map +1 -1
  22. package/dist/commands/shield.d.ts +3 -1
  23. package/dist/commands/shield.d.ts.map +1 -1
  24. package/dist/commands/shield.js +17 -9
  25. package/dist/commands/shield.js.map +1 -1
  26. package/dist/commands/spec.d.ts +29 -4
  27. package/dist/commands/spec.d.ts.map +1 -1
  28. package/dist/commands/spec.js +51 -13
  29. package/dist/commands/spec.js.map +1 -1
  30. package/dist/commands/spec.test.js +221 -15
  31. package/dist/commands/spec.test.js.map +1 -1
  32. package/dist/commands/triage-retrieve.test.d.ts +2 -0
  33. package/dist/commands/triage-retrieve.test.d.ts.map +1 -0
  34. package/dist/commands/triage-retrieve.test.js +90 -0
  35. package/dist/commands/triage-retrieve.test.js.map +1 -0
  36. package/dist/commands/triage.d.ts +14 -1
  37. package/dist/commands/triage.d.ts.map +1 -1
  38. package/dist/commands/triage.js +21 -6
  39. package/dist/commands/triage.js.map +1 -1
  40. package/dist/utils.d.ts +5 -5
  41. package/dist/utils.d.ts.map +1 -1
  42. package/dist/utils.js +5 -6
  43. package/dist/utils.js.map +1 -1
  44. package/dist/utils.test.js +42 -48
  45. package/dist/utils.test.js.map +1 -1
  46. package/package.json +2 -2
@@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
6
  import { GROUNDING_ANCHOR_FREE_TEXT, GROUNDING_ANCHOR_ISSUE, GROUNDING_ANCHOR_MIXED, GROUNDING_ANCHOR_RECORD, GroundingAnchorSchema, hasUnrenderableHookChar, PROMPT_SOURCE_BUILTIN, PROMPT_SOURCE_OVERRIDE, TotemConfigError, } from '@mmnto/totem';
7
7
  import { cleanTmpDir } from '../test-utils.js';
8
8
  import { log } from '../ui.js';
9
- import { assemblePrompt, assertOutDoesNotOverwriteRecord, buildRecordSearchQuery, evaluateGroundingFloor, expandSpecQuery, formatGroundingRefusal, isRecordPathOutsideRoot, loadSpecRecord, MAX_LESSON_CHARS, MAX_LESSONS, resolveDefaultSpecPath, resolveGroundingAnchor, retrieveContext, sanitizeSpecFilename, SPEC_SYSTEM_PROMPT, specCommand, validateOutputOptions, validateSpecInvocation, } from './spec.js';
9
+ import { assemblePrompt, assertOutDoesNotOverwriteRecord, buildRecordSearchQuery, evaluateGroundingFloor, expandSpecQuery, formatGroundingRefusal, isRecordPathOutsideRoot, loadSpecRecord, MAX_LESSON_CHARS, MAX_LESSONS, MAX_SPECS, resolveDefaultSpecPath, resolveGroundingAnchor, retrieveContext, sanitizeSpecFilename, SPEC_SEARCH_POOL, SPEC_SYSTEM_PROMPT, specCommand, validateOutputOptions, validateSpecInvocation, } from './spec.js';
10
10
  import { SPEC_REQUIRED_SECTIONS } from './spec-templates.js';
11
11
  // ─── Mocks for the executed `specCommand` suite ─────────
12
12
  //
@@ -18,6 +18,8 @@ import { SPEC_REQUIRED_SECTIONS } from './spec-templates.js';
18
18
  const harness = vi.hoisted(() => ({
19
19
  /** Store hits keyed by the `typeFilter` `retrieveContext` asks for. */
20
20
  searchResults: {},
21
+ /** Every `typeFilter` the store was asked for, in call order (mmnto-ai/totem#2735). */
22
+ searchTypeFilters: [],
21
23
  /** Every `runOrchestrator` invocation, in order — empty means no artifact could exist. */
22
24
  orchestratorArgs: [],
23
25
  /** What the stubbed orchestrator returns as the draft. */
@@ -39,6 +41,7 @@ vi.mock('@mmnto/totem', async () => {
39
41
  harness.connects += 1;
40
42
  }
41
43
  async search({ typeFilter }) {
44
+ harness.searchTypeFilters.push(typeFilter);
42
45
  return harness.searchResults[typeFilter] ?? [];
43
46
  }
44
47
  },
@@ -80,9 +83,11 @@ function makeLesson(overrides = {}) {
80
83
  return {
81
84
  content: '**Tags:** testing\n\nAlways validate input at boundaries.',
82
85
  contextPrefix: 'Totem Lessons > Lesson — Always validate input',
83
- filePath: '.totem/lessons.md',
84
- absoluteFilePath: '.totem/lessons.md',
85
- type: 'spec',
86
+ // The post-mmnto-ai/totem#431 shape: lessons carry their own content type
87
+ // and live under `.totem/lessons/`, one file each.
88
+ filePath: '.totem/lessons/lesson-always-validate-input.md',
89
+ absoluteFilePath: '.totem/lessons/lesson-always-validate-input.md',
90
+ type: 'lesson',
86
91
  label: 'Totem Lessons > Lesson — Always validate input',
87
92
  score: 0.5,
88
93
  metadata: {},
@@ -286,19 +291,100 @@ describe('retrieveContext — cross-totem linked stores', () => {
286
291
  expect(withEmpty.specs.length).toBe(withUndefined.specs.length);
287
292
  });
288
293
  });
289
- describe('retrieveContext partitioning', () => {
290
- it('lessons from lessons.md are separated from regular specs', async () => {
291
- // Simulate what retrieveContext produces: lessons in lessons array, specs in specs array
294
+ // ─── retrieveContext lesson delivery (mmnto-ai/totem#2735) ──
295
+ /** A store that answers per `typeFilter` and records every request it received. */
296
+ function typedStore(rows) {
297
+ const typeFilters = [];
298
+ const requests = [];
299
+ const store = {
300
+ search: async (req) => {
301
+ typeFilters.push(req.typeFilter);
302
+ requests.push({ typeFilter: req.typeFilter, maxResults: req.maxResults });
303
+ return rows[req.typeFilter] ?? [];
304
+ },
305
+ };
306
+ return { store, typeFilters, requests };
307
+ }
308
+ describe('retrieveContext — lessons are their own pool', () => {
309
+ it('delivers a lesson AND a spec when the store holds one of each', async () => {
310
+ const { store, typeFilters } = typedStore({
311
+ lesson: [makeLesson({ type: 'lesson', label: 'Lesson A' })],
312
+ spec: [makeSpec({ label: 'Spec A' })],
313
+ });
314
+ const ctx = await retrieveContext('test query', store);
315
+ // The regression mmnto-ai/totem#431 introduced: lessons were partitioned
316
+ // out of a `spec`-typed pool, so this length was 0 for every run since.
317
+ expect(ctx.lessons.length).toBe(1);
318
+ expect(ctx.lessons[0].label).toBe('Lesson A');
319
+ expect(ctx.specs.length).toBe(1);
320
+ expect(ctx.specs[0].label).toBe('Spec A');
321
+ expect(typeFilters).toContain('lesson');
322
+ });
323
+ it('never asks a LINKED store for lessons — lessons come from the primary only', async () => {
324
+ const primary = typedStore({ lesson: [makeLesson({ type: 'lesson' })], spec: [makeSpec()] });
325
+ const linked = typedStore({ spec: [makeSpec({ label: 'linked' })] });
326
+ const ctx = await retrieveContext('test query', primary.store, [linked.store]);
327
+ expect(ctx.lessons.length).toBe(1);
328
+ expect(primary.typeFilters).toContain('lesson');
329
+ expect(linked.typeFilters).not.toContain('lesson');
330
+ expect(linked.typeFilters).toEqual(['spec']);
331
+ });
332
+ // Request identity is the only thing that CAN pin "the specs delivered are
333
+ // unchanged" against a real store: on the hybrid path the requested width is
334
+ // the RRF fusion window (`packages/core/src/store/lance-search.ts` fetches
335
+ // `maxResults * HYBRID_OVERFETCH_FACTOR` per leg), so a narrower request
336
+ // changes WHICH rows survive fusion, not merely how many are cut.
337
+ it('asks for the spec pool at exactly SPEC_SEARCH_POOL, unchanged by this slice', async () => {
338
+ // The literal is the pin: bound to the constant alone, this test would go
339
+ // green on a change to the constant itself. 20 is the pre-mmnto-ai/totem#2735
340
+ // width, which is the hybrid fusion window.
341
+ expect(SPEC_SEARCH_POOL).toBe(20);
342
+ const { store, requests } = typedStore({ spec: [makeSpec()] });
343
+ await retrieveContext('test query', store);
344
+ const specRequests = requests.filter((r) => r.typeFilter === 'spec');
345
+ expect(specRequests).toEqual([{ typeFilter: 'spec', maxResults: SPEC_SEARCH_POOL }]);
346
+ });
347
+ it('asks a LINKED store for specs at the delivery cap, unchanged by this slice', async () => {
348
+ const primary = typedStore({ spec: [makeSpec()] });
349
+ const linked = typedStore({ spec: [makeSpec({ label: 'linked' })] });
350
+ await retrieveContext('test query', primary.store, [linked.store]);
351
+ expect(linked.requests).toEqual([{ typeFilter: 'spec', maxResults: MAX_SPECS }]);
352
+ });
353
+ it('delivers the top-MAX_SPECS specs by score, in score order', async () => {
354
+ const scores = [0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3];
355
+ expect(scores.length).toBeGreaterThan(MAX_SPECS);
356
+ const { store } = typedStore({
357
+ spec: scores.map((score, i) => makeSpec({ label: `Spec ${i}`, score })),
358
+ });
359
+ const ctx = await retrieveContext('test query', store);
360
+ expect(ctx.specs.length).toBe(MAX_SPECS);
361
+ expect(ctx.specs.map((s) => s.score)).toEqual(scores.slice(0, MAX_SPECS));
362
+ });
363
+ it('delivers the top-MAX_SPECS by score ACROSS the primary and a linked store', async () => {
364
+ const primary = typedStore({
365
+ spec: [0.9, 0.7, 0.5, 0.3].map((score, i) => makeSpec({ label: `P${i}`, score })),
366
+ });
367
+ const linked = typedStore({
368
+ spec: [0.8, 0.6, 0.4, 0.2].map((score, i) => makeSpec({ label: `L${i}`, score })),
369
+ });
370
+ const ctx = await retrieveContext('test query', primary.store, [linked.store]);
371
+ expect(ctx.specs.length).toBe(MAX_SPECS);
372
+ expect(ctx.specs.map((s) => s.score)).toEqual([0.9, 0.8, 0.7, 0.6, 0.5]);
373
+ });
374
+ });
375
+ describe('assemblePrompt — lessons render in their own section', () => {
376
+ it('a lesson never appears inside the RELATED SPECS & ADRs section', async () => {
292
377
  const ctx = {
293
378
  specs: [makeSpec({ filePath: 'docs/reference/architecture.md' })],
294
379
  sessions: [],
295
380
  code: [],
296
- lessons: [makeLesson({ filePath: '.totem/lessons.md' })],
381
+ lessons: [makeLesson({ type: 'lesson', filePath: '.totem/lessons/lesson-abc.md' })],
297
382
  };
298
383
  const result = await assemblePrompt([{ issue: null, freeText: 'test', record: null }], ctx, 'system prompt');
299
384
  // Lessons appear in their own section, not mixed with specs
300
385
  const specSection = result.split('RELATED SPECS & ADRs')[1]?.split('===')[0] ?? '';
301
- expect(specSection).not.toContain('lessons.md');
386
+ expect(specSection).not.toContain('.totem/lessons/');
387
+ expect(result).toContain('=== RELEVANT LESSONS (HARD CONSTRAINTS) ===');
302
388
  });
303
389
  });
304
390
  // ─── expandSpecQuery (#1016) ────────────────────────────
@@ -587,6 +673,8 @@ function relevantHit(relevance, overrides = {}) {
587
673
  });
588
674
  }
589
675
  const FLOOR = 0.25;
676
+ /** The floor's PLACE exactly as a refusal renders it (`FLOOR_PLACE` in spec.ts). */
677
+ const FLOOR_PLACE_TEXT = 'searchRelevanceFloor in totem.config.ts (schema default 0.25 when unset)';
590
678
  describe('evaluateGroundingFloor', () => {
591
679
  it('0 retrieved items REFUSES — nothing grounds the run (the charter rule, not an MCP mirror)', () => {
592
680
  const verdict = evaluateGroundingFloor(emptyContext(), FLOOR);
@@ -616,6 +704,42 @@ describe('evaluateGroundingFloor', () => {
616
704
  expect(verdict.floorExempt).toBe(1);
617
705
  expect(verdict.withheld).toEqual([]);
618
706
  });
707
+ // Lessons are DELIVERED but never judged by the floor (mmnto-ai/totem#2735).
708
+ // The gate was ruled over the spec/session/code partitions while the lesson
709
+ // partition was structurally empty; feeding a now-populated one in would
710
+ // silently loosen the refusal arm, since an FTS-only lesson is floor-EXEMPT
711
+ // and `refuse` requires `floorExempt === 0`.
712
+ it('a run whose ONLY retrieved item is an FTS-only lesson REFUSES as 0 hits', () => {
713
+ const verdict = evaluateGroundingFloor({ ...emptyContext(), lessons: [relevantHit(undefined, { type: 'lesson' })] }, FLOOR);
714
+ expect(verdict.refuse).toBe(true);
715
+ expect(verdict.hits).toBe(0);
716
+ expect(verdict.floorExempt).toBe(0);
717
+ expect(verdict.bestRelevance).toBeNull();
718
+ });
719
+ it('a below-floor spec beside an FTS-only lesson still REFUSES (a lesson is not floor-exempt evidence)', () => {
720
+ const verdict = evaluateGroundingFloor({
721
+ ...emptyContext(),
722
+ specs: [relevantHit(0.05, { filePath: 'docs/weak.md' })],
723
+ lessons: [relevantHit(undefined, { type: 'lesson' })],
724
+ }, FLOOR);
725
+ expect(verdict.refuse).toBe(true);
726
+ expect(verdict.hits).toBe(1);
727
+ expect(verdict.floorExempt).toBe(0);
728
+ expect(verdict.withheld).toEqual([{ filePath: 'docs/weak.md', relevance: 0.05 }]);
729
+ });
730
+ it('an AT-floor spec beside lessons PROCEEDS, and the lessons are not counted as hits', () => {
731
+ const verdict = evaluateGroundingFloor({
732
+ ...emptyContext(),
733
+ specs: [relevantHit(FLOOR)],
734
+ lessons: [relevantHit(undefined, { type: 'lesson' }), relevantHit(0.9, { type: 'lesson' })],
735
+ }, FLOOR);
736
+ // Delivery is not the gate: `retrieveContext` still returns those lessons
737
+ // (see the lesson-delivery suite) and the executed-command suite proves
738
+ // they reach the prompt. The floor simply never reads them.
739
+ expect(verdict.refuse).toBe(false);
740
+ expect(verdict.hits).toBe(1);
741
+ expect(verdict.bestRelevance).toBeCloseTo(FLOOR, 10);
742
+ });
619
743
  it('no relevance anywhere PROCEEDS — a pure-FTS corpus is never demoted', () => {
620
744
  const verdict = evaluateGroundingFloor({ ...emptyContext(), specs: [relevantHit(undefined), relevantHit(undefined)] }, FLOOR);
621
745
  expect(verdict.refuse).toBe(false);
@@ -642,14 +766,19 @@ describe('evaluateGroundingFloor', () => {
642
766
  expect(verdict.refuse).toBe(false);
643
767
  expect(verdict.withheld).toEqual([]);
644
768
  });
645
- it('counts hits across ALL FOUR partitions', () => {
769
+ // Was "counts hits across ALL FOUR partitions" when the lesson partition was
770
+ // structurally empty and could not change the count. mmnto-ai/totem#2735
771
+ // makes it populated, so the gate holds its ruled inputs: the three
772
+ // partitions it was exercised over. A delivered lesson is counted on the
773
+ // `Found:` line and in the artifact, never as a grounding hit.
774
+ it('counts hits across the spec, session and code partitions — never lessons', () => {
646
775
  const verdict = evaluateGroundingFloor({
647
776
  specs: [relevantHit(0.9)],
648
777
  sessions: [relevantHit(0.8)],
649
778
  code: [relevantHit(0.7)],
650
- lessons: [relevantHit(0.6)],
779
+ lessons: [relevantHit(0.6, { type: 'lesson' })],
651
780
  }, FLOOR);
652
- expect(verdict.hits).toBe(4);
781
+ expect(verdict.hits).toBe(3);
653
782
  expect(verdict.bestRelevance).toBeCloseTo(0.9, 10);
654
783
  });
655
784
  it('the withheld list carries every below-floor candidate as path + relevance (linked hits keep their store)', () => {
@@ -670,11 +799,57 @@ describe('evaluateGroundingFloor', () => {
670
799
  describe('formatGroundingRefusal', () => {
671
800
  it('a 0-hit refusal names the topic, the 0 hits, and the floor VALUE and PLACE', () => {
672
801
  const verdict = evaluateGroundingFloor(emptyContext(), FLOOR);
673
- const { message } = formatGroundingRefusal('an-unanchored-slug', verdict, FLOOR);
802
+ const { message } = formatGroundingRefusal('an-unanchored-slug', verdict, FLOOR, 0);
674
803
  expect(message).toContain('an-unanchored-slug');
675
804
  expect(message).toContain('0 hits');
676
805
  expect(message).toContain('floor 0.250 — searchRelevanceFloor in totem.config.ts (schema default 0.25 when unset)');
677
806
  });
807
+ // With lessons delivered, "nothing in the index grounds this run" sits beside
808
+ // a `Found: … N lessons` line. The message names the contradiction rather
809
+ // than leaving the reader to reconcile the two (mmnto-ai/totem#2735).
810
+ it('a 0-hit refusal on a lesson-holding index names the lessons it did not judge', () => {
811
+ // The seeded count IS the delivered count — the invariant production holds,
812
+ // where the caller passes `context.lessons.length`.
813
+ const lessons = [makeLesson(), makeLesson()];
814
+ const verdict = evaluateGroundingFloor({ ...emptyContext(), lessons }, FLOOR);
815
+ expect(verdict.hits).toBe(0);
816
+ const { message } = formatGroundingRefusal('an-unanchored-slug', verdict, FLOOR, lessons.length);
817
+ expect(message).toBe([
818
+ 'Refusing to draft an unanchored spec for topic(s): an-unanchored-slug.',
819
+ 'Retrieval returned 0 grounding hits (specs, sessions, code) — nothing in the index grounds this run.',
820
+ '2 lessons were retrieved, but lessons do not ground a run (mmnto-ai/totem#2727 rules whether they may).',
821
+ `floor 0.250 — ${FLOOR_PLACE_TEXT}`,
822
+ ].join('\n'));
823
+ });
824
+ it('a single delivered lesson reads as one, not as "1 lessons"', () => {
825
+ const verdict = evaluateGroundingFloor({ ...emptyContext(), lessons: [makeLesson()] }, FLOOR);
826
+ const { message } = formatGroundingRefusal('an-unanchored-slug', verdict, FLOOR, 1);
827
+ expect(message).toContain('1 lesson was retrieved, but lessons do not ground a run (mmnto-ai/totem#2727 rules whether they may).');
828
+ });
829
+ // Pinned against the LITERAL pre-mmnto-ai/totem#2735 message, not against a
830
+ // sibling call: the guarantee is that this text did not move, and only an
831
+ // exact comparison with the old bytes can say so.
832
+ it('with NO lessons delivered the 0-hit message is the pre-fold text, byte for byte', () => {
833
+ const verdict = evaluateGroundingFloor(emptyContext(), FLOOR);
834
+ const { message } = formatGroundingRefusal('an-unanchored-slug', verdict, FLOOR, 0);
835
+ expect(message).toBe([
836
+ 'Refusing to draft an unanchored spec for topic(s): an-unanchored-slug.',
837
+ 'Retrieval returned 0 hits — nothing in the index grounds this run.',
838
+ `floor 0.250 — ${FLOOR_PLACE_TEXT}`,
839
+ ].join('\n'));
840
+ });
841
+ it('the below-floor message is the pre-fold text whether or not lessons were delivered', () => {
842
+ const verdict = evaluateGroundingFloor({ ...emptyContext(), specs: [relevantHit(0.2, { filePath: 'docs/a.md' })] }, FLOOR);
843
+ const expected = [
844
+ 'Refusing to draft an unanchored spec for topic(s): weak topic.',
845
+ 'Retrieval returned 1 hits, but best relevance 0.200 is below the floor.',
846
+ `floor 0.250 — ${FLOOR_PLACE_TEXT}`,
847
+ 'Withheld candidates (path + relevance only, no content):',
848
+ '1. docs/a.md — relevance 0.200',
849
+ ].join('\n');
850
+ expect(formatGroundingRefusal('weak topic', verdict, FLOOR, 10).message).toBe(expected);
851
+ expect(formatGroundingRefusal('weak topic', verdict, FLOOR, 0).message).toBe(expected);
852
+ });
678
853
  it('a below-floor refusal names the best relevance and DISCLOSES every withheld candidate', () => {
679
854
  const verdict = evaluateGroundingFloor({
680
855
  ...emptyContext(),
@@ -683,13 +858,13 @@ describe('formatGroundingRefusal', () => {
683
858
  relevantHit(0.1, { filePath: 'doctrine/b.md', sourceRepo: 'strategy' }),
684
859
  ],
685
860
  }, FLOOR);
686
- const { message } = formatGroundingRefusal('weak topic', verdict, FLOOR);
861
+ const { message } = formatGroundingRefusal('weak topic', verdict, FLOOR, 0);
687
862
  expect(message).toContain('best relevance 0.200');
688
863
  expect(message).toContain('1. docs/a.md — relevance 0.200');
689
864
  expect(message).toContain('2. [strategy] doctrine/b.md — relevance 0.100');
690
865
  });
691
866
  it('the hint names both cures and the --raw inspection path', () => {
692
- const { recoveryHint } = formatGroundingRefusal('topic', evaluateGroundingFloor(emptyContext(), FLOOR), FLOOR);
867
+ const { recoveryHint } = formatGroundingRefusal('topic', evaluateGroundingFloor(emptyContext(), FLOOR), FLOOR, 0);
693
868
  expect(recoveryHint).toContain('totem spec <issue>');
694
869
  expect(recoveryHint).toContain('totem spec --from <record>');
695
870
  expect(recoveryHint).toContain('--raw');
@@ -1063,6 +1238,7 @@ describe('specCommand — anchored evidence, executed against stubbed seams', ()
1063
1238
  originalCwd = process.cwd();
1064
1239
  process.chdir(tmpDir);
1065
1240
  harness.searchResults = {};
1241
+ harness.searchTypeFilters = [];
1066
1242
  harness.orchestratorArgs = [];
1067
1243
  harness.orchestratorContent = 'DRAFT';
1068
1244
  harness.connects = 0;
@@ -1126,6 +1302,27 @@ describe('specCommand — anchored evidence, executed against stubbed seams', ()
1126
1302
  expect(harness.orchestratorArgs).toEqual([]);
1127
1303
  expect(runArtifactNames()).toEqual([]);
1128
1304
  });
1305
+ it('asks the store for lessons and carries them into the prompt (mmnto-ai/totem#2735)', async () => {
1306
+ harness.searchResults = {
1307
+ spec: [relevantHit(0.7)],
1308
+ lesson: [
1309
+ relevantHit(0.7, {
1310
+ type: 'lesson',
1311
+ label: 'Lesson A',
1312
+ filePath: '.totem/lessons/lesson-abc.md',
1313
+ content: 'Always validate input at boundaries.',
1314
+ }),
1315
+ ],
1316
+ };
1317
+ await specCommand(['2735'], { stdout: true });
1318
+ expect(harness.searchTypeFilters).toContain('lesson');
1319
+ const prompt = String(harness.orchestratorArgs[0]['prompt']);
1320
+ // The real section header (`formatLessonSection` in utils.ts), not the bare
1321
+ // phrase — the system prompt carries "RELEVANT LESSONS" on its own, so a
1322
+ // `toContain` on that alone passes with zero lessons delivered.
1323
+ expect(prompt).toContain('=== RELEVANT LESSONS (HARD CONSTRAINTS) ===');
1324
+ expect(prompt).toContain('Always validate input at boundaries.');
1325
+ });
1129
1326
  it('a --from run anchors on the record, leaves its bytes UNCHANGED, and drafts to stdout', async () => {
1130
1327
  const record = writeRecord('# Design record\n\nThe ruled contract.\n');
1131
1328
  const before = sha256Of(record);
@@ -1202,6 +1399,15 @@ describe('specCommand — anchored evidence, executed against stubbed seams', ()
1202
1399
  await expect(specCommand(['nonsense slug'], { stdout: true })).rejects.toThrowError(/Retrieval returned 0 hits/);
1203
1400
  expect(harness.orchestratorArgs).toEqual([]);
1204
1401
  });
1402
+ // The WIRING, not the formatter: this is the only test that fails if the
1403
+ // caller stops passing `context.lessons.length` (a literal 0 there leaves
1404
+ // every formatter-level test green). One delivered lesson, no spec/session/
1405
+ // code rows — the count in the refusal has to come from the delivered pool.
1406
+ it('a free-text run with 0 grounding hits carries the DELIVERED lesson count into the refusal', async () => {
1407
+ harness.searchResults = { lesson: [makeLesson()] };
1408
+ await expect(specCommand(['nonsense slug'], { stdout: true })).rejects.toThrowError(/1 lesson was retrieved, but lessons do not ground a run/);
1409
+ expect(harness.orchestratorArgs).toEqual([]);
1410
+ });
1205
1411
  it('a free-text run entirely below the floor REFUSES, naming the floor and every withheld candidate', async () => {
1206
1412
  harness.searchResults = { spec: [relevantHit(0.1, { filePath: 'docs/a.md' })] };
1207
1413
  let thrown;