@mmnto/cli 1.121.0 → 1.123.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 (73) 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 +89 -1
  6. package/dist/commands/config-drift.test.js.map +1 -1
  7. package/dist/commands/init-templates.d.ts +6 -6
  8. package/dist/commands/init-templates.d.ts.map +1 -1
  9. package/dist/commands/init-templates.js +8 -7
  10. package/dist/commands/init-templates.js.map +1 -1
  11. package/dist/commands/init.test.js +18 -5
  12. package/dist/commands/init.test.js.map +1 -1
  13. package/dist/commands/install-hooks.d.ts.map +1 -1
  14. package/dist/commands/install-hooks.js +241 -17
  15. package/dist/commands/install-hooks.js.map +1 -1
  16. package/dist/commands/install-hooks.test.js +1019 -23
  17. package/dist/commands/install-hooks.test.js.map +1 -1
  18. package/dist/commands/legs.d.ts +259 -0
  19. package/dist/commands/legs.d.ts.map +1 -0
  20. package/dist/commands/legs.js +623 -0
  21. package/dist/commands/legs.js.map +1 -0
  22. package/dist/commands/legs.test.d.ts +20 -0
  23. package/dist/commands/legs.test.d.ts.map +1 -0
  24. package/dist/commands/legs.test.js +1004 -0
  25. package/dist/commands/legs.test.js.map +1 -0
  26. package/dist/commands/pre-push-gate-matrix.test.js +14 -0
  27. package/dist/commands/pre-push-gate-matrix.test.js.map +1 -1
  28. package/dist/commands/review-fan.d.ts +40 -1
  29. package/dist/commands/review-fan.d.ts.map +1 -1
  30. package/dist/commands/review-fan.js +108 -3
  31. package/dist/commands/review-fan.js.map +1 -1
  32. package/dist/commands/review-fan.test.js +444 -6
  33. package/dist/commands/review-fan.test.js.map +1 -1
  34. package/dist/commands/shield-covariate.test.js +19 -2
  35. package/dist/commands/shield-covariate.test.js.map +1 -1
  36. package/dist/commands/shield-nonreview.test.js +140 -1
  37. package/dist/commands/shield-nonreview.test.js.map +1 -1
  38. package/dist/commands/shield.d.ts.map +1 -1
  39. package/dist/commands/shield.js +53 -1
  40. package/dist/commands/shield.js.map +1 -1
  41. package/dist/commands/spec-cli-wiring.test.d.ts +19 -0
  42. package/dist/commands/spec-cli-wiring.test.d.ts.map +1 -0
  43. package/dist/commands/spec-cli-wiring.test.js +90 -0
  44. package/dist/commands/spec-cli-wiring.test.js.map +1 -0
  45. package/dist/commands/spec-templates.d.ts +18 -0
  46. package/dist/commands/spec-templates.d.ts.map +1 -1
  47. package/dist/commands/spec-templates.js +21 -0
  48. package/dist/commands/spec-templates.js.map +1 -1
  49. package/dist/commands/spec.d.ts +168 -1
  50. package/dist/commands/spec.d.ts.map +1 -1
  51. package/dist/commands/spec.js +448 -7
  52. package/dist/commands/spec.js.map +1 -1
  53. package/dist/commands/spec.test.js +903 -20
  54. package/dist/commands/spec.test.js.map +1 -1
  55. package/dist/git.d.ts +19 -0
  56. package/dist/git.d.ts.map +1 -1
  57. package/dist/git.js +13 -4
  58. package/dist/git.js.map +1 -1
  59. package/dist/index.js +47 -2
  60. package/dist/index.js.map +1 -1
  61. package/dist/services/run-artifacts.d.ts +12 -1
  62. package/dist/services/run-artifacts.d.ts.map +1 -1
  63. package/dist/services/run-artifacts.js +48 -3
  64. package/dist/services/run-artifacts.js.map +1 -1
  65. package/dist/services/run-artifacts.test.js +97 -1
  66. package/dist/services/run-artifacts.test.js.map +1 -1
  67. package/dist/utils.d.ts +22 -3
  68. package/dist/utils.d.ts.map +1 -1
  69. package/dist/utils.js +4 -0
  70. package/dist/utils.js.map +1 -1
  71. package/dist/utils.test.js +65 -1
  72. package/dist/utils.test.js.map +1 -1
  73. package/package.json +2 -2
@@ -1,10 +1,14 @@
1
- import { execSync, spawnSync } from 'node:child_process';
1
+ import { execFileSync, 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';
6
+ import { fileURLToPath } from 'node:url';
5
7
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
8
+ 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
9
  import { cleanTmpDir } from '../test-utils.js';
7
10
  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';
11
+ import { SPEC_REQUIRED_SECTIONS } from './spec-templates.js';
8
12
  // The default render options every pre-#2692 positional call implied
9
13
  // (mmnto-ai/totem#2692 C2 — the builders take a REQUIRED options object now, so
10
14
  // each site states the tier/totemDir/fallbackCmd it always assumed). Spread and
@@ -14,6 +18,84 @@ const RENDER = {
14
18
  totemDir: '.totem',
15
19
  fallbackCmd: 'pnpm dlx @mmnto/cli',
16
20
  };
21
+ // ─── Anchored-evidence fixtures (mmnto-ai/totem#2700) ───
22
+ //
23
+ // Since #2700 an artifact is evidence only when it is ANCHORED and its SUBJECT
24
+ // carries a shape. These build the smallest artifact that satisfies each arm,
25
+ // so a test that means to exercise something else (age, newest-wins, torn
26
+ // files) does not accidentally exercise the shape check.
27
+ /** A draft that satisfies the TEMPLATE shape: every required heading with a body. */
28
+ const TEMPLATE_DRAFT = SPEC_REQUIRED_SECTIONS.map((heading) => `${heading}\n\nA non-blank body under ${heading}.\n`).join('\n');
29
+ /** A draft that satisfies only the looser DOCUMENT shape — one heading with a body. */
30
+ const DOCUMENT_DRAFT = '## A hand-shaped section\n\nA body under it.\n';
31
+ /** The minimal ISSUE-anchored, template-shaped run artifact the gate accepts. */
32
+ function specEvidenceArtifact(overrides = {}) {
33
+ return {
34
+ admission: { runMetadata: { caller: 'spec', promptSource: PROMPT_SOURCE_BUILTIN } },
35
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_ISSUE, ref: '#2700' } },
36
+ output: { content: TEMPLATE_DRAFT },
37
+ createdAt: new Date().toISOString(),
38
+ ...overrides,
39
+ };
40
+ }
41
+ /**
42
+ * The fixture wrapped in the fields every run artifact must carry and the
43
+ * reader never touches — so the whole thing can be parsed by the REAL schema.
44
+ */
45
+ function asRunArtifact(fixture) {
46
+ const grounding = fixture['grounding'];
47
+ const output = fixture['output'];
48
+ return {
49
+ ...fixture,
50
+ schemaVersion: RUN_ARTIFACT_SCHEMA_VERSION,
51
+ inputBundle: { maskedPrompt: 'the masked prompt' },
52
+ inputHash: 'a'.repeat(64),
53
+ grounding: { hash: 'b'.repeat(64), provenanceSummary: 'similarity-only:1', ...grounding },
54
+ backend: {
55
+ provider: 'gemini',
56
+ model: 'gemini-3-flash-preview',
57
+ qualifiedModel: 'gemini:gemini-3-flash-preview',
58
+ admissionClass: 'completion_only',
59
+ taskProfile: 'Spec',
60
+ },
61
+ output: { metrics: { durationMs: 500 }, ...output },
62
+ };
63
+ }
64
+ // The hook's reader is a rendered `node -e` string: it walks the artifact by
65
+ // HAND-SPELLED paths (`grounding.anchor`, `admission.runMetadata.promptSource`,
66
+ // `output.content`) that no type checker sees. These tests bind the PASS
67
+ // fixtures those paths are exercised against to the real core schema, so a
68
+ // rename or a shape change on the writer's side breaks a test here rather than
69
+ // silently leaving the hook reading a field that no longer exists.
70
+ describe('the anchored-evidence fixtures parse as real run artifacts (mmnto-ai/totem#2700)', () => {
71
+ it('the issue-anchored PASS fixture round-trips through RunArtifactSchema with its three read paths intact', () => {
72
+ const parsed = RunArtifactSchema.parse(asRunArtifact(specEvidenceArtifact()));
73
+ expect(parsed.grounding.anchor).toEqual({ kind: GROUNDING_ANCHOR_ISSUE, ref: '#2700' });
74
+ expect(parsed.admission?.runMetadata?.promptSource).toBe(PROMPT_SOURCE_BUILTIN);
75
+ expect(parsed.output.content).toBe(TEMPLATE_DRAFT);
76
+ });
77
+ it('the record-anchored PASS fixture round-trips, sha256 and all', () => {
78
+ const sha256 = 'a'.repeat(64);
79
+ const parsed = RunArtifactSchema.parse(asRunArtifact(specEvidenceArtifact({
80
+ grounding: {
81
+ anchor: { kind: GROUNDING_ANCHOR_RECORD, ref: '.totem/specs/2700.md', sha256 },
82
+ },
83
+ })));
84
+ expect(parsed.grounding.anchor).toEqual({
85
+ kind: GROUNDING_ANCHOR_RECORD,
86
+ ref: '.totem/specs/2700.md',
87
+ sha256,
88
+ });
89
+ });
90
+ it('the override-prompt PASS fixture round-trips with promptSource "override"', () => {
91
+ const parsed = RunArtifactSchema.parse(asRunArtifact(specEvidenceArtifact({
92
+ admission: { runMetadata: { caller: 'spec', promptSource: PROMPT_SOURCE_OVERRIDE } },
93
+ output: { content: DOCUMENT_DRAFT },
94
+ })));
95
+ expect(parsed.admission?.runMetadata?.promptSource).toBe(PROMPT_SOURCE_OVERRIDE);
96
+ expect(parsed.output.content).toBe(DOCUMENT_DRAFT);
97
+ });
98
+ });
17
99
  describe('detectTotemPrefix', () => {
18
100
  let tmpDir;
19
101
  beforeEach(() => {
@@ -1315,6 +1397,30 @@ describe('buildPreCommitHook with strict tier', () => {
1315
1397
  expect(hook).toContain('spec evidence:');
1316
1398
  expect(hook).not.toContain('(legacy marker)');
1317
1399
  });
1400
+ // mmnto-ai/totem#2700 — the reader's vocabulary is RENDERED from the one
1401
+ // canonical constant, never re-spelled in the hook text, so a change to the
1402
+ // writer's spelling can never leave the gate reading a stale word.
1403
+ it('renders SPEC_REQUIRED_SECTIONS and the anchor vocabulary as JSON literals', () => {
1404
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1405
+ expect(hook).toContain(`const REQUIRED = ${JSON.stringify(SPEC_REQUIRED_SECTIONS)};`);
1406
+ expect(hook).toContain(`const KIND_ISSUE = ${JSON.stringify(GROUNDING_ANCHOR_ISSUE)};`);
1407
+ expect(hook).toContain(`const KIND_RECORD = ${JSON.stringify(GROUNDING_ANCHOR_RECORD)};`);
1408
+ expect(hook).toContain(`const PROMPT_OVERRIDE = ${JSON.stringify(PROMPT_SOURCE_OVERRIDE)};`);
1409
+ });
1410
+ it('carries the exit-3 arm distinctly from the exit-2 and reader-failure arms', () => {
1411
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1412
+ expect(hook).toContain('elif [ "$reader_status" = "3" ]; then');
1413
+ 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)"`);
1414
+ // The two pre-existing arms keep their exact text.
1415
+ expect(hook).toContain('no totem spec run artifact under .totem/artifacts/runs/');
1416
+ expect(hook).toContain('the spec-evidence reader could not run (node exit status');
1417
+ });
1418
+ it('the reader body carries no single quote (it lives inside a single-quoted node -e word)', () => {
1419
+ const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
1420
+ const reader = hook.split("node -e '")[1]?.split("\n' 2>/dev/null)")[0] ?? '';
1421
+ expect(reader.length).toBeGreaterThan(0);
1422
+ expect(reader).not.toContain("'");
1423
+ });
1318
1424
  });
1319
1425
  // The strict block EXECUTED under sh (mmnto-ai/totem#2690): the string checks
1320
1426
  // above pin the text; these pin the behavior the gate exists for. `git init` +
@@ -1382,10 +1488,7 @@ describe('buildPreCommitHook strict evidence — executed under sh (mmnto-ai/tot
1382
1488
  expect(r.stdout).toContain('no totem spec run artifact under .totem/artifacts/runs/');
1383
1489
  });
1384
1490
  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
- });
1491
+ writeRun('a.json', specEvidenceArtifact());
1389
1492
  const r = runHook();
1390
1493
  expect(r.status).toBe(0);
1391
1494
  expect(r.stdout).toContain('[Totem] spec evidence: .totem/artifacts/runs/a.json (');
@@ -1423,10 +1526,7 @@ describe('buildPreCommitHook strict evidence — executed under sh (mmnto-ai/tot
1423
1526
  });
1424
1527
  it.skipIf(!shellOk)('skips a torn (unparseable) artifact and still passes on a valid one beside it', () => {
1425
1528
  writeRun('torn.json', '{"admission": {"runMetadata": {"caller": "spec"');
1426
- writeRun('ok.json', {
1427
- admission: { runMetadata: { caller: 'spec' } },
1428
- createdAt: new Date().toISOString(),
1429
- });
1529
+ writeRun('ok.json', specEvidenceArtifact());
1430
1530
  const r = runHook();
1431
1531
  expect(r.status).toBe(0);
1432
1532
  expect(r.stdout).toContain('.totem/artifacts/runs/ok.json');
@@ -1437,18 +1537,463 @@ describe('buildPreCommitHook strict evidence — executed under sh (mmnto-ai/tot
1437
1537
  expect(r.status).toBe(1);
1438
1538
  });
1439
1539
  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',
1540
+ writeRun('old.json', specEvidenceArtifact({ createdAt: '2026-01-01T00:00:00.000Z' }));
1541
+ writeRun('new.json', specEvidenceArtifact());
1542
+ const r = runHook();
1543
+ expect(r.status).toBe(0);
1544
+ expect(r.stdout).toContain('.totem/artifacts/runs/new.json');
1545
+ expect(r.stdout).not.toContain('old.json');
1546
+ });
1547
+ });
1548
+ // The mmnto-ai/totem#2700 arm, EXECUTED: an artifact is evidence only when it
1549
+ // is ANCHORED (issue | record) and its SUBJECT carries a shape. Every negation
1550
+ // must BLOCK with its OWN named reason — never with the "no artifact" line and
1551
+ // never as a reader failure — because the reason is the whole cure.
1552
+ describe('buildPreCommitHook anchored evidence — executed under sh (mmnto-ai/totem#2700)', () => {
1553
+ const shellOk = spawnSync('sh', ['-c', 'command -v node >/dev/null 2>&1'], { encoding: 'utf-8' }).status === 0;
1554
+ /**
1555
+ * Whether this platform (and this account) can make a symlink — a Windows
1556
+ * account without SeCreateSymbolicLinkPrivilege cannot. Probed ONCE, outside
1557
+ * any test, so the skip is a real capability check rather than a swallowed
1558
+ * failure inside the assertion.
1559
+ */
1560
+ const symlinkable = (() => {
1561
+ const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-link-probe-'));
1562
+ const target = path.join(probeDir, 'target');
1563
+ fs.writeFileSync(target, 'probe');
1564
+ let ok = false;
1565
+ try {
1566
+ fs.symlinkSync(target, path.join(probeDir, 'sym'));
1567
+ ok = true;
1568
+ }
1569
+ catch (err) {
1570
+ void err;
1571
+ ok = false;
1572
+ }
1573
+ cleanTmpDir(probeDir);
1574
+ return ok;
1575
+ })();
1576
+ let tmpDir;
1577
+ /** A directory OUTSIDE the worktree — the target of the escaping-symlink case. */
1578
+ let outsideDir;
1579
+ beforeEach(() => {
1580
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-anchor-'));
1581
+ outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-outside-'));
1582
+ execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' });
1583
+ execSync('git checkout -q -b feat/anchored', { cwd: tmpDir, stdio: 'ignore' });
1584
+ fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook(RENDER));
1585
+ });
1586
+ afterEach(() => {
1587
+ cleanTmpDir(tmpDir);
1588
+ cleanTmpDir(outsideDir);
1589
+ });
1590
+ function runHook() {
1591
+ const r = spawnSync('sh', ['./pre-commit'], {
1592
+ cwd: tmpDir,
1593
+ encoding: 'utf-8',
1594
+ env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
1443
1595
  });
1444
- writeRun('new.json', {
1596
+ return { status: r.status, stdout: r.stdout };
1597
+ }
1598
+ function writeRun(name, artifact) {
1599
+ const dir = path.join(tmpDir, '.totem', 'artifacts', 'runs');
1600
+ fs.mkdirSync(dir, { recursive: true });
1601
+ fs.writeFileSync(path.join(dir, name), JSON.stringify(artifact, null, 2));
1602
+ }
1603
+ /** Write a bound record at a repo-relative path and return its sha256. */
1604
+ function writeRecord(relPath, body) {
1605
+ const abs = path.join(tmpDir, ...relPath.split('/'));
1606
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
1607
+ fs.writeFileSync(abs, body, 'utf-8');
1608
+ return crypto.createHash('sha256').update(fs.readFileSync(abs)).digest('hex');
1609
+ }
1610
+ /** Every BLOCKED arm must be distinguishable from the other two arms. */
1611
+ function expectDistinctBlock(stdout) {
1612
+ expect(stdout).not.toContain('no totem spec run artifact');
1613
+ expect(stdout).not.toContain('the spec-evidence reader could not run');
1614
+ }
1615
+ // ── The migration invariant ──
1616
+ it.skipIf(!shellOk)('a PRE-EXISTING artifact (no grounding.anchor) BLOCKS with the `predates` reason, not the no-artifact line', () => {
1617
+ writeRun('legacy.json', {
1445
1618
  admission: { runMetadata: { caller: 'spec' } },
1619
+ output: { content: TEMPLATE_DRAFT },
1446
1620
  createdAt: new Date().toISOString(),
1447
1621
  });
1448
1622
  const r = runHook();
1623
+ expect(r.status).toBe(1);
1624
+ expect(r.stdout).toContain('predates the anchored-evidence rule (no grounding.anchor)');
1625
+ expect(r.stdout).toContain('.totem/artifacts/runs/legacy.json');
1626
+ expectDistinctBlock(r.stdout);
1627
+ });
1628
+ // ── The unanchored kinds ──
1629
+ it.skipIf(!shellOk)('a `free-text` anchor BLOCKS, naming the kind and the topic', () => {
1630
+ writeRun('ft.json', specEvidenceArtifact({
1631
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'a loose slug' } },
1632
+ }));
1633
+ const r = runHook();
1634
+ expect(r.status).toBe(1);
1635
+ expect(r.stdout).toContain(`is anchored ${GROUNDING_ANCHOR_FREE_TEXT} (a loose slug)`);
1636
+ expect(r.stdout).toContain('not gate evidence');
1637
+ expectDistinctBlock(r.stdout);
1638
+ });
1639
+ it.skipIf(!shellOk)('a `mixed` anchor BLOCKS, naming the kind and the ref', () => {
1640
+ writeRun('mx.json', specEvidenceArtifact({
1641
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_MIXED, ref: '#2700 | a loose slug' } },
1642
+ }));
1643
+ const r = runHook();
1644
+ expect(r.status).toBe(1);
1645
+ expect(r.stdout).toContain(`is anchored ${GROUNDING_ANCHOR_MIXED} (#2700 | a loose slug)`);
1646
+ expectDistinctBlock(r.stdout);
1647
+ });
1648
+ it.skipIf(!shellOk)('both cures are named on every BLOCKED arm', () => {
1649
+ writeRun('ft.json', specEvidenceArtifact({
1650
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'slug' } },
1651
+ }));
1652
+ const r = runHook();
1653
+ expect(r.stdout).toContain("run 'totem spec <issue>' or 'totem spec --from <record>'");
1654
+ });
1655
+ // ── The TEMPLATE shape (issue anchor, built-in prompt) ──
1656
+ it.skipIf(!shellOk)('the one-token draft (a lone newline) BLOCKS on the first missing heading', () => {
1657
+ writeRun('thin.json', specEvidenceArtifact({ output: { content: '\n' } }));
1658
+ const r = runHook();
1659
+ expect(r.status).toBe(1);
1660
+ expect(r.stdout).toContain('is missing heading ### Problem Statement');
1661
+ expectDistinctBlock(r.stdout);
1662
+ });
1663
+ it.skipIf(!shellOk)('both required headings present over EMPTY bodies BLOCK, naming the empty heading', () => {
1664
+ writeRun('empty.json', specEvidenceArtifact({
1665
+ output: { content: '### Problem Statement\n\n### Implementation Tasks\n' },
1666
+ }));
1667
+ const r = runHook();
1668
+ expect(r.status).toBe(1);
1669
+ expect(r.stdout).toContain('has an empty heading ### Problem Statement');
1670
+ expectDistinctBlock(r.stdout);
1671
+ });
1672
+ it.skipIf(!shellOk)('a second required heading left empty BLOCKS, naming THAT heading', () => {
1673
+ writeRun('half.json', specEvidenceArtifact({
1674
+ output: {
1675
+ content: '### Problem Statement\n\nA real body.\n\n### Implementation Tasks\n',
1676
+ },
1677
+ }));
1678
+ const r = runHook();
1679
+ expect(r.status).toBe(1);
1680
+ expect(r.stdout).toContain('has an empty heading ### Implementation Tasks');
1681
+ });
1682
+ it.skipIf(!shellOk)('both required headings with bodies PASS, and the evidence line carries the anchor and the shape', () => {
1683
+ writeRun('ok.json', specEvidenceArtifact());
1684
+ const r = runHook();
1685
+ expect(r.status).toBe(0);
1686
+ expect(r.stdout).toContain('[Totem] spec evidence: .totem/artifacts/runs/ok.json (');
1687
+ expect(r.stdout).toContain(`· anchor ${GROUNDING_ANCHOR_ISSUE} #2700`);
1688
+ expect(r.stdout).toContain('· shape TEMPLATE');
1689
+ });
1690
+ it.skipIf(!shellOk)('a non-string draft on an issue anchor BLOCKS as not-text', () => {
1691
+ writeRun('nt.json', specEvidenceArtifact({ output: { content: { nested: true } } }));
1692
+ const r = runHook();
1693
+ expect(r.status).toBe(1);
1694
+ expect(r.stdout).toContain('the draft is not text');
1695
+ expectDistinctBlock(r.stdout);
1696
+ });
1697
+ // ── The DOCUMENT shape (override prompt on an issue anchor) ──
1698
+ it.skipIf(!shellOk)('an OVERRIDE-prompt draft without the template headings PASSES on one heading with a body', () => {
1699
+ writeRun('ov.json', specEvidenceArtifact({
1700
+ admission: {
1701
+ runMetadata: { caller: 'spec', promptSource: PROMPT_SOURCE_OVERRIDE },
1702
+ },
1703
+ output: { content: DOCUMENT_DRAFT },
1704
+ }));
1705
+ const r = runHook();
1706
+ expect(r.status).toBe(0);
1707
+ expect(r.stdout).toContain('· shape DOCUMENT');
1708
+ });
1709
+ it.skipIf(!shellOk)('an OVERRIDE-prompt draft with no heading at all BLOCKS', () => {
1710
+ writeRun('ovbad.json', specEvidenceArtifact({
1711
+ admission: { runMetadata: { caller: 'spec', promptSource: PROMPT_SOURCE_OVERRIDE } },
1712
+ output: { content: 'just prose, no heading\n' },
1713
+ }));
1714
+ const r = runHook();
1715
+ expect(r.status).toBe(1);
1716
+ expect(r.stdout).toContain('has no heading with a body');
1717
+ expect(r.stdout).toContain('custom prompt');
1718
+ expectDistinctBlock(r.stdout);
1719
+ });
1720
+ // ── The record arm: the SUBJECT is the record's bytes ──
1721
+ function recordArtifact(ref, sha256, draft = TEMPLATE_DRAFT) {
1722
+ return specEvidenceArtifact({
1723
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_RECORD, ref, sha256 } },
1724
+ output: { content: draft },
1725
+ });
1726
+ }
1727
+ it.skipIf(!shellOk)('a present record with a heading and a body PASSES, reporting `record sha256 matches`', () => {
1728
+ const sha = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
1729
+ writeRun('rec.json', recordArtifact('.totem/specs/2700.md', sha));
1730
+ const r = runHook();
1449
1731
  expect(r.status).toBe(0);
1450
- expect(r.stdout).toContain('.totem/artifacts/runs/new.json');
1451
- expect(r.stdout).not.toContain('old.json');
1732
+ expect(r.stdout).toContain(`· anchor ${GROUNDING_ANCHOR_RECORD} .totem/specs/2700.md`);
1733
+ expect(r.stdout).toContain('· shape DOCUMENT');
1734
+ expect(r.stdout).toContain('· record sha256 matches');
1735
+ });
1736
+ it.skipIf(!shellOk)('a REVISED record still PASSES — the digest is a SENSOR, never a gate', () => {
1737
+ const bound = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
1738
+ writeRun('rec.json', recordArtifact('.totem/specs/2700.md', bound));
1739
+ const now = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract, folded.\n');
1740
+ const r = runHook();
1741
+ expect(r.status).toBe(0);
1742
+ expect(r.stdout).toContain(`record revised since binding (bound ${bound.slice(0, 8)}, now ${now.slice(0, 8)})`);
1743
+ });
1744
+ it.skipIf(!shellOk)('a MISSING record BLOCKS, naming the path it was bound at', () => {
1745
+ writeRun('rec.json', recordArtifact('.totem/specs/gone.md', 'b'.repeat(64)));
1746
+ const r = runHook();
1747
+ expect(r.status).toBe(1);
1748
+ expect(r.stdout).toContain('the bound record is missing at .totem/specs/gone.md');
1749
+ expectDistinctBlock(r.stdout);
1750
+ });
1751
+ it.skipIf(!shellOk)('a record with NO heading-with-a-body BLOCKS', () => {
1752
+ const sha = writeRecord('.totem/specs/2700.md', 'prose with no heading\n');
1753
+ writeRun('rec.json', recordArtifact('.totem/specs/2700.md', sha));
1754
+ const r = runHook();
1755
+ expect(r.status).toBe(1);
1756
+ expect(r.stdout).toContain('the bound record at .totem/specs/2700.md has no heading with a body');
1757
+ expectDistinctBlock(r.stdout);
1758
+ });
1759
+ it.skipIf(!shellOk)('a record whose heading has no body BLOCKS (a heading alone is not a document)', () => {
1760
+ const sha = writeRecord('.totem/specs/2700.md', '# Heading only\n\n## Another heading\n');
1761
+ writeRun('rec.json', recordArtifact('.totem/specs/2700.md', sha));
1762
+ const r = runHook();
1763
+ expect(r.status).toBe(1);
1764
+ expect(r.stdout).toContain('has no heading with a body');
1765
+ });
1766
+ it.skipIf(!shellOk)('a THIN draft with a rich record PASSES — the reader never reads output.content on a record anchor', () => {
1767
+ const sha = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
1768
+ writeRun('rec.json', recordArtifact('.totem/specs/2700.md', sha, '\n'));
1769
+ const r = runHook();
1770
+ expect(r.status).toBe(0);
1771
+ expect(r.stdout).toContain('· record sha256 matches');
1772
+ });
1773
+ it.skipIf(!shellOk)('a RICH draft with a missing record BLOCKS — the record is the subject, not the draft', () => {
1774
+ writeRun('rec.json', recordArtifact('.totem/specs/gone.md', 'c'.repeat(64), TEMPLATE_DRAFT));
1775
+ const r = runHook();
1776
+ expect(r.status).toBe(1);
1777
+ expect(r.stdout).toContain('the bound record is missing at');
1778
+ });
1779
+ // ── The body-line boundary is a HEADING, not a leading "#" ──
1780
+ //
1781
+ // `#2700 is the issue.` is prose about an issue number, not a heading: no
1782
+ // space follows the hashes. Reading it as a boundary made a real body look
1783
+ // empty — the exact sentence a totem design record is most likely to open a
1784
+ // section with.
1785
+ it.skipIf(!shellOk)('a body line starting with `#2700` is a BODY on the TEMPLATE arm', () => {
1786
+ const draft = SPEC_REQUIRED_SECTIONS.map((heading) => `${heading}\n\n#2700 is the issue.\n`).join('\n');
1787
+ writeRun('hashbody.json', specEvidenceArtifact({ output: { content: draft } }));
1788
+ const r = runHook();
1789
+ expect(r.status).toBe(0);
1790
+ expect(r.stdout).toContain('· shape TEMPLATE');
1791
+ });
1792
+ it.skipIf(!shellOk)('a body line starting with `#2700` is a BODY on the DOCUMENT arm', () => {
1793
+ const sha = writeRecord('.totem/specs/2700.md', '# Record\n\n#2700 is the issue.\n');
1794
+ writeRun('hashrec.json', recordArtifact('.totem/specs/2700.md', sha));
1795
+ const r = runHook();
1796
+ expect(r.status).toBe(0);
1797
+ expect(r.stdout).toContain('· record sha256 matches');
1798
+ });
1799
+ // ── The DOCUMENT shape tolerates two ordinary authoring bytes ──
1800
+ it.skipIf(!shellOk)('a DRAFT opening with a UTF-8 BOM still reads as a template', () => {
1801
+ // The BOM strip runs on the SUBJECT, before the split — so it covers the
1802
+ // TEMPLATE arm too, where an unstripped BOM would make the first required
1803
+ // heading fail its exact-line match and BLOCK a well-formed draft.
1804
+ const bom = String.fromCharCode(0xfeff);
1805
+ writeRun('bomdraft.json', specEvidenceArtifact({ output: { content: bom + TEMPLATE_DRAFT } }));
1806
+ const r = runHook();
1807
+ expect(r.status).toBe(0);
1808
+ expect(r.stdout).toContain('· shape TEMPLATE');
1809
+ });
1810
+ it.skipIf(!shellOk)('a record opening with a UTF-8 BOM still reads as a document', () => {
1811
+ const bom = String.fromCharCode(0xfeff);
1812
+ const sha = writeRecord('.totem/specs/2700.md', `${bom}# Record\n\nThe ruled contract.\n`);
1813
+ writeRun('bom.json', recordArtifact('.totem/specs/2700.md', sha));
1814
+ const r = runHook();
1815
+ expect(r.status).toBe(0);
1816
+ expect(r.stdout).toContain('· shape DOCUMENT');
1817
+ });
1818
+ it.skipIf(!shellOk)('a heading separated from its text by a TAB is a heading', () => {
1819
+ const sha = writeRecord('.totem/specs/2700.md', '#\tTabbed heading\n\nThe body.\n');
1820
+ writeRun('tab.json', recordArtifact('.totem/specs/2700.md', sha));
1821
+ const r = runHook();
1822
+ expect(r.status).toBe(0);
1823
+ expect(r.stdout).toContain('· shape DOCUMENT');
1824
+ });
1825
+ // ── The artifact is hand-editable: nothing read out of it is trusted text ──
1826
+ it.skipIf(!shellOk)('a newline in anchor.ref cannot forge a second [Totem] line', () => {
1827
+ const forged = `slug${String.fromCharCode(0x0a)}[Totem] spec evidence: forged`;
1828
+ writeRun('inject.json', specEvidenceArtifact({
1829
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: forged } },
1830
+ }));
1831
+ const r = runHook();
1832
+ expect(r.status).toBe(1);
1833
+ // Exactly one line of the hook's own output claims to be from Totem.
1834
+ expect(r.stdout.split('\n').filter((line) => line.startsWith('[Totem]'))).toHaveLength(1);
1835
+ // The control character is collapsed, not dropped — the ref stays legible.
1836
+ expect(r.stdout).toContain('slug?[Totem] spec evidence: forged');
1837
+ });
1838
+ it.skipIf(!shellOk)('a C1 control (U+0085 NEL) in anchor.ref is collapsed too', () => {
1839
+ // A predicate stopping at 0x7f let the whole C1 band through; NEL is a
1840
+ // LINE BREAK to some terminals and pagers, so it is a forged-line vector
1841
+ // of exactly the same kind as 0x0a.
1842
+ const forged = `slug${String.fromCharCode(0x85)}[Totem] spec evidence: forged`;
1843
+ writeRun('nel.json', specEvidenceArtifact({
1844
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: forged } },
1845
+ }));
1846
+ const r = runHook();
1847
+ expect(r.status).toBe(1);
1848
+ expect(r.stdout).toContain('slug?[Totem] spec evidence: forged');
1849
+ expect(r.stdout).not.toContain(String.fromCharCode(0x85));
1850
+ });
1851
+ it.skipIf(!shellOk)('a newline in createdAt cannot forge a second [Totem] line on the PASS path', () => {
1852
+ // `createdAt` is read raw off the artifact and ECHOED in the evidence
1853
+ // line: unsanitized, a hand-edited stamp forges a second `[Totem] spec
1854
+ // evidence:` line inside a passing commit — the quietest place to hide
1855
+ // one.
1856
+ const forged = `2026-09-02T00:00:00.000Z${String.fromCharCode(0x0a)}[Totem] spec evidence: FORGED`;
1857
+ writeRun('stamp.json', specEvidenceArtifact({ createdAt: forged }));
1858
+ const r = runHook();
1859
+ expect(r.status).toBe(0);
1860
+ expect(r.stdout.split('\n').filter((line) => line.startsWith('[Totem]'))).toHaveLength(1);
1861
+ expect(r.stdout).toContain('.000Z?[Totem] spec evidence: FORGED');
1862
+ });
1863
+ it.skipIf(!shellOk)('a record ref that is ABSOLUTE is refused as outside the worktree', () => {
1864
+ writeRun('abs.json', recordArtifact('/etc/passwd', 'd'.repeat(64)));
1865
+ const r = runHook();
1866
+ expect(r.status).toBe(1);
1867
+ expect(r.stdout).toContain('the bound record ref is outside the worktree: /etc/passwd');
1868
+ expectDistinctBlock(r.stdout);
1869
+ });
1870
+ it.skipIf(!shellOk)('a record ref whose first segment is `..` is refused as outside the worktree', () => {
1871
+ writeRun('dotdot.json', recordArtifact('../outside.md', 'd'.repeat(64)));
1872
+ const r = runHook();
1873
+ expect(r.status).toBe(1);
1874
+ expect(r.stdout).toContain('the bound record ref is outside the worktree: ../outside.md');
1875
+ expectDistinctBlock(r.stdout);
1876
+ });
1877
+ // ── Containment is decided by RESOLUTION, not by segment 0 ──
1878
+ //
1879
+ // A first-segment test reads `sub/../../OUTSIDE.md` and `./../OUTSIDE.md` as
1880
+ // contained, so the reader would have READ and PASSED a file outside the
1881
+ // worktree. The reader now resolves the ref against `process.cwd()` — the
1882
+ // worktree top git runs hooks from — and refuses anything that lands outside
1883
+ // it, which is also the honest rule for the legitimate mid-path `..`.
1884
+ /** A backslash, built rather than escaped — the mmnto-ai/totem#2692 authoring trap. */
1885
+ const BACKSLASH = String.fromCharCode(0x5c);
1886
+ it.skipIf(!shellOk).each([
1887
+ ['sub/../../OUTSIDE.md', 'a `..` past segment 0'],
1888
+ ['./../OUTSIDE.md', 'a `.` first segment hiding a `..`'],
1889
+ [
1890
+ `a${BACKSLASH}..${BACKSLASH}..${BACKSLASH}OUTSIDE.md`,
1891
+ 'the same escape spelled with backslashes',
1892
+ ],
1893
+ ])('refuses %s as outside the worktree (%s)', (ref) => {
1894
+ writeRun('escape.json', recordArtifact(ref, 'd'.repeat(64)));
1895
+ const r = runHook();
1896
+ expect(r.status).toBe(1);
1897
+ expect(r.stdout).toContain(`the bound record ref is outside the worktree: ${ref}`);
1898
+ expectDistinctBlock(r.stdout);
1899
+ });
1900
+ it.skipIf(!shellOk)('a mid-path `..` that stays INSIDE the worktree still PASSES — containment, not a segment ban', () => {
1901
+ // `a/` must exist for POSIX path resolution to reach `a/../b.md`.
1902
+ fs.mkdirSync(path.join(tmpDir, 'a'), { recursive: true });
1903
+ const sha = writeRecord('b.md', '# Record\n\nThe ruled contract.\n');
1904
+ writeRun('inside.json', recordArtifact('a/../b.md', sha));
1905
+ const r = runHook();
1906
+ expect(r.status).toBe(0);
1907
+ expect(r.stdout).toContain('· record sha256 matches');
1908
+ });
1909
+ it.skipIf(!shellOk)('a record anchor with NO sha256 BLOCKS — a binding without bytes is not a binding', () => {
1910
+ writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
1911
+ writeRun('nosha.json', specEvidenceArtifact({
1912
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_RECORD, ref: '.totem/specs/2700.md' } },
1913
+ }));
1914
+ const r = runHook();
1915
+ expect(r.status).toBe(1);
1916
+ expect(r.stdout).toContain('the record anchor carries no sha256 — not evidence');
1917
+ expectDistinctBlock(r.stdout);
1918
+ });
1919
+ it.skipIf(!shellOk)('a record anchor whose sha256 is MALFORMED BLOCKS with its OWN reason, not the absent one', () => {
1920
+ // "No digest" and "a digest that is not a digest" are different repairs:
1921
+ // the first says re-bind, the second says the artifact was edited. One
1922
+ // shared message would send both to the wrong cure.
1923
+ writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
1924
+ writeRun('badsha.json', recordArtifact('.totem/specs/2700.md', 'not-a-digest'));
1925
+ const r = runHook();
1926
+ expect(r.status).toBe(1);
1927
+ expect(r.stdout).toContain('the record anchor sha256 is not a 64-hex digest (not-a-digest) — not evidence');
1928
+ expect(r.stdout).not.toContain('carries no sha256');
1929
+ expectDistinctBlock(r.stdout);
1930
+ });
1931
+ // ── Containment sees THROUGH a link (mmnto-ai/totem#2700) ──
1932
+ //
1933
+ // A symlink inside the worktree is LEXICALLY contained, so the resolve-based
1934
+ // test above reads it as legal — and the reader would open, hash and judge a
1935
+ // file outside the tree. The realpath pair is compared once the ref is known
1936
+ // to exist and BEFORE its bytes are read.
1937
+ it.skipIf(!shellOk || !symlinkable)('a record ref that is a SYMLINK to OUTSIDE the worktree BLOCKS, naming both spellings', () => {
1938
+ const target = path.join(outsideDir, 'record.md');
1939
+ fs.writeFileSync(target, '# Outside\n\nThe ruled contract.\n', 'utf-8');
1940
+ const sha = crypto.createHash('sha256').update(fs.readFileSync(target)).digest('hex');
1941
+ fs.symlinkSync(target, path.join(tmpDir, 'linked.md'));
1942
+ // A CORRECT digest, so the block cannot be the sha256 arm firing early.
1943
+ writeRun('symlink-out.json', recordArtifact('linked.md', sha));
1944
+ const r = runHook();
1945
+ expect(r.status).toBe(1);
1946
+ expect(r.stdout).toContain('the bound record resolves outside the worktree: linked.md -> ');
1947
+ expect(r.stdout).toContain(fs.realpathSync.native(target));
1948
+ expectDistinctBlock(r.stdout);
1949
+ });
1950
+ it.skipIf(!shellOk || !symlinkable)('a record ref that is a SYMLINK to an IN-worktree file PASSES — containment, not a link ban', () => {
1951
+ const sha = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
1952
+ fs.symlinkSync(path.join(tmpDir, '.totem', 'specs', '2700.md'), path.join(tmpDir, 'linked.md'));
1953
+ writeRun('symlink-in.json', recordArtifact('linked.md', sha));
1954
+ const r = runHook();
1955
+ expect(r.status).toBe(0);
1956
+ expect(r.stdout).toContain('· record sha256 matches');
1957
+ });
1958
+ // ── The cure names the cache (mmnto-ai/totem#2700 m11) ──
1959
+ it.skipIf(!shellOk)('the BLOCKED cure names --fresh — a cached response mints no artifact', () => {
1960
+ writeRun('ft.json', specEvidenceArtifact({
1961
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'slug' } },
1962
+ }));
1963
+ const r = runHook();
1964
+ expect(r.stdout).toContain('(add --fresh if the response is cached)');
1965
+ });
1966
+ // ── The other arms stay distinct ──
1967
+ it.skipIf(!shellOk)('exit 3 is NEVER reported as a reader failure', () => {
1968
+ writeRun('ft.json', specEvidenceArtifact({
1969
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'slug' } },
1970
+ }));
1971
+ const r = runHook();
1972
+ expect(r.status).toBe(1);
1973
+ expect(r.stdout).not.toContain('node exit status');
1974
+ });
1975
+ it.skipIf(!shellOk)('the carried-object substring control still BLOCKS with the no-artifact line', () => {
1976
+ // Only the TOP-LEVEL caller counts: a review artifact that carries a
1977
+ // `caller: spec` object below the top level is not a spec artifact at all,
1978
+ // so the gate reports "none found", not "not evidence".
1979
+ writeRun('r.json', {
1980
+ admission: { runMetadata: { caller: 'review' } },
1981
+ inputBundle: { runMetadata: { caller: 'spec' } },
1982
+ createdAt: new Date().toISOString(),
1983
+ });
1984
+ const r = runHook();
1985
+ expect(r.status).toBe(1);
1986
+ expect(r.stdout).toContain('no totem spec run artifact');
1987
+ });
1988
+ it.skipIf(!shellOk)('the retired hand-set marker still rescues nothing', () => {
1989
+ fs.mkdirSync(path.join(tmpDir, '.totem', 'cache'), { recursive: true });
1990
+ fs.writeFileSync(path.join(tmpDir, '.totem', 'cache', '.spec-completed'), '');
1991
+ writeRun('ft.json', specEvidenceArtifact({
1992
+ grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'slug' } },
1993
+ }));
1994
+ const r = runHook();
1995
+ expect(r.status).toBe(1);
1996
+ expect(r.stdout).not.toContain('spec evidence:');
1452
1997
  });
1453
1998
  });
1454
1999
  // The mmnto-ai/totem#2692 keystone, EXECUTED: under a custom `totemDir` the
@@ -1485,10 +2030,7 @@ describe('buildPreCommitHook strict evidence under a CUSTOM totemDir — execute
1485
2030
  fs.mkdirSync(dir, { recursive: true });
1486
2031
  fs.writeFileSync(path.join(dir, name), JSON.stringify(artifact, null, 2));
1487
2032
  }
1488
- const specArtifact = () => ({
1489
- admission: { runMetadata: { caller: 'spec' } },
1490
- createdAt: new Date().toISOString(),
1491
- });
2033
+ const specArtifact = () => specEvidenceArtifact();
1492
2034
  it.skipIf(!shellOk)('PASSES on evidence written under the configured totemDir', () => {
1493
2035
  writeRunUnder(CUSTOM_TOTEM_DIR, 'a.json', specArtifact());
1494
2036
  const r = runHook();
@@ -1565,9 +2107,16 @@ describe('buildPrePushHook with strict tier', () => {
1565
2107
  const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
1566
2108
  // Find the position of the doctor --strict invocation and the surrounding
1567
2109
  // guard; assert the invocation lives INSIDE the agent-or-tier block.
1568
- const guardIdx = hook.indexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]');
1569
2110
  const doctorIdx = hook.indexOf('$TOTEM_CMD doctor --strict');
1570
- const fiCloseIdx = hook.indexOf('fi', guardIdx);
2111
+ // Anchored on the guard that ENCLOSES this invocation: since
2112
+ // mmnto-ai/totem#2698 the pre-push hook carries more than one agent/strict
2113
+ // guard (the review-leg floor arm has its own, and it is rendered first),
2114
+ // so a bare first-match lookup would measure the wrong block.
2115
+ const guardIdx = hook.lastIndexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]', doctorIdx);
2116
+ // Anchored to a LINE, not a substring: a bare `fi` also matches the one
2117
+ // inside "findings" in the shield block's comment, which sits between the
2118
+ // invocation and the real closing `fi` (CodeRabbit on PR mmnto-ai/totem#2745).
2119
+ const fiCloseIdx = hook.indexOf(`${String.fromCharCode(10)} fi`, doctorIdx);
1571
2120
  expect(guardIdx).toBeGreaterThan(-1);
1572
2121
  expect(doctorIdx).toBeGreaterThan(guardIdx);
1573
2122
  expect(doctorIdx).toBeLessThan(fiCloseIdx);
@@ -1599,9 +2148,16 @@ describe('buildPrePushHook with standard tier', () => {
1599
2148
  // standard, so the guard branch never enters.
1600
2149
  it('emits doctor --strict gated by agent/strict guard (no unconditional fire in standard tier)', () => {
1601
2150
  const hook = buildPrePushHook(RENDER);
1602
- const guardIdx = hook.indexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]');
1603
2151
  const doctorIdx = hook.indexOf('$TOTEM_CMD doctor --strict');
1604
- const fiCloseIdx = hook.indexOf('fi', guardIdx);
2152
+ // Anchored on the guard that ENCLOSES this invocation: since
2153
+ // mmnto-ai/totem#2698 the pre-push hook carries more than one agent/strict
2154
+ // guard (the review-leg floor arm has its own, and it is rendered first),
2155
+ // so a bare first-match lookup would measure the wrong block.
2156
+ const guardIdx = hook.lastIndexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]', doctorIdx);
2157
+ // Anchored to a LINE, not a substring: a bare `fi` also matches the one
2158
+ // inside "findings" in the shield block's comment, which sits between the
2159
+ // invocation and the real closing `fi` (CodeRabbit on PR mmnto-ai/totem#2745).
2160
+ const fiCloseIdx = hook.indexOf(`${String.fromCharCode(10)} fi`, doctorIdx);
1605
2161
  expect(guardIdx).toBeGreaterThan(-1);
1606
2162
  expect(doctorIdx).toBeGreaterThan(guardIdx);
1607
2163
  expect(doctorIdx).toBeLessThan(fiCloseIdx);
@@ -1629,4 +2185,444 @@ describe('agent detection uses POSIX syntax', () => {
1629
2185
  expect(hook).toMatch(/^#!\/bin\/sh\n/);
1630
2186
  });
1631
2187
  });
2188
+ // ─── The strict pre-push review-leg floor arm (mmnto-ai/totem#2698) ──────────
2189
+ describe('buildPrePushHook — the review-leg floor arm (mmnto-ai/totem#2698)', () => {
2190
+ const hook = buildPrePushHook(RENDER);
2191
+ it("probes the VERB's own help for an option only that verb has", () => {
2192
+ expect(hook).toContain(`if $TOTEM_CMD legs gate --help 2>/dev/null | grep -q -- '--advisory'; then`);
2193
+ // NOT the group's help for the word `gate` (mmnto-ai/totem#2698 fold 2): a
2194
+ // CLI predating `totem legs` answers any `legs …` with its curated
2195
+ // TOP-LEVEL help and exits 0, so that grep reads a moving surface and the
2196
+ // queued `merge-gate` verb (mmnto-ai/totem#2708) would satisfy it.
2197
+ expect(hook).not.toContain(`$TOTEM_CMD legs --help`);
2198
+ });
2199
+ it('invokes the bare gate on strict and the advisory form otherwise', () => {
2200
+ expect(hook).toContain('$TOTEM_CMD legs gate\n');
2201
+ expect(hook).toContain('$TOTEM_CMD legs gate --advisory');
2202
+ expect(hook).toContain('legs_status=$?');
2203
+ });
2204
+ it('blocks on exit 3 and on any other non-zero with DISTINCT lines', () => {
2205
+ expect(hook).toContain(`echo "[Totem] BLOCKED: this push is legs-owed and carries no fresh falsification-leg deposit — run the leg, then 'totem legs deposit --sha HEAD --from <findings.json>' (mmnto-ai/totem#2698, strict mode)"`);
2206
+ expect(hook).toContain('echo "[Totem] BLOCKED: the legs gate could not derive (totem legs gate exit status $legs_status) — fix the checkout and retry (strict mode)"');
2207
+ expect(hook).toContain('if [ "$legs_status" = "3" ]; then');
2208
+ expect(hook).toContain('elif [ "$legs_status" != "0" ]; then');
2209
+ });
2210
+ it('FAILS CLOSED on strict when the resolved CLI lacks the verb, compat-opens otherwise', () => {
2211
+ // The OPPOSITE of the `--gate` / `--scope-to-diff` precedent, by ruling
2212
+ // (mmnto-ai/totem#2698 OQ2): those flags degrade to a form that still runs;
2213
+ // an absent VERB has no degraded form, so strict blocks with the cure.
2214
+ expect(hook).toContain(`echo "[Totem] BLOCKED: this hook expects 'totem legs gate' (mmnto-ai/totem#2698) but the resolved CLI lacks it — 'npm i -g @mmnto/cli@latest' (strict mode)" >&2`);
2215
+ expect(hook).toContain(`echo "[totem] Hook running without the legs gate (CLI predates 'totem legs'); 'npm i -g @mmnto/cli@latest' enables it." >&2`);
2216
+ });
2217
+ it('runs BEFORE the shield block and inside the $TOTEM_CMD guard', () => {
2218
+ const cmdGuardIdx = hook.indexOf('if [ -n "$TOTEM_CMD" ]; then');
2219
+ const legsIdx = hook.indexOf('$TOTEM_CMD legs gate --help');
2220
+ const shieldIdx = hook.indexOf('Running shield gate (strict mode)');
2221
+ expect(cmdGuardIdx).toBeGreaterThan(-1);
2222
+ expect(legsIdx).toBeGreaterThan(cmdGuardIdx);
2223
+ // Slotted first so a legs-owed push is not paid for with the slow review gate.
2224
+ expect(legsIdx).toBeLessThan(shieldIdx);
2225
+ });
2226
+ it('gates the blocking arms on the agent/strict guard', () => {
2227
+ const legsIdx = hook.indexOf('$TOTEM_CMD legs gate --help');
2228
+ const guardIdx = hook.indexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]', legsIdx);
2229
+ const gateIdx = hook.indexOf('$TOTEM_CMD legs gate\n');
2230
+ expect(guardIdx).toBeGreaterThan(legsIdx);
2231
+ expect(gateIdx).toBeGreaterThan(guardIdx);
2232
+ });
2233
+ });
2234
+ // The arm, EXECUTED. String assertions alone are satisfiable without the
2235
+ // behavior, and the exit-code mapping IS the contract here. The stub `totem` is
2236
+ // the FIRST entry on an isolated PATH and records its argv, so every case
2237
+ // proves the hook actually reached it — an un-isolated PATH or a stub on the
2238
+ // wrong stream is the false-green shape this suite exists to exclude.
2239
+ describe('the review-leg floor arm executed under sh (mmnto-ai/totem#2698)', () => {
2240
+ const shellOk = spawnSync('sh', ['-c', 'exit 0'], { encoding: 'utf-8' }).status === 0;
2241
+ let tmpDir;
2242
+ let repoDir;
2243
+ let binDir;
2244
+ let logPath;
2245
+ beforeEach(() => {
2246
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-legs-hook-'));
2247
+ repoDir = path.join(tmpDir, 'repo');
2248
+ binDir = path.join(tmpDir, 'stub-bin');
2249
+ fs.mkdirSync(repoDir, { recursive: true });
2250
+ fs.mkdirSync(binDir);
2251
+ // Forward slashes: the stub is read by `sh`, and a Windows path's
2252
+ // backslashes would not survive the shell's quoting.
2253
+ logPath = path.join(tmpDir, 'totem-invocations.log').split(path.sep).join('/');
2254
+ });
2255
+ afterEach(() => {
2256
+ cleanTmpDir(tmpDir);
2257
+ });
2258
+ /**
2259
+ * A stub `totem` that records every invocation, answers the `legs --help`
2260
+ * probe with the given help text, and exits `gateExit` for `legs gate`. The
2261
+ * bare repo dir clears every earlier hook gate (no manifest, no compiled
2262
+ * rules, no lockfile, no package.json), so the legs arm and the shield block
2263
+ * are the only live ones.
2264
+ */
2265
+ function writeStub(params) {
2266
+ const stub = [
2267
+ '#!/bin/sh',
2268
+ `printf '%s\\n' "$*" >> "${logPath}"`,
2269
+ // The help arm is tested FIRST and on THREE words: since
2270
+ // mmnto-ai/totem#2698 fold 2 the probe is `legs gate --help`, which also
2271
+ // matches the gate arm's first two.
2272
+ 'if [ "$1" = "legs" ] && [ "$2" = "gate" ] && [ "$3" = "--help" ]; then',
2273
+ " cat <<'TOTEM_STUB_HELP'",
2274
+ params.helpText,
2275
+ 'TOTEM_STUB_HELP',
2276
+ ` exit ${params.helpExit ?? 0}`,
2277
+ 'fi',
2278
+ 'if [ "$1" = "legs" ] && [ "$2" = "gate" ]; then',
2279
+ ' echo "[Totem] legs: stub gate line"',
2280
+ ` exit ${params.gateExit ?? 0}`,
2281
+ 'fi',
2282
+ 'exit 0',
2283
+ '',
2284
+ ].join('\n');
2285
+ fs.writeFileSync(path.join(binDir, 'totem'), stub, { mode: 0o755 });
2286
+ }
2287
+ /**
2288
+ * The verb's OWN help, and the top-level help an older CLI answers with.
2289
+ *
2290
+ * The unsupported fixture is a HOSTILE VARIANT of the 1.122.0 shape, not
2291
+ * that shape verbatim: the measured seven-command top-level help contains no
2292
+ * `gate` at all, so today's probe does not false-pass on it. This fixture
2293
+ * INJECTS a `merge-gate` entry (mmnto-ai/totem#2708 is queued for that list),
2294
+ * so the OLD group-level grep would false-pass and the verb-specific probe
2295
+ * must not. The defect it guards is latent, and this is what keeps it so.
2296
+ */
2297
+ const HELP_WITH_GATE = 'Usage: totem legs gate [options]\n\nOptions:\n --advisory Print the same lines for every state\n -h, --help display help for command';
2298
+ const HELP_WITHOUT_GATE = 'Totem: local-first toolkit\n\nUsage: totem [command]\n\nCommands:\n init Initialize Totem\n lint Run compiled rules\n merge-gate Gate a merge';
2299
+ function runHook(options) {
2300
+ fs.writeFileSync(path.join(repoDir, 'pre-push'), buildPrePushHook({ ...RENDER, tier: options.tier }));
2301
+ // Every agent-detection variable is cleared explicitly: this suite runs
2302
+ // inside an agent session, and an inherited CLAUDE_CODE_AGENT would make
2303
+ // the standard-tier cases silently exercise the strict arm.
2304
+ const env = {
2305
+ ...process.env,
2306
+ PATH: [binDir, process.env['PATH'] ?? ''].join(path.delimiter),
2307
+ };
2308
+ delete env['CLAUDE_CODE_AGENT'];
2309
+ delete env['CLAUDE_VERSION'];
2310
+ delete env['CURSOR_TRACE_ID'];
2311
+ if (options.agent)
2312
+ env['CLAUDE_CODE_AGENT'] = '1';
2313
+ const result = spawnSync('sh', ['./pre-push'], { cwd: repoDir, env, encoding: 'utf-8' });
2314
+ return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
2315
+ }
2316
+ /** Every recorded stub invocation, in order. Empty when it was never reached. */
2317
+ const invocations = () => {
2318
+ const native = logPath.split('/').join(path.sep);
2319
+ return fs.existsSync(native) ? fs.readFileSync(native, 'utf-8').trim().split('\n') : [];
2320
+ };
2321
+ it.skipIf(!shellOk)('probe UNSUPPORTED + strict: blocks with the upgrade line', () => {
2322
+ writeStub({ helpText: HELP_WITHOUT_GATE });
2323
+ const r = runHook({ tier: 'strict', agent: false });
2324
+ expect(invocations()).toContain('legs gate --help');
2325
+ expect(r.status).toBe(1);
2326
+ expect(r.stderr).toContain("this hook expects 'totem legs gate'");
2327
+ expect(r.stderr).toContain('npm i -g @mmnto/cli@latest');
2328
+ // Fail-closed means the gate was never invoked, not that it passed.
2329
+ expect(invocations()).not.toContain('legs gate');
2330
+ });
2331
+ it.skipIf(!shellOk)('probe UNSUPPORTED + standard: passes with the compat line on stderr', () => {
2332
+ writeStub({ helpText: HELP_WITHOUT_GATE });
2333
+ const r = runHook({ tier: 'standard', agent: false });
2334
+ expect(invocations()).toContain('legs gate --help');
2335
+ expect(r.status).toBe(0);
2336
+ expect(r.stderr).toContain("Hook running without the legs gate (CLI predates 'totem legs')");
2337
+ });
2338
+ it.skipIf(!shellOk)('gate exit 3 + strict: blocks with the legs-owed line and the cure', () => {
2339
+ writeStub({ helpText: HELP_WITH_GATE, gateExit: 3 });
2340
+ const r = runHook({ tier: 'strict', agent: false });
2341
+ expect(invocations()).toContain('legs gate');
2342
+ expect(r.status).toBe(1);
2343
+ expect(r.stdout).toContain('[Totem] BLOCKED: this push is legs-owed');
2344
+ expect(r.stdout).toContain('totem legs deposit --sha HEAD --from <findings.json>');
2345
+ // The gate's own line is not swallowed.
2346
+ expect(r.stdout).toContain('[Totem] legs: stub gate line');
2347
+ });
2348
+ it.skipIf(!shellOk)('gate exit 2 + strict: blocks with the DISTINCT not-derived line', () => {
2349
+ writeStub({ helpText: HELP_WITH_GATE, gateExit: 2 });
2350
+ const r = runHook({ tier: 'strict', agent: false });
2351
+ expect(invocations()).toContain('legs gate');
2352
+ expect(r.status).toBe(1);
2353
+ expect(r.stdout).toContain('the legs gate could not derive (totem legs gate exit status 2)');
2354
+ expect(r.stdout).not.toContain('this push is legs-owed');
2355
+ });
2356
+ it.skipIf(!shellOk)('gate exit 0 + strict: the hook proceeds PAST the arm', () => {
2357
+ writeStub({ helpText: HELP_WITH_GATE, gateExit: 0 });
2358
+ const r = runHook({ tier: 'strict', agent: false });
2359
+ expect(invocations()).toContain('legs gate');
2360
+ // The shield block sits after the arm — reaching it proves the arm passed.
2361
+ expect(invocations()).toContain('doctor --strict');
2362
+ expect(r.status).toBe(0);
2363
+ expect(r.stdout).not.toContain('BLOCKED');
2364
+ });
2365
+ it.skipIf(!shellOk)('an AGENT on the standard tier still takes the strict arm', () => {
2366
+ writeStub({ helpText: HELP_WITH_GATE, gateExit: 3 });
2367
+ const r = runHook({ tier: 'standard', agent: true });
2368
+ expect(invocations()).toContain('legs gate');
2369
+ expect(invocations()).not.toContain('legs gate --advisory');
2370
+ expect(r.status).toBe(1);
2371
+ });
2372
+ it.skipIf(!shellOk)('standard tier, no agent: --advisory is passed and exit 3 does NOT block', () => {
2373
+ writeStub({ helpText: HELP_WITH_GATE, gateExit: 3 });
2374
+ const r = runHook({ tier: 'standard', agent: false });
2375
+ // Read from the recorded argv, not from the hook text: the flag has to
2376
+ // have reached the CLI, not merely appear in the template.
2377
+ expect(invocations()).toContain('legs gate --advisory');
2378
+ expect(invocations()).not.toContain('legs gate');
2379
+ expect(r.status).toBe(0);
2380
+ expect(r.stdout).not.toContain('BLOCKED');
2381
+ });
2382
+ });
2383
+ // The arm COMPOSED with the real gate (mmnto-ai/totem#2698 fold 2, MATERIAL).
2384
+ //
2385
+ // The stub suite above proves the hook's exit MAPPING — it scripts the gate's
2386
+ // status, so the gate's own derivation is never exercised through the hook, and
2387
+ // a gate that stopped returning 3 for an owed-and-unanswered push would leave
2388
+ // every one of those tests green. This suite closes that: the shim on the
2389
+ // isolated PATH DELEGATES `legs` to the real built CLI, so each case runs the
2390
+ // whole composition — predicate, store, ancestry, exit code, hook arm, hook
2391
+ // line. Everything after the arm (the shield block's `doctor --strict` and
2392
+ // `review --gate`) is a different gate with its own coverage and stays stubbed,
2393
+ // so this suite's verdict never depends on state it does not control.
2394
+ //
2395
+ // COVERAGE DECLARATION (Q5). It needs `packages/cli/dist/index.js`, which
2396
+ // exists after this repo's CI builds before it tests (`pnpm -r build` precedes
2397
+ // the test run); when it does not, every case SKIPS rather than passing
2398
+ // vacuously, and the skip is visible in the reporter. It runs wherever `sh`
2399
+ // resolves: win32 through git-bash, plus the ubuntu and macos CI legs. The stub
2400
+ // suite above is retained and carries the mapping, including the states this
2401
+ // suite cannot reach through a real CLI (a build that predates the verb).
2402
+ //
2403
+ // One case inside it is POSIX-only, and says so at its own site: a quote- and
2404
+ // backslash-bearing owed path cannot exist on NTFS. Every other case, the
2405
+ // non-ASCII one included, runs on win32 too.
2406
+ describe('the review-leg floor arm COMPOSED with the real gate (mmnto-ai/totem#2698)', () => {
2407
+ /** Built, never authored as an escape (the banked decode trap). */
2408
+ const NL = String.fromCharCode(10);
2409
+ /**
2410
+ * A NON-ASCII owed path (mmnto-ai/totem#2698 fold 4). git C-quotes this name
2411
+ * on BOTH surfaces the gate reads, in two different ways, so an un-normalized
2412
+ * pair reports a covering leg as covering nothing. Driven here through the
2413
+ * REAL CLI, where the argv and the quoting actually happen.
2414
+ */
2415
+ const ACCENTED = `docs/caf${String.fromCharCode(0xe9)}.md`;
2416
+ /** The two-character sequence `printf` needs in the shim, likewise built. */
2417
+ const BACKSLASH_N = String.fromCharCode(92) + 'n';
2418
+ const DIST = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../dist/index.js');
2419
+ const shellOk = spawnSync('sh', ['-c', 'exit 0'], { encoding: 'utf-8' }).status === 0;
2420
+ const composedOk = shellOk && fs.existsSync(DIST);
2421
+ /**
2422
+ * The REAL working directory, restored after each case: `cross-spawn` (which
2423
+ * the CLI's `safeExec` goes through) chdirs into a child's cwd and restores
2424
+ * with `process.cwd()`, which on Windows would otherwise leave this process
2425
+ * holding the temp repo open and fail teardown.
2426
+ */
2427
+ const realCwd = process.cwd();
2428
+ let tmpDir;
2429
+ let repoDir;
2430
+ let binDir;
2431
+ let logPath;
2432
+ let baseSha;
2433
+ let ancestorSha;
2434
+ let headSha;
2435
+ function git(...args) {
2436
+ return execFileSync('git', args, { cwd: repoDir, encoding: 'utf-8' });
2437
+ }
2438
+ beforeEach(() => {
2439
+ if (!composedOk)
2440
+ return;
2441
+ tmpDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-legs-composed-')));
2442
+ repoDir = path.join(tmpDir, 'repo');
2443
+ binDir = path.join(tmpDir, 'stub-bin');
2444
+ fs.mkdirSync(repoDir, { recursive: true });
2445
+ fs.mkdirSync(binDir);
2446
+ logPath = path.join(tmpDir, 'totem-invocations.log').split(path.sep).join('/');
2447
+ git('init', '-q', '-b', 'main');
2448
+ git('config', 'user.email', 'leg@example.test');
2449
+ git('config', 'user.name', 'Leg');
2450
+ // The minimal config the schema accepts, declaring ONE glob so the
2451
+ // owed / not-owed axis of this suite is unambiguous.
2452
+ fs.writeFileSync(path.join(repoDir, 'totem.config.ts'), [
2453
+ 'export default {',
2454
+ " targets: [{ glob: 'docs/**/*.md', type: 'spec', strategy: 'markdown-heading' }],",
2455
+ " hooks: { legsOwed: { globs: ['docs/**'] } },",
2456
+ '};',
2457
+ '',
2458
+ ].join(NL));
2459
+ fs.writeFileSync(path.join(repoDir, 'src.ts'), 'export const a = 1;' + NL);
2460
+ git('add', '.');
2461
+ git('commit', '-q', '-m', 'base');
2462
+ baseSha = git('rev-parse', 'HEAD').trim();
2463
+ // The branch under test: it touches the declared glob, so it is legs-owed.
2464
+ git('checkout', '-q', '-b', 'feat/owed');
2465
+ fs.mkdirSync(path.join(repoDir, 'docs'), { recursive: true });
2466
+ fs.writeFileSync(path.join(repoDir, 'docs', 'note.md'), '# a judgment-dense page' + NL);
2467
+ fs.writeFileSync(path.join(repoDir, ...ACCENTED.split('/')), '# an accented page' + NL);
2468
+ git('add', '-A', 'docs');
2469
+ git('commit', '-q', '-m', 'a docs change');
2470
+ // C1 — the head a leg could read, and an ANCESTOR of the head being pushed.
2471
+ ancestorSha = git('rev-parse', 'HEAD').trim();
2472
+ // C2 — one commit further, touching nothing owed, so the owed set is still
2473
+ // C1's two docs pages and a deposit at C1 covers all of them. Without it
2474
+ // that deposit would be EXACT, credited by construction, and the reach
2475
+ // probe this suite exists to exercise would never run.
2476
+ fs.writeFileSync(path.join(repoDir, 'src.ts'), 'export const a = 2;' + NL);
2477
+ git('add', 'src.ts');
2478
+ git('commit', '-q', '-m', 'a code-only change');
2479
+ headSha = git('rev-parse', 'HEAD').trim();
2480
+ // The shim IS the real CLI for `legs`, recorded either way. First on PATH,
2481
+ // so the hook's resolve block finds it as a plain `totem` (the temp repo has
2482
+ // no package.json, no node_modules and no workspace file, so no earlier
2483
+ // resolution tier can win).
2484
+ fs.writeFileSync(path.join(binDir, 'totem'), [
2485
+ '#!/bin/sh',
2486
+ `printf '%s${BACKSLASH_N}' "$*" >> "${logPath}"`,
2487
+ 'if [ "$1" = "legs" ]; then',
2488
+ ` exec node "${DIST.split(path.sep).join('/')}" "$@"`,
2489
+ 'fi',
2490
+ 'exit 0',
2491
+ '',
2492
+ ].join(NL), { mode: 0o755 });
2493
+ });
2494
+ afterEach(() => {
2495
+ if (!composedOk)
2496
+ return;
2497
+ process.chdir(realCwd);
2498
+ cleanTmpDir(tmpDir);
2499
+ });
2500
+ function runHook() {
2501
+ fs.writeFileSync(path.join(repoDir, 'pre-push'), buildPrePushHook({ ...RENDER, tier: 'strict' }));
2502
+ const env = {
2503
+ ...process.env,
2504
+ PATH: [binDir, process.env['PATH'] ?? ''].join(path.delimiter),
2505
+ };
2506
+ delete env['CLAUDE_CODE_AGENT'];
2507
+ delete env['CLAUDE_VERSION'];
2508
+ delete env['CURSOR_TRACE_ID'];
2509
+ const result = spawnSync('sh', ['./pre-push'], { cwd: repoDir, env, encoding: 'utf-8' });
2510
+ return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
2511
+ }
2512
+ const invocations = () => {
2513
+ const native = logPath.split('/').join(path.sep);
2514
+ return fs.existsSync(native) ? fs.readFileSync(native, 'utf-8').trim().split(NL) : [];
2515
+ };
2516
+ /** Deposit through the REAL writer, at `sha`. */
2517
+ function deposit(sha) {
2518
+ fs.writeFileSync(path.join(repoDir, 'findings.json'), JSON.stringify({
2519
+ readAt: new Date().toISOString(),
2520
+ findings: [
2521
+ {
2522
+ id: 'f1',
2523
+ severity: 'MATERIAL',
2524
+ file: 'docs/note.md',
2525
+ line: 1,
2526
+ claim: 'the page overstates the floor',
2527
+ counterexample: 'the gate never reads severities',
2528
+ },
2529
+ ],
2530
+ folded: ['f1'],
2531
+ verdict: 'one material finding, folded',
2532
+ }));
2533
+ const result = spawnSync('node', [DIST, 'legs', 'deposit', '--sha', sha, '--from', 'findings.json'], { cwd: repoDir, encoding: 'utf-8' });
2534
+ // Anchored, with the writer's own stderr as the failure message: the
2535
+ // previous `toContain('0:')` also passed on a status of 10 or 130.
2536
+ expect(result.status, result.stderr ?? '').toBe(0);
2537
+ }
2538
+ it.skipIf(!composedOk)('OWED with no deposit: the hook blocks, and BOTH lines are present', () => {
2539
+ const r = runHook();
2540
+ expect(invocations()).toContain('legs gate');
2541
+ expect(r.status).toBe(1);
2542
+ // The GATE's own line, derived end to end from the real predicate + store.
2543
+ // Both owed paths are named, the accented one spelled as it is on disk.
2544
+ expect(r.stdout).toContain(`[Totem] BLOCKED: this push is legs-owed (docs/** → ${ACCENTED}, docs/** → docs/note.md)`);
2545
+ expect(r.stdout).toContain(`for head ${headSha.slice(0, 8)}`);
2546
+ // And the HOOK's own cure line, which only the strict arm emits.
2547
+ expect(r.stdout).toContain("run the leg, then 'totem legs deposit --sha HEAD --from <findings.json>'");
2548
+ });
2549
+ it.skipIf(!composedOk)('OWED with an ANCESTOR deposit that covers the owed set: the hook passes', () => {
2550
+ deposit(ancestorSha);
2551
+ const r = runHook();
2552
+ expect(invocations()).toContain('legs gate');
2553
+ expect(r.stdout).toContain('[Totem] legs evidence: .totem/artifacts/legs/');
2554
+ // ANCESTOR, so the reach probe actually RAN: this is the only composed
2555
+ // state that drives `git diff --name-only -z base...<deposit>` end to
2556
+ // end. Depositing at the head took core's exact branch, which credits
2557
+ // coverage by construction and never probes (mmnto-ai/totem#2698 fold 5).
2558
+ expect(r.stdout).toContain(`· head ${headSha.slice(0, 8)} · nearest ancestor, +1 commits since the leg read ·`);
2559
+ // 2/2 with an accented owed path among them: the measured result of
2560
+ // reading both sides raw.
2561
+ expect(r.stdout).toContain('· covers 2/2 owed paths ·');
2562
+ expect(r.stdout).toContain('blocking=0 material=1 folded=1');
2563
+ expect(r.stdout).not.toContain('BLOCKED');
2564
+ expect(r.status).toBe(0);
2565
+ });
2566
+ it.skipIf(!composedOk)('OWED with a deposit on a SIBLING branch: the hook blocks and the gate calls it stale', () => {
2567
+ // A commit that is not an ancestor of HEAD: a second branch off the base.
2568
+ git('checkout', '-q', '-b', 'feat/sibling', baseSha);
2569
+ fs.writeFileSync(path.join(repoDir, 'other.ts'), 'export const b = 2;' + NL);
2570
+ git('add', 'other.ts');
2571
+ git('commit', '-q', '-m', 'a sibling commit');
2572
+ const siblingSha = git('rev-parse', 'HEAD').trim();
2573
+ git('checkout', '-q', 'feat/owed');
2574
+ deposit(siblingSha);
2575
+ const r = runHook();
2576
+ expect(invocations()).toContain('legs gate');
2577
+ expect(r.status).toBe(1);
2578
+ expect(r.stdout).toContain(`[Totem] legs: stale deposit ${siblingSha.slice(0, 8)}: not an ancestor of head`);
2579
+ expect(r.stdout).toContain('[Totem] BLOCKED: this push is legs-owed');
2580
+ });
2581
+ it.skipIf(!composedOk)('OWED with a MERGE-BASE deposit: ancestor, but it covers none of the owed paths', () => {
2582
+ // The fold-3 exhibit, end to end: a deposit at the base is a real
2583
+ // ancestor one commit behind, so ancestry alone passed it — while the leg
2584
+ // that wrote it saw none of the diff this push proposes.
2585
+ deposit(baseSha);
2586
+ const r = runHook();
2587
+ expect(invocations()).toContain('legs gate');
2588
+ expect(r.status).toBe(1);
2589
+ expect(r.stdout).toContain(`[Totem] legs: stale deposit ${baseSha.slice(0, 8)}: covers none of the owed paths (the deposit predates every owed change)`);
2590
+ expect(r.stdout).toContain('[Totem] BLOCKED: this push is legs-owed');
2591
+ // And it is NOT reported as an ancestry failure — the reason is the cure.
2592
+ expect(r.stdout).not.toContain('not an ancestor of head');
2593
+ });
2594
+ // POSIX-only, and only for these two names: NTFS refuses a double quote and a
2595
+ // backslash in a filename, while Linux and macOS — where the strict-tier
2596
+ // population runs — allow both, and git C-quotes both in the `diff --git`
2597
+ // headers the owed set used to be parsed from. This drives them through the
2598
+ // REAL CLI and the generated hook (mmnto-ai/totem#2698 fold 5).
2599
+ it.skipIf(!composedOk || process.platform === 'win32')('OWED on quote- and backslash-bearing paths: the hook names them as they are on disk', () => {
2600
+ const quoted = `docs/a${String.fromCharCode(34)}quoted${String.fromCharCode(34)}.md`;
2601
+ const backslashed = `docs/back${String.fromCharCode(92)}slash.md`;
2602
+ fs.writeFileSync(path.join(repoDir, ...quoted.split('/')), '# quoted' + NL);
2603
+ fs.writeFileSync(path.join(repoDir, ...backslashed.split('/')), '# backslashed' + NL);
2604
+ git('add', '-A', 'docs');
2605
+ git('commit', '-q', '-m', 'the hostile pages');
2606
+ const r = runHook();
2607
+ expect(invocations()).toContain('legs gate');
2608
+ expect(r.status).toBe(1);
2609
+ // Raw in the basis. Escaped names here would be the fold-4 defect, and
2610
+ // the decoder that fold shipped could not have reached either of these.
2611
+ expect(r.stdout).toContain(quoted);
2612
+ expect(r.stdout).toContain(backslashed);
2613
+ expect(r.stdout).not.toContain(String.fromCharCode(92) + '"');
2614
+ });
2615
+ it.skipIf(!composedOk)('NOT OWED: the hook passes and the gate says what it judged', () => {
2616
+ // A branch whose only change misses the one declared glob.
2617
+ git('checkout', '-q', '-b', 'feat/not-owed', baseSha);
2618
+ fs.writeFileSync(path.join(repoDir, 'src.ts'), 'export const a = 2;' + NL);
2619
+ git('add', 'src.ts');
2620
+ git('commit', '-q', '-m', 'a code-only change');
2621
+ const r = runHook();
2622
+ expect(invocations()).toContain('legs gate');
2623
+ expect(r.status).toBe(0);
2624
+ expect(r.stdout).toContain('[Totem] legs: not owed — no changed path matched hooks.legsOwed.globs (1 globs; head');
2625
+ expect(r.stdout).not.toContain('BLOCKED');
2626
+ });
2627
+ });
1632
2628
  //# sourceMappingURL=install-hooks.test.js.map