@mmnto/cli 1.114.0 → 1.115.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-seat-identity.d.ts +71 -0
- package/dist/commands/doctor-seat-identity.d.ts.map +1 -0
- package/dist/commands/doctor-seat-identity.js +352 -0
- package/dist/commands/doctor-seat-identity.js.map +1 -0
- package/dist/commands/doctor-seat-identity.test.d.ts +13 -0
- package/dist/commands/doctor-seat-identity.test.d.ts.map +1 -0
- package/dist/commands/doctor-seat-identity.test.js +230 -0
- package/dist/commands/doctor-seat-identity.test.js.map +1 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +3 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +1 -0
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/ecl-gc.d.ts.map +1 -1
- package/dist/commands/ecl-gc.js +18 -7
- package/dist/commands/ecl-gc.js.map +1 -1
- package/dist/commands/ecl-gc.test.js +63 -1
- package/dist/commands/ecl-gc.test.js.map +1 -1
- package/dist/commands/eject.d.ts +62 -3
- package/dist/commands/eject.d.ts.map +1 -1
- package/dist/commands/eject.js +382 -47
- package/dist/commands/eject.js.map +1 -1
- package/dist/commands/eject.test.js +557 -7
- package/dist/commands/eject.test.js.map +1 -1
- package/dist/commands/gemini-beforetool-contract.test.d.ts +2 -0
- package/dist/commands/gemini-beforetool-contract.test.d.ts.map +1 -0
- package/dist/commands/gemini-beforetool-contract.test.js +106 -0
- package/dist/commands/gemini-beforetool-contract.test.js.map +1 -0
- package/dist/commands/gemini-sessionstart-contract.test.d.ts +2 -0
- package/dist/commands/gemini-sessionstart-contract.test.d.ts.map +1 -0
- package/dist/commands/gemini-sessionstart-contract.test.js +184 -0
- package/dist/commands/gemini-sessionstart-contract.test.js.map +1 -0
- package/dist/commands/init-templates.d.ts +2 -2
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +267 -15
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.test.js +25 -7
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/install-hooks-exit-contract.test.js +5 -0
- package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
- package/dist/commands/mail-degraded-e4.test.js +126 -0
- package/dist/commands/mail-degraded-e4.test.js.map +1 -1
- package/dist/commands/mail.d.ts +19 -1
- package/dist/commands/mail.d.ts.map +1 -1
- package/dist/commands/mail.js +195 -30
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/mail.test.js +291 -3
- package/dist/commands/mail.test.js.map +1 -1
- package/dist/commands/orient.d.ts.map +1 -1
- package/dist/commands/orient.js +82 -0
- package/dist/commands/orient.js.map +1 -1
- package/dist/commands/orient.test.js +144 -1
- package/dist/commands/orient.test.js.map +1 -1
- package/dist/commands/seat-cli-wiring.test.d.ts +20 -0
- package/dist/commands/seat-cli-wiring.test.d.ts.map +1 -0
- package/dist/commands/seat-cli-wiring.test.js +147 -0
- package/dist/commands/seat-cli-wiring.test.js.map +1 -0
- package/dist/commands/seat-poll-chain.test.d.ts +13 -0
- package/dist/commands/seat-poll-chain.test.d.ts.map +1 -0
- package/dist/commands/seat-poll-chain.test.js +103 -0
- package/dist/commands/seat-poll-chain.test.js.map +1 -0
- package/dist/commands/seat.d.ts +92 -0
- package/dist/commands/seat.d.ts.map +1 -0
- package/dist/commands/seat.js +649 -0
- package/dist/commands/seat.js.map +1 -0
- package/dist/commands/seat.test.d.ts +19 -0
- package/dist/commands/seat.test.d.ts.map +1 -0
- package/dist/commands/seat.test.js +532 -0
- package/dist/commands/seat.test.js.map +1 -0
- package/dist/commands/sessionstart-manifest-contract.test.d.ts +2 -0
- package/dist/commands/sessionstart-manifest-contract.test.d.ts.map +1 -0
- package/dist/commands/sessionstart-manifest-contract.test.js +176 -0
- package/dist/commands/sessionstart-manifest-contract.test.js.map +1 -0
- package/dist/hooks/__tests__/auto-context.test.js +20 -0
- package/dist/hooks/__tests__/auto-context.test.js.map +1 -1
- package/dist/hooks/auto-context.d.ts +19 -0
- package/dist/hooks/auto-context.d.ts.map +1 -1
- package/dist/hooks/auto-context.js +22 -4
- package/dist/hooks/auto-context.js.map +1 -1
- package/dist/index.js +71 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/commands/eject.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
+
// Subpath import, NOT the core barrel: fs-atomic pulls only node builtins, so
|
|
4
|
+
// the command file's cold-start discipline holds without threading a lazy seam
|
|
5
|
+
// through the sync scrub helpers that need it (mmnto-ai/totem#2620).
|
|
6
|
+
import { writeFileAtomicSync } from '@mmnto/totem/fs-atomic';
|
|
3
7
|
// ─── Constants ──────────────────────────────────────────
|
|
4
8
|
const TAG = 'Eject';
|
|
5
9
|
const TOTEM_HOOK_MARKER = '[totem] post-merge hook';
|
|
@@ -9,10 +13,10 @@ const TOTEM_CHECKOUT_END = '[totem] end post-checkout';
|
|
|
9
13
|
// Comment-shape-agnostic: hooks open `// [totem] auto-generated …`, markdown
|
|
10
14
|
// skills `<!-- [totem] auto-generated … -->` — the first-line gate matches both.
|
|
11
15
|
const TOTEM_FILE_MARKER = '[totem] auto-generated';
|
|
12
|
-
/** Files
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const TOTEM_SCAFFOLDED_FILES = [
|
|
16
|
+
/** Files scaffolded by `totem init` that are fully owned by Totem.
|
|
17
|
+
* Exported for the sense-roster set-equality test (mmnto-ai/totem#2620) —
|
|
18
|
+
* a sampled roster assertion cannot detect a dropped member. */
|
|
19
|
+
export const TOTEM_SCAFFOLDED_FILES = [
|
|
16
20
|
// Current (BeforeTool: mmnto-ai/totem#2481; SessionStart: mmnto-ai/totem#2488) +
|
|
17
21
|
// the pre-migration `.js` each one renamed — eject removes both shapes so an
|
|
18
22
|
// upgraded-then-ejected consumer leaves no fail-open artifact behind.
|
|
@@ -30,15 +34,24 @@ const TOTEM_SCAFFOLDED_FILES = [
|
|
|
30
34
|
// PR-C action-gate wrapper (mmnto-ai/totem#2048, eject parity).
|
|
31
35
|
'.claude/hooks/gate-wrapper.cjs',
|
|
32
36
|
];
|
|
37
|
+
/** Deletion/scrub targets shared by the mutation steps AND the rule-2 sense
|
|
38
|
+
* roster — one source, read by both sides and by the parity test (Tenet 20;
|
|
39
|
+
* round 2, F5: a literal re-declared in the sense was a mirror that could
|
|
40
|
+
* drift when `deleteArtifacts` gained a member). */
|
|
41
|
+
export const EJECT_ARTIFACT_DIRS = ['.lancedb', '.totem'];
|
|
42
|
+
export const EJECT_CONFIG_FILE = 'totem.config.ts';
|
|
43
|
+
export const CLAUDE_SETTINGS_LOCAL_FILE = '.claude/settings.local.json';
|
|
44
|
+
export const CLAUDE_SETTINGS_FILE = '.claude/settings.json';
|
|
33
45
|
/**
|
|
34
46
|
* Resolve where eject should look for git hooks, reusing the SAME helpers every
|
|
35
47
|
* `hook install` entry point uses (mmnto-ai/totem#2426, sibling of #2422): the
|
|
36
48
|
* git root via `resolveGitRootForHookPath` (anchors from a subdirectory; maps an
|
|
37
49
|
* unparseable pointer to a null root) and the hooks dir via `resolveHooksDir`
|
|
38
50
|
* (git's own worktree/`commondir` walk — the rev-parse root resolution the
|
|
39
|
-
* hardcode-`.git/hooks` lesson prescribes).
|
|
40
|
-
*
|
|
41
|
-
*
|
|
51
|
+
* hardcode-`.git/hooks` lesson prescribes). Resolution is a READ, not the
|
|
52
|
+
* mutation boundary: a genuine git failure degrades to an unresolvable-hooks
|
|
53
|
+
* result that the caller reports as per-hook skip lines. No backstop rides
|
|
54
|
+
* this path — nothing has been mutated when it fires.
|
|
42
55
|
*/
|
|
43
56
|
export async function resolveEjectHooksContext(cwd) {
|
|
44
57
|
// Lazy-load the hook-path resolvers: they pull the core git barrel, so keeping
|
|
@@ -48,9 +61,9 @@ export async function resolveEjectHooksContext(cwd) {
|
|
|
48
61
|
let gitRoot;
|
|
49
62
|
try {
|
|
50
63
|
({ gitRoot } = resolveGitRootForHookPath(cwd));
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
64
|
+
// A genuine git failure (NOT not-a-repo / unparseable pointer, which
|
|
65
|
+
// return a null root WITHOUT throwing) degrades to "unresolvable" — a
|
|
66
|
+
// read-path degrade the caller reports per hook, not a mutation swallow.
|
|
54
67
|
// totem-context: intentional cleanup — best-effort git resolution for eject.
|
|
55
68
|
}
|
|
56
69
|
catch {
|
|
@@ -80,11 +93,40 @@ function scrubHook(hooksDir, summary, hookName, startMarker, endMarker) {
|
|
|
80
93
|
summary.skipped.push(`${hookFileName} (not found)`);
|
|
81
94
|
return;
|
|
82
95
|
}
|
|
83
|
-
|
|
96
|
+
// Raw bytes first: the bak must be BYTE-exact (rule 3, "pre-mutation
|
|
97
|
+
// bytes"), and a hook that does not round-trip through UTF-8 cannot be
|
|
98
|
+
// line-scrubbed without silently substituting U+FFFD into the KEPT user
|
|
99
|
+
// content — that class degrades to a reported skip instead (2026-08-09
|
|
100
|
+
// falsification round 1, finding 3).
|
|
101
|
+
const rawContent = fs.readFileSync(hookPath);
|
|
102
|
+
const content = rawContent.toString('utf-8');
|
|
84
103
|
if (!content.includes(startMarker)) {
|
|
85
104
|
summary.skipped.push(`${hookFileName} (no Totem section)`);
|
|
86
105
|
return;
|
|
87
106
|
}
|
|
107
|
+
if (Buffer.compare(Buffer.from(content, 'utf-8'), rawContent) !== 0) {
|
|
108
|
+
summary.skipped.push(`${hookFileName} (non-UTF-8 content — not scrubbed; remove the Totem block manually)`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// Rule-3 recovery artifact (User-File Mutation Contract corollary,
|
|
112
|
+
// mmnto-ai/totem#2620): `.git/hooks/*` is unrecoverable through git BY
|
|
113
|
+
// SURFACE CLASS — no derivable-state escape, so the full-removal path baks
|
|
114
|
+
// too (author intent-read Q1: "these bytes are fully ours" is a belief
|
|
115
|
+
// produced by the same logic that would be the bug — the #2602 provenance).
|
|
116
|
+
// Written BEFORE mutating, carrying the source hook's mode; a bak failure
|
|
117
|
+
// skips the mutation entirely, because disclosure never substitutes for
|
|
118
|
+
// recovery (the strategy#1045 addendum).
|
|
119
|
+
const bakFileName = `${hookFileName}.totem-bak`;
|
|
120
|
+
try {
|
|
121
|
+
const sourceMode = fs.statSync(hookPath).mode & 0o7777;
|
|
122
|
+
writeFileAtomicSync(`${hookPath}.totem-bak`, rawContent, { mode: sourceMode });
|
|
123
|
+
// totem-context: intentional cleanup — bak failure degrades to a reported skip and WITHHOLDS the mutation
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
127
|
+
summary.skipped.push(`${hookFileName} (recovery backup failed — hook left untouched: ${msg})`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
88
130
|
const endSentinel = `# ${endMarker}`;
|
|
89
131
|
const hasEndMarker = content.includes(endSentinel);
|
|
90
132
|
const lines = content.split('\n');
|
|
@@ -119,11 +161,11 @@ function scrubHook(hooksDir, summary, hookName, startMarker, endMarker) {
|
|
|
119
161
|
try {
|
|
120
162
|
if (!remaining || remaining === '#!/bin/sh') {
|
|
121
163
|
fs.unlinkSync(hookPath);
|
|
122
|
-
summary.removed.push(hookFileName);
|
|
164
|
+
summary.removed.push(`${hookFileName} (pre-removal bytes: ${bakFileName})`);
|
|
123
165
|
}
|
|
124
166
|
else {
|
|
125
|
-
|
|
126
|
-
summary.scrubbed.push(hookFileName);
|
|
167
|
+
writeFileAtomicSync(hookPath, remaining + '\n');
|
|
168
|
+
summary.scrubbed.push(`${hookFileName} (pre-scrub bytes: ${bakFileName})`);
|
|
127
169
|
}
|
|
128
170
|
}
|
|
129
171
|
catch (err) {
|
|
@@ -179,7 +221,7 @@ function removeScaffoldedFiles(cwd, summary) {
|
|
|
179
221
|
* Remove the Totem PreToolUse hook entry from Claude's settings.local.json.
|
|
180
222
|
*/
|
|
181
223
|
function scrubClaudeSettings(cwd, summary) {
|
|
182
|
-
const filePath = path.join(cwd,
|
|
224
|
+
const filePath = path.join(cwd, CLAUDE_SETTINGS_LOCAL_FILE);
|
|
183
225
|
if (!fs.existsSync(filePath)) {
|
|
184
226
|
summary.skipped.push('.claude/settings.local.json (not found)');
|
|
185
227
|
return;
|
|
@@ -228,7 +270,7 @@ function scrubClaudeSettings(cwd, summary) {
|
|
|
228
270
|
summary.removed.push('.claude/settings.local.json');
|
|
229
271
|
}
|
|
230
272
|
else {
|
|
231
|
-
|
|
273
|
+
writeFileAtomicSync(filePath, JSON.stringify(parsed, null, 2) + '\n');
|
|
232
274
|
summary.scrubbed.push('.claude/settings.local.json');
|
|
233
275
|
}
|
|
234
276
|
}
|
|
@@ -249,14 +291,14 @@ function scrubClaudeSettings(cwd, summary) {
|
|
|
249
291
|
* filesystem state, matching the legacy scrubClaudeSettings cleanup chain.
|
|
250
292
|
*/
|
|
251
293
|
function scrubCommittedClaudeSettings(cwd, summary) {
|
|
252
|
-
const filePath = path.join(cwd,
|
|
294
|
+
const filePath = path.join(cwd, CLAUDE_SETTINGS_FILE);
|
|
253
295
|
if (!fs.existsSync(filePath)) {
|
|
254
296
|
summary.skipped.push('.claude/settings.json (not found)');
|
|
255
297
|
return;
|
|
256
298
|
}
|
|
257
299
|
// Read separately from parse so permission errors fail loud (they are
|
|
258
|
-
// a distinct failure mode from invalid JSON, which is recoverable as
|
|
259
|
-
//
|
|
300
|
+
// a distinct failure mode from invalid JSON, which is recoverable as a
|
|
301
|
+
// documented eject skip — a read-path degrade, before any mutation).
|
|
260
302
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
261
303
|
let rawParsed;
|
|
262
304
|
try {
|
|
@@ -338,7 +380,7 @@ function scrubCommittedClaudeSettings(cwd, summary) {
|
|
|
338
380
|
summary.removed.push('.claude/settings.json');
|
|
339
381
|
}
|
|
340
382
|
else {
|
|
341
|
-
|
|
383
|
+
writeFileAtomicSync(filePath, JSON.stringify(parsed, null, 2) + '\n');
|
|
342
384
|
summary.scrubbed.push('.claude/settings.json');
|
|
343
385
|
}
|
|
344
386
|
}
|
|
@@ -376,7 +418,7 @@ async function scrubClaudeSkills(cwd, summary) {
|
|
|
376
418
|
try {
|
|
377
419
|
fs.unlinkSync(filePath);
|
|
378
420
|
summary.removed.push(rel);
|
|
379
|
-
// totem-context: intentional cleanup —
|
|
421
|
+
// totem-context: intentional cleanup — per-item best-effort; the failure is a reported skip (no backstop rides this step)
|
|
380
422
|
}
|
|
381
423
|
catch (err) {
|
|
382
424
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -406,42 +448,196 @@ async function scrubClaudeSkills(cwd, summary) {
|
|
|
406
448
|
/* eject best-effort */
|
|
407
449
|
}
|
|
408
450
|
}
|
|
451
|
+
/** Reflex files that predate the marker era and may carry a v1 marker-less
|
|
452
|
+
* block. REFLEX_START shipped before GEMINI.md / .junie/guidelines.md /
|
|
453
|
+
* .github/copilot-instructions.md joined the tool table, so on those files a
|
|
454
|
+
* bare legacy heading can only be user-authored — never scrubbed.
|
|
455
|
+
* `.gemini/gemini.md` is the RETIRED pre-marker Gemini target (replaced by
|
|
456
|
+
* GEMINI.md when the tool table landed): init no longer injects there, but an
|
|
457
|
+
* old block can still be sitting in it, so eject still visits it. */
|
|
458
|
+
export const LEGACY_REFLEX_FILES = ['CLAUDE.md', '.gemini/gemini.md', '.cursorrules'];
|
|
459
|
+
/** The alt heading of the oldest legacy block shape (pre-LEGACY_SENTINEL). */
|
|
460
|
+
const LEGACY_ALT_HEADING = '## Totem Memory Reflexes';
|
|
409
461
|
/**
|
|
410
462
|
* Remove the AI Integration block appended by `totem init` to reflex files.
|
|
463
|
+
*
|
|
464
|
+
* Exported for the summary-contract tests (mmnto-ai/totem#2602).
|
|
411
465
|
*/
|
|
412
|
-
function scrubReflexFiles(cwd, summary) {
|
|
413
|
-
|
|
466
|
+
export async function scrubReflexFiles(cwd, summary) {
|
|
467
|
+
// Lazy-load both sources (the CLI command-file cold-start discipline
|
|
468
|
+
// scrubClaudeSkills documents). The roster is derived from the SAME tool
|
|
469
|
+
// table init injects through — the hand-mirrored pair this replaces silently
|
|
470
|
+
// missed GEMINI.md, .junie/guidelines.md, and .github/copilot-instructions.md
|
|
471
|
+
// (mmnto-ai/totem#2602).
|
|
472
|
+
const { AI_TOOLS } = await import('./init-detect.js');
|
|
473
|
+
const { LEGACY_SENTINEL, REFLEX_END, REFLEX_START } = await import('./init-templates.js');
|
|
474
|
+
const reflexFiles = AI_TOOLS.flatMap((t) => (t.reflexFile === null ? [] : [t.reflexFile]));
|
|
475
|
+
// Visit the union: the roster init injects through today, plus the retired
|
|
476
|
+
// pre-marker targets that can still carry an old block (scoped fold round).
|
|
477
|
+
const scanFiles = [...new Set([...reflexFiles, ...LEGACY_REFLEX_FILES])];
|
|
478
|
+
// Tenet 4 licenses per-item best-effort ONLY together with failure
|
|
479
|
+
// accounting and a loud backstop (the licensing gap this closes was
|
|
480
|
+
// mmnto-ai/totem#2620 pre-build leg finding 4 — before it, an eject where every
|
|
481
|
+
// scrub write failed still exited clean over a wall of skip lines).
|
|
482
|
+
let failed = 0;
|
|
483
|
+
let succeeded = 0;
|
|
484
|
+
for (const rel of scanFiles) {
|
|
414
485
|
const filePath = path.join(cwd, rel);
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
486
|
+
// Per-file best-effort: one unreadable/locked file degrades to a reported
|
|
487
|
+
// skip, never an abort that strands the remaining eject steps.
|
|
488
|
+
try {
|
|
489
|
+
if (!fs.existsSync(filePath))
|
|
490
|
+
continue;
|
|
491
|
+
// Same guard as scrubHook's (round 2, F2 — the sibling gap): a reflex
|
|
492
|
+
// file that does not round-trip through UTF-8 cannot be scrubbed
|
|
493
|
+
// without substituting U+FFFD into the KEPT user content, and rule 4
|
|
494
|
+
// licenses no bak for tracked files — refuse with a reason instead.
|
|
495
|
+
// Ordering mirrors scrubHook (round 3, finding 3): block-presence FIRST
|
|
496
|
+
// (markers are ASCII and survive the lossy decode), so an untouched
|
|
497
|
+
// non-UTF-8 file keeps its honest "(no Totem block)" line instead of a
|
|
498
|
+
// false remove-the-block instruction.
|
|
499
|
+
const rawContent = fs.readFileSync(filePath);
|
|
500
|
+
const content = rawContent.toString('utf-8');
|
|
501
|
+
const hasMarkerBlock = content.includes(REFLEX_START) || content.includes(REFLEX_END);
|
|
502
|
+
const hasLegacyBlock = LEGACY_REFLEX_FILES.includes(rel) &&
|
|
503
|
+
(content.includes(LEGACY_SENTINEL) || content.includes(LEGACY_ALT_HEADING));
|
|
504
|
+
if ((hasMarkerBlock || hasLegacyBlock) &&
|
|
505
|
+
Buffer.compare(Buffer.from(content, 'utf-8'), rawContent) !== 0) {
|
|
506
|
+
summary.skipped.push(`${rel} (non-UTF-8 content — not scrubbed; remove the Totem block manually)`);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
// Marker era (v2+): remove each complete REFLEX_START..REFLEX_END span.
|
|
510
|
+
// The span AFTER an end marker is contractually user content
|
|
511
|
+
// (mmnto-ai/totem#1890; regen preserves it byte-exactly since
|
|
512
|
+
// mmnto-ai/totem#2599) — the pre-fix heading-to-EOF scrub deleted it and
|
|
513
|
+
// left an orphan start+version pair that detectReflexStatus reads as
|
|
514
|
+
// `current` (mmnto-ai/totem#2602). Pairing is END-anchored (lastIndexOf)
|
|
515
|
+
// so an orphan START from that same pre-fix damage sitting ABOVE a
|
|
516
|
+
// complete block never widens the removal span; the loop clears
|
|
517
|
+
// double-injection damage instead of leaving a live block behind a
|
|
518
|
+
// "Scrubbed" line.
|
|
519
|
+
let out = content;
|
|
520
|
+
let removedBlocks = 0;
|
|
521
|
+
// A leading orphan END (no START anywhere before it) must not halt the
|
|
522
|
+
// scan — later complete pairs are still totem's to remove, and the
|
|
523
|
+
// changeset contract is "loops until no complete pair remains". Skip
|
|
524
|
+
// past the orphan (it stays residue) and keep scanning; reset after a
|
|
525
|
+
// removal since indices shifted (GCA round on this PR).
|
|
526
|
+
let searchStart = 0;
|
|
527
|
+
for (;;) {
|
|
528
|
+
const endIdx = out.indexOf(REFLEX_END, searchStart);
|
|
529
|
+
if (endIdx === -1)
|
|
530
|
+
break;
|
|
531
|
+
const startIdx = out.lastIndexOf(REFLEX_START, endIdx);
|
|
532
|
+
if (startIdx === -1) {
|
|
533
|
+
searchStart = endIdx + REFLEX_END.length;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
// Single-owner seams, mirroring upgradeReflexes with the block replaced
|
|
537
|
+
// by nothing: the prefix keeps at most one trailing newline (the blank
|
|
538
|
+
// line above the block was AI_PROMPT_BLOCK's own leading `\n`), and the
|
|
539
|
+
// end marker's own line terminator leaves with the block. Seam
|
|
540
|
+
// whitespace normalizes exactly as regen would — a seam contract, not a
|
|
541
|
+
// byte-restore of the pre-init file.
|
|
542
|
+
let before = out.slice(0, startIdx).replace(/(?:\r?\n)*$/, '\n');
|
|
543
|
+
if (before === '\n')
|
|
544
|
+
before = '';
|
|
545
|
+
let after = out.slice(endIdx + REFLEX_END.length);
|
|
546
|
+
if (/^[ \t\r\n]*$/.test(after)) {
|
|
547
|
+
after = '';
|
|
548
|
+
}
|
|
549
|
+
else if (after.startsWith('\r\n')) {
|
|
550
|
+
after = after.slice(2);
|
|
551
|
+
}
|
|
552
|
+
else if (after.startsWith('\n')) {
|
|
553
|
+
after = after.slice(1);
|
|
554
|
+
}
|
|
555
|
+
out = before + after;
|
|
556
|
+
removedBlocks++;
|
|
557
|
+
searchStart = 0;
|
|
558
|
+
}
|
|
559
|
+
// Marker residue = an unpaired START or END (either shape of the pre-fix
|
|
560
|
+
// corruption, or an inverted pair). The content around it cannot be
|
|
561
|
+
// attributed, so it is never scrubbed — and while a version marker
|
|
562
|
+
// remains, detectReflexStatus keeps reading the file as `current`
|
|
563
|
+
// (the mmnto-ai/totem#2602 corrupt-but-green state): healing is manual.
|
|
564
|
+
const residue = out.includes(REFLEX_START) || out.includes(REFLEX_END);
|
|
565
|
+
if (removedBlocks > 0) {
|
|
566
|
+
writeFileAtomicSync(filePath, out);
|
|
567
|
+
succeeded++;
|
|
568
|
+
summary.scrubbed.push(residue ? `${rel} (marker residue remains — remove manually)` : rel);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (residue) {
|
|
572
|
+
summary.skipped.push(`${rel} (reflex marker residue — not scrubbed; remove manually, else init keeps reading the file as current)`);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
// Legacy era (pre-marker v1), LEGACY_REFLEX_FILES only. init's own legacy
|
|
576
|
+
// upgrade (upgradeReflexes Case 2) bounds a v1 block at the next
|
|
577
|
+
// non-Totem H2, NOT at EOF — mirror it, or user content below a v1 block
|
|
578
|
+
// takes the same mmnto-ai/totem#2602 loss the marker path just fixed.
|
|
579
|
+
if (!LEGACY_REFLEX_FILES.includes(rel)) {
|
|
580
|
+
summary.skipped.push(`${rel} (no Totem block)`);
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
const primaryIdx = content.indexOf(LEGACY_SENTINEL);
|
|
584
|
+
const altIdx = content.indexOf(LEGACY_ALT_HEADING);
|
|
585
|
+
const legacyIdx = primaryIdx === -1 ? altIdx : altIdx === -1 ? primaryIdx : Math.min(primaryIdx, altIdx);
|
|
586
|
+
if (legacyIdx === -1) {
|
|
587
|
+
summary.skipped.push(`${rel} (no Totem block)`);
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
const afterLegacy = content.slice(legacyIdx);
|
|
591
|
+
// init.ts upgradeReflexes Case 2's boundary, VERBATIM: the next H2 that
|
|
592
|
+
// is not a Totem AI Integration heading ends the block. No extra
|
|
593
|
+
// alternatives — both injector generations guarded on the absence of
|
|
594
|
+
// "Totem Memory Reflexes" before writing, so a second Totem-titled H2
|
|
595
|
+
// below the block can only be user-authored (scoped fold round).
|
|
596
|
+
const nextH2 = afterLegacy.match(/\r?\n## (?!Totem AI Integration)/);
|
|
597
|
+
const blockEnd = nextH2?.index !== undefined ? legacyIdx + nextH2.index : content.length;
|
|
598
|
+
let before = content.slice(0, legacyIdx).replace(/(?:\r?\n)*$/, '\n');
|
|
599
|
+
if (before === '\n')
|
|
600
|
+
before = '';
|
|
601
|
+
let tail = content.slice(blockEnd);
|
|
602
|
+
// The boundary newline is seam-owned: with a non-empty prefix it supplies
|
|
603
|
+
// the blank-line separation; at byte 0 it would mint a leading blank line
|
|
604
|
+
// the seam rules forbid.
|
|
605
|
+
if (before === '') {
|
|
606
|
+
if (tail.startsWith('\r\n'))
|
|
607
|
+
tail = tail.slice(2);
|
|
608
|
+
else if (tail.startsWith('\n'))
|
|
609
|
+
tail = tail.slice(1);
|
|
610
|
+
}
|
|
611
|
+
writeFileAtomicSync(filePath, before + tail);
|
|
612
|
+
succeeded++;
|
|
613
|
+
summary.scrubbed.push(rel);
|
|
614
|
+
// totem-context: intentional cleanup — per-file best-effort scrub; the failure is a reported skip the accounting below counts toward the loud backstop, never a silent drop.
|
|
429
615
|
}
|
|
430
|
-
|
|
431
|
-
|
|
616
|
+
catch (err) {
|
|
617
|
+
failed++;
|
|
618
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
619
|
+
summary.skipped.push(`${rel} (could not scrub: ${msg})`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
// The loud systemic backstop Tenet 4's licensed shape requires alongside
|
|
623
|
+
// the per-item accounting (mmnto-ai/totem#2620 pre-build leg finding 4): when every
|
|
624
|
+
// attempted scrub failed, eject must not exit clean.
|
|
625
|
+
if (failed > 0 && succeeded === 0) {
|
|
626
|
+
const { TotemError } = await import('@mmnto/totem');
|
|
627
|
+
throw new TotemError('EJECT_FAILED', `all ${failed} reflex-file scrub attempt(s) failed — nothing was scrubbed; the summary above carries the per-file skip reasons and any .totem-bak recovery paths from earlier steps`, 'Check permissions/locks on the listed reflex files, then re-run totem eject.');
|
|
432
628
|
}
|
|
433
629
|
}
|
|
434
630
|
/**
|
|
435
631
|
* Delete Totem directories and config file.
|
|
436
632
|
*/
|
|
437
633
|
function deleteArtifacts(cwd, summary) {
|
|
438
|
-
const
|
|
439
|
-
for (const dir of artifacts) {
|
|
634
|
+
for (const dir of EJECT_ARTIFACT_DIRS) {
|
|
440
635
|
const dirPath = path.join(cwd, dir);
|
|
441
636
|
if (fs.existsSync(dirPath)) {
|
|
442
637
|
try {
|
|
443
638
|
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
444
639
|
summary.removed.push(`${dir}/`);
|
|
640
|
+
// totem-context: intentional cleanup — per-item best-effort delete; the failure is a reported skip
|
|
445
641
|
}
|
|
446
642
|
catch (err) {
|
|
447
643
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -452,26 +648,137 @@ function deleteArtifacts(cwd, summary) {
|
|
|
452
648
|
summary.skipped.push(`${dir}/ (not found)`);
|
|
453
649
|
}
|
|
454
650
|
}
|
|
455
|
-
const configPath = path.join(cwd,
|
|
651
|
+
const configPath = path.join(cwd, EJECT_CONFIG_FILE);
|
|
456
652
|
if (fs.existsSync(configPath)) {
|
|
457
653
|
try {
|
|
458
654
|
fs.unlinkSync(configPath);
|
|
459
|
-
summary.removed.push(
|
|
655
|
+
summary.removed.push(EJECT_CONFIG_FILE);
|
|
656
|
+
// totem-context: intentional cleanup — per-item best-effort delete; the failure is a reported skip
|
|
460
657
|
}
|
|
461
658
|
catch (err) {
|
|
462
659
|
const msg = err instanceof Error ? err.message : String(err);
|
|
463
|
-
summary.skipped.push(
|
|
660
|
+
summary.skipped.push(`${EJECT_CONFIG_FILE} (could not delete: ${msg})`);
|
|
464
661
|
}
|
|
465
662
|
}
|
|
466
663
|
else {
|
|
467
|
-
summary.skipped.push(
|
|
664
|
+
summary.skipped.push(`${EJECT_CONFIG_FILE} (not found)`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
// ─── Dirty-tree sense (User-File Mutation Contract rule 2) ──────
|
|
668
|
+
/** Cap on per-path rows printed under each sense header — the exact COUNTS
|
|
669
|
+
* always ride the headers; unbounded rows can scroll the consent prompt off
|
|
670
|
+
* screen (CR round on the PR). */
|
|
671
|
+
const SENSE_DISPLAY_MAX_ROWS = 20;
|
|
672
|
+
function capSenseRows(rows) {
|
|
673
|
+
const shown = rows.slice(0, SENSE_DISPLAY_MAX_ROWS).map((l) => ` ${l}`);
|
|
674
|
+
if (rows.length > SENSE_DISPLAY_MAX_ROWS) {
|
|
675
|
+
shown.push(` … and ${rows.length - SENSE_DISPLAY_MAX_ROWS} more`);
|
|
676
|
+
}
|
|
677
|
+
return shown;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Rule-2 sense of the User-File Mutation Contract (mmnto-ai/totem#2620): the
|
|
681
|
+
* git-native form of backup-before-modify is knowing whether a commit exists
|
|
682
|
+
* to diff against, so eject SAYS which of its mutation targets carry
|
|
683
|
+
* uncommitted changes before asking consent. Sense-and-say only (Tenet 13):
|
|
684
|
+
* never a block, never an exit-code change; `--force` skips the prompt, not
|
|
685
|
+
* the sense. Deletions count — deleting a tracked-dirty file is the limiting
|
|
686
|
+
* case of mutation with no revert point (author intent-read Q3).
|
|
687
|
+
*
|
|
688
|
+
* The roster derives from the SAME constants the scrub/removal steps consume
|
|
689
|
+
* (Tenet 20 — a hand-mirrored sense list would be the prohibited mirror
|
|
690
|
+
* inside the very rule built to prevent silent loss). `.git/hooks/*` never
|
|
691
|
+
* appears here: git cannot see that surface — its recovery is scrubHook's
|
|
692
|
+
* rule-3 `.totem-bak` artifact.
|
|
693
|
+
*
|
|
694
|
+
* Exported for the sense-contract tests.
|
|
695
|
+
*/
|
|
696
|
+
export async function deriveDirtyTreeSense(cwd) {
|
|
697
|
+
let porcelain;
|
|
698
|
+
let ignoredVisible = true;
|
|
699
|
+
try {
|
|
700
|
+
// The roster imports live INSIDE the try: a sensor that can throw before
|
|
701
|
+
// its own catch is a sensor that can abort the eject it senses for
|
|
702
|
+
// (round 1, finding 11).
|
|
703
|
+
const { AI_TOOLS } = await import('./init-detect.js');
|
|
704
|
+
const { DISTRIBUTED_CLAUDE_SKILLS } = await import('./init-templates.js');
|
|
705
|
+
const roster = [
|
|
706
|
+
...new Set([
|
|
707
|
+
...TOTEM_SCAFFOLDED_FILES,
|
|
708
|
+
CLAUDE_SETTINGS_LOCAL_FILE,
|
|
709
|
+
CLAUDE_SETTINGS_FILE,
|
|
710
|
+
...DISTRIBUTED_CLAUDE_SKILLS.map((s) => `.claude/skills/${s.name}/SKILL.md`),
|
|
711
|
+
...AI_TOOLS.flatMap((t) => (t.reflexFile === null ? [] : [t.reflexFile])),
|
|
712
|
+
...LEGACY_REFLEX_FILES,
|
|
713
|
+
...EJECT_ARTIFACT_DIRS,
|
|
714
|
+
EJECT_CONFIG_FILE,
|
|
715
|
+
]),
|
|
716
|
+
];
|
|
717
|
+
// Lazy barrel import — the established eject runtime pattern
|
|
718
|
+
// (resolveEjectHooksContext pulls the core git barrel the same way).
|
|
719
|
+
// `--ignored=matching`: plain porcelain omits ignored paths by design, so
|
|
720
|
+
// without it the sense reads "invisible to git" as "committed and clean"
|
|
721
|
+
// for exactly the paths with NO git copy at all (round 1, finding 2).
|
|
722
|
+
const { safeExec } = await import('@mmnto/totem');
|
|
723
|
+
try {
|
|
724
|
+
porcelain = safeExec('git', ['status', '--porcelain', '--ignored=matching', '--', ...roster], { cwd });
|
|
725
|
+
}
|
|
726
|
+
catch (flagErr) {
|
|
727
|
+
// `--ignored=<mode>` landed in git 2.16 (2018). On an older git the
|
|
728
|
+
// flag-bearing call fails whole — degrade to the DIRTY-ONLY sense
|
|
729
|
+
// rather than to no sense at all (round 2, F3). Not-a-repo rethrows to
|
|
730
|
+
// the outer honest-line arm; a second failure rethrows too.
|
|
731
|
+
if (!(flagErr instanceof Error) || flagErr.message.includes('not a git repository')) {
|
|
732
|
+
throw flagErr;
|
|
733
|
+
}
|
|
734
|
+
porcelain = safeExec('git', ['status', '--porcelain', '--', ...roster], { cwd });
|
|
735
|
+
ignoredVisible = false;
|
|
736
|
+
}
|
|
737
|
+
// totem-context: intentional sensor degradation — the failure is SAID as an honest sense line (Tenet 13), never swallowed
|
|
468
738
|
}
|
|
739
|
+
catch (err) {
|
|
740
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
741
|
+
// The stderr tail names the actual cause (git's own words); the wrapper
|
|
742
|
+
// message leads with the full ~24-path pathspec wall (round 2, F3). The
|
|
743
|
+
// instanceof guard keeps a non-object throw from TypeError-ing INSIDE this
|
|
744
|
+
// catch — a sensor that can crash its own catch can abort the eject it
|
|
745
|
+
// senses for (GCA round on the PR).
|
|
746
|
+
const stderrRaw = err instanceof Error ? err.stderr : undefined;
|
|
747
|
+
const stderrLine = typeof stderrRaw === 'string' ? stderrRaw.trim().split('\n')[0] : undefined;
|
|
748
|
+
const cause = stderrLine && stderrLine.length > 0 ? stderrLine : msg.split('\n')[0];
|
|
749
|
+
const line = msg.includes('not a git repository')
|
|
750
|
+
? 'Not a git repository — files eject modifies here have no VCS revert point.'
|
|
751
|
+
: `Could not derive VCS state for eject targets (${cause}) — proceeding without the dirty-tree sense.`;
|
|
752
|
+
return { dirty: [], ignored: [], lines: [line] };
|
|
753
|
+
}
|
|
754
|
+
const rows = porcelain.length === 0 ? [] : porcelain.split('\n').filter((l) => l.trim() !== '');
|
|
755
|
+
const ignored = rows.filter((l) => l.startsWith('!!'));
|
|
756
|
+
const dirty = rows.filter((l) => !l.startsWith('!!'));
|
|
757
|
+
const lines = [];
|
|
758
|
+
if (dirty.length > 0) {
|
|
759
|
+
lines.push(`Uncommitted changes in ${dirty.length} path(s) eject will modify — no revert point until committed:`, ...capSenseRows(dirty));
|
|
760
|
+
}
|
|
761
|
+
if (ignored.length > 0) {
|
|
762
|
+
lines.push(`${ignored.length} gitignored path(s) eject will remove — git holds no copy of these at all:`, ...capSenseRows(ignored));
|
|
763
|
+
}
|
|
764
|
+
if (!ignoredVisible) {
|
|
765
|
+
// States exactly what was measured (round 3, finding 4): the flagged call
|
|
766
|
+
// failed and the plain one succeeded — not a diagnosed git version.
|
|
767
|
+
lines.push('`git status --ignored=matching` failed here while plain status succeeded (git ≥ 2.16 is needed for ignored-path visibility) — ignored deletion targets are not sensed.');
|
|
768
|
+
}
|
|
769
|
+
return { dirty, ignored, lines };
|
|
469
770
|
}
|
|
470
771
|
export async function ejectCommand(options) {
|
|
471
772
|
const { stdin: input, stdout: output } = await import('node:process');
|
|
472
773
|
const readline = await import('node:readline/promises');
|
|
473
774
|
const { log } = await import('../ui.js');
|
|
474
775
|
const cwd = process.cwd();
|
|
776
|
+
// Rule-2 sense line(s) ride ahead of the consent prompt — and under
|
|
777
|
+
// --force too: the flag skips the prompt, never the sense (mmnto-ai/totem#2620).
|
|
778
|
+
const sense = await deriveDirtyTreeSense(cwd);
|
|
779
|
+
for (const line of sense.lines) {
|
|
780
|
+
log.warn(TAG, line);
|
|
781
|
+
}
|
|
475
782
|
if (!options.force) {
|
|
476
783
|
const rl = readline.createInterface({ input, output });
|
|
477
784
|
try {
|
|
@@ -526,8 +833,26 @@ export async function ejectCommand(options) {
|
|
|
526
833
|
scrubCommittedClaudeSettings(cwd, summary);
|
|
527
834
|
// 5. Scrub distributed Claude session-utility skills (Phase C slice 3)
|
|
528
835
|
await scrubClaudeSkills(cwd, summary);
|
|
529
|
-
// 6. Scrub AI reflex blocks from markdown files
|
|
530
|
-
|
|
836
|
+
// 6. Scrub AI reflex blocks from markdown files. An EJECT_FAILED throw here
|
|
837
|
+
// (the Tenet-4 backstop — and ONLY it, round 2 F1) is DEFERRED past step 7
|
|
838
|
+
// and the summary print, then rethrown: a backstop that fires before the summary
|
|
839
|
+
// destroys the very accounting surface — the skip reasons and the
|
|
840
|
+
// `.totem-bak` paths — that licenses it, and leaves the error text pointing
|
|
841
|
+
// at a summary that never printed (2026-08-09 falsification round 1, finding 1).
|
|
842
|
+
let deferredThrow;
|
|
843
|
+
try {
|
|
844
|
+
await scrubReflexFiles(cwd, summary);
|
|
845
|
+
}
|
|
846
|
+
catch (err) {
|
|
847
|
+
// Tenet 4 rethrow-unexpected: only the EJECT_FAILED backstop is
|
|
848
|
+
// type-discriminable as expected here. A programming/packaging defect
|
|
849
|
+
// (module resolution, TypeError) must fail loud NOW — deferring it would
|
|
850
|
+
// buy step 7's deletions before the failure surfaces (round 2, F1).
|
|
851
|
+
if (!(err instanceof Error) || err.code !== 'EJECT_FAILED') {
|
|
852
|
+
throw err;
|
|
853
|
+
}
|
|
854
|
+
deferredThrow = err;
|
|
855
|
+
}
|
|
531
856
|
// 7. Delete artifacts
|
|
532
857
|
deleteArtifacts(cwd, summary);
|
|
533
858
|
// Print summary
|
|
@@ -552,6 +877,16 @@ export async function ejectCommand(options) {
|
|
|
552
877
|
if (summary.removed.length === 0 && summary.scrubbed.length === 0) {
|
|
553
878
|
log.info(TAG, 'Nothing to remove — project appears clean.');
|
|
554
879
|
}
|
|
880
|
+
// The sense line rides the summary under --force (mmnto-ai/totem#2620 leg
|
|
881
|
+
// finding 5): the prompt was skipped, so the no-revert-point state is
|
|
882
|
+
// restated where the operator is actually looking.
|
|
883
|
+
const noRevertCount = sense.dirty.length + sense.ignored.length;
|
|
884
|
+
if (options.force && noRevertCount > 0) {
|
|
885
|
+
log.warn(TAG, `--force skipped the consent prompt with ${noRevertCount} touched path(s) lacking a revert point — see the sense lines above.`);
|
|
886
|
+
}
|
|
887
|
+
if (deferredThrow !== undefined) {
|
|
888
|
+
throw deferredThrow;
|
|
889
|
+
}
|
|
555
890
|
log.success(TAG, 'Totem has been ejected from this project.');
|
|
556
891
|
}
|
|
557
892
|
//# sourceMappingURL=eject.js.map
|