@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.
- package/dist/commands/doctor-parity-fetch.d.ts +84 -0
- package/dist/commands/doctor-parity-fetch.d.ts.map +1 -0
- package/dist/commands/doctor-parity-fetch.js +367 -0
- package/dist/commands/doctor-parity-fetch.js.map +1 -0
- package/dist/commands/doctor-parity-fetch.test.d.ts +13 -0
- package/dist/commands/doctor-parity-fetch.test.d.ts.map +1 -0
- package/dist/commands/doctor-parity-fetch.test.js +269 -0
- package/dist/commands/doctor-parity-fetch.test.js.map +1 -0
- package/dist/commands/doctor-parity.d.ts.map +1 -1
- package/dist/commands/doctor-parity.js +57 -1
- package/dist/commands/doctor-parity.js.map +1 -1
- package/dist/commands/eject.d.ts.map +1 -1
- package/dist/commands/eject.js +3 -0
- package/dist/commands/eject.js.map +1 -1
- package/dist/commands/init-templates.d.ts +15 -0
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +301 -11
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +30 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +450 -6
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/install-hooks-exit-contract.test.js +107 -3
- package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
- package/dist/commands/install-hooks.d.ts +39 -0
- package/dist/commands/install-hooks.d.ts.map +1 -1
- package/dist/commands/install-hooks.js +152 -0
- package/dist/commands/install-hooks.js.map +1 -1
- package/dist/commands/pr.d.ts +130 -0
- package/dist/commands/pr.d.ts.map +1 -0
- package/dist/commands/pr.js +427 -0
- package/dist/commands/pr.js.map +1 -0
- package/dist/commands/pr.test.d.ts +2 -0
- package/dist/commands/pr.test.d.ts.map +1 -0
- package/dist/commands/pr.test.js +494 -0
- package/dist/commands/pr.test.js.map +1 -0
- package/dist/commands/review-fan.d.ts +9 -3
- package/dist/commands/review-fan.d.ts.map +1 -1
- package/dist/commands/review-fan.js +100 -9
- package/dist/commands/review-fan.js.map +1 -1
- package/dist/commands/review-fan.test.js +93 -0
- package/dist/commands/review-fan.test.js.map +1 -1
- package/dist/commands/shield-nonreview.test.d.ts +24 -0
- package/dist/commands/shield-nonreview.test.d.ts.map +1 -0
- package/dist/commands/shield-nonreview.test.js +175 -0
- package/dist/commands/shield-nonreview.test.js.map +1 -0
- package/dist/commands/shield.d.ts +8 -4
- package/dist/commands/shield.d.ts.map +1 -1
- package/dist/commands/shield.js +29 -13
- package/dist/commands/shield.js.map +1 -1
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -1
- 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.
|
|
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/
|
|
1373
|
-
it('
|
|
1374
|
-
|
|
1375
|
-
|
|
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;
|