@mmnto/cli 1.102.0 → 1.104.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 (54) hide show
  1. package/dist/commands/doctor-parity-fetch.d.ts +84 -0
  2. package/dist/commands/doctor-parity-fetch.d.ts.map +1 -0
  3. package/dist/commands/doctor-parity-fetch.js +367 -0
  4. package/dist/commands/doctor-parity-fetch.js.map +1 -0
  5. package/dist/commands/doctor-parity-fetch.test.d.ts +13 -0
  6. package/dist/commands/doctor-parity-fetch.test.d.ts.map +1 -0
  7. package/dist/commands/doctor-parity-fetch.test.js +269 -0
  8. package/dist/commands/doctor-parity-fetch.test.js.map +1 -0
  9. package/dist/commands/doctor-parity.d.ts.map +1 -1
  10. package/dist/commands/doctor-parity.js +57 -1
  11. package/dist/commands/doctor-parity.js.map +1 -1
  12. package/dist/commands/eject.d.ts.map +1 -1
  13. package/dist/commands/eject.js +3 -0
  14. package/dist/commands/eject.js.map +1 -1
  15. package/dist/commands/init-templates.d.ts +15 -0
  16. package/dist/commands/init-templates.d.ts.map +1 -1
  17. package/dist/commands/init-templates.js +301 -11
  18. package/dist/commands/init-templates.js.map +1 -1
  19. package/dist/commands/init.d.ts.map +1 -1
  20. package/dist/commands/init.js +30 -2
  21. package/dist/commands/init.js.map +1 -1
  22. package/dist/commands/init.test.js +450 -6
  23. package/dist/commands/init.test.js.map +1 -1
  24. package/dist/commands/install-hooks-exit-contract.test.js +107 -3
  25. package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
  26. package/dist/commands/install-hooks.d.ts +39 -0
  27. package/dist/commands/install-hooks.d.ts.map +1 -1
  28. package/dist/commands/install-hooks.js +152 -0
  29. package/dist/commands/install-hooks.js.map +1 -1
  30. package/dist/commands/pr.d.ts +130 -0
  31. package/dist/commands/pr.d.ts.map +1 -0
  32. package/dist/commands/pr.js +427 -0
  33. package/dist/commands/pr.js.map +1 -0
  34. package/dist/commands/pr.test.d.ts +2 -0
  35. package/dist/commands/pr.test.d.ts.map +1 -0
  36. package/dist/commands/pr.test.js +494 -0
  37. package/dist/commands/pr.test.js.map +1 -0
  38. package/dist/commands/review-fan.d.ts +9 -3
  39. package/dist/commands/review-fan.d.ts.map +1 -1
  40. package/dist/commands/review-fan.js +100 -9
  41. package/dist/commands/review-fan.js.map +1 -1
  42. package/dist/commands/review-fan.test.js +93 -0
  43. package/dist/commands/review-fan.test.js.map +1 -1
  44. package/dist/commands/shield-nonreview.test.d.ts +24 -0
  45. package/dist/commands/shield-nonreview.test.d.ts.map +1 -0
  46. package/dist/commands/shield-nonreview.test.js +175 -0
  47. package/dist/commands/shield-nonreview.test.js.map +1 -0
  48. package/dist/commands/shield.d.ts +8 -4
  49. package/dist/commands/shield.d.ts.map +1 -1
  50. package/dist/commands/shield.js +29 -13
  51. package/dist/commands/shield.js.map +1 -1
  52. package/dist/index.js +34 -0
  53. package/dist/index.js.map +1 -1
  54. package/package.json +2 -2
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
- import { LedgerEventSchema, resolveSelfAgents } from '@mmnto/totem';
6
+ import { AUTO_CLOSE_REGEX_SOURCE, LedgerEventSchema, resolveSelfAgents } from '@mmnto/totem';
7
7
  import { UNIVERSAL_BASELINE_LESSONS, UNIVERSAL_BASELINE_MARKDOWN, UNIVERSAL_BASELINE_MARKER, } from '../assets/universal-baseline.js';
8
8
  import { cleanTmpDir } from '../test-utils.js';
9
9
  import { buildNpxCommand, detectEmbeddingTier, detectReflexStatus, generateConfig, initCommand, installBaselineLessons, OLLAMA_FLOOR_DEFAULT_BASE_URL, probeOllamaFloor, REFLEX_VERSION, scaffoldClaudeHooks, scaffoldClaudeSessionStart, scaffoldClaudeSkill, scaffoldClaudeWriteShield, scaffoldFile, scaffoldMcpConfig, upgradeReflexes, } from './init.js';
@@ -313,7 +313,7 @@ describe('Gemini hook scaffolding', () => {
313
313
  const hooksDir = path.join(tmpDir, '.gemini', 'hooks');
314
314
  const skillsDir = path.join(tmpDir, '.gemini', 'skills');
315
315
  const sessionStart = scaffoldFile(path.join(hooksDir, 'SessionStart.js'), '// [totem] auto-generated\ntest\n');
316
- const beforeTool = scaffoldFile(path.join(hooksDir, 'BeforeTool.js'), '// [totem] auto-generated\ntest\n');
316
+ const beforeTool = scaffoldFile(path.join(hooksDir, 'BeforeTool.cjs'), '// [totem] auto-generated\ntest\n');
317
317
  const skill = scaffoldFile(path.join(skillsDir, 'totem.md'), '<!-- [totem] auto-generated — Totem Architect skill -->\ntest\n', '<!-- [totem] auto-generated — Totem Architect skill -->');
318
318
  expect(sessionStart).toEqual({ action: 'created' });
319
319
  expect(beforeTool).toEqual({ action: 'created' });
@@ -1179,6 +1179,12 @@ describe('CLAUDE_PREWRITESHIELD template', () => {
1179
1179
  it('cites the seal at mmnto-ai/totem-strategy#145', () => {
1180
1180
  expect(CLAUDE_PREWRITESHIELD).toContain('mmnto-ai/totem-strategy#145');
1181
1181
  });
1182
+ it('inlines the shared AUTO_CLOSE_REGEX_SOURCE (mmnto-ai/totem#1762, JSON-encoded)', () => {
1183
+ expect(CLAUDE_PREWRITESHIELD).toContain(JSON.stringify(AUTO_CLOSE_REGEX_SOURCE));
1184
+ });
1185
+ it('cites the auto-close issue mmnto-ai/totem#1762', () => {
1186
+ expect(CLAUDE_PREWRITESHIELD).toContain('mmnto-ai/totem#1762');
1187
+ });
1182
1188
  });
1183
1189
  describe('PreWriteShield runtime behavior', () => {
1184
1190
  let tmpDir;
@@ -1291,6 +1297,269 @@ describe('PreWriteShield runtime behavior', () => {
1291
1297
  });
1292
1298
  expect(result.exitCode).toBe(0);
1293
1299
  });
1300
+ // ─── Auto-close keyword guard (mmnto-ai/totem#1762) ───────────────────────
1301
+ it('exits 2 on a close keyword adjacent to an issue ref in a **/*.md write', () => {
1302
+ const result = runHook({
1303
+ tool_name: 'Write',
1304
+ tool_input: { file_path: 'docs/x.md', content: 'Closes #131' },
1305
+ });
1306
+ expect(result.exitCode).toBe(2);
1307
+ expect(result.stderr).toMatch(/auto-close/i);
1308
+ expect(result.stderr).toContain('#131');
1309
+ });
1310
+ it('exits 2 even under NEGATION (presence invariant — the #2471 shape)', () => {
1311
+ const result = runHook({
1312
+ tool_name: 'Write',
1313
+ tool_input: { file_path: '.journal/t.md', content: 'Does not close #2466' },
1314
+ });
1315
+ expect(result.exitCode).toBe(2);
1316
+ expect(result.stderr).toMatch(/auto-close/i);
1317
+ expect(result.stderr).toContain('#2466');
1318
+ });
1319
+ it('exits 2 on a QUALIFIED close ref (owner/repo#N still auto-closes)', () => {
1320
+ const result = runHook({
1321
+ tool_name: 'Write',
1322
+ tool_input: { file_path: 'docs/x.md', content: 'Closes mmnto-ai/totem#131' },
1323
+ });
1324
+ expect(result.exitCode).toBe(2);
1325
+ expect(result.stderr).toMatch(/auto-close/i);
1326
+ });
1327
+ it('exits 2 on the issue-URL close form (kimi BLOCKING-1)', () => {
1328
+ const result = runHook({
1329
+ tool_name: 'Write',
1330
+ tool_input: {
1331
+ file_path: 'docs/x.md',
1332
+ content: 'Fixes https://github.com/mmnto-ai/totem/issues/2466',
1333
+ },
1334
+ });
1335
+ expect(result.exitCode).toBe(2);
1336
+ expect(result.stderr).toMatch(/auto-close/i);
1337
+ expect(result.stderr).toContain('mmnto-ai/totem#2466');
1338
+ });
1339
+ it('EXEMPTS .github/** from the auto-close guard (AC-4 — keywords intentional there)', () => {
1340
+ // Qualified ref so the sealed bare-ref arm (unchanged) also stays quiet; this
1341
+ // isolates the auto-close .github exemption.
1342
+ const result = runHook({
1343
+ tool_name: 'Write',
1344
+ tool_input: {
1345
+ file_path: '.github/PULL_REQUEST_TEMPLATE.md',
1346
+ content: 'Closes mmnto-ai/totem#131',
1347
+ },
1348
+ });
1349
+ expect(result.exitCode).toBe(0);
1350
+ });
1351
+ it('EXEMPTS .totem/** from the auto-close guard (gemini #2 — tool/agent-authored content)', () => {
1352
+ // Qualified ref isolates the auto-close .totem exemption from the sealed
1353
+ // bare-ref rule (which still applies to .totem/*.md — out of this slice's scope).
1354
+ const result = runHook({
1355
+ tool_name: 'Write',
1356
+ tool_input: { file_path: '.totem/lessons/x.md', content: 'Closes mmnto-ai/totem#131' },
1357
+ });
1358
+ expect(result.exitCode).toBe(0);
1359
+ });
1360
+ it('does NOT exempt .changeset/** (its prose is composed into the VP-PR description)', () => {
1361
+ // gemini #2 DECLINED half: changeset prose lands in the Version-Packages PR
1362
+ // description (an auto-close surface — verified on PR mmnto-ai/totem#2474).
1363
+ const result = runHook({
1364
+ tool_name: 'Write',
1365
+ tool_input: { file_path: '.changeset/happy-lions.md', content: 'Fixes mmnto-ai/totem#131' },
1366
+ });
1367
+ expect(result.exitCode).toBe(2);
1368
+ expect(result.stderr).toMatch(/auto-close/i);
1369
+ });
1370
+ it('respects the suppression-directive bypass for a verbatim-quoted close keyword', () => {
1371
+ const result = runHook({
1372
+ tool_name: 'Write',
1373
+ tool_input: {
1374
+ file_path: 'docs/x.md',
1375
+ content: '<!-- totem-context: verbatim quotation of a historical commit -->\nThe commit said closes #131.',
1376
+ },
1377
+ });
1378
+ expect(result.exitCode).toBe(0);
1379
+ });
1380
+ it('exits 0 on a non-keyword issue reference in markdown (references/see form)', () => {
1381
+ const result = runHook({
1382
+ tool_name: 'Write',
1383
+ tool_input: {
1384
+ file_path: 'docs/x.md',
1385
+ content: 'references mmnto-ai/totem#131 and see mmnto-ai/totem#5',
1386
+ },
1387
+ });
1388
+ expect(result.exitCode).toBe(0);
1389
+ });
1390
+ // ─── Dispatch frontmatter-quoting guard (mmnto-ai/totem-status#123) ────────
1391
+ const OUTBOX_MD = '.totem/orchestration/totem-claude/outbox/2026-07-22T1200Z-reply.md';
1392
+ it('exits 2 on an unquoted ": " in an outbox subject: value (strict-YAML breaker)', () => {
1393
+ const result = runHook({
1394
+ tool_name: 'Write',
1395
+ tool_input: {
1396
+ file_path: OUTBOX_MD,
1397
+ content: '---\nschema: adr-098-v0.4\nsubject: Re: parity round -- positions\n---\nBody.',
1398
+ },
1399
+ });
1400
+ expect(result.exitCode).toBe(2);
1401
+ expect(result.stderr).toContain('subject');
1402
+ expect(result.stderr).toMatch(/quote/i);
1403
+ expect(result.stderr).toContain('mmnto-ai/totem-status#123');
1404
+ });
1405
+ it('exits 2 on an unquoted ": " in expected-action: (and names the offending key)', () => {
1406
+ const result = runHook({
1407
+ tool_name: 'Write',
1408
+ tool_input: {
1409
+ file_path: OUTBOX_MD,
1410
+ content: '---\nsubject: "safe"\nexpected-action: reply by Friday: with positions\n---\n',
1411
+ },
1412
+ });
1413
+ expect(result.exitCode).toBe(2);
1414
+ expect(result.stderr).toContain('expected-action');
1415
+ });
1416
+ it('exits 2 on a TRAILING colon in an unquoted subject: value', () => {
1417
+ const result = runHook({
1418
+ tool_name: 'Write',
1419
+ tool_input: { file_path: OUTBOX_MD, content: '---\nsubject: Re:\n---\n' },
1420
+ });
1421
+ expect(result.exitCode).toBe(2);
1422
+ });
1423
+ it('exits 2 at a Windows-backslash outbox path too', () => {
1424
+ const result = runHook({
1425
+ tool_name: 'Write',
1426
+ tool_input: {
1427
+ file_path: '.totem\\orchestration\\totem-claude\\outbox\\reply.md',
1428
+ content: '---\nsubject: Re: broken -- title\n---\n',
1429
+ },
1430
+ });
1431
+ expect(result.exitCode).toBe(2);
1432
+ });
1433
+ it('exits 0 on a double-quoted subject carrying colons', () => {
1434
+ const result = runHook({
1435
+ tool_name: 'Write',
1436
+ tool_input: {
1437
+ file_path: OUTBOX_MD,
1438
+ content: '---\nsubject: "Re: parity round -- positions"\n---\nBody.',
1439
+ },
1440
+ });
1441
+ expect(result.exitCode).toBe(0);
1442
+ });
1443
+ it('exits 2 on a fake block-scalar header with trailing text (">note: x" is not valid YAML)', () => {
1444
+ const result = runHook({
1445
+ tool_name: 'Write',
1446
+ tool_input: {
1447
+ file_path: OUTBOX_MD,
1448
+ content: '---\nsubject: >note: this is not a block scalar\n---\n',
1449
+ },
1450
+ });
1451
+ expect(result.exitCode).toBe(2);
1452
+ });
1453
+ it('exits 0 on a single-quoted value and on a block scalar (>-)', () => {
1454
+ const single = runHook({
1455
+ tool_name: 'Write',
1456
+ tool_input: { file_path: OUTBOX_MD, content: "---\nsubject: 'Re: quoted'\n---\n" },
1457
+ });
1458
+ expect(single.exitCode).toBe(0);
1459
+ const folded = runHook({
1460
+ tool_name: 'Write',
1461
+ tool_input: {
1462
+ file_path: OUTBOX_MD,
1463
+ content: '---\nsubject: >-\n Re: folded -- safe\n---\n',
1464
+ },
1465
+ });
1466
+ expect(folded.exitCode).toBe(0);
1467
+ });
1468
+ it('exits 0 on a colon-free unquoted subject (plain scalars stay legal)', () => {
1469
+ const result = runHook({
1470
+ tool_name: 'Write',
1471
+ tool_input: { file_path: OUTBOX_MD, content: '---\nsubject: parity round positions\n---\n' },
1472
+ });
1473
+ expect(result.exitCode).toBe(0);
1474
+ });
1475
+ it('scans the leading frontmatter block ONLY on full-file writes (body prose stays writable)', () => {
1476
+ const result = runHook({
1477
+ tool_name: 'Write',
1478
+ tool_input: {
1479
+ file_path: OUTBOX_MD,
1480
+ content: '---\nsubject: "safe"\n---\nsubject: unquoted: colon in body prose about the schema',
1481
+ },
1482
+ });
1483
+ expect(result.exitCode).toBe(0);
1484
+ });
1485
+ it('exits 0 on a frontmatter-LESS full Write (mode is tool-determined, body is never scanned)', () => {
1486
+ const result = runHook({
1487
+ tool_name: 'Write',
1488
+ tool_input: {
1489
+ file_path: OUTBOX_MD,
1490
+ content: 'Draft notes, no frontmatter yet.\nsubject: Re: unsafe-looking body line\n',
1491
+ },
1492
+ });
1493
+ expect(result.exitCode).toBe(0);
1494
+ });
1495
+ it('exits 2 on an UNTERMINATED quoted subject (a leading quote alone is not safety)', () => {
1496
+ const result = runHook({
1497
+ tool_name: 'Write',
1498
+ tool_input: { file_path: OUTBOX_MD, content: '---\nsubject: "Re: round\n---\n' },
1499
+ });
1500
+ expect(result.exitCode).toBe(2);
1501
+ });
1502
+ it('exits 2 on trailing junk after a closed quote (also invalid YAML)', () => {
1503
+ const result = runHook({
1504
+ tool_name: 'Write',
1505
+ tool_input: { file_path: OUTBOX_MD, content: '---\nsubject: "Re: round" oops\n---\n' },
1506
+ });
1507
+ expect(result.exitCode).toBe(2);
1508
+ });
1509
+ it('exits 2 on an invalid double-quote escape ("Re: D:\\q" is not YAML)', () => {
1510
+ const result = runHook({
1511
+ tool_name: 'Write',
1512
+ tool_input: { file_path: OUTBOX_MD, content: '---\nsubject: "Re: D:\\q"\n---\n' },
1513
+ });
1514
+ expect(result.exitCode).toBe(2);
1515
+ });
1516
+ it('exits 0 on YAML-legal escapes inside a quoted subject', () => {
1517
+ const result = runHook({
1518
+ tool_name: 'Write',
1519
+ tool_input: {
1520
+ file_path: OUTBOX_MD,
1521
+ content: '---\nsubject: "tab \\t quote \\" backslash \\\\ ok"\n---\n',
1522
+ },
1523
+ });
1524
+ expect(result.exitCode).toBe(0);
1525
+ });
1526
+ it('exits 2 on a multi-line quoted subject (single-physical-line policy for the line-oriented lenient consumer)', () => {
1527
+ const result = runHook({
1528
+ tool_name: 'Write',
1529
+ tool_input: {
1530
+ file_path: OUTBOX_MD,
1531
+ content: '---\nsubject: "Re: spans\n two lines"\n---\n',
1532
+ },
1533
+ });
1534
+ expect(result.exitCode).toBe(2);
1535
+ });
1536
+ it('does NOT fire outside the outbox surface (same content at a .journal path)', () => {
1537
+ const result = runHook({
1538
+ tool_name: 'Write',
1539
+ tool_input: {
1540
+ file_path: '.journal/test.md',
1541
+ content: '---\nsubject: Re: not a dispatch -- out of scope\n---\n',
1542
+ },
1543
+ });
1544
+ expect(result.exitCode).toBe(0);
1545
+ });
1546
+ it('exits 2 on an Edit fragment reintroducing an unquoted subject (fragment mode)', () => {
1547
+ const result = runHook({
1548
+ tool_name: 'Edit',
1549
+ tool_input: { file_path: OUTBOX_MD, new_string: 'subject: Re: sharpened -- title' },
1550
+ });
1551
+ expect(result.exitCode).toBe(2);
1552
+ });
1553
+ it('honors the totem-context escape in fragment mode (schema discussion in a body edit)', () => {
1554
+ const result = runHook({
1555
+ tool_name: 'Edit',
1556
+ tool_input: {
1557
+ file_path: OUTBOX_MD,
1558
+ new_string: '<!-- totem-context: verbatim quotation of the malformed specimen -->\nsubject: Re: the specimen that broke strict YAML',
1559
+ },
1560
+ });
1561
+ expect(result.exitCode).toBe(0);
1562
+ });
1294
1563
  });
1295
1564
  describe('CLAUDE_SESSION_START runtime behavior (A.3.a ledger write)', () => {
1296
1565
  // Fail-fast guard for the spawned hook process, matching the 30s timeout
@@ -1369,10 +1638,16 @@ describe('CLAUDE_SESSION_START runtime behavior (A.3.a ledger write)', () => {
1369
1638
  }
1370
1639
  });
1371
1640
  });
1372
- describe('GEMINI_BEFORE_TOOL write_file/edit_file extension', () => {
1373
- it('extends BeforeTool with write_file/edit_file branch', () => {
1374
- expect(GEMINI_BEFORE_TOOL).toContain("'write_file'");
1375
- expect(GEMINI_BEFORE_TOOL).toContain("'edit_file'");
1641
+ describe('GEMINI_BEFORE_TOOL write_file/replace extension', () => {
1642
+ it('gates BOTH check functions on write_file + replace (+ legacy edit_file)', () => {
1643
+ // Gemini CLI's real write tools are `write_file` + `replace` — there is NO
1644
+ // `edit_file` (docs.gemini file-system tools; gemini-cli#20321). Every check
1645
+ // function must list `replace`, else surgical edits bypass the hook
1646
+ // (gemini #1). `edit_file` is kept for backward-safety.
1647
+ const gates = [...GEMINI_BEFORE_TOOL.matchAll(/toolName !== '([a-z_]+)'/g)].map((m) => m[1]);
1648
+ for (const wanted of ['write_file', 'replace']) {
1649
+ expect(gates.filter((g) => g === wanted).length).toBeGreaterThanOrEqual(2);
1650
+ }
1376
1651
  });
1377
1652
  it('inlines BARE_REF_REGEX_SOURCE in write-tool branch (JSON-encoded for safe JS string-literal embedding)', () => {
1378
1653
  expect(GEMINI_BEFORE_TOOL).toContain(JSON.stringify(BARE_REF_REGEX_SOURCE));
@@ -1384,6 +1659,175 @@ describe('GEMINI_BEFORE_TOOL write_file/edit_file extension', () => {
1384
1659
  it('cites the seal at mmnto-ai/totem-strategy#145 in BeforeTool extension', () => {
1385
1660
  expect(GEMINI_BEFORE_TOOL).toContain('mmnto-ai/totem-strategy#145');
1386
1661
  });
1662
+ it('inlines the shared AUTO_CLOSE_REGEX_SOURCE (mmnto-ai/totem#1762, parity with PreWriteShield)', () => {
1663
+ expect(GEMINI_BEFORE_TOOL).toContain(JSON.stringify(AUTO_CLOSE_REGEX_SOURCE));
1664
+ });
1665
+ it('wires the auto-close check into beforeTool and cites mmnto-ai/totem#1762', () => {
1666
+ expect(GEMINI_BEFORE_TOOL).toContain('checkAutoCloseKeywords(toolName, toolInput)');
1667
+ expect(GEMINI_BEFORE_TOOL).toContain('mmnto-ai/totem#1762');
1668
+ });
1669
+ });
1670
+ describe('GEMINI_BEFORE_TOOL auto-close runtime behavior (mmnto-ai/totem#1762)', () => {
1671
+ let tmpDir;
1672
+ let hookPath;
1673
+ beforeEach(() => {
1674
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-gemini-beforetool-'));
1675
+ hookPath = path.join(tmpDir, 'BeforeTool.js');
1676
+ fs.writeFileSync(hookPath, GEMINI_BEFORE_TOOL, 'utf-8');
1677
+ });
1678
+ afterEach(() => {
1679
+ cleanTmpDir(tmpDir);
1680
+ });
1681
+ /** Require the rendered hook fresh and report whether it threw. */
1682
+ function runBeforeTool(tool, input) {
1683
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1684
+ const beforeTool = require(hookPath);
1685
+ try {
1686
+ beforeTool(tool, input);
1687
+ return { threw: false, message: '' };
1688
+ }
1689
+ catch (err) {
1690
+ return { threw: true, message: err instanceof Error ? err.message : String(err) };
1691
+ }
1692
+ }
1693
+ it('throws (blocks) on a close keyword adjacent to an issue ref in a **/*.md write, even negated', () => {
1694
+ const r = runBeforeTool('write_file', {
1695
+ file_path: 'docs/x.md',
1696
+ content: 'Does not close #2466',
1697
+ });
1698
+ expect(r.threw).toBe(true);
1699
+ expect(r.message).toMatch(/auto-close/i);
1700
+ expect(r.message).toContain('#2466');
1701
+ });
1702
+ it('blocks the auto-close rule via the real `replace` edit tool (gemini #1)', () => {
1703
+ const r = runBeforeTool('replace', {
1704
+ file_path: 'docs/x.md',
1705
+ new_string: 'Fixes https://github.com/mmnto-ai/totem/issues/2466',
1706
+ });
1707
+ expect(r.threw).toBe(true);
1708
+ expect(r.message).toContain('mmnto-ai/totem#2466');
1709
+ });
1710
+ it('blocks the xrepo bare-ref rule via `replace` too (fixes the same pre-existing gap)', () => {
1711
+ const r = runBeforeTool('replace', { file_path: '.journal/x.md', new_string: 'see #9 above' });
1712
+ expect(r.threw).toBe(true);
1713
+ expect(r.message).toMatch(/Bare PR\/issue/i);
1714
+ });
1715
+ it('uses the [totem BeforeTool] prefix, not [totem PreWriteShield] (gemini #3)', () => {
1716
+ const r = runBeforeTool('write_file', { file_path: 'docs/x.md', content: 'Closes #5' });
1717
+ expect(r.message).toContain('[totem BeforeTool]');
1718
+ expect(r.message).not.toContain('PreWriteShield');
1719
+ });
1720
+ it('exempts .github/** (qualified ref keeps the bare-ref arm quiet too)', () => {
1721
+ const r = runBeforeTool('write_file', {
1722
+ file_path: '.github/x.md',
1723
+ content: 'Closes mmnto-ai/totem#5',
1724
+ });
1725
+ expect(r.threw).toBe(false);
1726
+ });
1727
+ it('exempts .totem/** from the auto-close rule (gemini #2)', () => {
1728
+ const r = runBeforeTool('write_file', {
1729
+ file_path: '.totem/lessons/x.md',
1730
+ content: 'Closes mmnto-ai/totem#5',
1731
+ });
1732
+ expect(r.threw).toBe(false);
1733
+ });
1734
+ it('does not throw on a non-keyword reference form', () => {
1735
+ const r = runBeforeTool('edit_file', {
1736
+ file_path: 'docs/x.md',
1737
+ new_string: 'see mmnto-ai/totem#5',
1738
+ });
1739
+ expect(r.threw).toBe(false);
1740
+ });
1741
+ // ─── Dispatch frontmatter-quoting guard (mmnto-ai/totem-status#123) ────────
1742
+ it('throws on an unquoted ": " in an outbox subject: value (parity with PreWriteShield)', () => {
1743
+ const r = runBeforeTool('write_file', {
1744
+ file_path: '.totem/orchestration/totem-gemini/outbox/reply.md',
1745
+ content: '---\nsubject: Re: parity round -- positions\n---\nBody.',
1746
+ });
1747
+ expect(r.threw).toBe(true);
1748
+ expect(r.message).toContain('[totem BeforeTool]');
1749
+ expect(r.message).toContain('mmnto-ai/totem-status#123');
1750
+ });
1751
+ it('does not throw on a quoted outbox subject', () => {
1752
+ const r = runBeforeTool('write_file', {
1753
+ file_path: '.totem/orchestration/totem-gemini/outbox/reply.md',
1754
+ content: '---\nsubject: "Re: parity round -- positions"\n---\nBody.',
1755
+ });
1756
+ expect(r.threw).toBe(false);
1757
+ });
1758
+ it('blocks the quoting guard via the real `replace` edit tool too', () => {
1759
+ const r = runBeforeTool('replace', {
1760
+ file_path: '.totem/orchestration/totem-gemini/outbox/reply.md',
1761
+ new_string: 'subject: Re: reintroduced -- unquoted',
1762
+ });
1763
+ expect(r.threw).toBe(true);
1764
+ expect(r.message).toMatch(/quote/i);
1765
+ });
1766
+ it('blocks the quoting guard via the legacy `edit_file` gate (backward-safety branch)', () => {
1767
+ const r = runBeforeTool('edit_file', {
1768
+ file_path: '.totem/orchestration/totem-gemini/outbox/reply.md',
1769
+ new_string: 'subject: Re: legacy tool -- unquoted',
1770
+ });
1771
+ expect(r.threw).toBe(true);
1772
+ });
1773
+ it('does not throw on a frontmatter-less full write_file (tool-determined mode)', () => {
1774
+ const r = runBeforeTool('write_file', {
1775
+ file_path: '.totem/orchestration/totem-gemini/outbox/reply.md',
1776
+ content: 'Draft notes, no frontmatter yet.\nsubject: Re: unsafe-looking body line\n',
1777
+ });
1778
+ expect(r.threw).toBe(false);
1779
+ });
1780
+ it('throws on an unterminated quoted subject (leading quote alone is not safety)', () => {
1781
+ const r = runBeforeTool('write_file', {
1782
+ file_path: '.totem/orchestration/totem-gemini/outbox/reply.md',
1783
+ content: '---\nsubject: "Re: round\n---\n',
1784
+ });
1785
+ expect(r.threw).toBe(true);
1786
+ });
1787
+ it('throws on an invalid double-quote escape at the Gemini call site too', () => {
1788
+ const r = runBeforeTool('write_file', {
1789
+ file_path: '.totem/orchestration/totem-gemini/outbox/reply.md',
1790
+ content: '---\nsubject: "Re: D:\\q"\n---\n',
1791
+ });
1792
+ expect(r.threw).toBe(true);
1793
+ });
1794
+ });
1795
+ describe('GEMINI_BEFORE_TOOL ships as CJS for "type": "module" consumers (mmnto-ai/totem#2481)', () => {
1796
+ let tmpDir;
1797
+ beforeEach(() => {
1798
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-2481-typemodule-'));
1799
+ // A consumer repo whose package.json declares ESM — the environment that makes a
1800
+ // bare `.js` hook resolve as ESM and fail-open.
1801
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ type: 'module' }, null, 2) + '\n', 'utf-8');
1802
+ });
1803
+ afterEach(() => {
1804
+ cleanTmpDir(tmpDir);
1805
+ });
1806
+ it('executes the distributed .cjs hook and the guard evaluates (no `require is not defined`)', () => {
1807
+ const cjsPath = path.join(tmpDir, 'BeforeTool.cjs');
1808
+ fs.writeFileSync(cjsPath, GEMINI_BEFORE_TOOL, 'utf-8');
1809
+ // Require the shipped `.cjs` from a real node subprocess INSIDE the ESM consumer
1810
+ // and drive the guard with a close-keyword write. A `.cjs` is CommonJS regardless
1811
+ // of the enclosing package `type`, so `require` resolves and the guard BODY runs —
1812
+ // proven by the guard throwing its own `[totem BeforeTool]` error rather than a
1813
+ // `require is not defined` ReferenceError.
1814
+ const driver = `require(${JSON.stringify(cjsPath)})('write_file', { file_path: 'docs/x.md', content: 'Closes #5' })`;
1815
+ const res = spawnSync(process.execPath, ['-e', driver], { cwd: tmpDir, encoding: 'utf-8' });
1816
+ expect(res.stderr).not.toMatch(/require is not defined/);
1817
+ expect(res.stderr).toContain('[totem BeforeTool]');
1818
+ expect(res.status).not.toBe(0); // the guard blocked the write
1819
+ });
1820
+ it('proves the defect the .cjs fixes: the identical body as `.js` fail-opens as ESM', () => {
1821
+ // Contrast fixture documenting WHY the extension is load-bearing. The identical
1822
+ // hook body under a `.js` extension resolves as ESM in this consumer and throws
1823
+ // `require is not defined` at the top-level `require('child_process')`, before it
1824
+ // can read the tool call — the silent fail-open reported on mmnto-ai/totem#2481.
1825
+ const jsPath = path.join(tmpDir, 'BeforeTool.js');
1826
+ fs.writeFileSync(jsPath, GEMINI_BEFORE_TOOL, 'utf-8');
1827
+ const res = spawnSync(process.execPath, [jsPath], { cwd: tmpDir, encoding: 'utf-8' });
1828
+ expect(res.status).not.toBe(0);
1829
+ expect(res.stderr).toMatch(/require is not defined/);
1830
+ });
1387
1831
  });
1388
1832
  describe('scaffoldClaudeSkill (mmnto-ai/totem#1890 Phase C slice 3)', () => {
1389
1833
  let tmpDir;