@mmnto/cli 1.123.0 → 2.0.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 (71) hide show
  1. package/dist/commands/doctor.d.ts +16 -10
  2. package/dist/commands/doctor.d.ts.map +1 -1
  3. package/dist/commands/doctor.js +162 -54
  4. package/dist/commands/doctor.js.map +1 -1
  5. package/dist/commands/doctor.test.js +444 -28
  6. package/dist/commands/doctor.test.js.map +1 -1
  7. package/dist/commands/extract-shared.d.ts.map +1 -1
  8. package/dist/commands/extract-shared.js +2 -6
  9. package/dist/commands/extract-shared.js.map +1 -1
  10. package/dist/commands/extract.test.js +19 -1
  11. package/dist/commands/extract.test.js.map +1 -1
  12. package/dist/commands/hook-totemdir-render.test.js +55 -1
  13. package/dist/commands/hook-totemdir-render.test.js.map +1 -1
  14. package/dist/commands/init.d.ts.map +1 -1
  15. package/dist/commands/init.js +39 -0
  16. package/dist/commands/init.js.map +1 -1
  17. package/dist/commands/init.test.js +32 -0
  18. package/dist/commands/init.test.js.map +1 -1
  19. package/dist/commands/install-hooks-exit-contract.test.js +32 -1
  20. package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
  21. package/dist/commands/install-hooks.d.ts +143 -10
  22. package/dist/commands/install-hooks.d.ts.map +1 -1
  23. package/dist/commands/install-hooks.js +441 -69
  24. package/dist/commands/install-hooks.js.map +1 -1
  25. package/dist/commands/install-hooks.test.js +1075 -7
  26. package/dist/commands/install-hooks.test.js.map +1 -1
  27. package/dist/commands/review-learn.d.ts +3 -0
  28. package/dist/commands/review-learn.d.ts.map +1 -1
  29. package/dist/commands/review-learn.js +4 -7
  30. package/dist/commands/review-learn.js.map +1 -1
  31. package/dist/commands/review-learn.test.js +19 -2
  32. package/dist/commands/review-learn.test.js.map +1 -1
  33. package/dist/commands/shield-learn.test.js +27 -5
  34. package/dist/commands/shield-learn.test.js.map +1 -1
  35. package/dist/commands/shield-retrieve.test.d.ts +2 -0
  36. package/dist/commands/shield-retrieve.test.d.ts.map +1 -0
  37. package/dist/commands/shield-retrieve.test.js +91 -0
  38. package/dist/commands/shield-retrieve.test.js.map +1 -0
  39. package/dist/commands/shield-templates.d.ts +7 -0
  40. package/dist/commands/shield-templates.d.ts.map +1 -1
  41. package/dist/commands/shield-templates.js +7 -0
  42. package/dist/commands/shield-templates.js.map +1 -1
  43. package/dist/commands/shield.d.ts +3 -1
  44. package/dist/commands/shield.d.ts.map +1 -1
  45. package/dist/commands/shield.js +17 -9
  46. package/dist/commands/shield.js.map +1 -1
  47. package/dist/commands/spec-templates.d.ts +17 -4
  48. package/dist/commands/spec-templates.d.ts.map +1 -1
  49. package/dist/commands/spec-templates.js +23 -3
  50. package/dist/commands/spec-templates.js.map +1 -1
  51. package/dist/commands/spec.d.ts +84 -10
  52. package/dist/commands/spec.d.ts.map +1 -1
  53. package/dist/commands/spec.js +169 -35
  54. package/dist/commands/spec.js.map +1 -1
  55. package/dist/commands/spec.test.js +487 -48
  56. package/dist/commands/spec.test.js.map +1 -1
  57. package/dist/commands/triage-retrieve.test.d.ts +2 -0
  58. package/dist/commands/triage-retrieve.test.d.ts.map +1 -0
  59. package/dist/commands/triage-retrieve.test.js +90 -0
  60. package/dist/commands/triage-retrieve.test.js.map +1 -0
  61. package/dist/commands/triage.d.ts +14 -1
  62. package/dist/commands/triage.d.ts.map +1 -1
  63. package/dist/commands/triage.js +21 -6
  64. package/dist/commands/triage.js.map +1 -1
  65. package/dist/utils.d.ts +10 -8
  66. package/dist/utils.d.ts.map +1 -1
  67. package/dist/utils.js +5 -6
  68. package/dist/utils.js.map +1 -1
  69. package/dist/utils.test.js +50 -51
  70. package/dist/utils.test.js.map +1 -1
  71. package/package.json +2 -2
@@ -3,10 +3,10 @@ import * as fs from 'node:fs';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
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';
6
+ import { GROUNDING_ANCHOR_FREE_TEXT, GROUNDING_ANCHOR_ISSUE, GROUNDING_ANCHOR_MIXED, GROUNDING_ANCHOR_RECORD, GroundingAnchorSchema, hasUnrenderableHeadingChar, isRelevanceInRange, 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) ────────────────────────────
@@ -445,19 +531,26 @@ describe('SPEC_REQUIRED_SECTIONS', () => {
445
531
  expect(promptLines, `${section} is not a line of SPEC_SYSTEM_PROMPT`).toContain(section);
446
532
  }
447
533
  });
448
- it('every entry is renderable into the single-quoted node -e reader (the mmnto-ai/totem#2692 C4 predicate)', () => {
534
+ it('every entry is renderable into the single-quoted node -e reader as a HEADING (mmnto-ai/totem#2737)', () => {
449
535
  for (const section of SPEC_REQUIRED_SECTIONS) {
450
- // A quote, backslash, dollar, backtick, control character or non-ASCII
451
- // byte would break the `sh` single-quoted word, the JS string literal
452
- // inside it, or both and could forge hook lines.
453
- expect(hasUnrenderableHookChar(section), `${section} cannot be rendered`).toBe(false);
536
+ // A quote, backslash, dollar, backtick or control character would break
537
+ // the `sh` single-quoted word, the JS string literal inside it, or both —
538
+ // and could forge hook lines. Printable non-ASCII could not: the heading
539
+ // predicate permits it where the PATH predicate cannot, because git
540
+ // C-quotes path bytes above 0x7e in the `diff --name-only` output the
541
+ // hooks' `grep -q` filters read and a heading meets no such filter. The
542
+ // executed proof that the em dash survives the render is the frozen
543
+ // falsifier in install-hooks.test.ts, over the four schema-constrained R3
544
+ // drafts that carry the Verification heading byte-identical.
545
+ expect(hasUnrenderableHeadingChar(section), `${section} cannot be rendered`).toBe(false);
454
546
  }
455
547
  });
456
- it('names both promised sections, in prompt order', () => {
457
- expect([...SPEC_REQUIRED_SECTIONS]).toEqual([
458
- '### Problem Statement',
459
- '### Implementation Tasks',
460
- ]);
548
+ it('names all nine promised sections, in prompt order', () => {
549
+ // Copied from SPEC_SYSTEM_PROMPT rather than retyped, so the em dash in the
550
+ // Verification heading can never enter this file as a look-alike byte
551
+ // (the mmnto-ai/totem#2692 authoring trap).
552
+ expect([...SPEC_REQUIRED_SECTIONS]).toEqual(SPEC_SYSTEM_PROMPT.split('\n').filter((line) => line.startsWith('### ')));
553
+ expect(SPEC_REQUIRED_SECTIONS).toHaveLength(9);
461
554
  });
462
555
  });
463
556
  // ─── resolveGroundingAnchor (mmnto-ai/totem#2700) ────────
@@ -587,73 +680,210 @@ function relevantHit(relevance, overrides = {}) {
587
680
  });
588
681
  }
589
682
  const FLOOR = 0.25;
683
+ /** The floor's PLACE exactly as a refusal renders it (`FLOOR_PLACE` in spec.ts). */
684
+ const FLOOR_PLACE_TEXT = 'searchRelevanceFloor in totem.config.ts';
685
+ /** The whole floor line when NO floor is configured (`FLOOR_LINE_UNSET` in spec.ts, mmnto-ai/totem#2727). */
686
+ const FLOOR_LINE_UNSET_TEXT = 'floor none — searchRelevanceFloor unset in totem.config.ts (no default; calibrate per repo — see config-reference)';
687
+ /**
688
+ * The refusal's floor LINE, whole. A `toContain` of the value-and-place text
689
+ * alone is satisfied by a line that appends MORE after it — which is exactly
690
+ * the retired "(schema default 0.25 when unset)" suffix — so the pins that
691
+ * guard that suffix's removal compare the entire line, not a prefix of it
692
+ * (mmnto-ai/totem#2727 fold, F3).
693
+ */
694
+ function floorLineOf(message) {
695
+ const line = message.split('\n').find((candidate) => candidate.startsWith('floor '));
696
+ if (line === undefined)
697
+ throw new Error(`no floor line in refusal message:\n${message}`);
698
+ return line;
699
+ }
700
+ /**
701
+ * Every floor call below injects the SAME predicate production injects
702
+ * (mmnto-ai/totem#2738 fold 2, F2): core's `isRelevanceInRange`, the one the
703
+ * grounding bundle builder omits on. The predicate is a PARAMETER in
704
+ * production because a static value import from `@mmnto/totem` inside
705
+ * `commands/**` pulls LanceDB into every CLI startup (mmnto-ai/totem#2339);
706
+ * tests may import it statically — that rule excludes `**\/*.test.ts`.
707
+ *
708
+ * The predicate precedes `floor` in the real signature so mmnto-ai/totem#2758
709
+ * can make `floor` optional without a required-after-optional parameter.
710
+ */
711
+ function evaluateFloor(context, floor) {
712
+ return evaluateGroundingFloor(context, isRelevanceInRange, floor);
713
+ }
590
714
  describe('evaluateGroundingFloor', () => {
591
715
  it('0 retrieved items REFUSES — nothing grounds the run (the charter rule, not an MCP mirror)', () => {
592
- const verdict = evaluateGroundingFloor(emptyContext(), FLOOR);
716
+ const verdict = evaluateFloor(emptyContext(), FLOOR);
593
717
  expect(verdict).toEqual({
594
718
  refuse: true,
595
719
  hits: 0,
596
720
  bestRelevance: null,
597
721
  withheld: [],
598
722
  floorExempt: 0,
723
+ faulted: 0,
599
724
  });
600
725
  });
601
726
  it('every signal-bearing hit below the floor, none exempt, REFUSES', () => {
602
- const verdict = evaluateGroundingFloor({ ...emptyContext(), specs: [relevantHit(0.2), relevantHit(0.11)] }, FLOOR);
727
+ const verdict = evaluateFloor({ ...emptyContext(), specs: [relevantHit(0.2), relevantHit(0.11)] }, FLOOR);
603
728
  expect(verdict.refuse).toBe(true);
604
729
  expect(verdict.hits).toBe(2);
605
730
  expect(verdict.bestRelevance).toBeCloseTo(0.2, 10);
606
731
  expect(verdict.floorExempt).toBe(0);
607
732
  });
608
733
  it('one hit AT the floor PROCEEDS (the floor is inclusive)', () => {
609
- const verdict = evaluateGroundingFloor({ ...emptyContext(), specs: [relevantHit(0.1), relevantHit(FLOOR)] }, FLOOR);
734
+ const verdict = evaluateFloor({ ...emptyContext(), specs: [relevantHit(0.1), relevantHit(FLOOR)] }, FLOOR);
610
735
  expect(verdict.refuse).toBe(false);
611
736
  expect(verdict.withheld).toEqual([]);
612
737
  });
613
738
  it('one floor-EXEMPT hit beside below-floor signal PROCEEDS (a keyword-only hit is never withheld for a weak sibling)', () => {
614
- const verdict = evaluateGroundingFloor({ ...emptyContext(), specs: [relevantHit(0.05)], code: [relevantHit(undefined)] }, FLOOR);
739
+ const verdict = evaluateFloor({ ...emptyContext(), specs: [relevantHit(0.05)], code: [relevantHit(undefined)] }, FLOOR);
615
740
  expect(verdict.refuse).toBe(false);
616
741
  expect(verdict.floorExempt).toBe(1);
617
742
  expect(verdict.withheld).toEqual([]);
618
743
  });
744
+ // Lessons are DELIVERED but never judged by the floor (mmnto-ai/totem#2735).
745
+ // The gate was ruled over the spec/session/code partitions while the lesson
746
+ // partition was structurally empty; feeding a now-populated one in would
747
+ // silently loosen the refusal arm, since an FTS-only lesson is floor-EXEMPT
748
+ // and `refuse` requires `floorExempt === 0`.
749
+ it('a run whose ONLY retrieved item is an FTS-only lesson REFUSES as 0 hits', () => {
750
+ const verdict = evaluateFloor({ ...emptyContext(), lessons: [relevantHit(undefined, { type: 'lesson' })] }, FLOOR);
751
+ expect(verdict.refuse).toBe(true);
752
+ expect(verdict.hits).toBe(0);
753
+ expect(verdict.floorExempt).toBe(0);
754
+ expect(verdict.bestRelevance).toBeNull();
755
+ });
756
+ it('a below-floor spec beside an FTS-only lesson still REFUSES (a lesson is not floor-exempt evidence)', () => {
757
+ const verdict = evaluateFloor({
758
+ ...emptyContext(),
759
+ specs: [relevantHit(0.05, { filePath: 'docs/weak.md' })],
760
+ lessons: [relevantHit(undefined, { type: 'lesson' })],
761
+ }, FLOOR);
762
+ expect(verdict.refuse).toBe(true);
763
+ expect(verdict.hits).toBe(1);
764
+ expect(verdict.floorExempt).toBe(0);
765
+ expect(verdict.withheld).toEqual([{ filePath: 'docs/weak.md', relevance: 0.05 }]);
766
+ });
767
+ it('an AT-floor spec beside lessons PROCEEDS, and the lessons are not counted as hits', () => {
768
+ const verdict = evaluateFloor({
769
+ ...emptyContext(),
770
+ specs: [relevantHit(FLOOR)],
771
+ lessons: [relevantHit(undefined, { type: 'lesson' }), relevantHit(0.9, { type: 'lesson' })],
772
+ }, FLOOR);
773
+ // Delivery is not the gate: `retrieveContext` still returns those lessons
774
+ // (see the lesson-delivery suite) and the executed-command suite proves
775
+ // they reach the prompt. The floor simply never reads them.
776
+ expect(verdict.refuse).toBe(false);
777
+ expect(verdict.hits).toBe(1);
778
+ expect(verdict.bestRelevance).toBeCloseTo(FLOOR, 10);
779
+ });
619
780
  it('no relevance anywhere PROCEEDS — a pure-FTS corpus is never demoted', () => {
620
- const verdict = evaluateGroundingFloor({ ...emptyContext(), specs: [relevantHit(undefined), relevantHit(undefined)] }, FLOOR);
781
+ const verdict = evaluateFloor({ ...emptyContext(), specs: [relevantHit(undefined), relevantHit(undefined)] }, FLOOR);
621
782
  expect(verdict.refuse).toBe(false);
622
783
  expect(verdict.bestRelevance).toBeNull();
623
784
  expect(verdict.floorExempt).toBe(2);
624
785
  });
625
- // A non-finite relevance is what the CORE builder drops: the item it writes
626
- // carries no relevance at all, exactly an FTS-only hit's shape. The floor
627
- // must read it the same way, or the artifact and the judgment disagree.
628
- it.each([NaN, Infinity, -Infinity])('a non-finite relevance (%s) is floor-EXEMPT, never counted as signal', (relevance) => {
629
- const verdict = evaluateGroundingFloor({ ...emptyContext(), specs: [relevantHit(relevance)] }, FLOOR);
630
- expect(verdict.floorExempt).toBe(1);
786
+ // A relevance the CORE builder drops is one the floor must not read as
787
+ // signal: the item it writes carries no relevance at all, so counting it as
788
+ // a measurement would make the judgment and the artifact disagree about the
789
+ // same hit. The predicate is literally shared `isRelevanceInRange` from
790
+ // `@mmnto/totem` (mmnto-ai/totem#2738 fold 2, F2) which covers non-finite
791
+ // values AND finite ones outside [0, 1]. The range arm matters: before it, a
792
+ // relevance of `2` (a negative `_distance` under l2) set `bestRelevance`
793
+ // above the floor and DEFEATED the refusal, while the artifact write threw
794
+ // on that same value.
795
+ //
796
+ // But a fault is not a keyword-only hit either (the mmnto-ai/totem#2761 bot
797
+ // round, Greptile P1): counting it EXEMPT let it save a run. It is FAULTED —
798
+ // neither signal nor exemption — and a run whose every hit is faulted has
799
+ // nothing usable and refuses.
800
+ it.each([NaN, Infinity, -Infinity, 2, -1e-16])('a relevance outside [0, 1] (%s) is FAULTED — not signal, not exempt — and alone it REFUSES', (relevance) => {
801
+ const verdict = evaluateFloor({ ...emptyContext(), specs: [relevantHit(relevance)] }, FLOOR);
802
+ expect(verdict.faulted).toBe(1);
803
+ expect(verdict.floorExempt).toBe(0);
631
804
  expect(verdict.bestRelevance).toBeNull();
632
805
  expect(verdict.withheld).toEqual([]);
633
- expect(verdict.refuse).toBe(false);
806
+ expect(verdict.refuse).toBe(true);
807
+ });
808
+ it('a NEGATIVE relevance beside a weak one does NOT save the run — a fault is not evidence (Greptile P1)', () => {
809
+ // The build's earlier reading counted the fault as exempt, so this context
810
+ // proceeded into synthesis on the strength of an SDK fault. It refuses: the
811
+ // weak sibling is the only signal, it is below the floor, and nothing
812
+ // legitimately exempts it. The fault is disclosed by count, never withheld
813
+ // as a measurement.
814
+ const verdict = evaluateFloor({
815
+ ...emptyContext(),
816
+ specs: [relevantHit(0.05, { filePath: 'docs/weak.md' }), relevantHit(-0.5)],
817
+ }, FLOOR);
818
+ expect(verdict.refuse).toBe(true);
819
+ expect(verdict.faulted).toBe(1);
820
+ expect(verdict.floorExempt).toBe(0);
821
+ expect(verdict.bestRelevance).toBeCloseTo(0.05, 10);
822
+ expect(verdict.withheld).toEqual([{ filePath: 'docs/weak.md', relevance: 0.05 }]);
634
823
  });
635
- it('a NaN hit beside a genuinely weak one is not disclosed as a withheld candidate', () => {
636
- const verdict = evaluateGroundingFloor({
824
+ it('a NaN hit beside a genuinely weak one is not disclosed as a withheld candidate, and does not save the run', () => {
825
+ const verdict = evaluateFloor({
637
826
  ...emptyContext(),
638
827
  specs: [relevantHit(0.05, { filePath: 'docs/weak.md' }), relevantHit(NaN)],
639
828
  }, FLOOR);
640
- // One exempt hit is enough to proceed — and nothing is withheld.
641
- expect(verdict.floorExempt).toBe(1);
829
+ expect(verdict.faulted).toBe(1);
830
+ expect(verdict.floorExempt).toBe(0);
831
+ expect(verdict.refuse).toBe(true);
832
+ expect(verdict.withheld).toEqual([{ filePath: 'docs/weak.md', relevance: 0.05 }]);
833
+ });
834
+ it('a fault beside a hit AT or above the floor PROCEEDS on the real signal', () => {
835
+ const verdict = evaluateFloor({ ...emptyContext(), specs: [relevantHit(-0.5), relevantHit(0.6)] }, FLOOR);
642
836
  expect(verdict.refuse).toBe(false);
837
+ expect(verdict.faulted).toBe(1);
838
+ expect(verdict.bestRelevance).toBeCloseTo(0.6, 10);
643
839
  expect(verdict.withheld).toEqual([]);
644
840
  });
645
- it('counts hits across ALL FOUR partitions', () => {
646
- const verdict = evaluateGroundingFloor({
841
+ it('a fault beside a keyword-only hit PROCEEDS — the exemption is the keyword hit, never the fault', () => {
842
+ const verdict = evaluateFloor({ ...emptyContext(), specs: [relevantHit(-0.5)], code: [relevantHit(undefined)] }, FLOOR);
843
+ expect(verdict.refuse).toBe(false);
844
+ expect(verdict.faulted).toBe(1);
845
+ expect(verdict.floorExempt).toBe(1);
846
+ expect(verdict.bestRelevance).toBeNull();
847
+ });
848
+ it('the refusal names an all-faulted retrieval and a faulted count beside a weak signal', () => {
849
+ const allFaulted = evaluateFloor({ ...emptyContext(), specs: [relevantHit(-0.5), relevantHit(2)] }, FLOOR);
850
+ expect(allFaulted.refuse).toBe(true);
851
+ const { message: allFaultedText } = formatGroundingRefusal('weak topic', allFaulted, FLOOR, 0);
852
+ expect(allFaultedText).toContain('Retrieval returned 2 hits, but every one carried a relevance that is not a finite number in [0, 1]');
853
+ expect(allFaultedText).toContain('nothing usable grounds this run');
854
+ // The cause is the search layer's to name (metric-specific); the refusal must
855
+ // not assert an l2 fault the running metric might not have (leg F3).
856
+ expect(allFaultedText).not.toContain('l2');
857
+ // The delivered-lessons disclosure the zero-hit arm carries (mmnto-ai/totem#2735).
858
+ const { message: withLessons } = formatGroundingRefusal('weak topic', allFaulted, FLOOR, 3);
859
+ expect(withLessons).toContain('3 lessons were retrieved, but lessons do not ground a run (ruled mmnto-ai/totem#2727).');
860
+ expect(allFaultedText).not.toContain('best relevance');
861
+ const mixed = evaluateFloor({
862
+ ...emptyContext(),
863
+ specs: [relevantHit(0.05, { filePath: 'docs/weak.md' }), relevantHit(-0.5)],
864
+ }, FLOOR);
865
+ const { message: mixedText } = formatGroundingRefusal('weak topic', mixed, FLOOR, 0);
866
+ expect(mixedText).toContain('best relevance 0.050 is below the floor');
867
+ expect(mixedText).toContain('1 hit carried a relevance that is not a finite number in [0, 1] (tallied out of range by the search layer) and did not count as signal or as exemption.');
868
+ expect(mixedText).toContain('1. docs/weak.md — relevance 0.050');
869
+ });
870
+ // Was "counts hits across ALL FOUR partitions" when the lesson partition was
871
+ // structurally empty and could not change the count. mmnto-ai/totem#2735
872
+ // makes it populated, so the gate holds its ruled inputs: the three
873
+ // partitions it was exercised over. A delivered lesson is counted on the
874
+ // `Found:` line and in the artifact, never as a grounding hit.
875
+ it('counts hits across the spec, session and code partitions — never lessons', () => {
876
+ const verdict = evaluateFloor({
647
877
  specs: [relevantHit(0.9)],
648
878
  sessions: [relevantHit(0.8)],
649
879
  code: [relevantHit(0.7)],
650
- lessons: [relevantHit(0.6)],
880
+ lessons: [relevantHit(0.6, { type: 'lesson' })],
651
881
  }, FLOOR);
652
- expect(verdict.hits).toBe(4);
882
+ expect(verdict.hits).toBe(3);
653
883
  expect(verdict.bestRelevance).toBeCloseTo(0.9, 10);
654
884
  });
655
885
  it('the withheld list carries every below-floor candidate as path + relevance (linked hits keep their store)', () => {
656
- const verdict = evaluateGroundingFloor({
886
+ const verdict = evaluateFloor({
657
887
  ...emptyContext(),
658
888
  specs: [
659
889
  relevantHit(0.2, { filePath: 'docs/a.md' }),
@@ -665,31 +895,173 @@ describe('evaluateGroundingFloor', () => {
665
895
  { filePath: 'doctrine/b.md', sourceRepo: 'strategy', relevance: 0.1 },
666
896
  ]);
667
897
  });
898
+ // --- No floor configured (mmnto-ai/totem#2727) ---
899
+ //
900
+ // `searchRelevanceFloor` lost its default, so `undefined` is the shape a
901
+ // repo that never set the key hands this function. The below-floor arm has
902
+ // to be unreachable then — not reachable at some fallback number.
903
+ it('with NO floor, hits far below any plausible floor still PROCEED and withhold nothing', () => {
904
+ const verdict = evaluateGroundingFloor({ ...emptyContext(), specs: [relevantHit(0.01), relevantHit(0.02)] }, isRelevanceInRange, undefined);
905
+ expect(verdict.refuse).toBe(false);
906
+ expect(verdict.withheld).toEqual([]);
907
+ // The MEASUREMENT is not conditional on a floor — it is still reported.
908
+ expect(verdict.hits).toBe(2);
909
+ expect(verdict.bestRelevance).toBeCloseTo(0.02, 10);
910
+ expect(verdict.floorExempt).toBe(0);
911
+ });
912
+ it('with NO floor, the ZERO-HIT arm still refuses (it is not a floor arm)', () => {
913
+ expect(evaluateGroundingFloor(emptyContext(), isRelevanceInRange, undefined)).toEqual({
914
+ refuse: true,
915
+ hits: 0,
916
+ bestRelevance: null,
917
+ withheld: [],
918
+ floorExempt: 0,
919
+ faulted: 0,
920
+ });
921
+ });
922
+ it('with NO floor, floorExempt is still counted for FTS-only hits', () => {
923
+ const verdict = evaluateGroundingFloor({ ...emptyContext(), specs: [relevantHit(undefined), relevantHit(0.01)] }, isRelevanceInRange, undefined);
924
+ expect(verdict.refuse).toBe(false);
925
+ expect(verdict.floorExempt).toBe(1);
926
+ expect(verdict.bestRelevance).toBeCloseTo(0.01, 10);
927
+ });
928
+ // The one state the mmnto-ai/totem#2758 rebase created: the all-faulted arm
929
+ // needs no floor (final leg, F12). A fault beside a real signal proceeds with
930
+ // no floor to judge; every hit faulted refuses regardless.
931
+ it('with NO floor, an all-faulted retrieval still REFUSES, and a fault beside real signal proceeds', () => {
932
+ const allFaulted = evaluateGroundingFloor({ ...emptyContext(), specs: [relevantHit(-0.5), relevantHit(2)] }, isRelevanceInRange, undefined);
933
+ expect(allFaulted).toEqual({
934
+ refuse: true,
935
+ hits: 2,
936
+ bestRelevance: null,
937
+ withheld: [],
938
+ floorExempt: 0,
939
+ faulted: 2,
940
+ });
941
+ const { message } = formatGroundingRefusal('an-unanchored-slug', allFaulted, undefined, 0);
942
+ expect(message).toBe([
943
+ 'Refusing to draft an unanchored spec for topic(s): an-unanchored-slug.',
944
+ 'Retrieval returned 2 hits, but every one carried a relevance that is not a finite number in [0, 1] (tallied out of range by the search layer) — so nothing usable grounds this run.',
945
+ FLOOR_LINE_UNSET_TEXT,
946
+ ].join('\n'));
947
+ const beside = evaluateGroundingFloor({ ...emptyContext(), specs: [relevantHit(-0.5), relevantHit(0.001)] }, isRelevanceInRange, undefined);
948
+ expect(beside.refuse).toBe(false);
949
+ expect(beside.faulted).toBe(1);
950
+ });
951
+ it('omitting the floor argument entirely is the same as passing undefined', () => {
952
+ const context = { ...emptyContext(), specs: [relevantHit(0.01)] };
953
+ expect(evaluateGroundingFloor(context, isRelevanceInRange)).toEqual(evaluateGroundingFloor(context, isRelevanceInRange, undefined));
954
+ });
668
955
  });
669
956
  // ─── formatGroundingRefusal (mmnto-ai/totem#2700) ────────
670
957
  describe('formatGroundingRefusal', () => {
671
958
  it('a 0-hit refusal names the topic, the 0 hits, and the floor VALUE and PLACE', () => {
672
- const verdict = evaluateGroundingFloor(emptyContext(), FLOOR);
673
- const { message } = formatGroundingRefusal('an-unanchored-slug', verdict, FLOOR);
959
+ const verdict = evaluateFloor(emptyContext(), FLOOR);
960
+ const { message } = formatGroundingRefusal('an-unanchored-slug', verdict, FLOOR, 0);
674
961
  expect(message).toContain('an-unanchored-slug');
675
962
  expect(message).toContain('0 hits');
676
- expect(message).toContain('floor 0.250 searchRelevanceFloor in totem.config.ts (schema default 0.25 when unset)');
963
+ // The WHOLE line, not a prefix of it: a `toContain` of the value-and-place
964
+ // text alone still passes if the retired "(schema default 0.25 when unset)"
965
+ // suffix is restored after it (mmnto-ai/totem#2727 fold, F3).
966
+ expect(floorLineOf(message)).toBe('floor 0.250 — searchRelevanceFloor in totem.config.ts');
967
+ });
968
+ // With lessons delivered, "nothing in the index grounds this run" sits beside
969
+ // a `Found: … N lessons` line. The message names the contradiction rather
970
+ // than leaving the reader to reconcile the two (mmnto-ai/totem#2735).
971
+ it('a 0-hit refusal on a lesson-holding index names the lessons it did not judge', () => {
972
+ // The seeded count IS the delivered count — the invariant production holds,
973
+ // where the caller passes `context.lessons.length`.
974
+ const lessons = [makeLesson(), makeLesson()];
975
+ const verdict = evaluateFloor({ ...emptyContext(), lessons }, FLOOR);
976
+ expect(verdict.hits).toBe(0);
977
+ const { message } = formatGroundingRefusal('an-unanchored-slug', verdict, FLOOR, lessons.length);
978
+ expect(message).toBe([
979
+ 'Refusing to draft an unanchored spec for topic(s): an-unanchored-slug.',
980
+ 'Retrieval returned 0 grounding hits (specs, sessions, code) — nothing in the index grounds this run.',
981
+ '2 lessons were retrieved, but lessons do not ground a run (ruled mmnto-ai/totem#2727).',
982
+ `floor 0.250 — ${FLOOR_PLACE_TEXT}`,
983
+ ].join('\n'));
984
+ });
985
+ it('a single delivered lesson reads as one, not as "1 lessons"', () => {
986
+ const verdict = evaluateFloor({ ...emptyContext(), lessons: [makeLesson()] }, FLOOR);
987
+ const { message } = formatGroundingRefusal('an-unanchored-slug', verdict, FLOOR, 1);
988
+ expect(message).toContain('1 lesson was retrieved, but lessons do not ground a run (ruled mmnto-ai/totem#2727).');
989
+ });
990
+ // Pinned against the LITERAL pre-mmnto-ai/totem#2735 message, not against a
991
+ // sibling call: the guarantee is that this text did not move, and only an
992
+ // exact comparison with the old bytes can say so.
993
+ it('with NO lessons delivered the 0-hit message is the pre-fold text, byte for byte', () => {
994
+ const verdict = evaluateFloor(emptyContext(), FLOOR);
995
+ const { message } = formatGroundingRefusal('an-unanchored-slug', verdict, FLOOR, 0);
996
+ expect(message).toBe([
997
+ 'Refusing to draft an unanchored spec for topic(s): an-unanchored-slug.',
998
+ 'Retrieval returned 0 hits — nothing in the index grounds this run.',
999
+ `floor 0.250 — ${FLOOR_PLACE_TEXT}`,
1000
+ ].join('\n'));
1001
+ });
1002
+ // --- The floor line's two forms (mmnto-ai/totem#2727) ---
1003
+ //
1004
+ // Byte-pinned in BOTH forms. The unset form must never render a number: a
1005
+ // refusal that reached here with no floor did so on an arm that needs none —
1006
+ // zero hits, or every hit faulted — and printing `floor 0.250` would claim a
1007
+ // judgment no floor made.
1008
+ it('with NO floor the 0-hit refusal names the floor as none, byte for byte', () => {
1009
+ const verdict = evaluateGroundingFloor(emptyContext(), isRelevanceInRange, undefined);
1010
+ const { message } = formatGroundingRefusal('an-unanchored-slug', verdict, undefined, 0);
1011
+ expect(message).toBe([
1012
+ 'Refusing to draft an unanchored spec for topic(s): an-unanchored-slug.',
1013
+ 'Retrieval returned 0 hits — nothing in the index grounds this run.',
1014
+ FLOOR_LINE_UNSET_TEXT,
1015
+ ].join('\n'));
1016
+ expect(message).not.toContain('0.250');
1017
+ });
1018
+ it('with a floor CONFIGURED the 0-hit refusal names the value and its place, byte for byte', () => {
1019
+ const verdict = evaluateGroundingFloor(emptyContext(), isRelevanceInRange, FLOOR);
1020
+ const { message } = formatGroundingRefusal('an-unanchored-slug', verdict, FLOOR, 0);
1021
+ expect(message).toBe([
1022
+ 'Refusing to draft an unanchored spec for topic(s): an-unanchored-slug.',
1023
+ 'Retrieval returned 0 hits — nothing in the index grounds this run.',
1024
+ 'floor 0.250 — searchRelevanceFloor in totem.config.ts',
1025
+ ].join('\n'));
1026
+ });
1027
+ it('with NO floor the lessons clause still renders, above the none line', () => {
1028
+ const lessons = [makeLesson(), makeLesson()];
1029
+ const verdict = evaluateGroundingFloor({ ...emptyContext(), lessons }, isRelevanceInRange, undefined);
1030
+ const { message } = formatGroundingRefusal('an-unanchored-slug', verdict, undefined, lessons.length);
1031
+ expect(message).toBe([
1032
+ 'Refusing to draft an unanchored spec for topic(s): an-unanchored-slug.',
1033
+ 'Retrieval returned 0 grounding hits (specs, sessions, code) — nothing in the index grounds this run.',
1034
+ '2 lessons were retrieved, but lessons do not ground a run (ruled mmnto-ai/totem#2727).',
1035
+ FLOOR_LINE_UNSET_TEXT,
1036
+ ].join('\n'));
1037
+ });
1038
+ it('the below-floor message is the pre-fold text whether or not lessons were delivered', () => {
1039
+ const verdict = evaluateFloor({ ...emptyContext(), specs: [relevantHit(0.2, { filePath: 'docs/a.md' })] }, FLOOR);
1040
+ const expected = [
1041
+ 'Refusing to draft an unanchored spec for topic(s): weak topic.',
1042
+ 'Retrieval returned 1 hits, but best relevance 0.200 is below the floor.',
1043
+ `floor 0.250 — ${FLOOR_PLACE_TEXT}`,
1044
+ 'Withheld candidates (path + relevance only, no content):',
1045
+ '1. docs/a.md — relevance 0.200',
1046
+ ].join('\n');
1047
+ expect(formatGroundingRefusal('weak topic', verdict, FLOOR, 10).message).toBe(expected);
1048
+ expect(formatGroundingRefusal('weak topic', verdict, FLOOR, 0).message).toBe(expected);
677
1049
  });
678
1050
  it('a below-floor refusal names the best relevance and DISCLOSES every withheld candidate', () => {
679
- const verdict = evaluateGroundingFloor({
1051
+ const verdict = evaluateFloor({
680
1052
  ...emptyContext(),
681
1053
  specs: [
682
1054
  relevantHit(0.2, { filePath: 'docs/a.md' }),
683
1055
  relevantHit(0.1, { filePath: 'doctrine/b.md', sourceRepo: 'strategy' }),
684
1056
  ],
685
1057
  }, FLOOR);
686
- const { message } = formatGroundingRefusal('weak topic', verdict, FLOOR);
1058
+ const { message } = formatGroundingRefusal('weak topic', verdict, FLOOR, 0);
687
1059
  expect(message).toContain('best relevance 0.200');
688
1060
  expect(message).toContain('1. docs/a.md — relevance 0.200');
689
1061
  expect(message).toContain('2. [strategy] doctrine/b.md — relevance 0.100');
690
1062
  });
691
1063
  it('the hint names both cures and the --raw inspection path', () => {
692
- const { recoveryHint } = formatGroundingRefusal('topic', evaluateGroundingFloor(emptyContext(), FLOOR), FLOOR);
1064
+ const { recoveryHint } = formatGroundingRefusal('topic', evaluateFloor(emptyContext(), FLOOR), FLOOR, 0);
693
1065
  expect(recoveryHint).toContain('totem spec <issue>');
694
1066
  expect(recoveryHint).toContain('totem spec --from <record>');
695
1067
  expect(recoveryHint).toContain('--raw');
@@ -1063,6 +1435,7 @@ describe('specCommand — anchored evidence, executed against stubbed seams', ()
1063
1435
  originalCwd = process.cwd();
1064
1436
  process.chdir(tmpDir);
1065
1437
  harness.searchResults = {};
1438
+ harness.searchTypeFilters = [];
1066
1439
  harness.orchestratorArgs = [];
1067
1440
  harness.orchestratorContent = 'DRAFT';
1068
1441
  harness.connects = 0;
@@ -1126,6 +1499,27 @@ describe('specCommand — anchored evidence, executed against stubbed seams', ()
1126
1499
  expect(harness.orchestratorArgs).toEqual([]);
1127
1500
  expect(runArtifactNames()).toEqual([]);
1128
1501
  });
1502
+ it('asks the store for lessons and carries them into the prompt (mmnto-ai/totem#2735)', async () => {
1503
+ harness.searchResults = {
1504
+ spec: [relevantHit(0.7)],
1505
+ lesson: [
1506
+ relevantHit(0.7, {
1507
+ type: 'lesson',
1508
+ label: 'Lesson A',
1509
+ filePath: '.totem/lessons/lesson-abc.md',
1510
+ content: 'Always validate input at boundaries.',
1511
+ }),
1512
+ ],
1513
+ };
1514
+ await specCommand(['2735'], { stdout: true });
1515
+ expect(harness.searchTypeFilters).toContain('lesson');
1516
+ const prompt = String(harness.orchestratorArgs[0]['prompt']);
1517
+ // The real section header (`formatLessonSection` in utils.ts), not the bare
1518
+ // phrase — the system prompt carries "RELEVANT LESSONS" on its own, so a
1519
+ // `toContain` on that alone passes with zero lessons delivered.
1520
+ expect(prompt).toContain('=== RELEVANT LESSONS (HARD CONSTRAINTS) ===');
1521
+ expect(prompt).toContain('Always validate input at boundaries.');
1522
+ });
1129
1523
  it('a --from run anchors on the record, leaves its bytes UNCHANGED, and drafts to stdout', async () => {
1130
1524
  const record = writeRecord('# Design record\n\nThe ruled contract.\n');
1131
1525
  const before = sha256Of(record);
@@ -1202,6 +1596,15 @@ describe('specCommand — anchored evidence, executed against stubbed seams', ()
1202
1596
  await expect(specCommand(['nonsense slug'], { stdout: true })).rejects.toThrowError(/Retrieval returned 0 hits/);
1203
1597
  expect(harness.orchestratorArgs).toEqual([]);
1204
1598
  });
1599
+ // The WIRING, not the formatter: this is the only test that fails if the
1600
+ // caller stops passing `context.lessons.length` (a literal 0 there leaves
1601
+ // every formatter-level test green). One delivered lesson, no spec/session/
1602
+ // code rows — the count in the refusal has to come from the delivered pool.
1603
+ it('a free-text run with 0 grounding hits carries the DELIVERED lesson count into the refusal', async () => {
1604
+ harness.searchResults = { lesson: [makeLesson()] };
1605
+ await expect(specCommand(['nonsense slug'], { stdout: true })).rejects.toThrowError(/1 lesson was retrieved, but lessons do not ground a run/);
1606
+ expect(harness.orchestratorArgs).toEqual([]);
1607
+ });
1205
1608
  it('a free-text run entirely below the floor REFUSES, naming the floor and every withheld candidate', async () => {
1206
1609
  harness.searchResults = { spec: [relevantHit(0.1, { filePath: 'docs/a.md' })] };
1207
1610
  let thrown;
@@ -1215,7 +1618,8 @@ describe('specCommand — anchored evidence, executed against stubbed seams', ()
1215
1618
  const message = String(thrown.message);
1216
1619
  expect(message).toContain('weak slug');
1217
1620
  expect(message).toContain('best relevance 0.100');
1218
- expect(message).toContain('floor 0.250searchRelevanceFloor in totem.config.ts (schema default 0.25 when unset)');
1621
+ // Full-line pin see `floorLineOf` (mmnto-ai/totem#2727 fold, F3).
1622
+ expect(floorLineOf(message)).toBe('floor 0.250 — searchRelevanceFloor in totem.config.ts');
1219
1623
  expect(message).toContain('docs/a.md — relevance 0.100');
1220
1624
  expect(harness.orchestratorArgs).toEqual([]);
1221
1625
  });
@@ -1239,5 +1643,40 @@ describe('specCommand — anchored evidence, executed against stubbed seams', ()
1239
1643
  await specCommand(['2700'], { stdout: true });
1240
1644
  expect(artifactRequest()['floor']).toBe(0.6);
1241
1645
  });
1646
+ // --- No floor configured (mmnto-ai/totem#2727) ---
1647
+ it('with NO floor configured, a weak free-text run PROCEEDS instead of being refused', async () => {
1648
+ const { searchRelevanceFloor: _dropped, ...unfloored } = harness.config;
1649
+ harness.config = unfloored;
1650
+ // 0.1 is far below the 0.25 this suite otherwise configures — the sibling
1651
+ // test 'a free-text run entirely below the floor REFUSES' uses this exact
1652
+ // fixture WITH a floor and gets a GATE_INVALID. Same retrieval, no floor,
1653
+ // no refusal: that contrast is the invariant.
1654
+ harness.searchResults = { spec: [relevantHit(0.1, { filePath: 'docs/a.md' })] };
1655
+ await specCommand(['weak slug'], { stdout: true });
1656
+ expect(harness.orchestratorArgs.length).toBe(1);
1657
+ });
1658
+ it('with NO floor configured, the artifact OMITS grounding.floor rather than inventing one', async () => {
1659
+ const { searchRelevanceFloor: _dropped, ...unfloored } = harness.config;
1660
+ harness.config = unfloored;
1661
+ harness.searchResults = { spec: [relevantHit(0.9)] };
1662
+ await specCommand(['2700'], { stdout: true });
1663
+ expect(artifactRequest()['floor']).toBeUndefined();
1664
+ });
1665
+ it('with NO floor configured, a ZERO-HIT free-text run still refuses — with the none form', async () => {
1666
+ const { searchRelevanceFloor: _dropped, ...unfloored } = harness.config;
1667
+ harness.config = unfloored;
1668
+ let thrown;
1669
+ try {
1670
+ await specCommand(['nonsense slug'], { stdout: true });
1671
+ }
1672
+ catch (err) {
1673
+ thrown = err;
1674
+ }
1675
+ expect(thrown).toMatchObject({ code: 'GATE_INVALID' });
1676
+ const message = String(thrown.message);
1677
+ expect(message).toContain('Retrieval returned 0 hits');
1678
+ expect(message).toContain(FLOOR_LINE_UNSET_TEXT);
1679
+ expect(harness.orchestratorArgs).toEqual([]);
1680
+ });
1242
1681
  });
1243
1682
  //# sourceMappingURL=spec.test.js.map