@amoghvj/txr 0.1.1 → 0.2.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/index.js CHANGED
@@ -1,11 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- GitStore,
3
+ GitStore
4
+ } from "./chunk-MCJNEIJH.js";
5
+ import {
6
+ HistoryStore
7
+ } from "./chunk-KXCWVEEG.js";
8
+ import {
9
+ ExternalChangeDetector
10
+ } from "./chunk-6SSBMHMQ.js";
11
+ import {
4
12
  __require
5
- } from "./chunk-XKFFIQXW.js";
13
+ } from "./chunk-3RG5ZIWI.js";
6
14
 
7
15
  // src/index.ts
8
- import { Command } from "commander";
16
+ import { Command as Command3 } from "commander";
9
17
 
10
18
  // src/cli/commands/init.ts
11
19
  import * as fs from "fs/promises";
@@ -77,8 +85,8 @@ async function findTxrRoot(startDir) {
77
85
  while (true) {
78
86
  const candidate = path.join(dir, TXR_DIR);
79
87
  try {
80
- const stat3 = await fs.stat(candidate);
81
- if (stat3.isDirectory()) return dir;
88
+ const stat4 = await fs.stat(candidate);
89
+ if (stat4.isDirectory()) return dir;
82
90
  } catch {
83
91
  }
84
92
  const parent = path.dirname(dir);
@@ -126,34 +134,89 @@ var TransactionStore = class _TransactionStore {
126
134
  this.filePath = filePath;
127
135
  this.data = data;
128
136
  }
129
- /** Load from disk, or create empty store. */
137
+ /** Load from disk, migrating v1 → v2 if needed, or create empty store. */
130
138
  static async load(metadataDir) {
131
139
  const filePath = path2.join(metadataDir, "transactions.json");
132
140
  try {
133
141
  const raw = await fs2.readFile(filePath, "utf-8");
134
- const data = JSON.parse(raw);
135
- return new _TransactionStore(filePath, data);
142
+ const raw_data = JSON.parse(raw);
143
+ const data = _TransactionStore.migrate(raw_data);
144
+ const store = new _TransactionStore(filePath, data);
145
+ store.gc();
146
+ return store;
136
147
  } catch {
137
148
  const data = {
138
- version: 1,
149
+ version: 2,
139
150
  head: null,
140
151
  undoStack: [],
141
152
  counter: 0,
153
+ extCounter: 0,
142
154
  transactions: {}
143
155
  };
144
156
  return new _TransactionStore(filePath, data);
145
157
  }
146
158
  }
159
+ /**
160
+ * Migrate from v1 schema (no `type`, no `extCounter`) to v2.
161
+ * All v1 transactions are assumed to be type 'U' (undoable).
162
+ * Migration is in-memory; file is written on next save().
163
+ */
164
+ static migrate(raw) {
165
+ if (raw.version === 2) return raw;
166
+ const transactions = raw.transactions ?? {};
167
+ const normalized = Array.isArray(transactions) ? Object.fromEntries(transactions.map((t) => [t.id, t])) : transactions;
168
+ for (const tx of Object.values(normalized)) {
169
+ if (!tx.type) tx.type = "U";
170
+ if (tx.source === void 0) tx.source = "txr-run";
171
+ if (tx.command === void 0) tx.command = tx.command ?? null;
172
+ if (tx.exitCode === void 0) tx.exitCode = tx.exitCode ?? null;
173
+ if (tx.attributionTier === void 0) tx.attributionTier = tx.attributionTier ?? null;
174
+ }
175
+ return {
176
+ version: 2,
177
+ head: raw.head ?? null,
178
+ undoStack: raw.undoStack ?? [],
179
+ counter: raw.counter ?? 0,
180
+ extCounter: 0,
181
+ transactions: normalized
182
+ };
183
+ }
184
+ /** Run garbage collection to remove any orphaned transactions not reachable from HEAD or the undo stack. */
185
+ gc() {
186
+ const reachable = /* @__PURE__ */ new Set();
187
+ let current = this.data.head;
188
+ while (current) {
189
+ reachable.add(current);
190
+ current = this.data.transactions[current]?.parent ?? null;
191
+ }
192
+ for (const id of this.data.undoStack) {
193
+ let currentUndo = id;
194
+ while (currentUndo) {
195
+ reachable.add(currentUndo);
196
+ currentUndo = this.data.transactions[currentUndo]?.parent ?? null;
197
+ }
198
+ }
199
+ for (const id of Object.keys(this.data.transactions)) {
200
+ if (!reachable.has(id)) {
201
+ delete this.data.transactions[id];
202
+ }
203
+ }
204
+ }
147
205
  async save() {
148
206
  const tmp = this.filePath + ".tmp";
149
207
  await fs2.writeFile(tmp, JSON.stringify(this.data, null, 2), "utf-8");
150
208
  await fs2.rename(tmp, this.filePath);
151
209
  }
152
- /** Generate the next transaction ID (monotonically increasing). */
210
+ /** Generate the next user transaction ID (monotonically increasing). */
153
211
  nextId() {
154
212
  this.data.counter += 1;
155
213
  return `tx_${String(this.data.counter).padStart(3, "0")}`;
156
214
  }
215
+ /** Generate the next external change (E) transaction ID. */
216
+ nextExtId() {
217
+ this.data.extCounter += 1;
218
+ return `ext_${String(this.data.extCounter).padStart(3, "0")}`;
219
+ }
157
220
  /** Get the current HEAD transaction ID, or null if no transactions exist. */
158
221
  get head() {
159
222
  return this.data.head;
@@ -170,15 +233,31 @@ var TransactionStore = class _TransactionStore {
170
233
  get undoStack() {
171
234
  return this.data.undoStack;
172
235
  }
173
- /** Add a new transaction and set it as HEAD. Clears the undo stack. */
236
+ /**
237
+ * Add a new user-initiated transaction (U or P) and set it as HEAD.
238
+ * Clears the undo stack — new user action invalidates redo history.
239
+ */
174
240
  addTransaction(tx) {
241
+ const discarded = this.clearUndoStack();
242
+ this.data.transactions[tx.id] = tx;
243
+ this.data.head = tx.id;
244
+ return discarded;
245
+ }
246
+ /**
247
+ * Add an automatically detected external change transaction (E) and set it as HEAD.
248
+ * Does NOT clear the undo stack.
249
+ *
250
+ * Rationale: External changes are not user-initiated. They should not destroy
251
+ * the user's redo chain. However, if the redo operation itself runs detection
252
+ * and finds an E, it WILL clear the undo stack at that point (in UndoEngine).
253
+ */
254
+ addExternalTransaction(tx) {
175
255
  this.data.transactions[tx.id] = tx;
176
256
  this.data.head = tx.id;
177
- this.data.undoStack = [];
178
257
  }
179
258
  /**
180
259
  * Undo: move HEAD to the parent of the current HEAD.
181
- * Pushes the undone transaction ID onto the undo stack.
260
+ * Pushes the undone transaction ID onto the undo stack for potential redo.
182
261
  */
183
262
  undoHead() {
184
263
  const head = this.data.head;
@@ -203,6 +282,15 @@ var TransactionStore = class _TransactionStore {
203
282
  this.data.head = redoId;
204
283
  return tx;
205
284
  }
285
+ /** Clear the undo stack, permanently delete detached transactions, and return their IDs. */
286
+ clearUndoStack() {
287
+ const discarded = [...this.data.undoStack];
288
+ for (const id of discarded) {
289
+ delete this.data.transactions[id];
290
+ }
291
+ this.data.undoStack = [];
292
+ return discarded;
293
+ }
206
294
  /** Get all transactions in chain order (oldest first), up to and including HEAD. */
207
295
  getChain() {
208
296
  const chain = [];
@@ -225,53 +313,9 @@ var TransactionStore = class _TransactionStore {
225
313
  }
226
314
  };
227
315
 
228
- // src/core/history-store.ts
316
+ // src/core/file-map.ts
229
317
  import * as fs3 from "fs/promises";
230
318
  import * as path3 from "path";
231
- var HistoryStore = class _HistoryStore {
232
- data;
233
- filePath;
234
- constructor(filePath, data) {
235
- this.filePath = filePath;
236
- this.data = data;
237
- }
238
- static async load(metadataDir) {
239
- const filePath = path3.join(metadataDir, "history.json");
240
- try {
241
- const raw = await fs3.readFile(filePath, "utf-8");
242
- const data = JSON.parse(raw);
243
- return new _HistoryStore(filePath, data);
244
- } catch {
245
- const data = { version: 1, entries: [] };
246
- return new _HistoryStore(filePath, data);
247
- }
248
- }
249
- async save() {
250
- const tmp = this.filePath + ".tmp";
251
- await fs3.writeFile(tmp, JSON.stringify(this.data, null, 2), "utf-8");
252
- await fs3.rename(tmp, this.filePath);
253
- }
254
- /** Append a new history entry. */
255
- append(entry) {
256
- this.data.entries.push(entry);
257
- }
258
- /** Get all entries (oldest first). */
259
- getAll() {
260
- return this.data.entries;
261
- }
262
- /** Get the N most recent entries. */
263
- getRecent(n) {
264
- return this.data.entries.slice(-n);
265
- }
266
- /** Get total entry count. */
267
- get count() {
268
- return this.data.entries.length;
269
- }
270
- };
271
-
272
- // src/core/file-map.ts
273
- import * as fs4 from "fs/promises";
274
- import * as path4 from "path";
275
319
  import { nanoid } from "nanoid";
276
320
  var FileMap = class _FileMap {
277
321
  data;
@@ -286,9 +330,9 @@ var FileMap = class _FileMap {
286
330
  }
287
331
  /** Load mapping from disk, or create empty mapping if file doesn't exist. */
288
332
  static async load(metadataDir) {
289
- const filePath = path4.join(metadataDir, "mapping.json");
333
+ const filePath = path3.join(metadataDir, "mapping.json");
290
334
  try {
291
- const raw = await fs4.readFile(filePath, "utf-8");
335
+ const raw = await fs3.readFile(filePath, "utf-8");
292
336
  const data = JSON.parse(raw);
293
337
  return new _FileMap(filePath, data);
294
338
  } catch {
@@ -299,12 +343,12 @@ var FileMap = class _FileMap {
299
343
  /** Persist current state to disk atomically. */
300
344
  async save() {
301
345
  const tmp = this.filePath + ".tmp";
302
- await fs4.writeFile(tmp, JSON.stringify(this.data, null, 2), "utf-8");
303
- await fs4.rename(tmp, this.filePath);
346
+ await fs3.writeFile(tmp, JSON.stringify(this.data, null, 2), "utf-8");
347
+ await fs3.rename(tmp, this.filePath);
304
348
  }
305
349
  /** Normalize a path for consistent lookup (forward slashes, resolved). */
306
350
  static normalizePath(p) {
307
- return path4.resolve(p).replace(/\\/g, "/");
351
+ return path3.resolve(p).replace(/\\/g, "/");
308
352
  }
309
353
  /** Get file ID for a path, or undefined if not mapped. Only returns active (non-deleted) entries. */
310
354
  getIdByPath(absolutePath) {
@@ -369,6 +413,35 @@ var FileMap = class _FileMap {
369
413
  get activeCount() {
370
414
  return this.pathIndex.size;
371
415
  }
416
+ /**
417
+ * Register a brand-new file discovered externally, with a pre-assigned fileId.
418
+ * Used by ExternalChangeDetector for files that have never been seen by TXR.
419
+ */
420
+ registerNewFile(absolutePath, fileId, transactionId) {
421
+ const norm = _FileMap.normalizePath(absolutePath);
422
+ this.data.files[fileId] = {
423
+ path: norm,
424
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
425
+ createdByTx: transactionId
426
+ };
427
+ this.pathIndex.set(norm, fileId);
428
+ }
429
+ /**
430
+ * Untrack files that were created by the given transaction IDs.
431
+ * Called when the undo stack is cleared to discard abandoned file histories.
432
+ */
433
+ untrackByTransactions(transactionIds) {
434
+ const txSet = new Set(transactionIds);
435
+ for (const [id, entry] of Object.entries(this.data.files)) {
436
+ if (txSet.has(entry.createdByTx)) {
437
+ delete this.data.files[id];
438
+ const norm = _FileMap.normalizePath(entry.path);
439
+ if (this.pathIndex.get(norm) === id) {
440
+ this.pathIndex.delete(norm);
441
+ }
442
+ }
443
+ }
444
+ }
372
445
  /** Rebuild the in-memory path→ID index from persisted data. */
373
446
  rebuildPathIndex() {
374
447
  this.pathIndex.clear();
@@ -382,12 +455,12 @@ var FileMap = class _FileMap {
382
455
  };
383
456
 
384
457
  // src/core/transaction-engine.ts
385
- import * as fs6 from "fs/promises";
386
- import * as path7 from "path";
458
+ import * as fs5 from "fs/promises";
459
+ import * as path6 from "path";
387
460
 
388
461
  // src/attribution/tier3-hybrid.ts
389
- import * as fs5 from "fs/promises";
390
- import * as path5 from "path";
462
+ import * as fs4 from "fs/promises";
463
+ import * as path4 from "path";
391
464
  import * as crypto from "crypto";
392
465
  import { spawn } from "child_process";
393
466
  import { minimatch } from "minimatch";
@@ -402,7 +475,7 @@ var Tier3HybridProvider = class {
402
475
  "Using scoped filesystem diff (Tier 3). Changes by concurrent processes within watch paths may be mis-attributed."
403
476
  ];
404
477
  const resolvedWatchPaths = options.watchPaths.map(
405
- (p) => path5.isAbsolute(p) ? p : path5.resolve(cwd, p)
478
+ (p) => path4.isAbsolute(p) ? p : path4.resolve(cwd, p)
406
479
  );
407
480
  const preSnapshot = await this.snapshotPaths(resolvedWatchPaths, options.ignorePaths);
408
481
  const exitCode = await this.executeCommand(command, cwd, options);
@@ -428,36 +501,36 @@ var Tier3HybridProvider = class {
428
501
  return snapshots;
429
502
  }
430
503
  async walkAndHash(currentPath, rootPath, ignorePaths, snapshots) {
431
- let stat3;
504
+ let stat4;
432
505
  try {
433
- stat3 = await fs5.stat(currentPath);
506
+ stat4 = await fs4.stat(currentPath);
434
507
  } catch {
435
508
  return;
436
509
  }
437
510
  const normalized = currentPath.replace(/\\/g, "/");
438
- const relativePath = path5.relative(rootPath, currentPath).replace(/\\/g, "/");
511
+ const relativePath = path4.relative(rootPath, currentPath).replace(/\\/g, "/");
439
512
  for (const pattern of ignorePaths) {
440
- if (minimatch(relativePath, pattern, { dot: true }) || minimatch(path5.basename(currentPath), pattern, { dot: true })) {
513
+ if (minimatch(relativePath, pattern, { dot: true }) || minimatch(path4.basename(currentPath), pattern, { dot: true })) {
441
514
  return;
442
515
  }
443
516
  }
444
- if (stat3.isFile()) {
517
+ if (stat4.isFile()) {
445
518
  const hash = await this.hashFile(currentPath);
446
519
  snapshots.set(normalized, {
447
520
  path: normalized,
448
521
  hash,
449
- size: stat3.size
522
+ size: stat4.size
450
523
  });
451
- } else if (stat3.isDirectory()) {
524
+ } else if (stat4.isDirectory()) {
452
525
  let entries;
453
526
  try {
454
- entries = await fs5.readdir(currentPath);
527
+ entries = await fs4.readdir(currentPath);
455
528
  } catch {
456
529
  return;
457
530
  }
458
531
  for (const entry of entries) {
459
532
  await this.walkAndHash(
460
- path5.join(currentPath, entry),
533
+ path4.join(currentPath, entry),
461
534
  rootPath,
462
535
  ignorePaths,
463
536
  snapshots
@@ -466,7 +539,7 @@ var Tier3HybridProvider = class {
466
539
  }
467
540
  }
468
541
  async hashFile(filePath) {
469
- const content = await fs5.readFile(filePath);
542
+ const content = await fs4.readFile(filePath);
470
543
  return crypto.createHash("sha256").update(content).digest("hex");
471
544
  }
472
545
  /**
@@ -500,7 +573,7 @@ var Tier3HybridProvider = class {
500
573
  * Uses shell execution to support piping, env expansion, etc.
501
574
  */
502
575
  executeCommand(command, cwd, options) {
503
- return new Promise((resolve6, reject) => {
576
+ return new Promise((resolve7, reject) => {
504
577
  const isWindows = process.platform === "win32";
505
578
  const shell = isWindows ? "cmd.exe" : "/bin/sh";
506
579
  const shellArgs = isWindows ? ["/c", command] : ["-c", command];
@@ -515,7 +588,7 @@ var Tier3HybridProvider = class {
515
588
  });
516
589
  child.on("error", reject);
517
590
  child.on("close", (code) => {
518
- resolve6(code ?? 1);
591
+ resolve7(code ?? 1);
519
592
  });
520
593
  });
521
594
  }
@@ -549,19 +622,22 @@ function getOrderedProviders() {
549
622
  }
550
623
 
551
624
  // src/classifier/classifier.ts
552
- import * as path6 from "path";
625
+ import * as path5 from "path";
553
626
  var DEFAULT_RULES = [
554
627
  // ── Containers & Orchestration ──
555
- { pattern: /^docker\b/, type: "non-transactional", category: "container", reason: "Container orchestration \u2014 may modify container/image state" },
556
- { pattern: /^docker-compose\b/, type: "non-transactional", category: "container", reason: "Container orchestration" },
557
- { pattern: /^docker compose\b/, type: "non-transactional", category: "container", reason: "Container orchestration" },
558
- { pattern: /^podman\b/, type: "non-transactional", category: "container", reason: "Container orchestration" },
559
- { pattern: /^kubectl\b/, type: "non-transactional", category: "container", reason: "Kubernetes cluster management" },
560
- { pattern: /^helm\b/, type: "non-transactional", category: "container", reason: "Helm chart management" },
628
+ // These commands write to container layers / volumes (filesystem trackable)
629
+ // but also modify container/network state (not filesystem partial undo)
630
+ { pattern: /^docker\b/, type: "partially-transactional", category: "container", reason: "Container orchestration \u2014 modifies container/image state outside TXR rollback boundary" },
631
+ { pattern: /^docker-compose\b/, type: "partially-transactional", category: "container", reason: "Container orchestration \u2014 modifies container/image state outside TXR rollback boundary" },
632
+ { pattern: /^docker compose\b/, type: "partially-transactional", category: "container", reason: "Container orchestration \u2014 modifies container/image state outside TXR rollback boundary" },
633
+ { pattern: /^podman\b/, type: "partially-transactional", category: "container", reason: "Container orchestration \u2014 modifies container/image state outside TXR rollback boundary" },
634
+ { pattern: /^kubectl\b/, type: "non-transactional", category: "container", reason: "Kubernetes cluster management \u2014 no local filesystem changes" },
635
+ { pattern: /^helm\b/, type: "non-transactional", category: "container", reason: "Helm chart management \u2014 no local filesystem changes" },
561
636
  // ── Databases ──
562
- { pattern: /^(mysql|psql|mongosh?|redis-cli|sqlite3)\b/, type: "non-transactional", category: "database", reason: "Database client \u2014 may modify database state" },
563
- { pattern: /^prisma\s+(migrate|db\s+push|db\s+seed)/, type: "non-transactional", category: "database", reason: "Database migration/seeding" },
564
- { pattern: /^(knex|sequelize|typeorm)\s+migrate/, type: "non-transactional", category: "database", reason: "Database migration" },
637
+ // Database clients may write local files (e.g., .sql dumps, migration files) but also modify DB state
638
+ { pattern: /^(mysql|psql|mongosh?|redis-cli|sqlite3)\b/, type: "partially-transactional", category: "database", reason: "Database client \u2014 modifies database state outside TXR rollback boundary" },
639
+ { pattern: /^prisma\s+(migrate|db\s+push|db\s+seed)/, type: "partially-transactional", category: "database", reason: "Database migration/seeding \u2014 modifies database state outside TXR rollback boundary" },
640
+ { pattern: /^(knex|sequelize|typeorm)\s+migrate/, type: "partially-transactional", category: "database", reason: "Database migration \u2014 modifies database state outside TXR rollback boundary" },
565
641
  // ── Services & Daemons ──
566
642
  { pattern: /^(systemctl|service)\b/, type: "non-transactional", category: "service", reason: "System service management" },
567
643
  { pattern: /^sc\s+(start|stop|config|create|delete)\b/, type: "non-transactional", category: "service", reason: "Windows service management" },
@@ -635,18 +711,18 @@ function classifyCommand(command, scope = "workspace", projectRoot, extraRules =
635
711
  return { type: "transactional" };
636
712
  }
637
713
  function detectPathEscape(command, projectRoot) {
638
- const normalizedRoot = path6.resolve(projectRoot).replace(/\\/g, "/").toLowerCase();
714
+ const normalizedRoot = path5.resolve(projectRoot).replace(/\\/g, "/").toLowerCase();
639
715
  const tokens = command.split(/\s+/);
640
716
  for (const token of tokens) {
641
717
  const cleaned = token.replace(/["']/g, "");
642
718
  if (cleaned.startsWith("/") && !cleaned.startsWith("/dev/null")) {
643
- const normalizedToken = path6.resolve(cleaned).replace(/\\/g, "/").toLowerCase();
719
+ const normalizedToken = path5.resolve(cleaned).replace(/\\/g, "/").toLowerCase();
644
720
  if (!normalizedToken.startsWith(normalizedRoot)) {
645
721
  return `Path "${cleaned}" is outside the workspace root (${projectRoot})`;
646
722
  }
647
723
  }
648
724
  if (/^[A-Za-z]:[\\/]/.test(cleaned)) {
649
- const normalizedToken = path6.resolve(cleaned).replace(/\\/g, "/").toLowerCase();
725
+ const normalizedToken = path5.resolve(cleaned).replace(/\\/g, "/").toLowerCase();
650
726
  if (!normalizedToken.startsWith(normalizedRoot)) {
651
727
  return `Path "${cleaned}" is outside the workspace root (${projectRoot})`;
652
728
  }
@@ -679,7 +755,19 @@ var TransactionEngine = class {
679
755
  */
680
756
  async execute(command, cwd, options) {
681
757
  const startTime = Date.now();
758
+ const detector = new ExternalChangeDetector(
759
+ this.txStore,
760
+ this.fileMap,
761
+ this.gitStore,
762
+ this.config,
763
+ this.projectRoot
764
+ );
765
+ await detector.detectAndRecord();
682
766
  const classification = options?.forceTransactional ? { type: "transactional" } : options?.forceNonTransactional ? { type: "non-transactional", reason: "User override" } : classifyCommand(command, this.config.scope, this.projectRoot);
767
+ let txType = "U";
768
+ if (classification.type === "partially-transactional") {
769
+ txType = "P";
770
+ }
683
771
  if (classification.type === "non-transactional" || classification.type === "scope-external") {
684
772
  const exitCode = await this.executeDirectly(command, cwd);
685
773
  const durationMs2 = Date.now() - startTime;
@@ -691,14 +779,15 @@ var TransactionEngine = class {
691
779
  exitCode,
692
780
  classification: classification.type,
693
781
  attributionTier: null,
694
- durationMs: durationMs2
782
+ durationMs: durationMs2,
783
+ actionType: null
695
784
  });
696
785
  await this.historyStore.save();
697
786
  return null;
698
787
  }
699
788
  const provider = await selectProvider(this.config.preferredTier);
700
789
  const resolvedWatchPaths = this.config.watchPaths.map(
701
- (p) => path7.isAbsolute(p) ? p : path7.resolve(cwd, p)
790
+ (p) => path6.isAbsolute(p) ? p : path6.resolve(cwd, p)
702
791
  );
703
792
  const result = await provider.executeAndTrack(command, cwd, {
704
793
  watchPaths: resolvedWatchPaths,
@@ -710,7 +799,7 @@ var TransactionEngine = class {
710
799
  const scopeWarnings = [];
711
800
  let trackedChanges;
712
801
  if (this.config.scope === "workspace") {
713
- const normalizedRoot = path7.resolve(this.projectRoot).replace(/\\/g, "/").toLowerCase();
802
+ const normalizedRoot = path6.resolve(this.projectRoot).replace(/\\/g, "/").toLowerCase();
714
803
  trackedChanges = [];
715
804
  for (const change of result.changes) {
716
805
  const normalizedChange = change.absolutePath.replace(/\\/g, "/").toLowerCase();
@@ -740,7 +829,8 @@ var TransactionEngine = class {
740
829
  exitCode: result.processExitCode,
741
830
  classification: "transactional",
742
831
  attributionTier: result.tier,
743
- durationMs: durationMs2
832
+ durationMs: durationMs2,
833
+ actionType: null
744
834
  });
745
835
  await this.historyStore.save();
746
836
  for (const w of scopeWarnings) {
@@ -749,6 +839,10 @@ var TransactionEngine = class {
749
839
  }
750
840
  return null;
751
841
  }
842
+ const discarded = this.txStore.clearUndoStack();
843
+ if (discarded.length > 0) {
844
+ this.fileMap.untrackByTransactions(discarded);
845
+ }
752
846
  const txId = this.txStore.nextId();
753
847
  const touchedFiles = [];
754
848
  const changeManifest = {};
@@ -759,7 +853,7 @@ var TransactionEngine = class {
759
853
  if (change.type === "deleted" /* Deleted */) {
760
854
  await this.gitStore.removeFile(fileId);
761
855
  } else {
762
- const content = await fs6.readFile(change.absolutePath);
856
+ const content = await fs5.readFile(change.absolutePath);
763
857
  await this.gitStore.writeFile(fileId, content);
764
858
  }
765
859
  }
@@ -767,19 +861,27 @@ var TransactionEngine = class {
767
861
  const commitHash = await this.gitStore.commit(commitMessage);
768
862
  const tx = {
769
863
  id: txId,
864
+ type: txType,
770
865
  parent: this.txStore.head,
771
866
  commit: commitHash,
772
867
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
773
868
  command,
869
+ source: "txr-run",
774
870
  touchedFiles,
775
871
  changeManifest,
776
872
  workingDirectory: cwd,
777
873
  exitCode: result.processExitCode,
778
874
  attributionTier: result.tier,
779
875
  scope: this.config.scope,
780
- scopeWarnings
876
+ scopeWarnings,
877
+ ...txType === "P" && {
878
+ partialUndoReason: classification.reason ?? "Command has known side effects outside the filesystem."
879
+ }
781
880
  };
782
- this.txStore.addTransaction(tx);
881
+ const discarded2 = this.txStore.addTransaction(tx);
882
+ if (discarded2.length > 0) {
883
+ this.fileMap.untrackByTransactions(discarded2);
884
+ }
783
885
  await this.txStore.save();
784
886
  await this.fileMap.save();
785
887
  const durationMs = Date.now() - startTime;
@@ -789,18 +891,26 @@ var TransactionEngine = class {
789
891
  transactionId: txId,
790
892
  workingDirectory: cwd,
791
893
  exitCode: result.processExitCode,
792
- classification: "transactional",
894
+ classification: txType === "P" ? "partially-transactional" : "transactional",
793
895
  attributionTier: result.tier,
794
- durationMs
896
+ durationMs,
897
+ actionType: txType
795
898
  });
796
899
  await this.historyStore.save();
900
+ if (scopeWarnings.length > 0) {
901
+ for (const w of scopeWarnings) {
902
+ process.stderr.write(`\x1B[33m\u26A0\x1B[0m ${w}
903
+ `);
904
+ }
905
+ }
797
906
  return {
798
907
  transactionId: txId,
799
908
  commit: commitHash,
800
909
  changesCount: trackedChanges.length,
801
910
  tier: result.tier,
802
911
  durationMs,
803
- scopeWarnings
912
+ scopeWarnings,
913
+ actionType: txType
804
914
  };
805
915
  }
806
916
  /** Resolve a FileChange to a file ID (creating new IDs as needed). */
@@ -832,16 +942,148 @@ var TransactionEngine = class {
832
942
  return "deleted";
833
943
  case "renamed" /* Renamed */:
834
944
  return "modified";
945
+ default:
946
+ return "modified";
835
947
  }
836
948
  }
837
949
  /** Truncate command for commit message. */
838
950
  summarizeCommand(command) {
839
951
  return command.length > 72 ? command.substring(0, 69) + "..." : command;
840
952
  }
953
+ /**
954
+ * Commit a passive transaction (driven externally, e.g. by a Python Agent).
955
+ *
956
+ * @param source The tool or origin (e.g., "agent:write_file")
957
+ * @param changedPaths Absolute paths of files that were modified/created/deleted.
958
+ */
959
+ async commitPassive(source, changedPaths) {
960
+ const startTime = Date.now();
961
+ const scopeWarnings = [];
962
+ const inScopePaths = [];
963
+ if (this.config.scope === "workspace") {
964
+ const normalizedRoot = path6.resolve(this.projectRoot).replace(/\\/g, "/").toLowerCase();
965
+ for (const p of changedPaths) {
966
+ const normalizedPath = path6.resolve(p).replace(/\\/g, "/").toLowerCase();
967
+ if (normalizedPath.startsWith(normalizedRoot)) {
968
+ inScopePaths.push(p);
969
+ } else {
970
+ scopeWarnings.push(`Out-of-scope change ignored: ${p}`);
971
+ }
972
+ }
973
+ } else {
974
+ inScopePaths.push(...changedPaths);
975
+ }
976
+ if (inScopePaths.length === 0) {
977
+ if (scopeWarnings.length > 0) {
978
+ for (const w of scopeWarnings) process.stderr.write(`\x1B[33m\u26A0\x1B[0m ${w}
979
+ `);
980
+ }
981
+ return null;
982
+ }
983
+ const discarded = this.txStore.clearUndoStack();
984
+ if (discarded.length > 0) {
985
+ this.fileMap.untrackByTransactions(discarded);
986
+ }
987
+ const txId = this.txStore.nextId();
988
+ const touchedFiles = [];
989
+ const changeManifest = {};
990
+ for (const p of inScopePaths) {
991
+ const absolutePath = path6.resolve(p);
992
+ let existsOnDisk = false;
993
+ let content = null;
994
+ try {
995
+ content = await fs5.readFile(absolutePath);
996
+ existsOnDisk = true;
997
+ } catch {
998
+ existsOnDisk = false;
999
+ }
1000
+ let changeType = "modified";
1001
+ const existingId = this.fileMap.getIdByPath(absolutePath);
1002
+ let fileId;
1003
+ if (!existingId) {
1004
+ if (!existsOnDisk) {
1005
+ continue;
1006
+ }
1007
+ changeType = "created";
1008
+ fileId = this.fileMap.getOrCreateId(absolutePath, txId);
1009
+ } else {
1010
+ fileId = existingId;
1011
+ if (!existsOnDisk) {
1012
+ changeType = "deleted";
1013
+ } else {
1014
+ changeType = "modified";
1015
+ }
1016
+ }
1017
+ touchedFiles.push(fileId);
1018
+ changeManifest[fileId] = changeType;
1019
+ if (changeType === "deleted") {
1020
+ this.fileMap.markDeleted(fileId, txId);
1021
+ await this.gitStore.removeFile(fileId);
1022
+ } else {
1023
+ await this.gitStore.writeFile(fileId, content);
1024
+ }
1025
+ }
1026
+ if (touchedFiles.length === 0) {
1027
+ return null;
1028
+ }
1029
+ const commitMessage = `txr: ${txId} | passive: ${source}`;
1030
+ const commitHash = await this.gitStore.commit(commitMessage);
1031
+ const tx = {
1032
+ id: txId,
1033
+ type: "U",
1034
+ parent: this.txStore.head,
1035
+ commit: commitHash,
1036
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1037
+ command: source,
1038
+ source,
1039
+ touchedFiles,
1040
+ changeManifest,
1041
+ workingDirectory: this.projectRoot,
1042
+ exitCode: 0,
1043
+ attributionTier: null,
1044
+ scope: this.config.scope,
1045
+ scopeWarnings
1046
+ };
1047
+ const discarded2 = this.txStore.addTransaction(tx);
1048
+ if (discarded2.length > 0) {
1049
+ this.fileMap.untrackByTransactions(discarded2);
1050
+ }
1051
+ await this.txStore.save();
1052
+ await this.fileMap.save();
1053
+ const durationMs = Date.now() - startTime;
1054
+ this.historyStore.append({
1055
+ timestamp: tx.timestamp,
1056
+ command: source,
1057
+ transactionId: txId,
1058
+ workingDirectory: this.projectRoot,
1059
+ exitCode: 0,
1060
+ classification: "transactional",
1061
+ attributionTier: null,
1062
+ durationMs,
1063
+ actionType: "U"
1064
+ });
1065
+ await this.historyStore.save();
1066
+ if (scopeWarnings.length > 0) {
1067
+ for (const w of scopeWarnings) {
1068
+ process.stderr.write(`\x1B[33m\u26A0\x1B[0m ${w}
1069
+ `);
1070
+ }
1071
+ }
1072
+ return {
1073
+ transactionId: txId,
1074
+ commit: commitHash,
1075
+ changesCount: touchedFiles.length,
1076
+ tier: 1,
1077
+ // Default base tier for passive
1078
+ durationMs,
1079
+ scopeWarnings,
1080
+ actionType: "U"
1081
+ };
1082
+ }
841
1083
  /** Execute a command directly without tracking. */
842
1084
  executeDirectly(command, cwd) {
843
1085
  const { spawn: spawn2 } = __require("child_process");
844
- return new Promise((resolve6, reject) => {
1086
+ return new Promise((resolve7, reject) => {
845
1087
  const isWindows = process.platform === "win32";
846
1088
  const shell = isWindows ? "cmd.exe" : "/bin/sh";
847
1089
  const shellArgs = isWindows ? ["/c", command] : ["-c", command];
@@ -850,7 +1092,7 @@ var TransactionEngine = class {
850
1092
  stdio: "inherit"
851
1093
  });
852
1094
  child.on("error", reject);
853
- child.on("close", (code) => resolve6(code ?? 1));
1095
+ child.on("close", (code) => resolve7(code ?? 1));
854
1096
  });
855
1097
  }
856
1098
  };
@@ -866,7 +1108,7 @@ async function runCommand(command, options) {
866
1108
  if ((classification.type === "non-transactional" || classification.type === "scope-external") && config.confirmUnsafe && !options.yes) {
867
1109
  const readline = await import("readline");
868
1110
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
869
- const answer = await new Promise((resolve6) => {
1111
+ const answer = await new Promise((resolve7) => {
870
1112
  console.log();
871
1113
  if (classification.type === "scope-external") {
872
1114
  console.log(`\x1B[33m\u26A0 SCOPE WARNING:\x1B[0m This command may modify files outside your workspace.`);
@@ -878,7 +1120,7 @@ async function runCommand(command, options) {
878
1120
  console.log();
879
1121
  console.log(" Rollback guarantees cannot be provided.");
880
1122
  console.log();
881
- rl.question(" Continue without tracking? [y/N] ", resolve6);
1123
+ rl.question(" Continue without tracking? [y/N] ", resolve7);
882
1124
  });
883
1125
  rl.close();
884
1126
  if (answer.toLowerCase() !== "y") {
@@ -913,8 +1155,8 @@ async function runCommand(command, options) {
913
1155
  }
914
1156
 
915
1157
  // src/core/undo-engine.ts
916
- import * as fs7 from "fs/promises";
917
- import * as path8 from "path";
1158
+ import * as fs6 from "fs/promises";
1159
+ import * as path7 from "path";
918
1160
  import * as crypto2 from "crypto";
919
1161
  var UndoConflictError = class extends Error {
920
1162
  conflicts;
@@ -924,28 +1166,64 @@ var UndoConflictError = class extends Error {
924
1166
  this.conflicts = conflicts;
925
1167
  }
926
1168
  };
1169
+ var PartialUndoBlockedError = class extends Error {
1170
+ constructor(txId, reason) {
1171
+ super(
1172
+ `Cannot safely undo \x1B[1m${txId}\x1B[0m: This transaction has known side effects outside TXR's rollback boundary.
1173
+ Reason: ${reason}
1174
+
1175
+ Tracked filesystem effects CAN be reverted.
1176
+ Resolve any external side effects manually, then run: \x1B[1mtxr undo --force\x1B[0m`
1177
+ );
1178
+ this.name = "PartialUndoBlockedError";
1179
+ }
1180
+ };
1181
+ var ExternalUndoBlockedError = class extends Error {
1182
+ constructor(txId) {
1183
+ super(
1184
+ `Cannot undo \x1B[1m${txId}\x1B[0m: This is an automatically recorded external change.
1185
+ TXR did not create these changes, so it cannot undo them safely.
1186
+
1187
+ If you have already reverted these changes manually, run: \x1B[1mtxr undo --force\x1B[0m`
1188
+ );
1189
+ this.name = "ExternalUndoBlockedError";
1190
+ }
1191
+ };
927
1192
  var UndoEngine = class {
928
- constructor(txStore, fileMap, gitStore) {
1193
+ constructor(txStore, fileMap, gitStore, detector) {
929
1194
  this.txStore = txStore;
930
1195
  this.fileMap = fileMap;
931
1196
  this.gitStore = gitStore;
1197
+ this.detector = detector;
932
1198
  }
933
1199
  txStore;
934
1200
  fileMap;
935
1201
  gitStore;
1202
+ detector;
936
1203
  /** Undo the current HEAD transaction. */
937
- async undo() {
1204
+ async undo(options = {}) {
1205
+ if (!options.force && this.detector) {
1206
+ await this.detector.detectAndRecord();
1207
+ }
938
1208
  const headTx = this.txStore.getHead();
939
1209
  if (!headTx) throw new Error("Nothing to undo.");
940
1210
  const parentTx = headTx.parent ? this.txStore.get(headTx.parent) : void 0;
941
- const conflicts = await this.validateForUndo(headTx);
942
- if (conflicts.length > 0) {
943
- throw new UndoConflictError(
944
- `Cannot undo ${headTx.id}: ${conflicts.length} file(s) have been externally modified.
1211
+ if (!options.force) {
1212
+ if (headTx.type === "P") {
1213
+ throw new PartialUndoBlockedError(headTx.id, headTx.partialUndoReason ?? "Unknown side effect.");
1214
+ }
1215
+ if (headTx.type === "E") {
1216
+ throw new ExternalUndoBlockedError(headTx.id);
1217
+ }
1218
+ const conflicts = await this.validateForUndo(headTx);
1219
+ if (conflicts.length > 0) {
1220
+ throw new UndoConflictError(
1221
+ `Cannot undo ${headTx.id}: ${conflicts.length} file(s) have been externally modified.
945
1222
 
946
1223
  ` + conflicts.join("\n"),
947
- conflicts
948
- );
1224
+ conflicts
1225
+ );
1226
+ }
949
1227
  }
950
1228
  const ops = [];
951
1229
  for (const fileId of headTx.touchedFiles) {
@@ -958,18 +1236,31 @@ var UndoEngine = class {
958
1236
  break;
959
1237
  case "modified": {
960
1238
  if (!parentTx) {
961
- throw new Error(`Cannot undo ${headTx.id}: File was modified in the very first transaction and its original state is untracked.`);
1239
+ process.stderr.write(`\\x1b[33m\u26A0\\x1b[0m Skipping revert for ${filePath}: original state is untracked.\\n`);
1240
+ break;
1241
+ }
1242
+ try {
1243
+ const prevContent = await this.gitStore.readBlob(parentTx.commit, fileId);
1244
+ ops.push({ type: "write", path: filePath, content: prevContent });
1245
+ } catch (e) {
1246
+ process.stderr.write(`\\x1b[33m\u26A0\\x1b[0m Skipping revert for ${filePath}: original state is untracked.\\n`);
962
1247
  }
963
- const prevContent = await this.gitStore.readBlob(parentTx.commit, fileId);
964
- ops.push({ type: "write", path: filePath, content: prevContent });
965
1248
  break;
966
1249
  }
967
1250
  case "deleted": {
968
1251
  if (!parentTx) {
969
- throw new Error(`Cannot undo ${headTx.id}: File was deleted in the very first transaction and its original state is untracked.`);
1252
+ process.stderr.write(`\\x1b[33m\u26A0\\x1b[0m Skipping restore for ${filePath}: original state is untracked.\\n`);
1253
+ break;
1254
+ }
1255
+ try {
1256
+ const restoredContent = await this.gitStore.readBlob(parentTx.commit, fileId);
1257
+ ops.push({ type: "write", path: filePath, content: restoredContent });
1258
+ } catch (e) {
1259
+ process.stderr.write(`\\x1b[33m\u26A0\\x1b[0m Skipping restore for ${filePath}: original state is untracked.\\n`);
970
1260
  }
971
- const restoredContent = await this.gitStore.readBlob(parentTx.commit, fileId);
972
- ops.push({ type: "write", path: filePath, content: restoredContent });
1261
+ break;
1262
+ }
1263
+ case "tracked": {
973
1264
  break;
974
1265
  }
975
1266
  }
@@ -978,14 +1269,14 @@ var UndoEngine = class {
978
1269
  const deletes = ops.filter((o) => o.type === "delete");
979
1270
  for (const op of writes) {
980
1271
  if (op.type === "write") {
981
- await fs7.mkdir(path8.dirname(op.path), { recursive: true });
982
- await fs7.writeFile(op.path, op.content);
1272
+ await fs6.mkdir(path7.dirname(op.path), { recursive: true });
1273
+ await fs6.writeFile(op.path, op.content);
983
1274
  }
984
1275
  }
985
1276
  for (const op of deletes) {
986
1277
  if (op.type === "delete") {
987
1278
  try {
988
- await fs7.unlink(op.path);
1279
+ await fs6.unlink(op.path);
989
1280
  } catch {
990
1281
  }
991
1282
  await this.removeEmptyParents(op.path);
@@ -993,7 +1284,7 @@ var UndoEngine = class {
993
1284
  }
994
1285
  for (const fileId of headTx.touchedFiles) {
995
1286
  const changeType = headTx.changeManifest[fileId];
996
- if (changeType === "created") {
1287
+ if (changeType === "created" || changeType === "tracked") {
997
1288
  this.fileMap.markDeleted(fileId, `undo:${headTx.id}`);
998
1289
  } else if (changeType === "deleted") {
999
1290
  this.fileMap.markRestored(fileId);
@@ -1010,11 +1301,28 @@ var UndoEngine = class {
1010
1301
  return {
1011
1302
  undone: headTx.id,
1012
1303
  newHead: headTx.parent,
1013
- filesRestored: ops.length
1304
+ filesRestored: ops.length,
1305
+ forced: options.force ?? false
1014
1306
  };
1015
1307
  }
1016
1308
  /** Redo the most recently undone transaction. */
1017
1309
  async redo() {
1310
+ if (this.detector) {
1311
+ const ext = await this.detector.detectAndRecord();
1312
+ if (ext) {
1313
+ const discarded = this.txStore.clearUndoStack();
1314
+ if (discarded.length > 0) {
1315
+ this.fileMap.untrackByTransactions(discarded);
1316
+ }
1317
+ await this.txStore.save();
1318
+ await this.fileMap.save();
1319
+ throw new Error(
1320
+ `Redo cancelled: External changes were detected and recorded as \x1B[1m${ext.id}\x1B[0m.
1321
+ The redo chain has been cleared because the filesystem state no longer matches the expected pre-redo state.
1322
+ Run \x1B[1mtxr history\x1B[0m to review what changed.`
1323
+ );
1324
+ }
1325
+ }
1018
1326
  if (this.txStore.undoStack.length === 0) {
1019
1327
  throw new Error("Nothing to redo.");
1020
1328
  }
@@ -1040,25 +1348,28 @@ var UndoEngine = class {
1040
1348
  case "created":
1041
1349
  case "modified": {
1042
1350
  const content = await this.gitStore.readBlob(redoTx.commit, fileId);
1043
- await fs7.mkdir(path8.dirname(filePath), { recursive: true });
1044
- await fs7.writeFile(filePath, content);
1351
+ await fs6.mkdir(path7.dirname(filePath), { recursive: true });
1352
+ await fs6.writeFile(filePath, content);
1045
1353
  filesChanged++;
1046
1354
  break;
1047
1355
  }
1048
1356
  case "deleted": {
1049
1357
  try {
1050
- await fs7.unlink(filePath);
1358
+ await fs6.unlink(filePath);
1051
1359
  } catch {
1052
1360
  }
1053
1361
  await this.removeEmptyParents(filePath);
1054
1362
  filesChanged++;
1055
1363
  break;
1056
1364
  }
1365
+ case "tracked": {
1366
+ break;
1367
+ }
1057
1368
  }
1058
1369
  }
1059
1370
  for (const fileId of redoTx.touchedFiles) {
1060
1371
  const changeType = redoTx.changeManifest[fileId];
1061
- if (changeType === "created") {
1372
+ if (changeType === "created" || changeType === "tracked") {
1062
1373
  this.fileMap.markRestored(fileId);
1063
1374
  } else if (changeType === "deleted") {
1064
1375
  this.fileMap.markDeleted(fileId, `redo:${redoTx.id}`);
@@ -1076,8 +1387,7 @@ var UndoEngine = class {
1076
1387
  }
1077
1388
  /**
1078
1389
  * Validate all touched files for undo.
1079
- * Returns an array of human-readable conflict descriptions.
1080
- * Empty array = safe to proceed.
1390
+ * Returns human-readable conflict descriptions. Empty = safe to proceed.
1081
1391
  */
1082
1392
  async validateForUndo(tx) {
1083
1393
  const conflicts = [];
@@ -1105,16 +1415,21 @@ var UndoEngine = class {
1105
1415
  );
1106
1416
  continue;
1107
1417
  }
1108
- const expectedContent = await this.gitStore.readBlob(tx.commit, fileId);
1109
- const actualContent = await fs7.readFile(filePath);
1110
- if (!this.hashEquals(expectedContent, actualContent)) {
1111
- const expectedHash = this.hash(expectedContent).substring(0, 12);
1112
- const actualHash = this.hash(actualContent).substring(0, 12);
1113
- conflicts.push(
1114
- ` \u2717 ${filePath}
1418
+ try {
1419
+ const expectedContent = await this.gitStore.readBlob(tx.commit, fileId);
1420
+ const actualContent = await fs6.readFile(filePath);
1421
+ if (!this.hashEquals(expectedContent, actualContent)) {
1422
+ const expectedHash = this.hash(expectedContent).substring(0, 12);
1423
+ const actualHash = this.hash(actualContent).substring(0, 12);
1424
+ conflicts.push(
1425
+ ` \u2717 ${filePath}
1115
1426
  Expected hash: ${expectedHash}...
1116
1427
  Actual hash: ${actualHash}...`
1117
- );
1428
+ );
1429
+ }
1430
+ } catch {
1431
+ conflicts.push(` \u2717 ${filePath}
1432
+ Could not read blob from internal store.`);
1118
1433
  }
1119
1434
  }
1120
1435
  return conflicts;
@@ -1148,7 +1463,7 @@ var UndoEngine = class {
1148
1463
  if (parentTx) {
1149
1464
  try {
1150
1465
  const expectedContent = await this.gitStore.readBlob(parentTx.commit, fileId);
1151
- const actualContent = await fs7.readFile(filePath);
1466
+ const actualContent = await fs6.readFile(filePath);
1152
1467
  if (!this.hashEquals(expectedContent, actualContent)) {
1153
1468
  conflicts.push(
1154
1469
  ` \u2717 ${filePath}
@@ -1163,7 +1478,7 @@ var UndoEngine = class {
1163
1478
  }
1164
1479
  async fileExists(filePath) {
1165
1480
  try {
1166
- await fs7.access(filePath);
1481
+ await fs6.access(filePath);
1167
1482
  return true;
1168
1483
  } catch {
1169
1484
  return false;
@@ -1177,13 +1492,13 @@ var UndoEngine = class {
1177
1492
  }
1178
1493
  /** Remove empty parent directories up to a reasonable depth. */
1179
1494
  async removeEmptyParents(filePath) {
1180
- let dir = path8.dirname(filePath);
1495
+ let dir = path7.dirname(filePath);
1181
1496
  for (let i = 0; i < 10; i++) {
1182
1497
  try {
1183
- const entries = await fs7.readdir(dir);
1498
+ const entries = await fs6.readdir(dir);
1184
1499
  if (entries.length === 0) {
1185
- await fs7.rmdir(dir);
1186
- dir = path8.dirname(dir);
1500
+ await fs6.rmdir(dir);
1501
+ dir = path7.dirname(dir);
1187
1502
  } else {
1188
1503
  break;
1189
1504
  }
@@ -1195,18 +1510,25 @@ var UndoEngine = class {
1195
1510
  };
1196
1511
 
1197
1512
  // src/cli/commands/undo.ts
1198
- async function undoCommand() {
1513
+ async function undoCommand(options = {}) {
1199
1514
  const cwd = process.cwd();
1200
1515
  const projectRoot = await findTxrRoot(cwd);
1201
1516
  const paths = getTxrPaths(projectRoot);
1517
+ const config = await loadConfig(paths.metadataDir);
1202
1518
  const txStore = await TransactionStore.load(paths.metadataDir);
1203
1519
  const fileMap = await FileMap.load(paths.metadataDir);
1204
1520
  const gitStore = new GitStore(paths.repoDir);
1205
- const engine = new UndoEngine(txStore, fileMap, gitStore);
1521
+ const detector = new ExternalChangeDetector(txStore, fileMap, gitStore, config, projectRoot);
1522
+ const engine = new UndoEngine(txStore, fileMap, gitStore, detector);
1206
1523
  try {
1207
- const result = await engine.undo();
1524
+ const result = await engine.undo({ force: options.force });
1208
1525
  console.log();
1209
- console.log(`\x1B[32m\u2713\x1B[0m Undone: \x1B[1m${result.undone}\x1B[0m`);
1526
+ if (result.forced) {
1527
+ console.log(`\x1B[33m\u26A0 Force undo applied.\x1B[0m Undone: \x1B[1m${result.undone}\x1B[0m`);
1528
+ console.log(` TXR assumes you have resolved any external side effects.`);
1529
+ } else {
1530
+ console.log(`\x1B[32m\u2713\x1B[0m Undone: \x1B[1m${result.undone}\x1B[0m`);
1531
+ }
1210
1532
  console.log(` ${result.filesRestored} file operation(s) applied`);
1211
1533
  console.log(` HEAD: ${result.newHead ?? "(empty)"}`);
1212
1534
  } catch (err) {
@@ -1219,6 +1541,16 @@ async function undoCommand() {
1219
1541
  console.error("To resolve: manually revert the conflicting files, then retry `txr undo`.");
1220
1542
  process.exit(1);
1221
1543
  }
1544
+ if (err instanceof PartialUndoBlockedError) {
1545
+ console.error();
1546
+ console.error(`\x1B[33mBLOCKED [P]:\x1B[0m ${err.message}`);
1547
+ process.exit(1);
1548
+ }
1549
+ if (err instanceof ExternalUndoBlockedError) {
1550
+ console.error();
1551
+ console.error(`\x1B[33mBLOCKED [E]:\x1B[0m ${err.message}`);
1552
+ process.exit(1);
1553
+ }
1222
1554
  throw err;
1223
1555
  }
1224
1556
  }
@@ -1226,10 +1558,12 @@ async function redoCommand() {
1226
1558
  const cwd = process.cwd();
1227
1559
  const projectRoot = await findTxrRoot(cwd);
1228
1560
  const paths = getTxrPaths(projectRoot);
1561
+ const config = await loadConfig(paths.metadataDir);
1229
1562
  const txStore = await TransactionStore.load(paths.metadataDir);
1230
1563
  const fileMap = await FileMap.load(paths.metadataDir);
1231
1564
  const gitStore = new GitStore(paths.repoDir);
1232
- const engine = new UndoEngine(txStore, fileMap, gitStore);
1565
+ const detector = new ExternalChangeDetector(txStore, fileMap, gitStore, config, projectRoot);
1566
+ const engine = new UndoEngine(txStore, fileMap, gitStore, detector);
1233
1567
  try {
1234
1568
  const result = await engine.redo();
1235
1569
  console.log();
@@ -1244,6 +1578,11 @@ async function redoCommand() {
1244
1578
  console.error("Redo aborted. No files were changed.");
1245
1579
  process.exit(1);
1246
1580
  }
1581
+ if (err instanceof Error && err.message.startsWith("Redo cancelled:")) {
1582
+ console.error();
1583
+ console.error(`\x1B[33m\u26A0\x1B[0m ${err.message}`);
1584
+ process.exit(1);
1585
+ }
1247
1586
  throw err;
1248
1587
  }
1249
1588
  }
@@ -1266,30 +1605,47 @@ async function statusCommand() {
1266
1605
  console.log(` Redo stack: ${txStore.undoStack.length} transaction(s)`);
1267
1606
  console.log(` Watch paths: ${config.watchPaths.join(", ")}`);
1268
1607
  }
1269
- async function historyCommand(count = 20) {
1608
+ async function historyCommand() {
1270
1609
  const cwd = process.cwd();
1271
1610
  const projectRoot = await findTxrRoot(cwd);
1272
1611
  const paths = getTxrPaths(projectRoot);
1273
- const historyStore = await HistoryStore.load(paths.metadataDir);
1274
- const entries = historyStore.getRecent(count);
1275
- if (entries.length === 0) {
1276
- console.log("\nNo command history yet.");
1612
+ const txStore = await TransactionStore.load(paths.metadataDir);
1613
+ const chain = txStore.getChain();
1614
+ const redoStack = txStore.undoStack;
1615
+ if (chain.length === 0 && redoStack.length === 0) {
1616
+ console.log("\nNo transaction history yet.");
1277
1617
  return;
1278
1618
  }
1279
- console.log();
1280
- console.log(`\x1B[1mCommand History\x1B[0m (${entries.length} most recent)
1281
- `);
1282
- for (const entry of entries) {
1283
- const txLabel = entry.transactionId ? `\x1B[32m${entry.transactionId}\x1B[0m` : "\x1B[90mno-tx\x1B[0m";
1284
- const tierLabel = entry.attributionTier ? `T${entry.attributionTier}` : " ";
1285
- const timeStr = new Date(entry.timestamp).toLocaleTimeString();
1286
- console.log(` ${timeStr} ${tierLabel} ${txLabel} ${entry.command}`);
1619
+ const printTx = (tx) => {
1620
+ const txLabel = `\x1B[32m${tx.id}\x1B[0m`;
1621
+ const tierLabel = tx.attributionTier ? `T${tx.attributionTier}` : " ";
1622
+ const typeLabel = tx.type === "U" ? "\x1B[32m[U]\x1B[0m" : tx.type === "P" ? "\x1B[33m[P]\x1B[0m" : "\x1B[35m[E]\x1B[0m";
1623
+ const timeStr = new Date(tx.timestamp).toLocaleTimeString();
1624
+ const commandStr = tx.command ?? "\x1B[90m[external change]\x1B[0m";
1625
+ console.log(` ${timeStr} ${tierLabel} ${txLabel} ${typeLabel} ${commandStr}`);
1626
+ };
1627
+ console.log("\n\x1B[1m=== Active Timeline (Undoable) ===\x1B[0m\n");
1628
+ if (chain.length === 0) {
1629
+ console.log(" (empty)");
1630
+ } else {
1631
+ for (const tx of chain) {
1632
+ printTx(tx);
1633
+ }
1634
+ }
1635
+ console.log("\n\x1B[1m==== [ CURRENT HEAD ] ====\x1B[0m\n");
1636
+ if (redoStack.length > 0) {
1637
+ console.log("\x1B[1m=== Next Commands (Available to Redo) ===\x1B[0m\n");
1638
+ for (const txId of redoStack) {
1639
+ const tx = txStore.get(txId);
1640
+ if (tx) printTx(tx);
1641
+ }
1642
+ console.log();
1287
1643
  }
1288
1644
  }
1289
1645
 
1290
1646
  // src/cli/commands/clear.ts
1291
- import * as fs8 from "fs/promises";
1292
- import * as path9 from "path";
1647
+ import * as fs7 from "fs/promises";
1648
+ import * as path8 from "path";
1293
1649
  async function clearCommand(options) {
1294
1650
  const cwd = process.cwd();
1295
1651
  let projectRoot;
@@ -1302,7 +1658,7 @@ async function clearCommand(options) {
1302
1658
  if (!options.yes) {
1303
1659
  const readline = await import("readline");
1304
1660
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1305
- const answer = await new Promise((resolve6) => {
1661
+ const answer = await new Promise((resolve7) => {
1306
1662
  console.log();
1307
1663
  console.log(`\x1B[33mWARNING:\x1B[0m`);
1308
1664
  console.log(`This will permanently delete all transaction history, undo history,`);
@@ -1310,7 +1666,7 @@ async function clearCommand(options) {
1310
1666
  console.log();
1311
1667
  console.log(`This operation cannot be undone.`);
1312
1668
  console.log();
1313
- rl.question("Continue? [y/N] ", resolve6);
1669
+ rl.question("Continue? [y/N] ", resolve7);
1314
1670
  });
1315
1671
  rl.close();
1316
1672
  if (answer.trim().toLowerCase() !== "y") {
@@ -1318,14 +1674,14 @@ async function clearCommand(options) {
1318
1674
  return;
1319
1675
  }
1320
1676
  }
1321
- const txrDir = path9.join(projectRoot, ".txr");
1322
- const repoDir = path9.join(txrDir, "repo");
1323
- const metadataDir = path9.join(txrDir, "metadata");
1677
+ const txrDir = path8.join(projectRoot, ".txr");
1678
+ const repoDir = path8.join(txrDir, "repo");
1679
+ const metadataDir = path8.join(txrDir, "metadata");
1324
1680
  try {
1325
1681
  let retries = 3;
1326
1682
  while (retries > 0) {
1327
1683
  try {
1328
- await fs8.rm(repoDir, { recursive: true, force: true });
1684
+ await fs7.rm(repoDir, { recursive: true, force: true });
1329
1685
  break;
1330
1686
  } catch (err) {
1331
1687
  if (err.code === "EBUSY" || err.code === "EPERM") {
@@ -1334,27 +1690,27 @@ async function clearCommand(options) {
1334
1690
  throw new Error(`Files are locked by another process. Please close any IDEs or tools accessing the workspace and try again.
1335
1691
  Details: ${err.message}`);
1336
1692
  }
1337
- await new Promise((resolve6) => setTimeout(resolve6, 500));
1693
+ await new Promise((resolve7) => setTimeout(resolve7, 500));
1338
1694
  } else {
1339
1695
  throw err;
1340
1696
  }
1341
1697
  }
1342
1698
  }
1343
- const { GitStore: GitStore2 } = await import("./git-store-XSOIM4KZ.js");
1699
+ const { GitStore: GitStore2 } = await import("./git-store-MUETZYUT.js");
1344
1700
  const gitStore = new GitStore2(repoDir);
1345
1701
  await gitStore.init();
1346
- await fs8.writeFile(
1347
- path9.join(metadataDir, "transactions.json"),
1702
+ await fs7.writeFile(
1703
+ path8.join(metadataDir, "transactions.json"),
1348
1704
  JSON.stringify({ version: 1, head: null, undoStack: [], counter: 0, transactions: {} }, null, 2),
1349
1705
  "utf-8"
1350
1706
  );
1351
- await fs8.writeFile(
1352
- path9.join(metadataDir, "mapping.json"),
1707
+ await fs7.writeFile(
1708
+ path8.join(metadataDir, "mapping.json"),
1353
1709
  JSON.stringify({ version: 1, files: {} }, null, 2),
1354
1710
  "utf-8"
1355
1711
  );
1356
- await fs8.writeFile(
1357
- path9.join(metadataDir, "history.json"),
1712
+ await fs7.writeFile(
1713
+ path8.join(metadataDir, "history.json"),
1358
1714
  JSON.stringify({ version: 1, entries: [] }, null, 2),
1359
1715
  "utf-8"
1360
1716
  );
@@ -1369,9 +1725,133 @@ Details: ${err.message}`);
1369
1725
  }
1370
1726
  }
1371
1727
 
1728
+ // src/cli/commands/track.ts
1729
+ import { Command } from "commander";
1730
+ import * as path9 from "path";
1731
+ import * as fs8 from "fs/promises";
1732
+ function createTrackCommand() {
1733
+ return new Command("track").description("Manually track an existing file to capture its baseline state").argument("<file>", "File to track").action(async (file) => {
1734
+ try {
1735
+ const cwd = process.cwd();
1736
+ const absolutePath = path9.resolve(cwd, file);
1737
+ try {
1738
+ const stat4 = await fs8.stat(absolutePath);
1739
+ if (!stat4.isFile()) {
1740
+ console.error(`Error: ${file} is not a regular file.`);
1741
+ process.exit(1);
1742
+ }
1743
+ } catch {
1744
+ console.error(`Error: File ${file} does not exist.`);
1745
+ process.exit(1);
1746
+ }
1747
+ const projectRoot = await findTxrRoot(cwd);
1748
+ if (!projectRoot) {
1749
+ console.error("Error: Not in a txr workspace (no .txr directory found).");
1750
+ process.exit(1);
1751
+ }
1752
+ const paths = getTxrPaths(projectRoot);
1753
+ const config = await loadConfig(paths.metadataDir);
1754
+ const [txStore, fileMap, historyStore] = await Promise.all([
1755
+ TransactionStore.load(paths.metadataDir),
1756
+ FileMap.load(paths.metadataDir),
1757
+ HistoryStore.load(paths.metadataDir)
1758
+ ]);
1759
+ const gitStore = new GitStore(paths.repoDir);
1760
+ const existingId = fileMap.getIdByPath(absolutePath);
1761
+ if (existingId) {
1762
+ console.log(`File ${file} is already tracked.`);
1763
+ return;
1764
+ }
1765
+ const discarded = txStore.clearUndoStack();
1766
+ if (discarded.length > 0) {
1767
+ fileMap.untrackByTransactions(discarded);
1768
+ }
1769
+ const txId = txStore.nextId();
1770
+ const fileId = fileMap.getOrCreateId(absolutePath, txId);
1771
+ const content = await fs8.readFile(absolutePath);
1772
+ await gitStore.writeFile(fileId, content);
1773
+ const commitMessage = `txr: ${txId} | txr track ${path9.basename(absolutePath)}`;
1774
+ const commitHash = await gitStore.commit(commitMessage);
1775
+ const tx = {
1776
+ id: txId,
1777
+ type: "U",
1778
+ parent: txStore.head,
1779
+ commit: commitHash,
1780
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1781
+ command: `txr track ${file}`,
1782
+ source: "txr-run",
1783
+ touchedFiles: [fileId],
1784
+ changeManifest: { [fileId]: "tracked" },
1785
+ workingDirectory: cwd,
1786
+ exitCode: 0,
1787
+ attributionTier: null,
1788
+ scope: config.scope,
1789
+ scopeWarnings: []
1790
+ };
1791
+ const discarded2 = txStore.addTransaction(tx);
1792
+ if (discarded2.length > 0) {
1793
+ fileMap.untrackByTransactions(discarded2);
1794
+ }
1795
+ await txStore.save();
1796
+ await fileMap.save();
1797
+ historyStore.append({
1798
+ timestamp: tx.timestamp,
1799
+ command: tx.command,
1800
+ transactionId: txId,
1801
+ workingDirectory: cwd,
1802
+ exitCode: 0,
1803
+ classification: "transactional",
1804
+ attributionTier: null,
1805
+ durationMs: 0,
1806
+ actionType: "U"
1807
+ });
1808
+ await historyStore.save();
1809
+ console.log(`\u2713 Baseline tracked: ${file}`);
1810
+ console.log(` Recorded as transaction \x1B[1m${txId}\x1B[0m.`);
1811
+ } catch (error) {
1812
+ console.error(`Error: ${error.message}`);
1813
+ process.exit(1);
1814
+ }
1815
+ });
1816
+ }
1817
+
1818
+ // src/cli/commands/api.ts
1819
+ import { Command as Command2 } from "commander";
1820
+ function createApiCommand() {
1821
+ const api = new Command2("api").description("Headless API for agent integration");
1822
+ api.command("sync").description("Detect all filesystem changes since HEAD and commit them as a transaction").requiredOption("--source <string>", "Source of the transaction (e.g. agent:write_file or external)").action(async (opts) => {
1823
+ try {
1824
+ const cwd = process.cwd();
1825
+ const projectRoot = await findTxrRoot(cwd);
1826
+ if (!projectRoot) {
1827
+ console.error(JSON.stringify({ ok: false, error: "Not a txr repository" }));
1828
+ process.exit(1);
1829
+ }
1830
+ const paths = getTxrPaths(projectRoot);
1831
+ const config = await loadConfig(paths.metadataDir);
1832
+ const txStore = await TransactionStore.load(paths.metadataDir);
1833
+ const historyStore = await HistoryStore.load(paths.metadataDir);
1834
+ const fileMap = await FileMap.load(paths.metadataDir);
1835
+ const gitStore = new GitStore(paths.repoDir);
1836
+ const { ExternalChangeDetector: ExternalChangeDetector2 } = await import("./external-detector-5TT634UP.js");
1837
+ const detector = new ExternalChangeDetector2(txStore, fileMap, gitStore, config, projectRoot);
1838
+ const tx = await detector.detectAndRecordWithSource(opts.source);
1839
+ if (tx) {
1840
+ console.log(JSON.stringify({ ok: true, transactionId: tx.id, changesCount: Object.keys(tx.changeManifest).length }));
1841
+ } else {
1842
+ console.log(JSON.stringify({ ok: true, transactionId: null, changesCount: 0 }));
1843
+ }
1844
+ } catch (err) {
1845
+ console.error(JSON.stringify({ ok: false, error: err.stack }));
1846
+ process.exit(1);
1847
+ }
1848
+ });
1849
+ return api;
1850
+ }
1851
+
1372
1852
  // src/index.ts
1373
- var program = new Command();
1374
- program.name("txr").description("Transactional command runner \u2014 undo any shell command").version("0.1.0");
1853
+ var program = new Command3();
1854
+ program.name("txr").description("Transactional command runner \u2014 undo any shell command").version("0.2.0");
1375
1855
  program.command("init").description("Initialize txr in the current directory").option("-g, --global", "Enable global tracking mode (track files outside workspace)").action(async (opts) => {
1376
1856
  try {
1377
1857
  const scope = opts.global ? "global" : "workspace";
@@ -1401,9 +1881,9 @@ program.command("run").description("Execute a command with transactional trackin
1401
1881
  process.exit(1);
1402
1882
  }
1403
1883
  });
1404
- program.command("undo").description("Undo the most recent transaction").action(async () => {
1884
+ program.command("undo").description("Undo the most recent transaction").option("--force", "Skip safety checks and force the undo (for P and E transactions)").action(async (opts) => {
1405
1885
  try {
1406
- await undoCommand();
1886
+ await undoCommand({ force: opts.force });
1407
1887
  } catch (err) {
1408
1888
  console.error(`\x1B[31mError:\x1B[0m ${err.message}`);
1409
1889
  process.exit(1);
@@ -1425,9 +1905,9 @@ program.command("status").description("Show current transaction state").action(a
1425
1905
  process.exit(1);
1426
1906
  }
1427
1907
  });
1428
- program.command("history").description("Show command history").option("-n, --count <number>", "Number of entries to show", "20").action(async (opts) => {
1908
+ program.command("history").description("Show command history").option("-n, --count <number>", "Number of entries to show", "20").action(async () => {
1429
1909
  try {
1430
- await historyCommand(parseInt(opts.count, 10));
1910
+ await historyCommand();
1431
1911
  } catch (err) {
1432
1912
  console.error(`\x1B[31mError:\x1B[0m ${err.message}`);
1433
1913
  process.exit(1);
@@ -1441,4 +1921,6 @@ program.command("clear").description("Reset the internal state and delete all tr
1441
1921
  process.exit(1);
1442
1922
  }
1443
1923
  });
1924
+ program.addCommand(createTrackCommand());
1925
+ program.addCommand(createApiCommand());
1444
1926
  program.parse();