@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.
- package/dist/commands/doctor.d.ts +16 -10
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +162 -54
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +444 -28
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/extract-shared.d.ts.map +1 -1
- package/dist/commands/extract-shared.js +2 -6
- package/dist/commands/extract-shared.js.map +1 -1
- package/dist/commands/extract.test.js +19 -1
- package/dist/commands/extract.test.js.map +1 -1
- package/dist/commands/hook-totemdir-render.test.js +55 -1
- package/dist/commands/hook-totemdir-render.test.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +39 -0
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +32 -0
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/install-hooks-exit-contract.test.js +32 -1
- package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
- package/dist/commands/install-hooks.d.ts +143 -10
- package/dist/commands/install-hooks.d.ts.map +1 -1
- package/dist/commands/install-hooks.js +441 -69
- package/dist/commands/install-hooks.js.map +1 -1
- package/dist/commands/install-hooks.test.js +1075 -7
- package/dist/commands/install-hooks.test.js.map +1 -1
- package/dist/commands/review-learn.d.ts +3 -0
- package/dist/commands/review-learn.d.ts.map +1 -1
- package/dist/commands/review-learn.js +4 -7
- package/dist/commands/review-learn.js.map +1 -1
- package/dist/commands/review-learn.test.js +19 -2
- package/dist/commands/review-learn.test.js.map +1 -1
- package/dist/commands/shield-learn.test.js +27 -5
- package/dist/commands/shield-learn.test.js.map +1 -1
- package/dist/commands/shield-retrieve.test.d.ts +2 -0
- package/dist/commands/shield-retrieve.test.d.ts.map +1 -0
- package/dist/commands/shield-retrieve.test.js +91 -0
- package/dist/commands/shield-retrieve.test.js.map +1 -0
- package/dist/commands/shield-templates.d.ts +7 -0
- package/dist/commands/shield-templates.d.ts.map +1 -1
- package/dist/commands/shield-templates.js +7 -0
- package/dist/commands/shield-templates.js.map +1 -1
- package/dist/commands/shield.d.ts +3 -1
- package/dist/commands/shield.d.ts.map +1 -1
- package/dist/commands/shield.js +17 -9
- package/dist/commands/shield.js.map +1 -1
- package/dist/commands/spec-templates.d.ts +17 -4
- package/dist/commands/spec-templates.d.ts.map +1 -1
- package/dist/commands/spec-templates.js +23 -3
- package/dist/commands/spec-templates.js.map +1 -1
- package/dist/commands/spec.d.ts +84 -10
- package/dist/commands/spec.d.ts.map +1 -1
- package/dist/commands/spec.js +169 -35
- package/dist/commands/spec.js.map +1 -1
- package/dist/commands/spec.test.js +487 -48
- package/dist/commands/spec.test.js.map +1 -1
- package/dist/commands/triage-retrieve.test.d.ts +2 -0
- package/dist/commands/triage-retrieve.test.d.ts.map +1 -0
- package/dist/commands/triage-retrieve.test.js +90 -0
- package/dist/commands/triage-retrieve.test.js.map +1 -0
- package/dist/commands/triage.d.ts +14 -1
- package/dist/commands/triage.d.ts.map +1 -1
- package/dist/commands/triage.js +21 -6
- package/dist/commands/triage.js.map +1 -1
- package/dist/utils.d.ts +10 -8
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +5 -6
- package/dist/utils.js.map +1 -1
- package/dist/utils.test.js +50 -51
- package/dist/utils.test.js.map +1 -1
- 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,
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
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 (
|
|
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
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
|
|
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
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
626
|
-
// carries no relevance at all,
|
|
627
|
-
//
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
641
|
-
expect(verdict.floorExempt).toBe(
|
|
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('
|
|
646
|
-
const verdict =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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',
|
|
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
|
-
|
|
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
|