@mmnto/cli 1.121.0 → 1.122.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 (43) hide show
  1. package/dist/artifact-vocabulary.d.ts +23 -0
  2. package/dist/artifact-vocabulary.d.ts.map +1 -0
  3. package/dist/artifact-vocabulary.js +23 -0
  4. package/dist/artifact-vocabulary.js.map +1 -0
  5. package/dist/commands/config-drift.test.js +16 -0
  6. package/dist/commands/config-drift.test.js.map +1 -1
  7. package/dist/commands/init-templates.d.ts +2 -2
  8. package/dist/commands/init-templates.d.ts.map +1 -1
  9. package/dist/commands/init-templates.js +2 -2
  10. package/dist/commands/install-hooks.d.ts.map +1 -1
  11. package/dist/commands/install-hooks.js +183 -17
  12. package/dist/commands/install-hooks.js.map +1 -1
  13. package/dist/commands/install-hooks.test.js +559 -18
  14. package/dist/commands/install-hooks.test.js.map +1 -1
  15. package/dist/commands/spec-cli-wiring.test.d.ts +19 -0
  16. package/dist/commands/spec-cli-wiring.test.d.ts.map +1 -0
  17. package/dist/commands/spec-cli-wiring.test.js +90 -0
  18. package/dist/commands/spec-cli-wiring.test.js.map +1 -0
  19. package/dist/commands/spec-templates.d.ts +18 -0
  20. package/dist/commands/spec-templates.d.ts.map +1 -1
  21. package/dist/commands/spec-templates.js +21 -0
  22. package/dist/commands/spec-templates.js.map +1 -1
  23. package/dist/commands/spec.d.ts +168 -1
  24. package/dist/commands/spec.d.ts.map +1 -1
  25. package/dist/commands/spec.js +448 -7
  26. package/dist/commands/spec.js.map +1 -1
  27. package/dist/commands/spec.test.js +903 -20
  28. package/dist/commands/spec.test.js.map +1 -1
  29. package/dist/index.js +7 -2
  30. package/dist/index.js.map +1 -1
  31. package/dist/services/run-artifacts.d.ts +12 -1
  32. package/dist/services/run-artifacts.d.ts.map +1 -1
  33. package/dist/services/run-artifacts.js +48 -3
  34. package/dist/services/run-artifacts.js.map +1 -1
  35. package/dist/services/run-artifacts.test.js +97 -1
  36. package/dist/services/run-artifacts.test.js.map +1 -1
  37. package/dist/utils.d.ts +22 -3
  38. package/dist/utils.d.ts.map +1 -1
  39. package/dist/utils.js +4 -0
  40. package/dist/utils.js.map +1 -1
  41. package/dist/utils.test.js +65 -1
  42. package/dist/utils.test.js.map +1 -1
  43. package/package.json +2 -2
@@ -1,7 +1,80 @@
1
- import { describe, expect, it, vi } from 'vitest';
2
- import { TotemConfigError } from '@mmnto/totem';
1
+ import * as crypto from 'node:crypto';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
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';
7
+ import { cleanTmpDir } from '../test-utils.js';
3
8
  import { log } from '../ui.js';
4
- import { assemblePrompt, expandSpecQuery, MAX_LESSON_CHARS, MAX_LESSONS, resolveDefaultSpecPath, retrieveContext, sanitizeSpecFilename, SPEC_SYSTEM_PROMPT, validateOutputOptions, } from './spec.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';
10
+ import { SPEC_REQUIRED_SECTIONS } from './spec-templates.js';
11
+ // ─── Mocks for the executed `specCommand` suite ─────────
12
+ //
13
+ // Everything the command touches OUTSIDE its own logic is stubbed so the
14
+ // anchored-evidence invariants (mmnto-ai/totem#2700) are measured on control
15
+ // flow, not on a live index: `runOrchestrator` is the ONLY writer of a run
16
+ // artifact, so "a refusal mints nothing" is provable by it never being
17
+ // reached, and `connect` counts prove a validation refused before the store.
18
+ const harness = vi.hoisted(() => ({
19
+ /** Store hits keyed by the `typeFilter` `retrieveContext` asks for. */
20
+ searchResults: {},
21
+ /** Every `runOrchestrator` invocation, in order — empty means no artifact could exist. */
22
+ orchestratorArgs: [],
23
+ /** What the stubbed orchestrator returns as the draft. */
24
+ orchestratorContent: 'DRAFT',
25
+ /** Resolved config the command reads (floor, dirs, embedding). */
26
+ config: {},
27
+ /** How many times a store was connected — 0 proves a refusal preceded the store. */
28
+ connects: 0,
29
+ /** Every `writeOutput(content, outPath?)` call. */
30
+ writes: [],
31
+ }));
32
+ vi.mock('@mmnto/totem', async () => {
33
+ const actual = await vi.importActual('@mmnto/totem');
34
+ return {
35
+ ...actual,
36
+ createEmbedder: vi.fn(() => ({})),
37
+ LanceStore: class {
38
+ async connect() {
39
+ harness.connects += 1;
40
+ }
41
+ async search({ typeFilter }) {
42
+ return harness.searchResults[typeFilter] ?? [];
43
+ }
44
+ },
45
+ };
46
+ });
47
+ vi.mock('../utils.js', async () => {
48
+ const actual = await vi.importActual('../utils.js');
49
+ return {
50
+ ...actual,
51
+ resolveConfigPath: (cwd) => path.join(cwd, 'totem.config.ts'),
52
+ loadEnv: () => { },
53
+ loadConfig: async () => harness.config,
54
+ requireEmbedding: () => ({ provider: 'gemini', model: 'test' }),
55
+ runOrchestrator: async (args) => {
56
+ harness.orchestratorArgs.push(args);
57
+ return harness.orchestratorContent;
58
+ },
59
+ writeOutput: (content, outPath) => {
60
+ harness.writes.push(outPath === undefined ? { content } : { content, outPath });
61
+ },
62
+ };
63
+ });
64
+ vi.mock('./qbd-seam.js', () => ({
65
+ recordQbdDerive: async () => ({}),
66
+ }));
67
+ vi.mock('../adapters/create-issue-adapter.js', () => ({
68
+ createIssueAdapter: async () => ({
69
+ fetchIssue: (num) => ({
70
+ number: num,
71
+ title: `Issue ${num}`,
72
+ body: 'issue body',
73
+ state: 'open',
74
+ labels: [],
75
+ }),
76
+ }),
77
+ }));
5
78
  // ─── Helpers ─────────────────────────────────────────────
6
79
  function makeLesson(overrides = {}) {
7
80
  return {
@@ -66,12 +139,12 @@ describe('assemblePrompt', () => {
66
139
  ...emptyContext(),
67
140
  lessons: [makeLesson()],
68
141
  };
69
- const result = await assemblePrompt([{ issue: null, freeText: 'test topic' }], ctx, 'system prompt');
142
+ const result = await assemblePrompt([{ issue: null, freeText: 'test topic', record: null }], ctx, 'system prompt');
70
143
  expect(result).toContain('RELEVANT LESSONS (HARD CONSTRAINTS)');
71
144
  expect(result).toContain('Always validate input at boundaries.');
72
145
  });
73
146
  it('omits lessons section when no lessons found', async () => {
74
- const result = await assemblePrompt([{ issue: null, freeText: 'test topic' }], emptyContext(), 'system prompt');
147
+ const result = await assemblePrompt([{ issue: null, freeText: 'test topic', record: null }], emptyContext(), 'system prompt');
75
148
  expect(result).not.toContain('RELEVANT LESSONS');
76
149
  });
77
150
  it('includes full lesson body without truncation', async () => {
@@ -80,7 +153,7 @@ describe('assemblePrompt', () => {
80
153
  ...emptyContext(),
81
154
  lessons: [makeLesson({ content: longBody })],
82
155
  };
83
- const result = await assemblePrompt([{ issue: null, freeText: 'test' }], ctx, 'system prompt');
156
+ const result = await assemblePrompt([{ issue: null, freeText: 'test', record: null }], ctx, 'system prompt');
84
157
  expect(result).toContain(longBody);
85
158
  });
86
159
  it('includes lesson score in output', async () => {
@@ -88,7 +161,7 @@ describe('assemblePrompt', () => {
88
161
  ...emptyContext(),
89
162
  lessons: [makeLesson({ score: 0.789 })],
90
163
  };
91
- const result = await assemblePrompt([{ issue: null, freeText: 'test' }], ctx, 'system prompt');
164
+ const result = await assemblePrompt([{ issue: null, freeText: 'test', record: null }], ctx, 'system prompt');
92
165
  expect(result).toContain('0.789');
93
166
  });
94
167
  it('respects MAX_LESSON_CHARS budget', async () => {
@@ -96,7 +169,7 @@ describe('assemblePrompt', () => {
96
169
  const bigLesson = makeLesson({ content: 'X'.repeat(2000) });
97
170
  const lessons = Array.from({ length: 10 }, () => ({ ...bigLesson }));
98
171
  const ctx = { ...emptyContext(), lessons };
99
- const result = await assemblePrompt([{ issue: null, freeText: 'test' }], ctx, 'system prompt');
172
+ const result = await assemblePrompt([{ issue: null, freeText: 'test', record: null }], ctx, 'system prompt');
100
173
  // Extract just the lessons section (stop at the next === section)
101
174
  const afterLessons = result.split('RELEVANT LESSONS (HARD CONSTRAINTS)')[1] ?? '';
102
175
  const lessonSection = afterLessons.split(/\n===\s/)[0] ?? '';
@@ -109,13 +182,13 @@ describe('assemblePrompt', () => {
109
182
  ...emptyContext(),
110
183
  lessons: [hugeLesson, smallLesson],
111
184
  };
112
- const result = await assemblePrompt([{ issue: null, freeText: 'test' }], ctx, 'system prompt');
185
+ const result = await assemblePrompt([{ issue: null, freeText: 'test', record: null }], ctx, 'system prompt');
113
186
  expect(result).toContain('RELEVANT LESSONS');
114
187
  expect(result).toContain('Small lesson body');
115
188
  expect(result).not.toContain('H'.repeat(100));
116
189
  });
117
190
  it('includes shared helpers section (#1015)', async () => {
118
- const result = await assemblePrompt([{ issue: null, freeText: 'test topic' }], emptyContext(), 'system prompt');
191
+ const result = await assemblePrompt([{ issue: null, freeText: 'test topic', record: null }], emptyContext(), 'system prompt');
119
192
  expect(result).toContain('SHARED HELPERS');
120
193
  expect(result).toContain('safeExec');
121
194
  expect(result).toContain('Instead of:');
@@ -126,7 +199,7 @@ describe('assemblePrompt', () => {
126
199
  specs: [makeSpec()],
127
200
  lessons: [makeLesson()],
128
201
  };
129
- const result = await assemblePrompt([{ issue: null, freeText: 'test' }], ctx, 'system prompt');
202
+ const result = await assemblePrompt([{ issue: null, freeText: 'test', record: null }], ctx, 'system prompt');
130
203
  expect(result).toContain('RELATED SPECS & ADRs');
131
204
  expect(result).toContain('RELEVANT LESSONS (HARD CONSTRAINTS)');
132
205
  });
@@ -145,6 +218,7 @@ describe('assemblePrompt', () => {
145
218
  labels: ['bug'],
146
219
  },
147
220
  freeText: null,
221
+ record: null,
148
222
  },
149
223
  ], ctx, 'system prompt');
150
224
  expect(result).toContain('ISSUE #42');
@@ -221,7 +295,7 @@ describe('retrieveContext partitioning', () => {
221
295
  code: [],
222
296
  lessons: [makeLesson({ filePath: '.totem/lessons.md' })],
223
297
  };
224
- const result = await assemblePrompt([{ issue: null, freeText: 'test' }], ctx, 'system prompt');
298
+ const result = await assemblePrompt([{ issue: null, freeText: 'test', record: null }], ctx, 'system prompt');
225
299
  // Lessons appear in their own section, not mixed with specs
226
300
  const specSection = result.split('RELATED SPECS & ADRs')[1]?.split('===')[0] ?? '';
227
301
  expect(specSection).not.toContain('lessons.md');
@@ -322,11 +396,11 @@ describe('resolveDefaultSpecPath', () => {
322
396
  pathJoin: (...parts) => parts.join('/'),
323
397
  };
324
398
  it('resolves single issue input to <gitRoot>/.totem/specs/<number>.md', () => {
325
- const result = resolveDefaultSpecPath([{ issue: makeIssue(1555), freeText: null }], '/repo/packages/cli', deps);
399
+ const result = resolveDefaultSpecPath([{ issue: makeIssue(1555), freeText: null, record: null }], '/repo/packages/cli', deps);
326
400
  expect(result).toBe('/repo/.totem/specs/1555.md');
327
401
  });
328
402
  it('resolves single free-text input to sanitized filename', () => {
329
- const result = resolveDefaultSpecPath([{ issue: null, freeText: 'migration plan' }], '/repo', deps);
403
+ const result = resolveDefaultSpecPath([{ issue: null, freeText: 'migration plan', record: null }], '/repo', deps);
330
404
  expect(result).toBe('/repo/.totem/specs/migration-plan.md');
331
405
  });
332
406
  it('falls back to cwd when git root is unavailable', () => {
@@ -334,27 +408,836 @@ describe('resolveDefaultSpecPath', () => {
334
408
  resolveGitRoot: vi.fn(() => null),
335
409
  pathJoin: (...parts) => parts.join('/'),
336
410
  };
337
- const result = resolveDefaultSpecPath([{ issue: makeIssue(42), freeText: null }], '/some/cwd', fallbackDeps);
411
+ const result = resolveDefaultSpecPath([{ issue: makeIssue(42), freeText: null, record: null }], '/some/cwd', fallbackDeps);
338
412
  expect(result).toBe('/some/cwd/.totem/specs/42.md');
339
413
  });
340
414
  it('returns null for multi-input invocations', () => {
341
415
  const result = resolveDefaultSpecPath([
342
- { issue: makeIssue(1), freeText: null },
343
- { issue: makeIssue(2), freeText: null },
416
+ { issue: makeIssue(1), freeText: null, record: null },
417
+ { issue: makeIssue(2), freeText: null, record: null },
344
418
  ], '/repo', deps);
345
419
  expect(result).toBeNull();
346
420
  });
347
421
  it('returns null when free text sanitizes to empty', () => {
348
- const result = resolveDefaultSpecPath([{ issue: null, freeText: '!!!' }], '/repo', deps);
422
+ const result = resolveDefaultSpecPath([{ issue: null, freeText: '!!!', record: null }], '/repo', deps);
349
423
  expect(result).toBeNull();
350
424
  });
351
425
  it('returns null when single input has neither issue nor free text', () => {
352
- const result = resolveDefaultSpecPath([{ issue: null, freeText: null }], '/repo', deps);
426
+ const result = resolveDefaultSpecPath([{ issue: null, freeText: null, record: null }], '/repo', deps);
353
427
  expect(result).toBeNull();
354
428
  });
355
429
  it('uses git root over cwd for monorepo subpackages', () => {
356
- const result = resolveDefaultSpecPath([{ issue: makeIssue(99), freeText: null }], '/repo/packages/cli/src', deps);
430
+ const result = resolveDefaultSpecPath([{ issue: makeIssue(99), freeText: null, record: null }], '/repo/packages/cli/src', deps);
357
431
  expect(result).toBe('/repo/.totem/specs/99.md');
358
432
  });
433
+ // mmnto-ai/totem#2700: a bound record derives NO path — the only path it
434
+ // could derive is the record's own, and the tool never drafts over it.
435
+ it('returns null for a bound record (the draft goes to stdout or --out)', () => {
436
+ const result = resolveDefaultSpecPath([{ issue: null, freeText: null, record: makeRecord() }], '/repo', deps);
437
+ expect(result).toBeNull();
438
+ });
439
+ });
440
+ // ─── SPEC_REQUIRED_SECTIONS (mmnto-ai/totem#2700) ────────
441
+ describe('SPEC_REQUIRED_SECTIONS', () => {
442
+ it('every entry is a VERBATIM line of the system prompt (the gate requires only what the command asks for)', () => {
443
+ const promptLines = SPEC_SYSTEM_PROMPT.split('\n');
444
+ for (const section of SPEC_REQUIRED_SECTIONS) {
445
+ expect(promptLines, `${section} is not a line of SPEC_SYSTEM_PROMPT`).toContain(section);
446
+ }
447
+ });
448
+ it('every entry is renderable into the single-quoted node -e reader (the mmnto-ai/totem#2692 C4 predicate)', () => {
449
+ 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);
454
+ }
455
+ });
456
+ it('names both promised sections, in prompt order', () => {
457
+ expect([...SPEC_REQUIRED_SECTIONS]).toEqual([
458
+ '### Problem Statement',
459
+ '### Implementation Tasks',
460
+ ]);
461
+ });
462
+ });
463
+ // ─── resolveGroundingAnchor (mmnto-ai/totem#2700) ────────
464
+ const RECORD_SHA = 'a'.repeat(64);
465
+ function makeRecord(overrides = {}) {
466
+ return {
467
+ path: '.totem/specs/2700.md',
468
+ sha256: RECORD_SHA,
469
+ body: '# Design record\n\nA body under the heading.\n',
470
+ ...overrides,
471
+ };
472
+ }
473
+ function issueInput(num, typed) {
474
+ return {
475
+ issue: makeIssue(num),
476
+ freeText: null,
477
+ record: null,
478
+ ...(typed !== undefined ? { issueRef: typed } : {}),
479
+ };
480
+ }
481
+ function topicInput(text) {
482
+ return { issue: null, freeText: text, record: null };
483
+ }
484
+ describe('resolveGroundingAnchor', () => {
485
+ it('a single bare-number issue anchors `issue` with a #<n> ref', () => {
486
+ expect(resolveGroundingAnchor([issueInput(2700, '2700')])).toEqual({
487
+ kind: GROUNDING_ANCHOR_ISSUE,
488
+ ref: '#2700',
489
+ });
490
+ });
491
+ it('keeps an owner/repo#N or URL input AS TYPED (a fetched issue carries only a number)', () => {
492
+ expect(resolveGroundingAnchor([issueInput(2700, 'mmnto-ai/totem#2700')])).toEqual({
493
+ kind: GROUNDING_ANCHOR_ISSUE,
494
+ ref: 'mmnto-ai/totem#2700',
495
+ });
496
+ expect(resolveGroundingAnchor([issueInput(42, 'https://github.com/mmnto-ai/totem/issues/42')])).toEqual({
497
+ kind: GROUNDING_ANCHOR_ISSUE,
498
+ ref: 'https://github.com/mmnto-ai/totem/issues/42',
499
+ });
500
+ });
501
+ it('falls back to #<number> when no typed form was recorded', () => {
502
+ expect(resolveGroundingAnchor([issueInput(7)])).toEqual({
503
+ kind: GROUNDING_ANCHOR_ISSUE,
504
+ ref: '#7',
505
+ });
506
+ });
507
+ it('comma-joins several issue refs', () => {
508
+ expect(resolveGroundingAnchor([issueInput(1, '1'), issueInput(2, 'mmnto-ai/totem#2')])).toEqual({
509
+ kind: GROUNDING_ANCHOR_ISSUE,
510
+ ref: '#1, mmnto-ai/totem#2',
511
+ });
512
+ });
513
+ it('a bound record anchors `record` with the repo-relative path and the sha256 of its bytes', () => {
514
+ expect(resolveGroundingAnchor([{ issue: null, freeText: null, record: makeRecord() }])).toEqual({
515
+ kind: GROUNDING_ANCHOR_RECORD,
516
+ ref: '.totem/specs/2700.md',
517
+ sha256: RECORD_SHA,
518
+ });
519
+ });
520
+ it('topics only anchor `free-text`, several joined with a pipe', () => {
521
+ expect(resolveGroundingAnchor([topicInput('cache invalidation')])).toEqual({
522
+ kind: GROUNDING_ANCHOR_FREE_TEXT,
523
+ ref: 'cache invalidation',
524
+ });
525
+ expect(resolveGroundingAnchor([topicInput('alpha'), topicInput('beta')])).toEqual({
526
+ kind: GROUNDING_ANCHOR_FREE_TEXT,
527
+ ref: 'alpha | beta',
528
+ });
529
+ });
530
+ it('issues AND topics anchor the honest `mixed` kind — issue refs first, then topics', () => {
531
+ expect(resolveGroundingAnchor([issueInput(9, '9'), topicInput('alpha'), topicInput('beta')])).toEqual({
532
+ kind: GROUNDING_ANCHOR_MIXED,
533
+ ref: '#9 | alpha | beta',
534
+ });
535
+ });
536
+ // A topic is the one anchor ref built out of raw argv, so it is the one that
537
+ // can carry a control character the user typed. `GroundingAnchorSchema`
538
+ // refuses such a ref — inside `saveRunArtifact`, under `runOrchestrator`'s
539
+ // warn-and-continue catch, so the run would survive and only the ARTIFACT
540
+ // would be silently lost. Collapsing at the mint site keeps the request
541
+ // parsable for a ref the CLI itself produced.
542
+ it.each([
543
+ ['a tab', 0x09],
544
+ ['a newline', 0x0a],
545
+ ['a DEL', 0x7f],
546
+ ['a C1 NEL', 0x85],
547
+ ])('collapses %s in a free-text topic to `?`, keeping the anchor parsable', (_label, code) => {
548
+ const topic = `cache${String.fromCharCode(code)}invalidation`;
549
+ const anchor = resolveGroundingAnchor([topicInput(topic)]);
550
+ expect(anchor).toEqual({ kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'cache?invalidation' });
551
+ expect(GroundingAnchorSchema.safeParse(anchor).success).toBe(true);
552
+ });
553
+ it('leaves printable non-ASCII in a topic alone — a free-text ref is the topic AS TYPED', () => {
554
+ expect(resolveGroundingAnchor([topicInput('ancrage café')]).ref).toBe('ancrage café');
555
+ });
556
+ it('collapses the topic half of a `mixed` ref too', () => {
557
+ const anchor = resolveGroundingAnchor([
558
+ issueInput(9, '9'),
559
+ topicInput(`alpha${String.fromCharCode(0x0a)}beta`),
560
+ ]);
561
+ expect(anchor.ref).toBe('#9 | alpha?beta');
562
+ expect(GroundingAnchorSchema.safeParse(anchor).success).toBe(true);
563
+ });
564
+ // The topic arm is not the only ref taken verbatim from argv. The issue-URL
565
+ // match is NOT end-anchored (`.../issues/(\d+)` with trailing text allowed),
566
+ // so `https://host/o/r/issues/1<newline>x` resolves to issue 1 and the typed
567
+ // spelling — newline and all — becomes the ref. Unsanitized it costs the run
568
+ // its artifact exactly as an unsanitized topic did.
569
+ it('collapses a control character in a TYPED issue ref (the URL match is not end-anchored)', () => {
570
+ const typed = `https://github.com/mmnto-ai/totem/issues/1${String.fromCharCode(0x0a)}[Totem] forged`;
571
+ const anchor = resolveGroundingAnchor([issueInput(1, typed)]);
572
+ expect(anchor).toEqual({
573
+ kind: GROUNDING_ANCHOR_ISSUE,
574
+ ref: 'https://github.com/mmnto-ai/totem/issues/1?[Totem] forged',
575
+ });
576
+ expect(GroundingAnchorSchema.safeParse(anchor).success).toBe(true);
577
+ });
578
+ it('leaves a printable typed issue ref byte-identical', () => {
579
+ expect(resolveGroundingAnchor([issueInput(2700, 'mmnto-ai/totem#2700')]).ref).toBe('mmnto-ai/totem#2700');
580
+ });
581
+ });
582
+ // ─── evaluateGroundingFloor (mmnto-ai/totem#2700) ────────
583
+ function relevantHit(relevance, overrides = {}) {
584
+ return makeSpec({
585
+ ...(relevance !== undefined ? { relevance } : {}),
586
+ ...overrides,
587
+ });
588
+ }
589
+ const FLOOR = 0.25;
590
+ describe('evaluateGroundingFloor', () => {
591
+ it('0 retrieved items REFUSES — nothing grounds the run (the charter rule, not an MCP mirror)', () => {
592
+ const verdict = evaluateGroundingFloor(emptyContext(), FLOOR);
593
+ expect(verdict).toEqual({
594
+ refuse: true,
595
+ hits: 0,
596
+ bestRelevance: null,
597
+ withheld: [],
598
+ floorExempt: 0,
599
+ });
600
+ });
601
+ it('every signal-bearing hit below the floor, none exempt, REFUSES', () => {
602
+ const verdict = evaluateGroundingFloor({ ...emptyContext(), specs: [relevantHit(0.2), relevantHit(0.11)] }, FLOOR);
603
+ expect(verdict.refuse).toBe(true);
604
+ expect(verdict.hits).toBe(2);
605
+ expect(verdict.bestRelevance).toBeCloseTo(0.2, 10);
606
+ expect(verdict.floorExempt).toBe(0);
607
+ });
608
+ it('one hit AT the floor PROCEEDS (the floor is inclusive)', () => {
609
+ const verdict = evaluateGroundingFloor({ ...emptyContext(), specs: [relevantHit(0.1), relevantHit(FLOOR)] }, FLOOR);
610
+ expect(verdict.refuse).toBe(false);
611
+ expect(verdict.withheld).toEqual([]);
612
+ });
613
+ 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);
615
+ expect(verdict.refuse).toBe(false);
616
+ expect(verdict.floorExempt).toBe(1);
617
+ expect(verdict.withheld).toEqual([]);
618
+ });
619
+ it('no relevance anywhere PROCEEDS — a pure-FTS corpus is never demoted', () => {
620
+ const verdict = evaluateGroundingFloor({ ...emptyContext(), specs: [relevantHit(undefined), relevantHit(undefined)] }, FLOOR);
621
+ expect(verdict.refuse).toBe(false);
622
+ expect(verdict.bestRelevance).toBeNull();
623
+ expect(verdict.floorExempt).toBe(2);
624
+ });
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);
631
+ expect(verdict.bestRelevance).toBeNull();
632
+ expect(verdict.withheld).toEqual([]);
633
+ expect(verdict.refuse).toBe(false);
634
+ });
635
+ it('a NaN hit beside a genuinely weak one is not disclosed as a withheld candidate', () => {
636
+ const verdict = evaluateGroundingFloor({
637
+ ...emptyContext(),
638
+ specs: [relevantHit(0.05, { filePath: 'docs/weak.md' }), relevantHit(NaN)],
639
+ }, FLOOR);
640
+ // One exempt hit is enough to proceed — and nothing is withheld.
641
+ expect(verdict.floorExempt).toBe(1);
642
+ expect(verdict.refuse).toBe(false);
643
+ expect(verdict.withheld).toEqual([]);
644
+ });
645
+ it('counts hits across ALL FOUR partitions', () => {
646
+ const verdict = evaluateGroundingFloor({
647
+ specs: [relevantHit(0.9)],
648
+ sessions: [relevantHit(0.8)],
649
+ code: [relevantHit(0.7)],
650
+ lessons: [relevantHit(0.6)],
651
+ }, FLOOR);
652
+ expect(verdict.hits).toBe(4);
653
+ expect(verdict.bestRelevance).toBeCloseTo(0.9, 10);
654
+ });
655
+ it('the withheld list carries every below-floor candidate as path + relevance (linked hits keep their store)', () => {
656
+ const verdict = evaluateGroundingFloor({
657
+ ...emptyContext(),
658
+ specs: [
659
+ relevantHit(0.2, { filePath: 'docs/a.md' }),
660
+ relevantHit(0.1, { filePath: 'doctrine/b.md', sourceRepo: 'strategy' }),
661
+ ],
662
+ }, FLOOR);
663
+ expect(verdict.withheld).toEqual([
664
+ { filePath: 'docs/a.md', relevance: 0.2 },
665
+ { filePath: 'doctrine/b.md', sourceRepo: 'strategy', relevance: 0.1 },
666
+ ]);
667
+ });
668
+ });
669
+ // ─── formatGroundingRefusal (mmnto-ai/totem#2700) ────────
670
+ describe('formatGroundingRefusal', () => {
671
+ 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);
674
+ expect(message).toContain('an-unanchored-slug');
675
+ expect(message).toContain('0 hits');
676
+ expect(message).toContain('floor 0.250 — searchRelevanceFloor in totem.config.ts (schema default 0.25 when unset)');
677
+ });
678
+ it('a below-floor refusal names the best relevance and DISCLOSES every withheld candidate', () => {
679
+ const verdict = evaluateGroundingFloor({
680
+ ...emptyContext(),
681
+ specs: [
682
+ relevantHit(0.2, { filePath: 'docs/a.md' }),
683
+ relevantHit(0.1, { filePath: 'doctrine/b.md', sourceRepo: 'strategy' }),
684
+ ],
685
+ }, FLOOR);
686
+ const { message } = formatGroundingRefusal('weak topic', verdict, FLOOR);
687
+ expect(message).toContain('best relevance 0.200');
688
+ expect(message).toContain('1. docs/a.md — relevance 0.200');
689
+ expect(message).toContain('2. [strategy] doctrine/b.md — relevance 0.100');
690
+ });
691
+ it('the hint names both cures and the --raw inspection path', () => {
692
+ const { recoveryHint } = formatGroundingRefusal('topic', evaluateGroundingFloor(emptyContext(), FLOOR), FLOOR);
693
+ expect(recoveryHint).toContain('totem spec <issue>');
694
+ expect(recoveryHint).toContain('totem spec --from <record>');
695
+ expect(recoveryHint).toContain('--raw');
696
+ });
697
+ });
698
+ // ─── The record arm of assemblePrompt + its query ────────
699
+ describe('assemblePrompt — the record arm (mmnto-ai/totem#2700)', () => {
700
+ it('renders the RECORD banner with the path and the digest head, and the body verbatim', async () => {
701
+ const record = makeRecord({ body: '# Design record\n\nThe ruled contract.\n' });
702
+ const result = await assemblePrompt([{ issue: null, freeText: null, record }], emptyContext(), 'system prompt');
703
+ expect(result).toContain(`=== RECORD .totem/specs/2700.md (sha256 ${'a'.repeat(12)}) ===`);
704
+ expect(result).toContain('<record_body>');
705
+ expect(result).toContain('The ruled contract.');
706
+ });
707
+ it('does not emit an ISSUE or TOPIC section for a record', async () => {
708
+ const result = await assemblePrompt([{ issue: null, freeText: null, record: makeRecord() }], emptyContext(), 'system prompt');
709
+ expect(result).not.toContain('=== TOPIC ===');
710
+ expect(result).not.toContain('=== ISSUE #');
711
+ });
712
+ });
713
+ describe('buildRecordSearchQuery', () => {
714
+ it('queries on the record`s first heading plus the head of its body', () => {
715
+ const query = buildRecordSearchQuery(makeRecord({ body: '# Anchored evidence\n\nThe body head.\n' }));
716
+ expect(query.startsWith('Anchored evidence')).toBe(true);
717
+ expect(query).toContain('The body head.');
718
+ });
719
+ it('degrades to the body head when the record carries no heading', () => {
720
+ const query = buildRecordSearchQuery(makeRecord({ body: 'no heading at all' }));
721
+ expect(query).toBe('no heading at all');
722
+ });
723
+ });
724
+ // ─── --from validation (mmnto-ai/totem#2700) ─────────────
725
+ describe('validateSpecInvocation', () => {
726
+ it('refuses no inputs and no --from, carrying the usage line', () => {
727
+ let thrown;
728
+ try {
729
+ validateSpecInvocation([], {}, TotemConfigError);
730
+ }
731
+ catch (err) {
732
+ thrown = err;
733
+ }
734
+ expect(thrown).toMatchObject({ code: 'CONFIG_INVALID' });
735
+ expect(String(thrown.message)).toContain('at least one issue/topic');
736
+ expect(String(thrown.recoveryHint)).toContain('Usage: totem spec [inputs...] [--from <record>]');
737
+ });
738
+ it('refuses --from together with positional inputs', () => {
739
+ expect(() => validateSpecInvocation(['2700'], { from: 'record.md' }, TotemConfigError)).toThrowError(/--from <record> cannot be combined with positional inputs/);
740
+ });
741
+ it('accepts inputs alone and --from alone', () => {
742
+ expect(() => validateSpecInvocation(['2700'], {}, TotemConfigError)).not.toThrow();
743
+ expect(() => validateSpecInvocation([], { from: 'record.md' }, TotemConfigError)).not.toThrow();
744
+ });
745
+ });
746
+ /**
747
+ * Whether this platform (and this account) can make each link kind — a Windows
748
+ * account without SeCreateSymbolicLinkPrivilege cannot symlink, and a
749
+ * filesystem without hardlinks cannot link. Probed ONCE, outside any test, so
750
+ * every `skipIf` below is a real capability check rather than a swallowed
751
+ * failure inside an assertion. Shared by the containment suite (`loadSpecRecord`
752
+ * must see THROUGH a link) and the `--out` alias suite (a link is a second name
753
+ * for the record).
754
+ */
755
+ const linkable = (() => {
756
+ const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-link-probe-'));
757
+ const target = path.join(probeDir, 'target');
758
+ fs.writeFileSync(target, 'probe');
759
+ const attempt = (make) => {
760
+ try {
761
+ make();
762
+ return true;
763
+ }
764
+ catch (err) {
765
+ void err;
766
+ return false;
767
+ }
768
+ };
769
+ const hard = attempt(() => fs.linkSync(target, path.join(probeDir, 'hard')));
770
+ const sym = attempt(() => fs.symlinkSync(target, path.join(probeDir, 'sym')));
771
+ cleanTmpDir(probeDir);
772
+ return { hard, sym };
773
+ })();
774
+ describe('loadSpecRecord', () => {
775
+ let tmpDir;
776
+ const deps = { resolveGitRoot: () => null };
777
+ beforeEach(() => {
778
+ tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-spec-record-')));
779
+ });
780
+ afterEach(() => {
781
+ cleanTmpDir(tmpDir);
782
+ });
783
+ it('binds the record: repo-relative forward-slash path, sha256 of the bytes, the body from the SAME buffer', () => {
784
+ const nested = path.join(tmpDir, '.totem', 'specs');
785
+ fs.mkdirSync(nested, { recursive: true });
786
+ const file = path.join(nested, '2700.md');
787
+ const bytes = Buffer.from('# Record\n\nBody.\n', 'utf-8');
788
+ fs.writeFileSync(file, bytes);
789
+ const loaded = loadSpecRecord(file, tmpDir, deps, TotemConfigError);
790
+ expect(loaded.record.path).toBe('.totem/specs/2700.md');
791
+ expect(loaded.record.sha256).toBe(crypto.createHash('sha256').update(bytes).digest('hex'));
792
+ expect(loaded.record.body).toBe(bytes.toString('utf-8'));
793
+ expect(loaded.absolutePath).toBe(path.resolve(file));
794
+ });
795
+ it('refuses a missing path, naming it', () => {
796
+ const missing = path.join(tmpDir, 'nope.md');
797
+ let thrown;
798
+ try {
799
+ loadSpecRecord(missing, tmpDir, deps, TotemConfigError);
800
+ }
801
+ catch (err) {
802
+ thrown = err;
803
+ }
804
+ expect(thrown).toMatchObject({ code: 'CONFIG_INVALID' });
805
+ expect(String(thrown.message)).toContain('--from record not found');
806
+ expect(String(thrown.message)).toContain(missing);
807
+ });
808
+ it('refuses a directory', () => {
809
+ const dir = path.join(tmpDir, 'adir');
810
+ fs.mkdirSync(dir);
811
+ expect(() => loadSpecRecord(dir, tmpDir, deps, TotemConfigError)).toThrowError(/--from record is not a file/);
812
+ });
813
+ it('refuses an empty record', () => {
814
+ const file = path.join(tmpDir, 'empty.md');
815
+ fs.writeFileSync(file, '');
816
+ expect(() => loadSpecRecord(file, tmpDir, deps, TotemConfigError)).toThrowError(/--from record is empty/);
817
+ });
818
+ it('refuses a whitespace-only record', () => {
819
+ const file = path.join(tmpDir, 'blank.md');
820
+ fs.writeFileSync(file, ' \n\t\n \n');
821
+ expect(() => loadSpecRecord(file, tmpDir, deps, TotemConfigError)).toThrowError(/--from record is empty/);
822
+ });
823
+ // ── The ref is relative to the GIT ROOT, not the cwd (mmnto-ai/totem#2700) ──
824
+ //
825
+ // Every case above stubs `resolveGitRoot: () => null`, which collapses root
826
+ // and cwd and leaves the git-root half of the path untested. The pre-commit
827
+ // reader resolves the ref from the WORKTREE TOP, so a `root = cwd` mutation
828
+ // would publish a ref no hook could open.
829
+ it('binds the ref against the git root even when the command runs from a subdirectory', () => {
830
+ const sub = path.join(tmpDir, 'sub');
831
+ fs.mkdirSync(sub, { recursive: true });
832
+ const nested = path.join(tmpDir, '.totem', 'specs');
833
+ fs.mkdirSync(nested, { recursive: true });
834
+ const file = path.join(nested, 'x.md');
835
+ fs.writeFileSync(file, '# X\n\nBody.\n');
836
+ const loaded = loadSpecRecord(file, sub, { resolveGitRoot: () => tmpDir }, TotemConfigError);
837
+ // A `root = cwd` mutation yields `../.totem/specs/x.md` — and would now be
838
+ // refused outright by the containment gate below.
839
+ expect(loaded.record.path).toBe('.totem/specs/x.md');
840
+ });
841
+ // ── Containment: the record must live inside the git root ──
842
+ it('refuses a record OUTSIDE the git root, naming the path and the root', () => {
843
+ const root = path.join(tmpDir, 'repo');
844
+ fs.mkdirSync(root, { recursive: true });
845
+ const outside = path.join(tmpDir, 'sibling.md');
846
+ fs.writeFileSync(outside, '# S\n\nBody.\n');
847
+ let thrown;
848
+ try {
849
+ loadSpecRecord('../sibling.md', root, { resolveGitRoot: () => root }, TotemConfigError);
850
+ }
851
+ catch (err) {
852
+ thrown = err;
853
+ }
854
+ expect(thrown).toMatchObject({ code: 'CONFIG_INVALID' });
855
+ const message = String(thrown.message);
856
+ expect(message).toContain('outside the repository');
857
+ expect(message).toContain(outside);
858
+ expect(message).toContain(root);
859
+ });
860
+ it('a file named `..notes.md` INSIDE the root is contained and binds normally', () => {
861
+ const file = path.join(tmpDir, '..notes.md');
862
+ fs.writeFileSync(file, '# N\n\nBody.\n');
863
+ const loaded = loadSpecRecord(file, tmpDir, { resolveGitRoot: () => tmpDir }, TotemConfigError);
864
+ expect(loaded.record.path).toBe('..notes.md');
865
+ });
866
+ // Containment must run BEFORE the record is stat'd or read: otherwise the
867
+ // out-of-root refusal is decided by whatever the read happens to say, and a
868
+ // path that escapes the repo surfaces as "not a file" (or an EISDIR-class
869
+ // read error) rather than as the containment refusal that carries the cure.
870
+ it('an out-of-root DIRECTORY is refused as uncontained, not as "not a file"', () => {
871
+ const root = path.join(tmpDir, 'repo');
872
+ fs.mkdirSync(root, { recursive: true });
873
+ const outsideDir = path.join(tmpDir, 'sibling-dir');
874
+ fs.mkdirSync(outsideDir, { recursive: true });
875
+ let thrown;
876
+ try {
877
+ loadSpecRecord('../sibling-dir', root, { resolveGitRoot: () => root }, TotemConfigError);
878
+ }
879
+ catch (err) {
880
+ thrown = err;
881
+ }
882
+ expect(thrown).toMatchObject({ code: 'CONFIG_INVALID' });
883
+ const message = String(thrown.message);
884
+ expect(message).toContain('outside the repository');
885
+ expect(message).not.toContain('is not a file');
886
+ });
887
+ // ── Containment sees THROUGH a link (mmnto-ai/totem#2700) ──
888
+ //
889
+ // A symlink inside the repo is lexically contained, so normalization alone
890
+ // reads it as legal — and would publish a ref the pre-commit reader resolves
891
+ // to a file outside the tree. Containment therefore also compares the two
892
+ // REALPATHS. `record.path` still carries the LEXICAL spelling of the file as
893
+ // given: the hook resolves links on its own side, and rewriting the ref to a
894
+ // link's target would publish a path the operator never wrote.
895
+ it.skipIf(!linkable.sym)('refuses an in-repo SYMLINK whose target is OUTSIDE the root, naming both paths', () => {
896
+ const root = path.join(tmpDir, 'repo');
897
+ fs.mkdirSync(root, { recursive: true });
898
+ const outside = path.join(tmpDir, 'outside.md');
899
+ fs.writeFileSync(outside, '# Outside\n\nBody.\n');
900
+ const link = path.join(root, 'linked.md');
901
+ fs.symlinkSync(outside, link);
902
+ let thrown;
903
+ try {
904
+ loadSpecRecord('linked.md', root, { resolveGitRoot: () => root }, TotemConfigError);
905
+ }
906
+ catch (err) {
907
+ thrown = err;
908
+ }
909
+ expect(thrown).toMatchObject({ code: 'CONFIG_INVALID' });
910
+ const message = String(thrown.message);
911
+ expect(message).toContain('outside the repository');
912
+ // The link as given AND what it resolves to — the second is the whole
913
+ // reason the first was refused. The resolved form is the filesystem's
914
+ // canonical path (a Windows runner's temp dir is handed out as an 8.3
915
+ // short name like RUNNER~1, which realpath expands), so compare against
916
+ // the same canonicalization the code applies, never the raw temp path.
917
+ expect(message).toContain(link);
918
+ expect(message).toContain(`resolves to ${fs.realpathSync.native(outside)}`);
919
+ });
920
+ it.skipIf(!linkable.sym)('accepts an in-repo SYMLINK to an in-repo file, binding the LEXICAL path', () => {
921
+ const root = path.join(tmpDir, 'repo');
922
+ const nested = path.join(root, '.totem', 'specs');
923
+ fs.mkdirSync(nested, { recursive: true });
924
+ const target = path.join(nested, '2700.md');
925
+ fs.writeFileSync(target, '# Record\n\nBody.\n');
926
+ const link = path.join(root, 'linked.md');
927
+ fs.symlinkSync(target, link);
928
+ const loaded = loadSpecRecord('linked.md', root, { resolveGitRoot: () => root }, TotemConfigError);
929
+ expect(loaded.record.path).toBe('linked.md');
930
+ expect(loaded.record.body).toBe('# Record\n\nBody.\n');
931
+ });
932
+ // ── A leading BOM is a decoding artifact, not part of the document ──
933
+ it('strips ONE leading BOM from the body while hashing the RAW bytes', () => {
934
+ const file = path.join(tmpDir, 'bom.md');
935
+ // Built numerically: an authored `\u` escape lands in a source file as a
936
+ // raw control byte through some editing paths (mmnto-ai/totem#2692).
937
+ const bom = String.fromCharCode(0xfeff);
938
+ const text = `${bom}# Anchored evidence\n\nThe body head.\n`;
939
+ const bytes = Buffer.from(text, 'utf-8');
940
+ fs.writeFileSync(file, bytes);
941
+ const loaded = loadSpecRecord(file, tmpDir, deps, TotemConfigError);
942
+ // The body the prompt and the query see opens on the heading itself.
943
+ expect(loaded.record.body.startsWith('#')).toBe(true);
944
+ expect(loaded.record.body).toBe('# Anchored evidence\n\nThe body head.\n');
945
+ expect(buildRecordSearchQuery(loaded.record).startsWith('Anchored evidence')).toBe(true);
946
+ // The digest is over the RAW bytes, BOM included: the pre-commit reader
947
+ // hashes the file as it sits on disk, so a stripped-before-hash digest
948
+ // would read as "revised since binding" on the very first commit.
949
+ expect(loaded.record.sha256).toBe(crypto.createHash('sha256').update(bytes).digest('hex'));
950
+ expect(loaded.record.sha256).not.toBe(crypto.createHash('sha256').update(Buffer.from(loaded.record.body, 'utf-8')).digest('hex'));
951
+ });
952
+ it('renders a BOM-prefixed record into the prompt with no mark in record_body', async () => {
953
+ const file = path.join(tmpDir, 'bom-prompt.md');
954
+ const bom = String.fromCharCode(0xfeff);
955
+ fs.writeFileSync(file, `${bom}# Anchored evidence\n\nThe body head.\n`, 'utf-8');
956
+ const loaded = loadSpecRecord(file, tmpDir, deps, TotemConfigError);
957
+ const prompt = await assemblePrompt([{ issue: null, freeText: null, record: loaded.record }], emptyContext(), 'SYSTEM');
958
+ expect(prompt).toContain(`<record_body>\n${loaded.record.body}\n</record_body>`);
959
+ expect(prompt).toContain('<record_body>\n# Anchored evidence');
960
+ expect(prompt).not.toContain(bom);
961
+ });
962
+ });
963
+ describe('isRecordPathOutsideRoot', () => {
964
+ // The cross-drive arm (`path.relative` returning an ABSOLUTE path when the
965
+ // record lives on another volume) cannot be staged portably through
966
+ // loadSpecRecord, so the predicate is exercised directly. It consults both
967
+ // path flavors because its input is already normalized to forward slashes:
968
+ // `D:/x.md` is not a repo-relative path on any platform.
969
+ // `a/../../x.md` is the case a first-segment test misses: it escapes the root
970
+ // without SAYING `..` first. The strict pre-commit reader is probed with the
971
+ // same shape (resolved against the worktree top), so neither side can be the
972
+ // looser of the two.
973
+ it.each([
974
+ '..',
975
+ '../sibling.md',
976
+ '../../etc/passwd',
977
+ 'a/../../x.md',
978
+ '/etc/passwd',
979
+ 'D:/records/x.md',
980
+ ])('refuses %s as outside the root', (relativePath) => {
981
+ expect(isRecordPathOutsideRoot(relativePath)).toBe(true);
982
+ });
983
+ it.each(['.totem/specs/2700.md', '..notes.md', 'a/../b.md', 'x.md'])('accepts %s as contained', (relativePath) => {
984
+ expect(isRecordPathOutsideRoot(relativePath)).toBe(false);
985
+ });
986
+ });
987
+ describe('assertOutDoesNotOverwriteRecord', () => {
988
+ it('refuses an --out that resolves to the record — never drafts over it', () => {
989
+ expect(() => assertOutDoesNotOverwriteRecord('./specs/2700.md', path.resolve('/repo', 'specs/2700.md'), '/repo', TotemConfigError)).toThrowError(/never drafts over the record/);
990
+ });
991
+ it('allows any other --out, and a missing --out', () => {
992
+ expect(() => assertOutDoesNotOverwriteRecord('draft.md', path.resolve('/repo', 'specs/2700.md'), '/repo', TotemConfigError)).not.toThrow();
993
+ expect(() => assertOutDoesNotOverwriteRecord(undefined, path.resolve('/repo', 'specs/2700.md'), '/repo', TotemConfigError)).not.toThrow();
994
+ });
995
+ // ── Aliases: a second NAME for the record is still the record ──
996
+ //
997
+ // The path-spelling comparison alone is defeated by either link kind: a
998
+ // symlink has a different resolved path, and a hardlink's two names share no
999
+ // path relationship at all — both would let the draft clobber the record.
1000
+ describe('link aliases', () => {
1001
+ let tmpDir;
1002
+ beforeEach(() => {
1003
+ tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-out-alias-')));
1004
+ });
1005
+ afterEach(() => {
1006
+ cleanTmpDir(tmpDir);
1007
+ });
1008
+ /** The record, written fresh, plus the absolute path of a would-be alias. */
1009
+ function stage(aliasName) {
1010
+ const record = path.join(tmpDir, 'record.md');
1011
+ fs.writeFileSync(record, '# Record\n\nThe ruled contract.\n');
1012
+ return { record, alias: path.join(tmpDir, aliasName) };
1013
+ }
1014
+ it.skipIf(!linkable.hard)('refuses a HARDLINK to the record — the two names share an inode, not a path', () => {
1015
+ const { record, alias } = stage('hard-alias.md');
1016
+ fs.linkSync(record, alias);
1017
+ expect(() => assertOutDoesNotOverwriteRecord(alias, record, tmpDir, TotemConfigError)).toThrowError(/never drafts over the record/);
1018
+ });
1019
+ it.skipIf(!linkable.sym)('refuses a SYMLINK to the record', () => {
1020
+ const { record, alias } = stage('sym-alias.md');
1021
+ fs.symlinkSync(record, alias);
1022
+ expect(() => assertOutDoesNotOverwriteRecord(alias, record, tmpDir, TotemConfigError)).toThrowError(/never drafts over the record/);
1023
+ });
1024
+ it('an unrelated EXISTING file beside the record is still allowed', () => {
1025
+ const { record, alias } = stage('other.md');
1026
+ fs.writeFileSync(alias, 'a different file\n');
1027
+ expect(() => assertOutDoesNotOverwriteRecord(alias, record, tmpDir, TotemConfigError)).not.toThrow();
1028
+ });
1029
+ it('a --out that does not exist yet is allowed (the common case)', () => {
1030
+ const { record, alias } = stage('not-yet.md');
1031
+ expect(() => assertOutDoesNotOverwriteRecord(alias, record, tmpDir, TotemConfigError)).not.toThrow();
1032
+ });
1033
+ });
1034
+ });
1035
+ // ─── specCommand, executed (mmnto-ai/totem#2700) ─────────
1036
+ describe('specCommand — anchored evidence, executed against stubbed seams', () => {
1037
+ let tmpDir;
1038
+ let originalCwd;
1039
+ /** Every `log.warn` message the command emitted, in order. */
1040
+ let warnings = [];
1041
+ /** Every `log.dim` message the command emitted, in order. */
1042
+ let dims = [];
1043
+ /** Files currently under the run store the orchestrator would write to. */
1044
+ function runArtifactNames() {
1045
+ const dir = path.join(tmpDir, '.totem', 'artifacts', 'runs');
1046
+ return fs.existsSync(dir) ? fs.readdirSync(dir) : [];
1047
+ }
1048
+ function writeRecord(body, name = 'record.md') {
1049
+ const file = path.join(tmpDir, name);
1050
+ fs.writeFileSync(file, body, 'utf-8');
1051
+ return file;
1052
+ }
1053
+ function sha256Of(file) {
1054
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
1055
+ }
1056
+ /** The artifact request the (single) orchestrator call carried. */
1057
+ function artifactRequest() {
1058
+ expect(harness.orchestratorArgs.length).toBe(1);
1059
+ return harness.orchestratorArgs[0]['artifact'];
1060
+ }
1061
+ beforeEach(() => {
1062
+ tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-spec-cmd-')));
1063
+ originalCwd = process.cwd();
1064
+ process.chdir(tmpDir);
1065
+ harness.searchResults = {};
1066
+ harness.orchestratorArgs = [];
1067
+ harness.orchestratorContent = 'DRAFT';
1068
+ harness.connects = 0;
1069
+ harness.writes = [];
1070
+ harness.config = {
1071
+ totemDir: '.totem',
1072
+ lanceDir: '.lancedb',
1073
+ searchRelevanceFloor: FLOOR,
1074
+ embedding: { provider: 'gemini', model: 'test' },
1075
+ };
1076
+ warnings = [];
1077
+ dims = [];
1078
+ vi.spyOn(log, 'warn').mockImplementation((_tag, msg) => {
1079
+ warnings.push(msg);
1080
+ });
1081
+ vi.spyOn(log, 'dim').mockImplementation((_tag, msg) => {
1082
+ dims.push(msg);
1083
+ });
1084
+ vi.spyOn(log, 'info').mockImplementation(() => { });
1085
+ vi.spyOn(log, 'success').mockImplementation(() => { });
1086
+ });
1087
+ afterEach(() => {
1088
+ vi.restoreAllMocks();
1089
+ process.chdir(originalCwd);
1090
+ cleanTmpDir(tmpDir);
1091
+ });
1092
+ it('refuses "no inputs and no --from" BEFORE the store connects', async () => {
1093
+ await expect(specCommand([], {})).rejects.toThrowError(/at least one issue\/topic/);
1094
+ expect(harness.connects).toBe(0);
1095
+ expect(harness.orchestratorArgs).toEqual([]);
1096
+ });
1097
+ it.each([
1098
+ ['a missing record', () => path.join(tmpDir, 'nope.md'), /--from record not found/],
1099
+ [
1100
+ 'a directory',
1101
+ () => {
1102
+ const dir = path.join(tmpDir, 'adir');
1103
+ fs.mkdirSync(dir);
1104
+ return dir;
1105
+ },
1106
+ /--from record is not a file/,
1107
+ ],
1108
+ ['an empty record', () => writeRecord(''), /--from record is empty/],
1109
+ ['a whitespace-only record', () => writeRecord(' \n \n'), /--from record is empty/],
1110
+ ])('refuses %s BEFORE the store connects and before any LLM call', async (_label, make, re) => {
1111
+ await expect(specCommand([], { from: make() })).rejects.toThrowError(re);
1112
+ expect(harness.connects).toBe(0);
1113
+ expect(harness.orchestratorArgs).toEqual([]);
1114
+ expect(runArtifactNames()).toEqual([]);
1115
+ });
1116
+ it('refuses --from together with positional inputs BEFORE the store connects', async () => {
1117
+ const record = writeRecord('# R\n\nbody\n');
1118
+ await expect(specCommand(['2700'], { from: record })).rejects.toThrowError(/cannot be combined with positional inputs/);
1119
+ expect(harness.connects).toBe(0);
1120
+ expect(harness.orchestratorArgs).toEqual([]);
1121
+ });
1122
+ it('refuses an --out that resolves to the record BEFORE the store connects', async () => {
1123
+ const record = writeRecord('# R\n\nbody\n');
1124
+ await expect(specCommand([], { from: record, out: record })).rejects.toThrowError(/never drafts over the record/);
1125
+ expect(harness.connects).toBe(0);
1126
+ expect(harness.orchestratorArgs).toEqual([]);
1127
+ expect(runArtifactNames()).toEqual([]);
1128
+ });
1129
+ it('a --from run anchors on the record, leaves its bytes UNCHANGED, and drafts to stdout', async () => {
1130
+ const record = writeRecord('# Design record\n\nThe ruled contract.\n');
1131
+ const before = sha256Of(record);
1132
+ harness.searchResults = { spec: [relevantHit(0.7)] };
1133
+ await specCommand([], { from: record, stdout: true });
1134
+ expect(sha256Of(record)).toBe(before);
1135
+ const artifact = artifactRequest();
1136
+ expect(artifact['anchor']).toEqual({
1137
+ kind: GROUNDING_ANCHOR_RECORD,
1138
+ ref: 'record.md',
1139
+ sha256: before,
1140
+ });
1141
+ expect(artifact['floor']).toBe(FLOOR);
1142
+ expect(harness.orchestratorArgs[0]['runMetadata']).toMatchObject({
1143
+ caller: 'spec',
1144
+ promptSource: PROMPT_SOURCE_BUILTIN,
1145
+ });
1146
+ // The prompt carries the very bytes the digest binds — the WHOLE record,
1147
+ // not a fragment of it. A `toContain` on one sentence would still pass if
1148
+ // the record were truncated or re-wrapped on the way into the prompt, and
1149
+ // `anchor.sha256` would then name bytes the model never saw.
1150
+ const prompt = String(harness.orchestratorArgs[0]['prompt']);
1151
+ const bytes = fs.readFileSync(record, 'utf-8');
1152
+ expect(prompt).toContain(`<record_body>\n${bytes}\n</record_body>`);
1153
+ expect(harness.writes).toEqual([{ content: 'DRAFT' }]);
1154
+ });
1155
+ it('a --from run without --out or --stdout says there is NO derived path for a record', async () => {
1156
+ const record = writeRecord('# R\n\nbody\n');
1157
+ harness.searchResults = { spec: [relevantHit(0.7)] };
1158
+ await specCommand([], { from: record });
1159
+ expect(harness.writes).toEqual([{ content: 'DRAFT' }]);
1160
+ expect(dims).toContain('No derived path for a record — use --out <path> to keep the draft.');
1161
+ });
1162
+ it('records promptSource "override" when a custom system prompt drafted the run', async () => {
1163
+ const promptsDir = path.join(tmpDir, '.totem', 'prompts');
1164
+ fs.mkdirSync(promptsDir, { recursive: true });
1165
+ fs.writeFileSync(path.join(promptsDir, 'spec.md'), '# Custom prompt\n', 'utf-8');
1166
+ harness.searchResults = { spec: [relevantHit(0.7)] };
1167
+ await specCommand(['2700'], { stdout: true });
1168
+ expect(harness.orchestratorArgs[0]['runMetadata']).toMatchObject({
1169
+ promptSource: PROMPT_SOURCE_OVERRIDE,
1170
+ });
1171
+ });
1172
+ it('an ISSUE-anchored run proceeds with anchor kind `issue` and emits NO not-evidence warning', async () => {
1173
+ harness.searchResults = { spec: [relevantHit(0.7)] };
1174
+ await specCommand(['2700'], { stdout: true });
1175
+ expect(artifactRequest()['anchor']).toEqual({ kind: GROUNDING_ANCHOR_ISSUE, ref: '#2700' });
1176
+ expect(warnings.some((line) => line.includes('NOT gate evidence'))).toBe(false);
1177
+ });
1178
+ it('a free-text run above the floor PROCEEDS but is warned as NOT gate evidence', async () => {
1179
+ harness.searchResults = { spec: [relevantHit(0.7)] };
1180
+ await specCommand(['some topic'], { stdout: true });
1181
+ expect(artifactRequest()['anchor']).toEqual({
1182
+ kind: GROUNDING_ANCHOR_FREE_TEXT,
1183
+ ref: 'some topic',
1184
+ });
1185
+ expect(warnings.some((line) => line.includes('NOT gate evidence'))).toBe(true);
1186
+ expect(warnings.some((line) => line.includes(GROUNDING_ANCHOR_FREE_TEXT))).toBe(true);
1187
+ });
1188
+ it('an issue + topic run anchors `mixed`, proceeds, and is warned as NOT gate evidence', async () => {
1189
+ harness.searchResults = { spec: [relevantHit(0.7)] };
1190
+ await specCommand(['2700', 'a loose topic'], { stdout: true });
1191
+ expect(artifactRequest()['anchor']).toEqual({
1192
+ kind: GROUNDING_ANCHOR_MIXED,
1193
+ ref: '#2700 | a loose topic',
1194
+ });
1195
+ expect(warnings.some((line) => line.includes(GROUNDING_ANCHOR_MIXED))).toBe(true);
1196
+ expect(warnings.some((line) => line.includes('NOT gate evidence'))).toBe(true);
1197
+ });
1198
+ // "Mints nothing" is proved by the orchestrator never being REACHED —
1199
+ // `runOrchestrator` is the only writer of a run artifact and it is stubbed
1200
+ // here, so a run-store file count could not fail whatever the command did.
1201
+ it('a free-text run with 0 hits REFUSES and mints nothing (the orchestrator is never reached)', async () => {
1202
+ await expect(specCommand(['nonsense slug'], { stdout: true })).rejects.toThrowError(/Retrieval returned 0 hits/);
1203
+ expect(harness.orchestratorArgs).toEqual([]);
1204
+ });
1205
+ it('a free-text run entirely below the floor REFUSES, naming the floor and every withheld candidate', async () => {
1206
+ harness.searchResults = { spec: [relevantHit(0.1, { filePath: 'docs/a.md' })] };
1207
+ let thrown;
1208
+ try {
1209
+ await specCommand(['weak slug'], { stdout: true });
1210
+ }
1211
+ catch (err) {
1212
+ thrown = err;
1213
+ }
1214
+ expect(thrown).toMatchObject({ code: 'GATE_INVALID' });
1215
+ const message = String(thrown.message);
1216
+ expect(message).toContain('weak slug');
1217
+ expect(message).toContain('best relevance 0.100');
1218
+ expect(message).toContain('floor 0.250 — searchRelevanceFloor in totem.config.ts (schema default 0.25 when unset)');
1219
+ expect(message).toContain('docs/a.md — relevance 0.100');
1220
+ expect(harness.orchestratorArgs).toEqual([]);
1221
+ });
1222
+ it('an ISSUE-anchored run with 0 hits is NEVER refused (an issue is grounding)', async () => {
1223
+ await specCommand(['2700'], { stdout: true });
1224
+ expect(harness.orchestratorArgs.length).toBe(1);
1225
+ });
1226
+ it('a --from run with 0 hits is NEVER refused (a record is grounding)', async () => {
1227
+ const record = writeRecord('# R\n\nbody\n');
1228
+ await specCommand([], { from: record, stdout: true });
1229
+ expect(harness.orchestratorArgs.length).toBe(1);
1230
+ });
1231
+ it('--raw is EXEMPT from the refusal: it reaches the orchestrator, which prints context and mints nothing', async () => {
1232
+ await specCommand(['nonsense slug'], { raw: true });
1233
+ expect(harness.orchestratorArgs.length).toBe(1);
1234
+ expect(harness.orchestratorArgs[0]['options']).toMatchObject({ raw: true });
1235
+ });
1236
+ it('the proceed path records the floor it was judged against on the artifact', async () => {
1237
+ harness.config = { ...harness.config, searchRelevanceFloor: 0.6 };
1238
+ harness.searchResults = { spec: [relevantHit(0.9)] };
1239
+ await specCommand(['2700'], { stdout: true });
1240
+ expect(artifactRequest()['floor']).toBe(0.6);
1241
+ });
359
1242
  });
360
1243
  //# sourceMappingURL=spec.test.js.map