@mmnto/cli 1.123.0 → 1.124.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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.d.ts +29 -4
- package/dist/commands/spec.d.ts.map +1 -1
- package/dist/commands/spec.js +51 -13
- package/dist/commands/spec.js.map +1 -1
- package/dist/commands/spec.test.js +221 -15
- 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 +5 -5
- 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 +42 -48
- package/dist/utils.test.js.map +1 -1
- package/package.json +2 -2
|
@@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
6
6
|
import { GROUNDING_ANCHOR_FREE_TEXT, GROUNDING_ANCHOR_ISSUE, GROUNDING_ANCHOR_MIXED, GROUNDING_ANCHOR_RECORD, GroundingAnchorSchema, hasUnrenderableHookChar, PROMPT_SOURCE_BUILTIN, PROMPT_SOURCE_OVERRIDE, TotemConfigError, } from '@mmnto/totem';
|
|
7
7
|
import { cleanTmpDir } from '../test-utils.js';
|
|
8
8
|
import { log } from '../ui.js';
|
|
9
|
-
import { assemblePrompt, assertOutDoesNotOverwriteRecord, buildRecordSearchQuery, evaluateGroundingFloor, expandSpecQuery, formatGroundingRefusal, isRecordPathOutsideRoot, loadSpecRecord, MAX_LESSON_CHARS, MAX_LESSONS, resolveDefaultSpecPath, resolveGroundingAnchor, retrieveContext, sanitizeSpecFilename, SPEC_SYSTEM_PROMPT, specCommand, validateOutputOptions, validateSpecInvocation, } from './spec.js';
|
|
9
|
+
import { assemblePrompt, assertOutDoesNotOverwriteRecord, buildRecordSearchQuery, evaluateGroundingFloor, expandSpecQuery, formatGroundingRefusal, isRecordPathOutsideRoot, loadSpecRecord, MAX_LESSON_CHARS, MAX_LESSONS, MAX_SPECS, resolveDefaultSpecPath, resolveGroundingAnchor, retrieveContext, sanitizeSpecFilename, SPEC_SEARCH_POOL, SPEC_SYSTEM_PROMPT, specCommand, validateOutputOptions, validateSpecInvocation, } from './spec.js';
|
|
10
10
|
import { SPEC_REQUIRED_SECTIONS } from './spec-templates.js';
|
|
11
11
|
// ─── Mocks for the executed `specCommand` suite ─────────
|
|
12
12
|
//
|
|
@@ -18,6 +18,8 @@ import { SPEC_REQUIRED_SECTIONS } from './spec-templates.js';
|
|
|
18
18
|
const harness = vi.hoisted(() => ({
|
|
19
19
|
/** Store hits keyed by the `typeFilter` `retrieveContext` asks for. */
|
|
20
20
|
searchResults: {},
|
|
21
|
+
/** Every `typeFilter` the store was asked for, in call order (mmnto-ai/totem#2735). */
|
|
22
|
+
searchTypeFilters: [],
|
|
21
23
|
/** Every `runOrchestrator` invocation, in order — empty means no artifact could exist. */
|
|
22
24
|
orchestratorArgs: [],
|
|
23
25
|
/** What the stubbed orchestrator returns as the draft. */
|
|
@@ -39,6 +41,7 @@ vi.mock('@mmnto/totem', async () => {
|
|
|
39
41
|
harness.connects += 1;
|
|
40
42
|
}
|
|
41
43
|
async search({ typeFilter }) {
|
|
44
|
+
harness.searchTypeFilters.push(typeFilter);
|
|
42
45
|
return harness.searchResults[typeFilter] ?? [];
|
|
43
46
|
}
|
|
44
47
|
},
|
|
@@ -80,9 +83,11 @@ function makeLesson(overrides = {}) {
|
|
|
80
83
|
return {
|
|
81
84
|
content: '**Tags:** testing\n\nAlways validate input at boundaries.',
|
|
82
85
|
contextPrefix: 'Totem Lessons > Lesson — Always validate input',
|
|
83
|
-
|
|
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) ────────────────────────────
|
|
@@ -587,6 +673,8 @@ function relevantHit(relevance, overrides = {}) {
|
|
|
587
673
|
});
|
|
588
674
|
}
|
|
589
675
|
const FLOOR = 0.25;
|
|
676
|
+
/** The floor's PLACE exactly as a refusal renders it (`FLOOR_PLACE` in spec.ts). */
|
|
677
|
+
const FLOOR_PLACE_TEXT = 'searchRelevanceFloor in totem.config.ts (schema default 0.25 when unset)';
|
|
590
678
|
describe('evaluateGroundingFloor', () => {
|
|
591
679
|
it('0 retrieved items REFUSES — nothing grounds the run (the charter rule, not an MCP mirror)', () => {
|
|
592
680
|
const verdict = evaluateGroundingFloor(emptyContext(), FLOOR);
|
|
@@ -616,6 +704,42 @@ describe('evaluateGroundingFloor', () => {
|
|
|
616
704
|
expect(verdict.floorExempt).toBe(1);
|
|
617
705
|
expect(verdict.withheld).toEqual([]);
|
|
618
706
|
});
|
|
707
|
+
// Lessons are DELIVERED but never judged by the floor (mmnto-ai/totem#2735).
|
|
708
|
+
// The gate was ruled over the spec/session/code partitions while the lesson
|
|
709
|
+
// partition was structurally empty; feeding a now-populated one in would
|
|
710
|
+
// silently loosen the refusal arm, since an FTS-only lesson is floor-EXEMPT
|
|
711
|
+
// and `refuse` requires `floorExempt === 0`.
|
|
712
|
+
it('a run whose ONLY retrieved item is an FTS-only lesson REFUSES as 0 hits', () => {
|
|
713
|
+
const verdict = evaluateGroundingFloor({ ...emptyContext(), lessons: [relevantHit(undefined, { type: 'lesson' })] }, FLOOR);
|
|
714
|
+
expect(verdict.refuse).toBe(true);
|
|
715
|
+
expect(verdict.hits).toBe(0);
|
|
716
|
+
expect(verdict.floorExempt).toBe(0);
|
|
717
|
+
expect(verdict.bestRelevance).toBeNull();
|
|
718
|
+
});
|
|
719
|
+
it('a below-floor spec beside an FTS-only lesson still REFUSES (a lesson is not floor-exempt evidence)', () => {
|
|
720
|
+
const verdict = evaluateGroundingFloor({
|
|
721
|
+
...emptyContext(),
|
|
722
|
+
specs: [relevantHit(0.05, { filePath: 'docs/weak.md' })],
|
|
723
|
+
lessons: [relevantHit(undefined, { type: 'lesson' })],
|
|
724
|
+
}, FLOOR);
|
|
725
|
+
expect(verdict.refuse).toBe(true);
|
|
726
|
+
expect(verdict.hits).toBe(1);
|
|
727
|
+
expect(verdict.floorExempt).toBe(0);
|
|
728
|
+
expect(verdict.withheld).toEqual([{ filePath: 'docs/weak.md', relevance: 0.05 }]);
|
|
729
|
+
});
|
|
730
|
+
it('an AT-floor spec beside lessons PROCEEDS, and the lessons are not counted as hits', () => {
|
|
731
|
+
const verdict = evaluateGroundingFloor({
|
|
732
|
+
...emptyContext(),
|
|
733
|
+
specs: [relevantHit(FLOOR)],
|
|
734
|
+
lessons: [relevantHit(undefined, { type: 'lesson' }), relevantHit(0.9, { type: 'lesson' })],
|
|
735
|
+
}, FLOOR);
|
|
736
|
+
// Delivery is not the gate: `retrieveContext` still returns those lessons
|
|
737
|
+
// (see the lesson-delivery suite) and the executed-command suite proves
|
|
738
|
+
// they reach the prompt. The floor simply never reads them.
|
|
739
|
+
expect(verdict.refuse).toBe(false);
|
|
740
|
+
expect(verdict.hits).toBe(1);
|
|
741
|
+
expect(verdict.bestRelevance).toBeCloseTo(FLOOR, 10);
|
|
742
|
+
});
|
|
619
743
|
it('no relevance anywhere PROCEEDS — a pure-FTS corpus is never demoted', () => {
|
|
620
744
|
const verdict = evaluateGroundingFloor({ ...emptyContext(), specs: [relevantHit(undefined), relevantHit(undefined)] }, FLOOR);
|
|
621
745
|
expect(verdict.refuse).toBe(false);
|
|
@@ -642,14 +766,19 @@ describe('evaluateGroundingFloor', () => {
|
|
|
642
766
|
expect(verdict.refuse).toBe(false);
|
|
643
767
|
expect(verdict.withheld).toEqual([]);
|
|
644
768
|
});
|
|
645
|
-
|
|
769
|
+
// Was "counts hits across ALL FOUR partitions" when the lesson partition was
|
|
770
|
+
// structurally empty and could not change the count. mmnto-ai/totem#2735
|
|
771
|
+
// makes it populated, so the gate holds its ruled inputs: the three
|
|
772
|
+
// partitions it was exercised over. A delivered lesson is counted on the
|
|
773
|
+
// `Found:` line and in the artifact, never as a grounding hit.
|
|
774
|
+
it('counts hits across the spec, session and code partitions — never lessons', () => {
|
|
646
775
|
const verdict = evaluateGroundingFloor({
|
|
647
776
|
specs: [relevantHit(0.9)],
|
|
648
777
|
sessions: [relevantHit(0.8)],
|
|
649
778
|
code: [relevantHit(0.7)],
|
|
650
|
-
lessons: [relevantHit(0.6)],
|
|
779
|
+
lessons: [relevantHit(0.6, { type: 'lesson' })],
|
|
651
780
|
}, FLOOR);
|
|
652
|
-
expect(verdict.hits).toBe(
|
|
781
|
+
expect(verdict.hits).toBe(3);
|
|
653
782
|
expect(verdict.bestRelevance).toBeCloseTo(0.9, 10);
|
|
654
783
|
});
|
|
655
784
|
it('the withheld list carries every below-floor candidate as path + relevance (linked hits keep their store)', () => {
|
|
@@ -670,11 +799,57 @@ describe('evaluateGroundingFloor', () => {
|
|
|
670
799
|
describe('formatGroundingRefusal', () => {
|
|
671
800
|
it('a 0-hit refusal names the topic, the 0 hits, and the floor VALUE and PLACE', () => {
|
|
672
801
|
const verdict = evaluateGroundingFloor(emptyContext(), FLOOR);
|
|
673
|
-
const { message } = formatGroundingRefusal('an-unanchored-slug', verdict, FLOOR);
|
|
802
|
+
const { message } = formatGroundingRefusal('an-unanchored-slug', verdict, FLOOR, 0);
|
|
674
803
|
expect(message).toContain('an-unanchored-slug');
|
|
675
804
|
expect(message).toContain('0 hits');
|
|
676
805
|
expect(message).toContain('floor 0.250 — searchRelevanceFloor in totem.config.ts (schema default 0.25 when unset)');
|
|
677
806
|
});
|
|
807
|
+
// With lessons delivered, "nothing in the index grounds this run" sits beside
|
|
808
|
+
// a `Found: … N lessons` line. The message names the contradiction rather
|
|
809
|
+
// than leaving the reader to reconcile the two (mmnto-ai/totem#2735).
|
|
810
|
+
it('a 0-hit refusal on a lesson-holding index names the lessons it did not judge', () => {
|
|
811
|
+
// The seeded count IS the delivered count — the invariant production holds,
|
|
812
|
+
// where the caller passes `context.lessons.length`.
|
|
813
|
+
const lessons = [makeLesson(), makeLesson()];
|
|
814
|
+
const verdict = evaluateGroundingFloor({ ...emptyContext(), lessons }, FLOOR);
|
|
815
|
+
expect(verdict.hits).toBe(0);
|
|
816
|
+
const { message } = formatGroundingRefusal('an-unanchored-slug', verdict, FLOOR, lessons.length);
|
|
817
|
+
expect(message).toBe([
|
|
818
|
+
'Refusing to draft an unanchored spec for topic(s): an-unanchored-slug.',
|
|
819
|
+
'Retrieval returned 0 grounding hits (specs, sessions, code) — nothing in the index grounds this run.',
|
|
820
|
+
'2 lessons were retrieved, but lessons do not ground a run (mmnto-ai/totem#2727 rules whether they may).',
|
|
821
|
+
`floor 0.250 — ${FLOOR_PLACE_TEXT}`,
|
|
822
|
+
].join('\n'));
|
|
823
|
+
});
|
|
824
|
+
it('a single delivered lesson reads as one, not as "1 lessons"', () => {
|
|
825
|
+
const verdict = evaluateGroundingFloor({ ...emptyContext(), lessons: [makeLesson()] }, FLOOR);
|
|
826
|
+
const { message } = formatGroundingRefusal('an-unanchored-slug', verdict, FLOOR, 1);
|
|
827
|
+
expect(message).toContain('1 lesson was retrieved, but lessons do not ground a run (mmnto-ai/totem#2727 rules whether they may).');
|
|
828
|
+
});
|
|
829
|
+
// Pinned against the LITERAL pre-mmnto-ai/totem#2735 message, not against a
|
|
830
|
+
// sibling call: the guarantee is that this text did not move, and only an
|
|
831
|
+
// exact comparison with the old bytes can say so.
|
|
832
|
+
it('with NO lessons delivered the 0-hit message is the pre-fold text, byte for byte', () => {
|
|
833
|
+
const verdict = evaluateGroundingFloor(emptyContext(), FLOOR);
|
|
834
|
+
const { message } = formatGroundingRefusal('an-unanchored-slug', verdict, FLOOR, 0);
|
|
835
|
+
expect(message).toBe([
|
|
836
|
+
'Refusing to draft an unanchored spec for topic(s): an-unanchored-slug.',
|
|
837
|
+
'Retrieval returned 0 hits — nothing in the index grounds this run.',
|
|
838
|
+
`floor 0.250 — ${FLOOR_PLACE_TEXT}`,
|
|
839
|
+
].join('\n'));
|
|
840
|
+
});
|
|
841
|
+
it('the below-floor message is the pre-fold text whether or not lessons were delivered', () => {
|
|
842
|
+
const verdict = evaluateGroundingFloor({ ...emptyContext(), specs: [relevantHit(0.2, { filePath: 'docs/a.md' })] }, FLOOR);
|
|
843
|
+
const expected = [
|
|
844
|
+
'Refusing to draft an unanchored spec for topic(s): weak topic.',
|
|
845
|
+
'Retrieval returned 1 hits, but best relevance 0.200 is below the floor.',
|
|
846
|
+
`floor 0.250 — ${FLOOR_PLACE_TEXT}`,
|
|
847
|
+
'Withheld candidates (path + relevance only, no content):',
|
|
848
|
+
'1. docs/a.md — relevance 0.200',
|
|
849
|
+
].join('\n');
|
|
850
|
+
expect(formatGroundingRefusal('weak topic', verdict, FLOOR, 10).message).toBe(expected);
|
|
851
|
+
expect(formatGroundingRefusal('weak topic', verdict, FLOOR, 0).message).toBe(expected);
|
|
852
|
+
});
|
|
678
853
|
it('a below-floor refusal names the best relevance and DISCLOSES every withheld candidate', () => {
|
|
679
854
|
const verdict = evaluateGroundingFloor({
|
|
680
855
|
...emptyContext(),
|
|
@@ -683,13 +858,13 @@ describe('formatGroundingRefusal', () => {
|
|
|
683
858
|
relevantHit(0.1, { filePath: 'doctrine/b.md', sourceRepo: 'strategy' }),
|
|
684
859
|
],
|
|
685
860
|
}, FLOOR);
|
|
686
|
-
const { message } = formatGroundingRefusal('weak topic', verdict, FLOOR);
|
|
861
|
+
const { message } = formatGroundingRefusal('weak topic', verdict, FLOOR, 0);
|
|
687
862
|
expect(message).toContain('best relevance 0.200');
|
|
688
863
|
expect(message).toContain('1. docs/a.md — relevance 0.200');
|
|
689
864
|
expect(message).toContain('2. [strategy] doctrine/b.md — relevance 0.100');
|
|
690
865
|
});
|
|
691
866
|
it('the hint names both cures and the --raw inspection path', () => {
|
|
692
|
-
const { recoveryHint } = formatGroundingRefusal('topic', evaluateGroundingFloor(emptyContext(), FLOOR), FLOOR);
|
|
867
|
+
const { recoveryHint } = formatGroundingRefusal('topic', evaluateGroundingFloor(emptyContext(), FLOOR), FLOOR, 0);
|
|
693
868
|
expect(recoveryHint).toContain('totem spec <issue>');
|
|
694
869
|
expect(recoveryHint).toContain('totem spec --from <record>');
|
|
695
870
|
expect(recoveryHint).toContain('--raw');
|
|
@@ -1063,6 +1238,7 @@ describe('specCommand — anchored evidence, executed against stubbed seams', ()
|
|
|
1063
1238
|
originalCwd = process.cwd();
|
|
1064
1239
|
process.chdir(tmpDir);
|
|
1065
1240
|
harness.searchResults = {};
|
|
1241
|
+
harness.searchTypeFilters = [];
|
|
1066
1242
|
harness.orchestratorArgs = [];
|
|
1067
1243
|
harness.orchestratorContent = 'DRAFT';
|
|
1068
1244
|
harness.connects = 0;
|
|
@@ -1126,6 +1302,27 @@ describe('specCommand — anchored evidence, executed against stubbed seams', ()
|
|
|
1126
1302
|
expect(harness.orchestratorArgs).toEqual([]);
|
|
1127
1303
|
expect(runArtifactNames()).toEqual([]);
|
|
1128
1304
|
});
|
|
1305
|
+
it('asks the store for lessons and carries them into the prompt (mmnto-ai/totem#2735)', async () => {
|
|
1306
|
+
harness.searchResults = {
|
|
1307
|
+
spec: [relevantHit(0.7)],
|
|
1308
|
+
lesson: [
|
|
1309
|
+
relevantHit(0.7, {
|
|
1310
|
+
type: 'lesson',
|
|
1311
|
+
label: 'Lesson A',
|
|
1312
|
+
filePath: '.totem/lessons/lesson-abc.md',
|
|
1313
|
+
content: 'Always validate input at boundaries.',
|
|
1314
|
+
}),
|
|
1315
|
+
],
|
|
1316
|
+
};
|
|
1317
|
+
await specCommand(['2735'], { stdout: true });
|
|
1318
|
+
expect(harness.searchTypeFilters).toContain('lesson');
|
|
1319
|
+
const prompt = String(harness.orchestratorArgs[0]['prompt']);
|
|
1320
|
+
// The real section header (`formatLessonSection` in utils.ts), not the bare
|
|
1321
|
+
// phrase — the system prompt carries "RELEVANT LESSONS" on its own, so a
|
|
1322
|
+
// `toContain` on that alone passes with zero lessons delivered.
|
|
1323
|
+
expect(prompt).toContain('=== RELEVANT LESSONS (HARD CONSTRAINTS) ===');
|
|
1324
|
+
expect(prompt).toContain('Always validate input at boundaries.');
|
|
1325
|
+
});
|
|
1129
1326
|
it('a --from run anchors on the record, leaves its bytes UNCHANGED, and drafts to stdout', async () => {
|
|
1130
1327
|
const record = writeRecord('# Design record\n\nThe ruled contract.\n');
|
|
1131
1328
|
const before = sha256Of(record);
|
|
@@ -1202,6 +1399,15 @@ describe('specCommand — anchored evidence, executed against stubbed seams', ()
|
|
|
1202
1399
|
await expect(specCommand(['nonsense slug'], { stdout: true })).rejects.toThrowError(/Retrieval returned 0 hits/);
|
|
1203
1400
|
expect(harness.orchestratorArgs).toEqual([]);
|
|
1204
1401
|
});
|
|
1402
|
+
// The WIRING, not the formatter: this is the only test that fails if the
|
|
1403
|
+
// caller stops passing `context.lessons.length` (a literal 0 there leaves
|
|
1404
|
+
// every formatter-level test green). One delivered lesson, no spec/session/
|
|
1405
|
+
// code rows — the count in the refusal has to come from the delivered pool.
|
|
1406
|
+
it('a free-text run with 0 grounding hits carries the DELIVERED lesson count into the refusal', async () => {
|
|
1407
|
+
harness.searchResults = { lesson: [makeLesson()] };
|
|
1408
|
+
await expect(specCommand(['nonsense slug'], { stdout: true })).rejects.toThrowError(/1 lesson was retrieved, but lessons do not ground a run/);
|
|
1409
|
+
expect(harness.orchestratorArgs).toEqual([]);
|
|
1410
|
+
});
|
|
1205
1411
|
it('a free-text run entirely below the floor REFUSES, naming the floor and every withheld candidate', async () => {
|
|
1206
1412
|
harness.searchResults = { spec: [relevantHit(0.1, { filePath: 'docs/a.md' })] };
|
|
1207
1413
|
let thrown;
|