@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,10 +1,13 @@
1
1
  import { execSync, spawnSync } from 'node:child_process';
2
+ import * as crypto from 'node:crypto';
2
3
  import * as fs from 'node:fs';
3
4
  import * as os from 'node:os';
4
5
  import * as path from 'node:path';
5
6
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
+ import { GROUNDING_ANCHOR_FREE_TEXT, GROUNDING_ANCHOR_ISSUE, GROUNDING_ANCHOR_MIXED, GROUNDING_ANCHOR_RECORD, PROMPT_SOURCE_BUILTIN, PROMPT_SOURCE_OVERRIDE, RUN_ARTIFACT_SCHEMA_VERSION, RunArtifactSchema, } from '@mmnto/totem';
6
8
  import { cleanTmpDir } from '../test-utils.js';
7
9
  import { buildHookContent, buildPostCheckoutHookContent, buildPreCommitHook, buildPrePushHook, buildResolveBlock, checkHooksInstalled, detectTotemPrefix, generateHookHelpers, getFallbackCommand, installGitHook, installHooksNonInteractive, TOTEM_HOOK_END, TOTEM_HOOK_MARKER, TOTEM_PRECOMMIT_END, TOTEM_PRECOMMIT_MARKER, TOTEM_PREPUSH_END, TOTEM_PREPUSH_MARKER, upgradePrePushHookIfNeeded, } from './install-hooks.js';
10
+ import { SPEC_REQUIRED_SECTIONS } from './spec-templates.js';
8
11
  // The default render options every pre-#2692 positional call implied
9
12
  // (mmnto-ai/totem#2692 C2 — the builders take a REQUIRED options object now, so
10
13
  // each site states the tier/totemDir/fallbackCmd it always assumed). Spread and
@@ -14,6 +17,84 @@ const RENDER = {
14
17
  totemDir: '.totem',
15
18
  fallbackCmd: 'pnpm dlx @mmnto/cli',
16
19
  };
20
+ // ─── Anchored-evidence fixtures (mmnto-ai/totem#2700) ───
21
+ //
22
+ // Since #2700 an artifact is evidence only when it is ANCHORED and its SUBJECT
23
+ // carries a shape. These build the smallest artifact that satisfies each arm,
24
+ // so a test that means to exercise something else (age, newest-wins, torn
25
+ // files) does not accidentally exercise the shape check.
26
+ /** A draft that satisfies the TEMPLATE shape: every required heading with a body. */
27
+ const TEMPLATE_DRAFT = SPEC_REQUIRED_SECTIONS.map((heading) => `${heading}\n\nA non-blank body under ${heading}.\n`).join('\n');
28
+ /** A draft that satisfies only the looser DOCUMENT shape — one heading with a body. */
29
+ const DOCUMENT_DRAFT = '## A hand-shaped section\n\nA body under it.\n';
30
+ /** The minimal ISSUE-anchored, template-shaped run artifact the gate accepts. */
31
+ function specEvidenceArtifact(overrides = {}) {
32
+ return {
33
+ admission: { runMetadata: { caller: 'spec', promptSource: PROMPT_SOURCE_BUILTIN } },
34
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_ISSUE, ref: '#2700' } },
35
+ output: { content: TEMPLATE_DRAFT },
36
+ createdAt: new Date().toISOString(),
37
+ ...overrides,
38
+ };
39
+ }
40
+ /**
41
+ * The fixture wrapped in the fields every run artifact must carry and the
42
+ * reader never touches — so the whole thing can be parsed by the REAL schema.
43
+ */
44
+ function asRunArtifact(fixture) {
45
+ const grounding = fixture['grounding'];
46
+ const output = fixture['output'];
47
+ return {
48
+ ...fixture,
49
+ schemaVersion: RUN_ARTIFACT_SCHEMA_VERSION,
50
+ inputBundle: { maskedPrompt: 'the masked prompt' },
51
+ inputHash: 'a'.repeat(64),
52
+ grounding: { hash: 'b'.repeat(64), provenanceSummary: 'similarity-only:1', ...grounding },
53
+ backend: {
54
+ provider: 'gemini',
55
+ model: 'gemini-3-flash-preview',
56
+ qualifiedModel: 'gemini:gemini-3-flash-preview',
57
+ admissionClass: 'completion_only',
58
+ taskProfile: 'Spec',
59
+ },
60
+ output: { metrics: { durationMs: 500 }, ...output },
61
+ };
62
+ }
63
+ // The hook's reader is a rendered `node -e` string: it walks the artifact by
64
+ // HAND-SPELLED paths (`grounding.anchor`, `admission.runMetadata.promptSource`,
65
+ // `output.content`) that no type checker sees. These tests bind the PASS
66
+ // fixtures those paths are exercised against to the real core schema, so a
67
+ // rename or a shape change on the writer's side breaks a test here rather than
68
+ // silently leaving the hook reading a field that no longer exists.
69
+ describe('the anchored-evidence fixtures parse as real run artifacts (mmnto-ai/totem#2700)', () => {
70
+ it('the issue-anchored PASS fixture round-trips through RunArtifactSchema with its three read paths intact', () => {
71
+ const parsed = RunArtifactSchema.parse(asRunArtifact(specEvidenceArtifact()));
72
+ expect(parsed.grounding.anchor).toEqual({ kind: GROUNDING_ANCHOR_ISSUE, ref: '#2700' });
73
+ expect(parsed.admission?.runMetadata?.promptSource).toBe(PROMPT_SOURCE_BUILTIN);
74
+ expect(parsed.output.content).toBe(TEMPLATE_DRAFT);
75
+ });
76
+ it('the record-anchored PASS fixture round-trips, sha256 and all', () => {
77
+ const sha256 = 'a'.repeat(64);
78
+ const parsed = RunArtifactSchema.parse(asRunArtifact(specEvidenceArtifact({
79
+ grounding: {
80
+ anchor: { kind: GROUNDING_ANCHOR_RECORD, ref: '.totem/specs/2700.md', sha256 },
81
+ },
82
+ })));
83
+ expect(parsed.grounding.anchor).toEqual({
84
+ kind: GROUNDING_ANCHOR_RECORD,
85
+ ref: '.totem/specs/2700.md',
86
+ sha256,
87
+ });
88
+ });
89
+ it('the override-prompt PASS fixture round-trips with promptSource "override"', () => {
90
+ const parsed = RunArtifactSchema.parse(asRunArtifact(specEvidenceArtifact({
91
+ admission: { runMetadata: { caller: 'spec', promptSource: PROMPT_SOURCE_OVERRIDE } },
92
+ output: { content: DOCUMENT_DRAFT },
93
+ })));
94
+ expect(parsed.admission?.runMetadata?.promptSource).toBe(PROMPT_SOURCE_OVERRIDE);
95
+ expect(parsed.output.content).toBe(DOCUMENT_DRAFT);
96
+ });
97
+ });
17
98
  describe('detectTotemPrefix', () => {
18
99
  let tmpDir;
19
100
  beforeEach(() => {
@@ -1315,6 +1396,30 @@ describe('buildPreCommitHook with strict tier', () => {
1315
1396
  expect(hook).toContain('spec evidence:');
1316
1397
  expect(hook).not.toContain('(legacy marker)');
1317
1398
  });
1399
+ // mmnto-ai/totem#2700 — the reader's vocabulary is RENDERED from the one
1400
+ // canonical constant, never re-spelled in the hook text, so a change to the
1401
+ // writer's spelling can never leave the gate reading a stale word.
1402
+ it('renders SPEC_REQUIRED_SECTIONS and the anchor vocabulary as JSON literals', () => {
1403
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1404
+ expect(hook).toContain(`const REQUIRED = ${JSON.stringify(SPEC_REQUIRED_SECTIONS)};`);
1405
+ expect(hook).toContain(`const KIND_ISSUE = ${JSON.stringify(GROUNDING_ANCHOR_ISSUE)};`);
1406
+ expect(hook).toContain(`const KIND_RECORD = ${JSON.stringify(GROUNDING_ANCHOR_RECORD)};`);
1407
+ expect(hook).toContain(`const PROMPT_OVERRIDE = ${JSON.stringify(PROMPT_SOURCE_OVERRIDE)};`);
1408
+ });
1409
+ it('carries the exit-3 arm distinctly from the exit-2 and reader-failure arms', () => {
1410
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1411
+ expect(hook).toContain('elif [ "$reader_status" = "3" ]; then');
1412
+ expect(hook).toContain(`echo "[Totem] BLOCKED: $spec_evidence — run 'totem spec <issue>' or 'totem spec --from <record>' (add --fresh if the response is cached) (strict mode)"`);
1413
+ // The two pre-existing arms keep their exact text.
1414
+ expect(hook).toContain('no totem spec run artifact under .totem/artifacts/runs/');
1415
+ expect(hook).toContain('the spec-evidence reader could not run (node exit status');
1416
+ });
1417
+ it('the reader body carries no single quote (it lives inside a single-quoted node -e word)', () => {
1418
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1419
+ const reader = hook.split("node -e '")[1]?.split("\n' 2>/dev/null)")[0] ?? '';
1420
+ expect(reader.length).toBeGreaterThan(0);
1421
+ expect(reader).not.toContain("'");
1422
+ });
1318
1423
  });
1319
1424
  // The strict block EXECUTED under sh (mmnto-ai/totem#2690): the string checks
1320
1425
  // above pin the text; these pin the behavior the gate exists for. `git init` +
@@ -1382,10 +1487,7 @@ describe('buildPreCommitHook strict evidence — executed under sh (mmnto-ai/tot
1382
1487
  expect(r.stdout).toContain('no totem spec run artifact under .totem/artifacts/runs/');
1383
1488
  });
1384
1489
  it.skipIf(!shellOk)('passes on a spec run artifact and prints the evidence line with its age', () => {
1385
- writeRun('a.json', {
1386
- admission: { runMetadata: { caller: 'spec' } },
1387
- createdAt: new Date().toISOString(),
1388
- });
1490
+ writeRun('a.json', specEvidenceArtifact());
1389
1491
  const r = runHook();
1390
1492
  expect(r.status).toBe(0);
1391
1493
  expect(r.stdout).toContain('[Totem] spec evidence: .totem/artifacts/runs/a.json (');
@@ -1423,10 +1525,7 @@ describe('buildPreCommitHook strict evidence — executed under sh (mmnto-ai/tot
1423
1525
  });
1424
1526
  it.skipIf(!shellOk)('skips a torn (unparseable) artifact and still passes on a valid one beside it', () => {
1425
1527
  writeRun('torn.json', '{"admission": {"runMetadata": {"caller": "spec"');
1426
- writeRun('ok.json', {
1427
- admission: { runMetadata: { caller: 'spec' } },
1428
- createdAt: new Date().toISOString(),
1429
- });
1528
+ writeRun('ok.json', specEvidenceArtifact());
1430
1529
  const r = runHook();
1431
1530
  expect(r.status).toBe(0);
1432
1531
  expect(r.stdout).toContain('.totem/artifacts/runs/ok.json');
@@ -1437,18 +1536,463 @@ describe('buildPreCommitHook strict evidence — executed under sh (mmnto-ai/tot
1437
1536
  expect(r.status).toBe(1);
1438
1537
  });
1439
1538
  it.skipIf(!shellOk)('names the NEWEST spec artifact by its own createdAt', () => {
1440
- writeRun('old.json', {
1441
- admission: { runMetadata: { caller: 'spec' } },
1442
- createdAt: '2026-01-01T00:00:00.000Z',
1539
+ writeRun('old.json', specEvidenceArtifact({ createdAt: '2026-01-01T00:00:00.000Z' }));
1540
+ writeRun('new.json', specEvidenceArtifact());
1541
+ const r = runHook();
1542
+ expect(r.status).toBe(0);
1543
+ expect(r.stdout).toContain('.totem/artifacts/runs/new.json');
1544
+ expect(r.stdout).not.toContain('old.json');
1545
+ });
1546
+ });
1547
+ // The mmnto-ai/totem#2700 arm, EXECUTED: an artifact is evidence only when it
1548
+ // is ANCHORED (issue | record) and its SUBJECT carries a shape. Every negation
1549
+ // must BLOCK with its OWN named reason — never with the "no artifact" line and
1550
+ // never as a reader failure — because the reason is the whole cure.
1551
+ describe('buildPreCommitHook anchored evidence — executed under sh (mmnto-ai/totem#2700)', () => {
1552
+ const shellOk = spawnSync('sh', ['-c', 'command -v node >/dev/null 2>&1'], { encoding: 'utf-8' }).status === 0;
1553
+ /**
1554
+ * Whether this platform (and this account) can make a symlink — a Windows
1555
+ * account without SeCreateSymbolicLinkPrivilege cannot. Probed ONCE, outside
1556
+ * any test, so the skip is a real capability check rather than a swallowed
1557
+ * failure inside the assertion.
1558
+ */
1559
+ const symlinkable = (() => {
1560
+ const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-link-probe-'));
1561
+ const target = path.join(probeDir, 'target');
1562
+ fs.writeFileSync(target, 'probe');
1563
+ let ok = false;
1564
+ try {
1565
+ fs.symlinkSync(target, path.join(probeDir, 'sym'));
1566
+ ok = true;
1567
+ }
1568
+ catch (err) {
1569
+ void err;
1570
+ ok = false;
1571
+ }
1572
+ cleanTmpDir(probeDir);
1573
+ return ok;
1574
+ })();
1575
+ let tmpDir;
1576
+ /** A directory OUTSIDE the worktree — the target of the escaping-symlink case. */
1577
+ let outsideDir;
1578
+ beforeEach(() => {
1579
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-anchor-'));
1580
+ outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-outside-'));
1581
+ execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' });
1582
+ execSync('git checkout -q -b feat/anchored', { cwd: tmpDir, stdio: 'ignore' });
1583
+ fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook(RENDER));
1584
+ });
1585
+ afterEach(() => {
1586
+ cleanTmpDir(tmpDir);
1587
+ cleanTmpDir(outsideDir);
1588
+ });
1589
+ function runHook() {
1590
+ const r = spawnSync('sh', ['./pre-commit'], {
1591
+ cwd: tmpDir,
1592
+ encoding: 'utf-8',
1593
+ env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
1443
1594
  });
1444
- writeRun('new.json', {
1595
+ return { status: r.status, stdout: r.stdout };
1596
+ }
1597
+ function writeRun(name, artifact) {
1598
+ const dir = path.join(tmpDir, '.totem', 'artifacts', 'runs');
1599
+ fs.mkdirSync(dir, { recursive: true });
1600
+ fs.writeFileSync(path.join(dir, name), JSON.stringify(artifact, null, 2));
1601
+ }
1602
+ /** Write a bound record at a repo-relative path and return its sha256. */
1603
+ function writeRecord(relPath, body) {
1604
+ const abs = path.join(tmpDir, ...relPath.split('/'));
1605
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
1606
+ fs.writeFileSync(abs, body, 'utf-8');
1607
+ return crypto.createHash('sha256').update(fs.readFileSync(abs)).digest('hex');
1608
+ }
1609
+ /** Every BLOCKED arm must be distinguishable from the other two arms. */
1610
+ function expectDistinctBlock(stdout) {
1611
+ expect(stdout).not.toContain('no totem spec run artifact');
1612
+ expect(stdout).not.toContain('the spec-evidence reader could not run');
1613
+ }
1614
+ // ── The migration invariant ──
1615
+ it.skipIf(!shellOk)('a PRE-EXISTING artifact (no grounding.anchor) BLOCKS with the `predates` reason, not the no-artifact line', () => {
1616
+ writeRun('legacy.json', {
1445
1617
  admission: { runMetadata: { caller: 'spec' } },
1618
+ output: { content: TEMPLATE_DRAFT },
1446
1619
  createdAt: new Date().toISOString(),
1447
1620
  });
1448
1621
  const r = runHook();
1622
+ expect(r.status).toBe(1);
1623
+ expect(r.stdout).toContain('predates the anchored-evidence rule (no grounding.anchor)');
1624
+ expect(r.stdout).toContain('.totem/artifacts/runs/legacy.json');
1625
+ expectDistinctBlock(r.stdout);
1626
+ });
1627
+ // ── The unanchored kinds ──
1628
+ it.skipIf(!shellOk)('a `free-text` anchor BLOCKS, naming the kind and the topic', () => {
1629
+ writeRun('ft.json', specEvidenceArtifact({
1630
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'a loose slug' } },
1631
+ }));
1632
+ const r = runHook();
1633
+ expect(r.status).toBe(1);
1634
+ expect(r.stdout).toContain(`is anchored ${GROUNDING_ANCHOR_FREE_TEXT} (a loose slug)`);
1635
+ expect(r.stdout).toContain('not gate evidence');
1636
+ expectDistinctBlock(r.stdout);
1637
+ });
1638
+ it.skipIf(!shellOk)('a `mixed` anchor BLOCKS, naming the kind and the ref', () => {
1639
+ writeRun('mx.json', specEvidenceArtifact({
1640
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_MIXED, ref: '#2700 | a loose slug' } },
1641
+ }));
1642
+ const r = runHook();
1643
+ expect(r.status).toBe(1);
1644
+ expect(r.stdout).toContain(`is anchored ${GROUNDING_ANCHOR_MIXED} (#2700 | a loose slug)`);
1645
+ expectDistinctBlock(r.stdout);
1646
+ });
1647
+ it.skipIf(!shellOk)('both cures are named on every BLOCKED arm', () => {
1648
+ writeRun('ft.json', specEvidenceArtifact({
1649
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'slug' } },
1650
+ }));
1651
+ const r = runHook();
1652
+ expect(r.stdout).toContain("run 'totem spec <issue>' or 'totem spec --from <record>'");
1653
+ });
1654
+ // ── The TEMPLATE shape (issue anchor, built-in prompt) ──
1655
+ it.skipIf(!shellOk)('the one-token draft (a lone newline) BLOCKS on the first missing heading', () => {
1656
+ writeRun('thin.json', specEvidenceArtifact({ output: { content: '\n' } }));
1657
+ const r = runHook();
1658
+ expect(r.status).toBe(1);
1659
+ expect(r.stdout).toContain('is missing heading ### Problem Statement');
1660
+ expectDistinctBlock(r.stdout);
1661
+ });
1662
+ it.skipIf(!shellOk)('both required headings present over EMPTY bodies BLOCK, naming the empty heading', () => {
1663
+ writeRun('empty.json', specEvidenceArtifact({
1664
+ output: { content: '### Problem Statement\n\n### Implementation Tasks\n' },
1665
+ }));
1666
+ const r = runHook();
1667
+ expect(r.status).toBe(1);
1668
+ expect(r.stdout).toContain('has an empty heading ### Problem Statement');
1669
+ expectDistinctBlock(r.stdout);
1670
+ });
1671
+ it.skipIf(!shellOk)('a second required heading left empty BLOCKS, naming THAT heading', () => {
1672
+ writeRun('half.json', specEvidenceArtifact({
1673
+ output: {
1674
+ content: '### Problem Statement\n\nA real body.\n\n### Implementation Tasks\n',
1675
+ },
1676
+ }));
1677
+ const r = runHook();
1678
+ expect(r.status).toBe(1);
1679
+ expect(r.stdout).toContain('has an empty heading ### Implementation Tasks');
1680
+ });
1681
+ it.skipIf(!shellOk)('both required headings with bodies PASS, and the evidence line carries the anchor and the shape', () => {
1682
+ writeRun('ok.json', specEvidenceArtifact());
1683
+ const r = runHook();
1449
1684
  expect(r.status).toBe(0);
1450
- expect(r.stdout).toContain('.totem/artifacts/runs/new.json');
1451
- expect(r.stdout).not.toContain('old.json');
1685
+ expect(r.stdout).toContain('[Totem] spec evidence: .totem/artifacts/runs/ok.json (');
1686
+ expect(r.stdout).toContain(`· anchor ${GROUNDING_ANCHOR_ISSUE} #2700`);
1687
+ expect(r.stdout).toContain('· shape TEMPLATE');
1688
+ });
1689
+ it.skipIf(!shellOk)('a non-string draft on an issue anchor BLOCKS as not-text', () => {
1690
+ writeRun('nt.json', specEvidenceArtifact({ output: { content: { nested: true } } }));
1691
+ const r = runHook();
1692
+ expect(r.status).toBe(1);
1693
+ expect(r.stdout).toContain('the draft is not text');
1694
+ expectDistinctBlock(r.stdout);
1695
+ });
1696
+ // ── The DOCUMENT shape (override prompt on an issue anchor) ──
1697
+ it.skipIf(!shellOk)('an OVERRIDE-prompt draft without the template headings PASSES on one heading with a body', () => {
1698
+ writeRun('ov.json', specEvidenceArtifact({
1699
+ admission: {
1700
+ runMetadata: { caller: 'spec', promptSource: PROMPT_SOURCE_OVERRIDE },
1701
+ },
1702
+ output: { content: DOCUMENT_DRAFT },
1703
+ }));
1704
+ const r = runHook();
1705
+ expect(r.status).toBe(0);
1706
+ expect(r.stdout).toContain('· shape DOCUMENT');
1707
+ });
1708
+ it.skipIf(!shellOk)('an OVERRIDE-prompt draft with no heading at all BLOCKS', () => {
1709
+ writeRun('ovbad.json', specEvidenceArtifact({
1710
+ admission: { runMetadata: { caller: 'spec', promptSource: PROMPT_SOURCE_OVERRIDE } },
1711
+ output: { content: 'just prose, no heading\n' },
1712
+ }));
1713
+ const r = runHook();
1714
+ expect(r.status).toBe(1);
1715
+ expect(r.stdout).toContain('has no heading with a body');
1716
+ expect(r.stdout).toContain('custom prompt');
1717
+ expectDistinctBlock(r.stdout);
1718
+ });
1719
+ // ── The record arm: the SUBJECT is the record's bytes ──
1720
+ function recordArtifact(ref, sha256, draft = TEMPLATE_DRAFT) {
1721
+ return specEvidenceArtifact({
1722
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_RECORD, ref, sha256 } },
1723
+ output: { content: draft },
1724
+ });
1725
+ }
1726
+ it.skipIf(!shellOk)('a present record with a heading and a body PASSES, reporting `record sha256 matches`', () => {
1727
+ const sha = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
1728
+ writeRun('rec.json', recordArtifact('.totem/specs/2700.md', sha));
1729
+ const r = runHook();
1730
+ expect(r.status).toBe(0);
1731
+ expect(r.stdout).toContain(`· anchor ${GROUNDING_ANCHOR_RECORD} .totem/specs/2700.md`);
1732
+ expect(r.stdout).toContain('· shape DOCUMENT');
1733
+ expect(r.stdout).toContain('· record sha256 matches');
1734
+ });
1735
+ it.skipIf(!shellOk)('a REVISED record still PASSES — the digest is a SENSOR, never a gate', () => {
1736
+ const bound = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
1737
+ writeRun('rec.json', recordArtifact('.totem/specs/2700.md', bound));
1738
+ const now = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract, folded.\n');
1739
+ const r = runHook();
1740
+ expect(r.status).toBe(0);
1741
+ expect(r.stdout).toContain(`record revised since binding (bound ${bound.slice(0, 8)}, now ${now.slice(0, 8)})`);
1742
+ });
1743
+ it.skipIf(!shellOk)('a MISSING record BLOCKS, naming the path it was bound at', () => {
1744
+ writeRun('rec.json', recordArtifact('.totem/specs/gone.md', 'b'.repeat(64)));
1745
+ const r = runHook();
1746
+ expect(r.status).toBe(1);
1747
+ expect(r.stdout).toContain('the bound record is missing at .totem/specs/gone.md');
1748
+ expectDistinctBlock(r.stdout);
1749
+ });
1750
+ it.skipIf(!shellOk)('a record with NO heading-with-a-body BLOCKS', () => {
1751
+ const sha = writeRecord('.totem/specs/2700.md', 'prose with no heading\n');
1752
+ writeRun('rec.json', recordArtifact('.totem/specs/2700.md', sha));
1753
+ const r = runHook();
1754
+ expect(r.status).toBe(1);
1755
+ expect(r.stdout).toContain('the bound record at .totem/specs/2700.md has no heading with a body');
1756
+ expectDistinctBlock(r.stdout);
1757
+ });
1758
+ it.skipIf(!shellOk)('a record whose heading has no body BLOCKS (a heading alone is not a document)', () => {
1759
+ const sha = writeRecord('.totem/specs/2700.md', '# Heading only\n\n## Another heading\n');
1760
+ writeRun('rec.json', recordArtifact('.totem/specs/2700.md', sha));
1761
+ const r = runHook();
1762
+ expect(r.status).toBe(1);
1763
+ expect(r.stdout).toContain('has no heading with a body');
1764
+ });
1765
+ it.skipIf(!shellOk)('a THIN draft with a rich record PASSES — the reader never reads output.content on a record anchor', () => {
1766
+ const sha = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
1767
+ writeRun('rec.json', recordArtifact('.totem/specs/2700.md', sha, '\n'));
1768
+ const r = runHook();
1769
+ expect(r.status).toBe(0);
1770
+ expect(r.stdout).toContain('· record sha256 matches');
1771
+ });
1772
+ it.skipIf(!shellOk)('a RICH draft with a missing record BLOCKS — the record is the subject, not the draft', () => {
1773
+ writeRun('rec.json', recordArtifact('.totem/specs/gone.md', 'c'.repeat(64), TEMPLATE_DRAFT));
1774
+ const r = runHook();
1775
+ expect(r.status).toBe(1);
1776
+ expect(r.stdout).toContain('the bound record is missing at');
1777
+ });
1778
+ // ── The body-line boundary is a HEADING, not a leading "#" ──
1779
+ //
1780
+ // `#2700 is the issue.` is prose about an issue number, not a heading: no
1781
+ // space follows the hashes. Reading it as a boundary made a real body look
1782
+ // empty — the exact sentence a totem design record is most likely to open a
1783
+ // section with.
1784
+ it.skipIf(!shellOk)('a body line starting with `#2700` is a BODY on the TEMPLATE arm', () => {
1785
+ const draft = SPEC_REQUIRED_SECTIONS.map((heading) => `${heading}\n\n#2700 is the issue.\n`).join('\n');
1786
+ writeRun('hashbody.json', specEvidenceArtifact({ output: { content: draft } }));
1787
+ const r = runHook();
1788
+ expect(r.status).toBe(0);
1789
+ expect(r.stdout).toContain('· shape TEMPLATE');
1790
+ });
1791
+ it.skipIf(!shellOk)('a body line starting with `#2700` is a BODY on the DOCUMENT arm', () => {
1792
+ const sha = writeRecord('.totem/specs/2700.md', '# Record\n\n#2700 is the issue.\n');
1793
+ writeRun('hashrec.json', recordArtifact('.totem/specs/2700.md', sha));
1794
+ const r = runHook();
1795
+ expect(r.status).toBe(0);
1796
+ expect(r.stdout).toContain('· record sha256 matches');
1797
+ });
1798
+ // ── The DOCUMENT shape tolerates two ordinary authoring bytes ──
1799
+ it.skipIf(!shellOk)('a DRAFT opening with a UTF-8 BOM still reads as a template', () => {
1800
+ // The BOM strip runs on the SUBJECT, before the split — so it covers the
1801
+ // TEMPLATE arm too, where an unstripped BOM would make the first required
1802
+ // heading fail its exact-line match and BLOCK a well-formed draft.
1803
+ const bom = String.fromCharCode(0xfeff);
1804
+ writeRun('bomdraft.json', specEvidenceArtifact({ output: { content: bom + TEMPLATE_DRAFT } }));
1805
+ const r = runHook();
1806
+ expect(r.status).toBe(0);
1807
+ expect(r.stdout).toContain('· shape TEMPLATE');
1808
+ });
1809
+ it.skipIf(!shellOk)('a record opening with a UTF-8 BOM still reads as a document', () => {
1810
+ const bom = String.fromCharCode(0xfeff);
1811
+ const sha = writeRecord('.totem/specs/2700.md', `${bom}# Record\n\nThe ruled contract.\n`);
1812
+ writeRun('bom.json', recordArtifact('.totem/specs/2700.md', sha));
1813
+ const r = runHook();
1814
+ expect(r.status).toBe(0);
1815
+ expect(r.stdout).toContain('· shape DOCUMENT');
1816
+ });
1817
+ it.skipIf(!shellOk)('a heading separated from its text by a TAB is a heading', () => {
1818
+ const sha = writeRecord('.totem/specs/2700.md', '#\tTabbed heading\n\nThe body.\n');
1819
+ writeRun('tab.json', recordArtifact('.totem/specs/2700.md', sha));
1820
+ const r = runHook();
1821
+ expect(r.status).toBe(0);
1822
+ expect(r.stdout).toContain('· shape DOCUMENT');
1823
+ });
1824
+ // ── The artifact is hand-editable: nothing read out of it is trusted text ──
1825
+ it.skipIf(!shellOk)('a newline in anchor.ref cannot forge a second [Totem] line', () => {
1826
+ const forged = `slug${String.fromCharCode(0x0a)}[Totem] spec evidence: forged`;
1827
+ writeRun('inject.json', specEvidenceArtifact({
1828
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: forged } },
1829
+ }));
1830
+ const r = runHook();
1831
+ expect(r.status).toBe(1);
1832
+ // Exactly one line of the hook's own output claims to be from Totem.
1833
+ expect(r.stdout.split('\n').filter((line) => line.startsWith('[Totem]'))).toHaveLength(1);
1834
+ // The control character is collapsed, not dropped — the ref stays legible.
1835
+ expect(r.stdout).toContain('slug?[Totem] spec evidence: forged');
1836
+ });
1837
+ it.skipIf(!shellOk)('a C1 control (U+0085 NEL) in anchor.ref is collapsed too', () => {
1838
+ // A predicate stopping at 0x7f let the whole C1 band through; NEL is a
1839
+ // LINE BREAK to some terminals and pagers, so it is a forged-line vector
1840
+ // of exactly the same kind as 0x0a.
1841
+ const forged = `slug${String.fromCharCode(0x85)}[Totem] spec evidence: forged`;
1842
+ writeRun('nel.json', specEvidenceArtifact({
1843
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: forged } },
1844
+ }));
1845
+ const r = runHook();
1846
+ expect(r.status).toBe(1);
1847
+ expect(r.stdout).toContain('slug?[Totem] spec evidence: forged');
1848
+ expect(r.stdout).not.toContain(String.fromCharCode(0x85));
1849
+ });
1850
+ it.skipIf(!shellOk)('a newline in createdAt cannot forge a second [Totem] line on the PASS path', () => {
1851
+ // `createdAt` is read raw off the artifact and ECHOED in the evidence
1852
+ // line: unsanitized, a hand-edited stamp forges a second `[Totem] spec
1853
+ // evidence:` line inside a passing commit — the quietest place to hide
1854
+ // one.
1855
+ const forged = `2026-09-02T00:00:00.000Z${String.fromCharCode(0x0a)}[Totem] spec evidence: FORGED`;
1856
+ writeRun('stamp.json', specEvidenceArtifact({ createdAt: forged }));
1857
+ const r = runHook();
1858
+ expect(r.status).toBe(0);
1859
+ expect(r.stdout.split('\n').filter((line) => line.startsWith('[Totem]'))).toHaveLength(1);
1860
+ expect(r.stdout).toContain('.000Z?[Totem] spec evidence: FORGED');
1861
+ });
1862
+ it.skipIf(!shellOk)('a record ref that is ABSOLUTE is refused as outside the worktree', () => {
1863
+ writeRun('abs.json', recordArtifact('/etc/passwd', 'd'.repeat(64)));
1864
+ const r = runHook();
1865
+ expect(r.status).toBe(1);
1866
+ expect(r.stdout).toContain('the bound record ref is outside the worktree: /etc/passwd');
1867
+ expectDistinctBlock(r.stdout);
1868
+ });
1869
+ it.skipIf(!shellOk)('a record ref whose first segment is `..` is refused as outside the worktree', () => {
1870
+ writeRun('dotdot.json', recordArtifact('../outside.md', 'd'.repeat(64)));
1871
+ const r = runHook();
1872
+ expect(r.status).toBe(1);
1873
+ expect(r.stdout).toContain('the bound record ref is outside the worktree: ../outside.md');
1874
+ expectDistinctBlock(r.stdout);
1875
+ });
1876
+ // ── Containment is decided by RESOLUTION, not by segment 0 ──
1877
+ //
1878
+ // A first-segment test reads `sub/../../OUTSIDE.md` and `./../OUTSIDE.md` as
1879
+ // contained, so the reader would have READ and PASSED a file outside the
1880
+ // worktree. The reader now resolves the ref against `process.cwd()` — the
1881
+ // worktree top git runs hooks from — and refuses anything that lands outside
1882
+ // it, which is also the honest rule for the legitimate mid-path `..`.
1883
+ /** A backslash, built rather than escaped — the mmnto-ai/totem#2692 authoring trap. */
1884
+ const BACKSLASH = String.fromCharCode(0x5c);
1885
+ it.skipIf(!shellOk).each([
1886
+ ['sub/../../OUTSIDE.md', 'a `..` past segment 0'],
1887
+ ['./../OUTSIDE.md', 'a `.` first segment hiding a `..`'],
1888
+ [
1889
+ `a${BACKSLASH}..${BACKSLASH}..${BACKSLASH}OUTSIDE.md`,
1890
+ 'the same escape spelled with backslashes',
1891
+ ],
1892
+ ])('refuses %s as outside the worktree (%s)', (ref) => {
1893
+ writeRun('escape.json', recordArtifact(ref, 'd'.repeat(64)));
1894
+ const r = runHook();
1895
+ expect(r.status).toBe(1);
1896
+ expect(r.stdout).toContain(`the bound record ref is outside the worktree: ${ref}`);
1897
+ expectDistinctBlock(r.stdout);
1898
+ });
1899
+ it.skipIf(!shellOk)('a mid-path `..` that stays INSIDE the worktree still PASSES — containment, not a segment ban', () => {
1900
+ // `a/` must exist for POSIX path resolution to reach `a/../b.md`.
1901
+ fs.mkdirSync(path.join(tmpDir, 'a'), { recursive: true });
1902
+ const sha = writeRecord('b.md', '# Record\n\nThe ruled contract.\n');
1903
+ writeRun('inside.json', recordArtifact('a/../b.md', sha));
1904
+ const r = runHook();
1905
+ expect(r.status).toBe(0);
1906
+ expect(r.stdout).toContain('· record sha256 matches');
1907
+ });
1908
+ it.skipIf(!shellOk)('a record anchor with NO sha256 BLOCKS — a binding without bytes is not a binding', () => {
1909
+ writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
1910
+ writeRun('nosha.json', specEvidenceArtifact({
1911
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_RECORD, ref: '.totem/specs/2700.md' } },
1912
+ }));
1913
+ const r = runHook();
1914
+ expect(r.status).toBe(1);
1915
+ expect(r.stdout).toContain('the record anchor carries no sha256 — not evidence');
1916
+ expectDistinctBlock(r.stdout);
1917
+ });
1918
+ it.skipIf(!shellOk)('a record anchor whose sha256 is MALFORMED BLOCKS with its OWN reason, not the absent one', () => {
1919
+ // "No digest" and "a digest that is not a digest" are different repairs:
1920
+ // the first says re-bind, the second says the artifact was edited. One
1921
+ // shared message would send both to the wrong cure.
1922
+ writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
1923
+ writeRun('badsha.json', recordArtifact('.totem/specs/2700.md', 'not-a-digest'));
1924
+ const r = runHook();
1925
+ expect(r.status).toBe(1);
1926
+ expect(r.stdout).toContain('the record anchor sha256 is not a 64-hex digest (not-a-digest) — not evidence');
1927
+ expect(r.stdout).not.toContain('carries no sha256');
1928
+ expectDistinctBlock(r.stdout);
1929
+ });
1930
+ // ── Containment sees THROUGH a link (mmnto-ai/totem#2700) ──
1931
+ //
1932
+ // A symlink inside the worktree is LEXICALLY contained, so the resolve-based
1933
+ // test above reads it as legal — and the reader would open, hash and judge a
1934
+ // file outside the tree. The realpath pair is compared once the ref is known
1935
+ // to exist and BEFORE its bytes are read.
1936
+ it.skipIf(!shellOk || !symlinkable)('a record ref that is a SYMLINK to OUTSIDE the worktree BLOCKS, naming both spellings', () => {
1937
+ const target = path.join(outsideDir, 'record.md');
1938
+ fs.writeFileSync(target, '# Outside\n\nThe ruled contract.\n', 'utf-8');
1939
+ const sha = crypto.createHash('sha256').update(fs.readFileSync(target)).digest('hex');
1940
+ fs.symlinkSync(target, path.join(tmpDir, 'linked.md'));
1941
+ // A CORRECT digest, so the block cannot be the sha256 arm firing early.
1942
+ writeRun('symlink-out.json', recordArtifact('linked.md', sha));
1943
+ const r = runHook();
1944
+ expect(r.status).toBe(1);
1945
+ expect(r.stdout).toContain('the bound record resolves outside the worktree: linked.md -> ');
1946
+ expect(r.stdout).toContain(fs.realpathSync.native(target));
1947
+ expectDistinctBlock(r.stdout);
1948
+ });
1949
+ it.skipIf(!shellOk || !symlinkable)('a record ref that is a SYMLINK to an IN-worktree file PASSES — containment, not a link ban', () => {
1950
+ const sha = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
1951
+ fs.symlinkSync(path.join(tmpDir, '.totem', 'specs', '2700.md'), path.join(tmpDir, 'linked.md'));
1952
+ writeRun('symlink-in.json', recordArtifact('linked.md', sha));
1953
+ const r = runHook();
1954
+ expect(r.status).toBe(0);
1955
+ expect(r.stdout).toContain('· record sha256 matches');
1956
+ });
1957
+ // ── The cure names the cache (mmnto-ai/totem#2700 m11) ──
1958
+ it.skipIf(!shellOk)('the BLOCKED cure names --fresh — a cached response mints no artifact', () => {
1959
+ writeRun('ft.json', specEvidenceArtifact({
1960
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'slug' } },
1961
+ }));
1962
+ const r = runHook();
1963
+ expect(r.stdout).toContain('(add --fresh if the response is cached)');
1964
+ });
1965
+ // ── The other arms stay distinct ──
1966
+ it.skipIf(!shellOk)('exit 3 is NEVER reported as a reader failure', () => {
1967
+ writeRun('ft.json', specEvidenceArtifact({
1968
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'slug' } },
1969
+ }));
1970
+ const r = runHook();
1971
+ expect(r.status).toBe(1);
1972
+ expect(r.stdout).not.toContain('node exit status');
1973
+ });
1974
+ it.skipIf(!shellOk)('the carried-object substring control still BLOCKS with the no-artifact line', () => {
1975
+ // Only the TOP-LEVEL caller counts: a review artifact that carries a
1976
+ // `caller: spec` object below the top level is not a spec artifact at all,
1977
+ // so the gate reports "none found", not "not evidence".
1978
+ writeRun('r.json', {
1979
+ admission: { runMetadata: { caller: 'review' } },
1980
+ inputBundle: { runMetadata: { caller: 'spec' } },
1981
+ createdAt: new Date().toISOString(),
1982
+ });
1983
+ const r = runHook();
1984
+ expect(r.status).toBe(1);
1985
+ expect(r.stdout).toContain('no totem spec run artifact');
1986
+ });
1987
+ it.skipIf(!shellOk)('the retired hand-set marker still rescues nothing', () => {
1988
+ fs.mkdirSync(path.join(tmpDir, '.totem', 'cache'), { recursive: true });
1989
+ fs.writeFileSync(path.join(tmpDir, '.totem', 'cache', '.spec-completed'), '');
1990
+ writeRun('ft.json', specEvidenceArtifact({
1991
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'slug' } },
1992
+ }));
1993
+ const r = runHook();
1994
+ expect(r.status).toBe(1);
1995
+ expect(r.stdout).not.toContain('spec evidence:');
1452
1996
  });
1453
1997
  });
1454
1998
  // The mmnto-ai/totem#2692 keystone, EXECUTED: under a custom `totemDir` the
@@ -1485,10 +2029,7 @@ describe('buildPreCommitHook strict evidence under a CUSTOM totemDir — execute
1485
2029
  fs.mkdirSync(dir, { recursive: true });
1486
2030
  fs.writeFileSync(path.join(dir, name), JSON.stringify(artifact, null, 2));
1487
2031
  }
1488
- const specArtifact = () => ({
1489
- admission: { runMetadata: { caller: 'spec' } },
1490
- createdAt: new Date().toISOString(),
1491
- });
2032
+ const specArtifact = () => specEvidenceArtifact();
1492
2033
  it.skipIf(!shellOk)('PASSES on evidence written under the configured totemDir', () => {
1493
2034
  writeRunUnder(CUSTOM_TOTEM_DIR, 'a.json', specArtifact());
1494
2035
  const r = runHook();