engineering-memory 1.11.7 → 1.11.9

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.
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { mkdir, readFile, readdir } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
- import { dirname, join, resolve } from 'node:path';
4
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
5
5
  import * as z from 'zod/v4';
6
6
  import { GitInspector } from '../git/git-inspector.js';
7
7
  import { atomicWrite, assertManagedPath, canonicalPath, ensureManagedDirectory, pathExists, readJson, removeFile, safeSegment, } from '../utilities/files.js';
@@ -37,11 +37,29 @@ const allocationSchema = z.object({
37
37
  pendingDelivery: z.boolean(),
38
38
  recoveredWithoutReservation: z.boolean().optional(),
39
39
  reason: z.string().optional(),
40
+ root: z.string().optional(),
41
+ layout: z.union([z.literal(1), z.literal(2)]).optional(),
40
42
  });
41
43
  const registrySchema = z.object({
42
44
  schemaVersion: z.literal(1),
43
45
  projects: z.record(z.string(), z.object({ folder: z.string().regex(/^[a-zA-Z0-9._-]+$/), entries: z.array(allocationSchema) })),
44
46
  });
47
+ export function managedBase(root, layout) {
48
+ return layout === 1
49
+ ? join(root, 'engineeringmemory')
50
+ : join(root, 'engineering_memory', 'worktrees');
51
+ }
52
+ function projectDirectory(root, folder, layout) {
53
+ return layout === 1
54
+ ? join(root, 'engineeringmemory', folder, 'worktrees')
55
+ : join(root, 'engineering_memory', 'worktrees', folder);
56
+ }
57
+ function worktreeName(folder, slot, layout) {
58
+ return folder + (layout === 1 ? '-worktree' : '_worktree') + slot;
59
+ }
60
+ function managedPath(root, folder, slot, layout) {
61
+ return join(projectDirectory(root, folder, layout), worktreeName(folder, slot, layout));
62
+ }
45
63
  export class WorktreePool {
46
64
  stateRoot;
47
65
  ownerId;
@@ -53,6 +71,9 @@ export class WorktreePool {
53
71
  startTimes = new Map();
54
72
  ownStart;
55
73
  documents;
74
+ legacyDocuments;
75
+ overrideDocuments;
76
+ overrideProblem;
56
77
  before = new Map();
57
78
  pendingWork;
58
79
  constructor(stateRoot, options = {}) {
@@ -63,6 +84,7 @@ export class WorktreePool {
63
84
  this.now = options.now ?? Date.now;
64
85
  this.alive = options.processAlive ?? processAlive;
65
86
  this.documents = options.documentsRoot;
87
+ this.legacyDocuments = options.legacyDocumentsRoot ?? options.documentsRoot;
66
88
  this.pendingWork = options.pendingWork ?? (async () => false);
67
89
  this.registryPath = join(stateRoot, 'worktree-pool.json');
68
90
  }
@@ -79,8 +101,12 @@ export class WorktreePool {
79
101
  const commonDir = await this.commonDirectory(input.repoRoot);
80
102
  return this.transaction(async (registry) => {
81
103
  if (!registry.projects[input.projectId]) {
82
- const managedRoot = key(join(await this.documentsDirectory(), 'engineeringmemory'));
83
- const orphan = (await this.gitPaths(input.repoRoot)).find((p) => key(p.path).startsWith(managedRoot + (process.platform === 'win32' ? '\\' : '/')));
104
+ const separator = process.platform === 'win32' ? '\\' : '/';
105
+ const bases = [];
106
+ for (const root of await this.scanRoots())
107
+ for (const layout of [1, 2])
108
+ bases.push(key(managedBase(root, layout)) + separator);
109
+ const orphan = (await this.gitPaths(input.repoRoot)).find((p) => bases.some((base) => key(p.path).startsWith(base)));
84
110
  if (orphan)
85
111
  throw refuse('Existing managed Git worktrees have no project index. Run worktree.reconcile before allocating more directories.');
86
112
  }
@@ -107,7 +133,7 @@ export class WorktreePool {
107
133
  if (paths.some((p) => p.branch === entry.branch && key(p.path) !== key(entry.repoRoot)))
108
134
  throw refuse('The recorded branch is checked out at another location. Reconcile the moved worktree before resuming.');
109
135
  if (paths.some((p) => key(p.path) === key(entry.repoRoot))) {
110
- await assertManagedPath(await this.documentsDirectory(), entry.repoRoot, true);
136
+ await assertManagedPath(entry.root ?? (await this.legacyDocumentsDirectory()), entry.repoRoot, true);
111
137
  if (await pathExists(entry.repoRoot))
112
138
  throw refuse('The missing worktree reappeared; reconcile before recovery.');
113
139
  await this.command(input.repoRoot, ['worktree', 'remove', entry.repoRoot]);
@@ -151,8 +177,10 @@ export class WorktreePool {
151
177
  if (checkout)
152
178
  throw refuse('This branch already has a checkout. Resume its task instead of creating or resetting the branch.');
153
179
  let reusable;
180
+ const activeRoot = key(await this.documentsDirectory());
181
+ const legacyRoot = await this.legacyDocumentsDirectory();
154
182
  for (const candidate of project.entries
155
- .filter((e) => e.managed && e.commonDir === commonDir)
183
+ .filter((e) => e.managed && e.commonDir === commonDir && key(e.root ?? legacyRoot) === activeRoot)
156
184
  .sort((a, b) => a.slot - b.slot)) {
157
185
  if (candidate.pendingDelivery ||
158
186
  ['creating', 'quarantined'].includes(candidate.phase) ||
@@ -168,7 +196,7 @@ export class WorktreePool {
168
196
  if (entry) {
169
197
  const old = structuredClone(entry);
170
198
  await this.releaseReservation(old);
171
- const next = await this.entry(input, commonDir, old.repoRoot, old.slot, true);
199
+ const next = await this.entry(input, commonDir, old.repoRoot, old.slot, true, old.root ?? (await this.legacyDocumentsDirectory()), old.layout ?? 1);
172
200
  project.entries[project.entries.indexOf(entry)] = next;
173
201
  entry = next;
174
202
  await this.save(registry);
@@ -179,14 +207,15 @@ export class WorktreePool {
179
207
  if (project.entries.filter((e) => e.managed).length >= policy.maxWorktrees)
180
208
  throw refuse('The managed worktree limit is full. worktree.list explains protected directories; finish delivery or safely release a clean task. No files were deleted.');
181
209
  let slot = 1;
182
- const root = join(await this.documentsDirectory(), 'engineeringmemory', project.folder, 'worktrees');
183
- await ensureManagedDirectory(await this.documentsDirectory(), root);
210
+ const active = await this.documentsDirectory();
211
+ const root = projectDirectory(active, project.folder, 2);
212
+ await ensureManagedDirectory(active, root);
184
213
  while (project.entries.some((e) => e.slot === slot) ||
185
- (await pathExists(join(root, project.folder + '-worktree' + slot))))
214
+ (await pathExists(join(root, worktreeName(project.folder, slot, 2)))))
186
215
  slot++;
187
- const path = join(root, project.folder + '-worktree' + slot);
216
+ const path = join(root, worktreeName(project.folder, slot, 2));
188
217
  await assertManagedPath(root, path, true);
189
- entry = await this.entry(input, commonDir, path, slot, true);
218
+ entry = await this.entry(input, commonDir, path, slot, true, await this.documentsDirectory(), 2);
190
219
  project.entries.push(entry);
191
220
  await this.save(registry);
192
221
  await this.git.createWorktree(input.repoRoot, input.branch, path, input.baseCommit);
@@ -210,6 +239,12 @@ export class WorktreePool {
210
239
  .flatMap((p) => p.entries)
211
240
  .find((e) => e.taskId === taskId && e.phase !== 'released') ?? null));
212
241
  }
242
+ owns(entry) {
243
+ return (entry.phase !== 'released' &&
244
+ entry.owner !== null &&
245
+ entry.owner.id === this.ownerId &&
246
+ entry.owner.pid === process.pid);
247
+ }
213
248
  async forPath(projectId, repoRoot) {
214
249
  return this.transaction(async (registry) => structuredClone(registry.projects[projectId]?.entries.find((e) => key(e.repoRoot) === key(repoRoot)) ??
215
250
  null));
@@ -326,24 +361,19 @@ export class WorktreePool {
326
361
  let project = registry.projects[projectId];
327
362
  const locations = await this.gitPaths(repoRoot);
328
363
  const candidates = [];
329
- const managedRoot = join(await this.documentsDirectory(), 'engineeringmemory');
364
+ const managedRoots = await this.scanRoots();
330
365
  for (const location of locations) {
331
366
  if (project?.entries.some((entry) => key(entry.repoRoot) === key(location.path)))
332
367
  continue;
333
368
  if (!(await pathExists(location.path)))
334
369
  continue;
335
370
  const parent = dirname(location.path);
336
- const folder = dirname(parent).split(/[\\/]/).pop();
337
371
  const name = location.path.split(/[\\/]/).pop();
338
- if (key(parent) !== key(join(managedRoot, folder, 'worktrees')))
339
- continue;
340
- const prefix = folder + '-worktree';
341
- const slot = Number(name.slice(prefix.length));
342
- if (!name.startsWith(prefix) ||
343
- !Number.isSafeInteger(slot) ||
344
- slot < 1 ||
345
- name !== prefix + slot)
372
+ const placement = this.placement(managedRoots, parent, name);
373
+ if (!placement)
346
374
  continue;
375
+ const { documents, folder, slot, layout } = placement;
376
+ const managedRoot = managedBase(documents, layout);
347
377
  try {
348
378
  await assertManagedPath(managedRoot, location.path, false);
349
379
  if ((await this.commonDirectory(location.path)) !== commonDir)
@@ -357,7 +387,7 @@ export class WorktreePool {
357
387
  continue;
358
388
  if (Object.entries(registry.projects).some(([id, value]) => id !== projectId && key(value.folder) === key(folder)))
359
389
  continue;
360
- candidates.push({ path: location.path, folder, slot });
390
+ candidates.push({ path: location.path, folder, slot, root: documents, layout });
361
391
  }
362
392
  if (!project) {
363
393
  const folders = [...new Set(candidates.map((candidate) => candidate.folder))];
@@ -405,6 +435,8 @@ export class WorktreePool {
405
435
  repoRoot: await canonicalPath(location.path),
406
436
  slot: location.slot,
407
437
  managed: true,
438
+ root: resolve(location.root),
439
+ layout: location.layout,
408
440
  externalTaskId: reservation?.decision.externalTaskId ??
409
441
  'unregistered-' + sha256(key(location.path)).slice(0, 20),
410
442
  taskId: reservation?.decision.taskId,
@@ -451,9 +483,11 @@ export class WorktreePool {
451
483
  });
452
484
  return discoverOnly ? [] : this.list(projectId, policy);
453
485
  }
454
- async entry(input, commonDir, repoRoot, slot, managed) {
486
+ async entry(input, commonDir, repoRoot, slot, managed, root, layout) {
455
487
  const generation = randomUUID();
456
488
  return {
489
+ ...(root ? { root: resolve(root) } : {}),
490
+ ...(layout ? { layout } : {}),
457
491
  projectId: input.projectId,
458
492
  repoFingerprint: input.repoFingerprint,
459
493
  commonDir,
@@ -626,9 +660,9 @@ export class WorktreePool {
626
660
  .slice(0, 70);
627
661
  if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(folder))
628
662
  folder = 'project-' + folder;
629
- const root = join(await this.documentsDirectory(), 'engineeringmemory');
630
663
  if (Object.values(registry.projects).some((p) => key(p.folder) === key(folder)) ||
631
- (await pathExists(join(root, folder))))
664
+ (await pathExists(projectDirectory(await this.documentsDirectory(), folder, 2))) ||
665
+ (await pathExists(join(managedBase(await this.legacyDocumentsDirectory(), 1), folder))))
632
666
  folder += '-' + sha256(projectId).slice(0, 8);
633
667
  if (Object.values(registry.projects).some((p) => key(p.folder) === key(folder)))
634
668
  throw refuse('The managed project folder identity conflicts with another project.');
@@ -697,9 +731,87 @@ export class WorktreePool {
697
731
  return null;
698
732
  }
699
733
  }
734
+ async managedRoot() {
735
+ const root = managedBase(await this.documentsDirectory(), 2);
736
+ return {
737
+ root,
738
+ override: join(this.stateRoot, 'worktree-root.json'),
739
+ synced: /onedrive|dropbox|icloud|mobile documents|google\s?drive/i.test(root),
740
+ ...(this.overrideProblem ? { problem: this.overrideProblem } : {}),
741
+ };
742
+ }
700
743
  async documentsDirectory() {
744
+ if (this.overrideDocuments)
745
+ return this.overrideDocuments;
746
+ const overridePath = join(this.stateRoot, 'worktree-root.json');
747
+ this.overrideProblem = undefined;
748
+ let override = null;
749
+ try {
750
+ override = await readJson(overridePath);
751
+ }
752
+ catch (error) {
753
+ this.overrideProblem = `${overridePath} could not be read and is ignored: ${error instanceof Error ? error.message : String(error)}`;
754
+ }
755
+ if (override && typeof override.root === 'string' && isAbsolute(override.root)) {
756
+ try {
757
+ await mkdir(override.root, { recursive: true }).catch((error) => {
758
+ if (!['EEXIST', 'EPERM'].includes(error.code ?? ''))
759
+ throw error;
760
+ });
761
+ this.overrideDocuments = resolve(override.root);
762
+ return this.overrideDocuments;
763
+ }
764
+ catch (error) {
765
+ this.overrideProblem = `${overridePath} names ${override.root}, which could not be created and is ignored: ${error instanceof Error ? error.message : String(error)}`;
766
+ }
767
+ }
768
+ else if (override) {
769
+ this.overrideProblem = `${overridePath} must contain {"root": "<absolute directory>"} and is ignored.`;
770
+ }
771
+ return this.defaultDocumentsDirectory();
772
+ }
773
+ async defaultDocumentsDirectory() {
701
774
  if (this.documents)
702
775
  return resolve(this.documents);
776
+ this.documents =
777
+ process.platform === 'win32' ? (process.env.SystemDrive ?? 'C:') + '\\' : homedir();
778
+ return resolve(this.documents);
779
+ }
780
+ async scanRoots() {
781
+ return [
782
+ ...new Map([
783
+ await this.documentsDirectory(),
784
+ await this.defaultDocumentsDirectory(),
785
+ await this.legacyDocumentsDirectory(),
786
+ ].map((root) => [key(root), root])).values(),
787
+ ];
788
+ }
789
+ placement(roots, parent, name) {
790
+ for (const documents of roots) {
791
+ const parentName = parent.split(/[\\/]/).pop() ?? '';
792
+ const grandParent = dirname(parent);
793
+ const legacyFolder = grandParent.split(/[\\/]/).pop() ?? '';
794
+ const attempts = [
795
+ { folder: parentName, layout: 2 },
796
+ { folder: legacyFolder, layout: 1 },
797
+ ];
798
+ for (const { folder, layout } of attempts) {
799
+ if (!folder || key(parent) !== key(projectDirectory(documents, folder, layout)))
800
+ continue;
801
+ const prefix = folder + (layout === 1 ? '-worktree' : '_worktree');
802
+ const slot = Number(name.slice(prefix.length));
803
+ if (name.startsWith(prefix) &&
804
+ Number.isSafeInteger(slot) &&
805
+ slot >= 1 &&
806
+ name === prefix + slot)
807
+ return { documents, folder, slot, layout };
808
+ }
809
+ }
810
+ return null;
811
+ }
812
+ async legacyDocumentsDirectory() {
813
+ if (this.legacyDocuments)
814
+ return resolve(this.legacyDocuments);
703
815
  if (process.platform === 'win32') {
704
816
  const result = await this.runner.run('powershell.exe', [
705
817
  '-NoProfile',
@@ -709,19 +821,18 @@ export class WorktreePool {
709
821
  ], {});
710
822
  if (result.exitCode !== 0 || !result.stdout.trim())
711
823
  throw refuse('The Windows Documents folder could not be resolved.');
712
- this.documents = result.stdout.trim();
824
+ this.legacyDocuments = result.stdout.trim();
713
825
  }
714
826
  else if (process.platform === 'linux') {
715
827
  const result = await this.runner.run('xdg-user-dir', ['DOCUMENTS'], {}).catch(() => null);
716
- this.documents =
828
+ this.legacyDocuments =
717
829
  result?.exitCode === 0 && result.stdout.trim()
718
830
  ? result.stdout.trim()
719
831
  : join(homedir(), 'Documents');
720
832
  }
721
833
  else
722
- this.documents = join(homedir(), 'Documents');
723
- await mkdir(this.documents, { recursive: true });
724
- return resolve(this.documents);
834
+ this.legacyDocuments = join(homedir(), 'Documents');
835
+ return resolve(this.legacyDocuments);
725
836
  }
726
837
  async transaction(work) {
727
838
  await mkdir(this.stateRoot, { recursive: true });
@@ -764,8 +875,10 @@ export class WorktreePool {
764
875
  throw refuse('The registry contains conflicting task ownership. Restore a validated backup with worktree.reconcile.');
765
876
  paths.add(key(entry.repoRoot));
766
877
  if (entry.managed) {
767
- const documents = await this.documentsDirectory();
768
- const expected = join(documents, 'engineeringmemory', project.folder, 'worktrees', project.folder + '-worktree' + entry.slot);
878
+ const documents = entry.root ?? (await this.legacyDocumentsDirectory());
879
+ const expected = entry.slot
880
+ ? managedPath(documents, project.folder, entry.slot, entry.layout ?? 1)
881
+ : '';
769
882
  if (!entry.slot || key(entry.repoRoot) !== key(expected))
770
883
  throw refuse('A managed worktree path does not match its project Documents directory. Reconcile before continuing.');
771
884
  await assertManagedPath(documents, entry.repoRoot, true);
@@ -118,7 +118,10 @@ export async function assertManagedPath(root, target, allowMissingTarget) {
118
118
  export async function ensureManagedDirectory(root, directory) {
119
119
  const resolvedRoot = resolve(root);
120
120
  const resolvedDirectory = ensureWithinRoot(resolvedRoot, directory);
121
- await mkdir(resolvedRoot, { recursive: true });
121
+ await mkdir(resolvedRoot, { recursive: true }).catch((error) => {
122
+ if (!isNodeError(error) || !['EEXIST', 'EPERM'].includes(error.code ?? ''))
123
+ throw error;
124
+ });
122
125
  const rootStat = await lstat(resolvedRoot);
123
126
  if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
124
127
  throw new Error(`Managed root must be a real directory: ${resolvedRoot}`);
@@ -14,7 +14,7 @@ Before a new write or scaffold task, read `session.entry` and the project Git pr
14
14
 
15
15
  Call `task.branch` with the stable `externalTaskId` and `repoRoot`. Its native forms show configured branch names, the current commit, another branch, and the explicit keep-current option. New branches always use a separate managed worktree. Configured remote bases are fetched and their exact commits pinned; after a fetch failure select an explicit local source through a new native decision, never silently use a cached branch. Existing task decisions survive retries and restarts. Resume their recorded branch rather than recreating or resetting it.
16
16
 
17
- Use the returned `repoRoot` for **every** file read/write, terminal, context, validation and Git/delivery operation. The user-local pool is shared by Codex and Claude. Managed directories live in the OS Documents folder under `engineeringmemory/<stable-project-folder>/worktrees/<folder>-worktreeN`; do not supply arbitrary `worktreePath` values or create ad-hoc siblings. Independent clones cannot reuse each other's worktrees. `worktree.list` explains which slots are active, inactive but protected, or safely reusable. The versioned backend policy defaults to 50 directories per project/computer, 30-second heartbeats and 10-minute inactivity. Protected inactive directories still count toward the limit. Only a global admin changes `worktree.set_policy`; it is not an environment setting.
17
+ Use the returned `repoRoot` for **every** file read/write, terminal, context, validation and Git/delivery operation. The user-local pool is shared by Codex and Claude. Managed directories live under `engineering_memory/worktrees/<stable-project-folder>/<folder>_worktreeN`, at the root of the system drive on Windows (`C:\engineering_memory\worktrees\...`) and in the home directory elsewhere, or under the absolute directory named by `worktree-root.json` in the user-level API state directory when that file exists; `worktree.list` reports the effective root, and worktrees created by earlier clients under the Documents folder keep working where they are. Do not supply arbitrary `worktreePath` values or create ad-hoc siblings. Independent clones cannot reuse each other's worktrees. `worktree.list` explains which slots are active, inactive but protected, or safely reusable. The versioned backend policy defaults to 50 directories per project/computer, 30-second heartbeats and 10-minute inactivity. Protected inactive directories still count toward the limit. Only a global admin changes `worktree.set_policy`; it is not an environment setting.
18
18
 
19
19
  Renew `task.heartbeat` using the exact returned task, path and ownership generation during actual work and at approximately the cached heartbeat interval during long local commands. The bridge renews while its own task operation is running. Never run a perpetual heartbeat for an idle chat or MCP process. Inactivity never authorizes takeover of a live owner or deletion of files. Before writing after interruption, use `session.resume`; follow any returned worktree redirect and resume there. A stale generation cannot renew, release, verify or commit another owner's work.
20
20