engineering-memory 1.11.9 → 1.11.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dispatcher/sections.mjs +7 -2
  2. package/install/codex-approval.mjs +457 -0
  3. package/install/files.mjs +5 -1
  4. package/install/installer.mjs +24 -1
  5. package/package.json +1 -1
  6. package/runtime/build.json +1 -1
  7. package/runtime/dist/src/config.js +2 -0
  8. package/runtime/dist/src/git/git-inspector.js +114 -4
  9. package/runtime/dist/src/git/verification-gate.js +30 -17
  10. package/runtime/dist/src/mcp/onboarding-tools.js +110 -16
  11. package/runtime/dist/src/mcp/questionnaire-tools.js +99 -15
  12. package/runtime/dist/src/mcp/tool-annotations.js +112 -0
  13. package/runtime/dist/src/mcp/tool-definitions.js +150 -29
  14. package/runtime/dist/src/mcp/worktree-tools.js +395 -236
  15. package/runtime/dist/src/project/repository.js +26 -8
  16. package/runtime/dist/src/runtime/active-context-store.js +3 -0
  17. package/runtime/dist/src/runtime/api-client.js +1 -0
  18. package/runtime/dist/src/runtime/branch-preferences.js +0 -19
  19. package/runtime/dist/src/runtime/bridge-service.js +1320 -246
  20. package/runtime/dist/src/runtime/offline-outbox.js +33 -158
  21. package/runtime/dist/src/runtime/phase-timer.js +26 -0
  22. package/runtime/dist/src/runtime/privacy-detector.js +263 -0
  23. package/runtime/dist/src/runtime/questionnaire-store.js +210 -49
  24. package/runtime/dist/src/runtime/recovery-error.js +3 -1
  25. package/runtime/dist/src/runtime/runtime-entry.js +8 -0
  26. package/runtime/dist/src/runtime/runtime-host.js +1 -3
  27. package/runtime/dist/src/runtime/task-branch-store.js +17 -1
  28. package/runtime/dist/src/runtime/task-start.js +281 -0
  29. package/runtime/dist/src/runtime/worktree-pool.js +374 -26
  30. package/runtime/dist/src/utilities/process.js +1 -0
  31. package/skill/SKILL.md +3 -3
  32. package/skill/references/lifecycle.md +59 -19
  33. package/skill/references/memory-updates.md +9 -3
  34. package/skill/references/project-onboarding.md +19 -2
  35. package/skill/references/questionnaires.md +86 -15
@@ -39,6 +39,9 @@ const allocationSchema = z.object({
39
39
  reason: z.string().optional(),
40
40
  root: z.string().optional(),
41
41
  layout: z.union([z.literal(1), z.literal(2)]).optional(),
42
+ deliveryOutcome: z.enum(['delivered', 'cancelled']).optional(),
43
+ cancelledAt: z.string().optional(),
44
+ preservedRef: z.string().optional(),
42
45
  });
43
46
  const registrySchema = z.object({
44
47
  schemaVersion: z.literal(1),
@@ -149,11 +152,11 @@ export class WorktreePool {
149
152
  throw refuse('HEAD changed after the current-branch choice. Review a fresh task.branch decision.');
150
153
  const root = await this.git.findRoot(input.repoRoot);
151
154
  entry = project.entries.find((e) => key(e.repoRoot) === key(root));
152
- if (entry && entry.phase !== 'released')
153
- throw refuse('This checkout is reserved for another task. Choose a new branch in an isolated worktree.');
154
- const reservation = await this.git.branchStore(root).read();
155
- if (reservation && reservation.decision.externalTaskId !== input.externalTaskId)
156
- throw refuse('A legacy task reservation owns this checkout. Resume it or choose an isolated worktree.');
155
+ const holder = entry && entry.phase !== 'released'
156
+ ? entry.externalTaskId
157
+ : (await this.git.branchStore(root).read())?.decision.externalTaskId;
158
+ if (holder && holder !== input.externalTaskId)
159
+ throw new BridgeRecoveryError(`Task ${holder} holds this checkout. Continue that task, or call task.branch with a new decisionAttempt and choose a new branch: its question offers a separate worktree, and this folder too when it is clean and ${holder} can move out.`, 'task.branch');
157
160
  entry = await this.entry(input, commonDir, root, null, false);
158
161
  project.entries = project.entries.filter((e) => key(e.repoRoot) !== key(root));
159
162
  project.entries.push(entry);
@@ -176,11 +179,41 @@ export class WorktreePool {
176
179
  const checkout = (await this.gitPaths(input.repoRoot)).find((p) => p.branch === input.branch);
177
180
  if (checkout)
178
181
  throw refuse('This branch already has a checkout. Resume its task instead of creating or resetting the branch.');
182
+ if (input.inPlace) {
183
+ const root = await this.git.findRoot(input.repoRoot);
184
+ const held = project.entries.find((e) => key(e.repoRoot) === key(root) && e.phase !== 'released');
185
+ if (held)
186
+ throw new BridgeRecoveryError(`Task ${held.externalTaskId} still holds this folder in the worktree pool. Call task.branch with a new decisionAttempt and open the new task in a separate worktree; the folder stayed on its branch.`, 'task.branch');
187
+ entry = await this.entry(input, commonDir, root, null, false);
188
+ const reasons = await this.fileProtection(entry, true);
189
+ if (reasons.length)
190
+ throw new BridgeRecoveryError('A new branch cannot start in this folder: ' +
191
+ reasons.join(', ') +
192
+ '. Call task.branch with a new decisionAttempt and open the task in a separate worktree; the folder stayed on its branch.', 'task.branch');
193
+ project.entries = project.entries.filter((e) => key(e.repoRoot) !== key(root));
194
+ project.entries.push(entry);
195
+ await this.save(registry);
196
+ await this.reserve(entry);
197
+ try {
198
+ await this.checkout(entry);
199
+ }
200
+ catch {
201
+ await this.git.branchStore(root).cancelUnopened(entry.projectId, entry.externalTaskId);
202
+ project.entries = project.entries.filter((e) => e !== entry);
203
+ await this.save(registry);
204
+ throw new BridgeRecoveryError('Git could not switch this folder to the new branch, so the folder was left as it was. Call task.branch with a new decisionAttempt and open the task in a separate worktree.', 'task.branch');
205
+ }
206
+ entry.phase = 'working';
207
+ await this.save(registry);
208
+ return structuredClone(entry);
209
+ }
179
210
  let reusable;
180
211
  const activeRoot = key(await this.documentsDirectory());
181
212
  const legacyRoot = await this.legacyDocumentsDirectory();
182
213
  for (const candidate of project.entries
183
- .filter((e) => e.managed && e.commonDir === commonDir && key(e.root ?? legacyRoot) === activeRoot)
214
+ .filter((e) => e.managed &&
215
+ e.commonDir === commonDir &&
216
+ !this.outsideActiveRoot(e, activeRoot, legacyRoot))
184
217
  .sort((a, b) => a.slot - b.slot)) {
185
218
  if (candidate.pendingDelivery ||
186
219
  ['creating', 'quarantined'].includes(candidate.phase) ||
@@ -204,13 +237,14 @@ export class WorktreePool {
204
237
  await this.checkout(entry);
205
238
  }
206
239
  else {
207
- if (project.entries.filter((e) => e.managed).length >= policy.maxWorktrees)
240
+ if (project.entries.filter((e) => e.managed &&
241
+ !(e.phase === 'released' && this.outsideActiveRoot(e, activeRoot, legacyRoot))).length >= policy.maxWorktrees)
208
242
  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.');
209
243
  let slot = 1;
210
244
  const active = await this.documentsDirectory();
211
245
  const root = projectDirectory(active, project.folder, 2);
212
246
  await ensureManagedDirectory(active, root);
213
- while (project.entries.some((e) => e.slot === slot) ||
247
+ while (project.entries.some((e) => e.slot === slot && (e.layout ?? 1) === 2 && key(e.root ?? legacyRoot) === activeRoot) ||
214
248
  (await pathExists(join(root, worktreeName(project.folder, slot, 2)))))
215
249
  slot++;
216
250
  const path = join(root, worktreeName(project.folder, slot, 2));
@@ -249,6 +283,10 @@ export class WorktreePool {
249
283
  return this.transaction(async (registry) => structuredClone(registry.projects[projectId]?.entries.find((e) => key(e.repoRoot) === key(repoRoot)) ??
250
284
  null));
251
285
  }
286
+ async forSlug(projectId, externalTaskId) {
287
+ return this.transaction(async (registry) => structuredClone(registry.projects[projectId]?.entries.find((e) => e.externalTaskId === externalTaskId) ??
288
+ null));
289
+ }
252
290
  async validateCommit(projectId, taskId, repoRoot, generation) {
253
291
  return this.transaction(async (registry) => {
254
292
  const entry = registry.projects[projectId]?.entries.find((e) => key(e.repoRoot) === key(repoRoot));
@@ -311,24 +349,146 @@ export class WorktreePool {
311
349
  await this.save(registry);
312
350
  });
313
351
  }
314
- async release(projectId, repoRoot, generation, delivered) {
352
+ async release(projectId, repoRoot, generation, deliveryOutcome) {
315
353
  return this.transaction(async (registry) => {
316
- const entry = this.owned(registry, projectId, repoRoot, generation);
317
- const reasons = await this.fileProtection(entry);
318
- if (reasons.length)
319
- throw refuse('The worktree is protected: ' +
320
- reasons.join(', ') +
321
- '. Finish or preserve the work before release.');
322
- if (entry.pendingDelivery && !delivered)
323
- throw refuse('Delivery is pending. Complete delivery before safely releasing this worktree.');
324
- await this.releaseReservation(entry);
325
- entry.pendingDelivery = false;
326
- entry.phase = 'released';
327
- entry.owner = null;
328
- entry.generation = randomUUID();
354
+ const entry = await this.deliveryOwned(registry, projectId, repoRoot, generation);
355
+ if (entry.pendingDelivery && !deliveryOutcome)
356
+ throw new BridgeRecoveryError('Delivery is pending. Ask the user whether it was delivered, then call worktree.release with deliveryOutcome delivered, or cancelled when they decided not to deliver.', 'worktree.release');
357
+ if (entry.managed) {
358
+ const reasons = await this.fileProtection(entry);
359
+ if (reasons.length)
360
+ throw protectedRefusal(reasons, 'Finish the work, or when the user decided not to deliver it call worktree.release with deliveryOutcome cancelled to save the changes before release.');
361
+ }
362
+ if (deliveryOutcome)
363
+ entry.deliveryOutcome = deliveryOutcome;
364
+ await this.releaseEntry(registry, entry);
365
+ });
366
+ }
367
+ async cancelDelivery(projectId, repoRoot, generation, choice) {
368
+ return this.transaction(async (registry) => {
369
+ const entry = await this.deliveryOwned(registry, projectId, repoRoot, generation);
370
+ if (!entry.pendingDelivery)
371
+ throw new BridgeRecoveryError('This worktree is not waiting for delivery, so there is no delivery to cancel. Release it with worktree.release.', 'worktree.release');
372
+ if (choice === 'keep') {
373
+ entry.deliveryOutcome = 'cancelled';
374
+ entry.cancelledAt = new Date(this.now()).toISOString();
375
+ entry.phase = 'paused';
376
+ entry.pendingDelivery = false;
377
+ entry.owner = null;
378
+ entry.generation = randomUUID();
379
+ await this.save(registry);
380
+ return { choice };
381
+ }
382
+ let preservedRef;
383
+ if (entry.managed) {
384
+ const reasons = (await this.fileProtection(entry)).filter((reason) => choice !== 'save_and_release' || reason !== changedFiles);
385
+ if (reasons.length)
386
+ throw protectedRefusal(reasons, 'Nothing in the folder was changed. Once that is settled call worktree.release with deliveryOutcome cancelled again; it asks about the folder as it is now, and keeping the folder is always possible.');
387
+ if (choice === 'save_and_release') {
388
+ const saved = await this.snapshot(entry);
389
+ preservedRef = entry.preservedRef = saved.ref;
390
+ await this.save(registry);
391
+ await this.cleanSnapshot(entry, saved);
392
+ }
393
+ }
394
+ entry.deliveryOutcome = 'cancelled';
395
+ entry.cancelledAt = new Date(this.now()).toISOString();
396
+ await this.releaseEntry(registry, entry);
397
+ return preservedRef
398
+ ? { choice, preservedRef, restoreCommand: restoreCommand(preservedRef) }
399
+ : { choice };
400
+ });
401
+ }
402
+ async ownedElsewhere(entry) {
403
+ return Boolean(entry.owner && entry.owner.id !== this.ownerId && (await this.ownerAlive(entry.owner)));
404
+ }
405
+ async moveOut(projectId, repoRoot, generation, oid) {
406
+ await this.transaction(async (registry) => {
407
+ const project = registry.projects[projectId];
408
+ const entry = project?.entries.find((e) => key(e.repoRoot) === key(repoRoot));
409
+ if (!project ||
410
+ !entry ||
411
+ entry.managed ||
412
+ entry.phase === 'released' ||
413
+ entry.generation !== generation)
414
+ throw new BridgeRecoveryError('The task recorded in this folder changed after it was looked at. Call task.branch again; its question shows which task holds the folder now.', 'task.branch');
415
+ if (await this.ownedElsewhere(entry))
416
+ throw new BridgeRecoveryError(`Task ${entry.externalTaskId} is still running in another client in this folder. Pause it there with task.pause, or call task.branch again and open the new task in a separate worktree; the folder was left as it is.`, 'task.branch');
417
+ if (entry.pendingDelivery)
418
+ throw new BridgeRecoveryError(`Task ${entry.externalTaskId} closed with delivery still pending in this folder. Resume it here and call worktree.release with deliveryOutcome delivered or cancelled to settle it, or call task.branch again and open the new task in a separate worktree; the folder was left as it is.`, 'worktree.release');
419
+ if (oid)
420
+ await this.git.branchStore(entry.repoRoot).detach({
421
+ projectId,
422
+ externalTaskId: entry.externalTaskId,
423
+ branch: entry.branch,
424
+ ...(entry.taskId ? { taskId: entry.taskId } : {}),
425
+ }, oid);
426
+ else
427
+ await this.releaseReservation(entry);
428
+ project.entries = project.entries.filter((e) => e !== entry);
329
429
  await this.save(registry);
330
430
  });
331
431
  }
432
+ async retire(projectId, repoRoot, path, generation) {
433
+ const commonDir = await this.commonDirectory(repoRoot);
434
+ const activeRoot = key(await this.documentsDirectory());
435
+ const legacyRoot = await this.legacyDocumentsDirectory();
436
+ await this.transaction(async (registry) => {
437
+ const project = registry.projects[projectId];
438
+ const entry = project?.entries.find((e) => key(e.repoRoot) === key(path));
439
+ if (!project ||
440
+ !entry ||
441
+ entry.generation !== generation ||
442
+ entry.phase !== 'released' ||
443
+ entry.commonDir !== commonDir ||
444
+ !this.outsideActiveRoot(entry, activeRoot, legacyRoot))
445
+ throw refuse('This folder is no longer the same retired slot. List the worktrees again before removing it.');
446
+ if (await pathExists(entry.repoRoot)) {
447
+ const reasons = await this.fileProtection(entry);
448
+ if (reasons.length)
449
+ throw refuse('The retired folder is protected: ' +
450
+ reasons.join(', ') +
451
+ '. It stays listed as retired and is never reused; nothing was removed.');
452
+ const removed = await this.runner.run('git', ['worktree', 'remove', entry.repoRoot], {
453
+ cwd: repoRoot,
454
+ });
455
+ if (removed.exitCode !== 0)
456
+ throw refuse('Git refused to remove the retired folder: ' +
457
+ removed.stderr.trim() +
458
+ '. It stays listed as retired and is never reused; nothing was removed.');
459
+ }
460
+ project.entries = project.entries.filter((e) => e !== entry);
461
+ await this.save(registry);
462
+ });
463
+ }
464
+ async forgetUnreadable(projectId, externalTaskId, generation) {
465
+ return this.transaction(async (registry) => {
466
+ const project = registry.projects[projectId];
467
+ const entry = project?.entries.find((e) => e.externalTaskId === externalTaskId && e.generation === generation);
468
+ if (!project || !entry)
469
+ throw refuse('This is no longer the same registry entry for that task. List the checkouts again with worktree.list before forgetting one.');
470
+ if (await this.ownedElsewhere(entry))
471
+ throw refuse('Another live client still owns this task. Stop or pause it there first; worktree.list shows the checkout meanwhile.');
472
+ if (!(await this.unreadable(entry)))
473
+ throw new BridgeRecoveryError('This checkout can still be read as a Git worktree, so it is not forgotten. Settle its delivery and free it with worktree.release.', 'worktree.release');
474
+ project.entries = project.entries.filter((e) => e !== entry);
475
+ await this.save(registry);
476
+ return structuredClone(entry);
477
+ });
478
+ }
479
+ async unreadable(entry) {
480
+ if (!(await pathExists(entry.repoRoot)))
481
+ return true;
482
+ try {
483
+ if (key(await this.git.findRoot(entry.repoRoot)) !== key(await canonicalPath(entry.repoRoot)))
484
+ return true;
485
+ await this.commonDirectory(entry.repoRoot);
486
+ return false;
487
+ }
488
+ catch {
489
+ return true;
490
+ }
491
+ }
332
492
  async releaseUnowned(projectId, repoRoot, generation, sourceCommit) {
333
493
  await this.transaction(async (registry) => {
334
494
  const entry = registry.projects[projectId]?.entries.find((e) => key(e.repoRoot) === key(repoRoot));
@@ -400,6 +560,9 @@ export class WorktreePool {
400
560
  if (!recoveredProject)
401
561
  return;
402
562
  project = recoveredProject;
563
+ const activeRootKey = key(await this.documentsDirectory());
564
+ const legacyRootDir = await this.legacyDocumentsDirectory();
565
+ await this.preloadOwnerStarts(project.entries.filter((entry) => entry.owner).map((entry) => entry.owner.pid));
403
566
  for (const entry of project.entries.filter((e) => !discoverOnly && e.commonDir === commonDir)) {
404
567
  if (entry.owner && entry.owner.id !== this.ownerId && (await this.ownerAlive(entry.owner)))
405
568
  continue;
@@ -456,6 +619,9 @@ export class WorktreePool {
456
619
  for (const entry of project.entries.filter((e) => !discoverOnly && e.commonDir === commonDir)) {
457
620
  if (entry.owner && (await this.ownerAlive(entry.owner)) && entry.owner.id !== this.ownerId)
458
621
  continue;
622
+ if (entry.phase === 'released' &&
623
+ this.outsideActiveRoot(entry, activeRootKey, legacyRootDir))
624
+ continue;
459
625
  try {
460
626
  if (entry.recoveredWithoutReservation &&
461
627
  !entry.owner &&
@@ -548,6 +714,105 @@ export class WorktreePool {
548
714
  throw refuse('Worktree ownership has changed or was paused. Resume the task and use its returned repoRoot before any further write.');
549
715
  return entry;
550
716
  }
717
+ async deliveryOwned(registry, projectId, repoRoot, generation) {
718
+ const entry = registry.projects[projectId]?.entries.find((e) => key(e.repoRoot) === key(repoRoot));
719
+ if (!entry ||
720
+ entry.phase !== 'delivery' ||
721
+ entry.generation !== generation ||
722
+ entry.owner?.id === this.ownerId)
723
+ return this.owned(registry, projectId, repoRoot, generation);
724
+ await this.requireTakeover(entry);
725
+ await this.assertIdentity(entry);
726
+ entry.owner = {
727
+ id: this.ownerId,
728
+ pid: process.pid,
729
+ generation,
730
+ ...((await this.ownerStart()) ? { startedAt: (await this.ownerStart()) } : {}),
731
+ };
732
+ return entry;
733
+ }
734
+ async releaseEntry(registry, entry) {
735
+ await this.releaseReservation(entry);
736
+ if (entry.managed) {
737
+ entry.pendingDelivery = false;
738
+ entry.phase = 'released';
739
+ entry.owner = null;
740
+ entry.generation = randomUUID();
741
+ }
742
+ else {
743
+ const project = registry.projects[entry.projectId];
744
+ project.entries = project.entries.filter((e) => e !== entry);
745
+ }
746
+ await this.save(registry);
747
+ }
748
+ async snapshot(entry) {
749
+ const gitDir = await this.command(entry.repoRoot, ['rev-parse', '--absolute-git-dir']);
750
+ const saved = 'refs/engineering-memory/preserved/' +
751
+ entry.externalTaskId.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+/, '') +
752
+ '/' +
753
+ entry.generation;
754
+ let ref = saved;
755
+ for (let attempt = 2; (await this.runner.run('git', ['rev-parse', '--verify', '--quiet', ref], {
756
+ cwd: entry.repoRoot,
757
+ })).exitCode === 0; attempt++)
758
+ ref = saved + '-' + attempt;
759
+ const index = join(gitDir, 'engineering-memory-preserve-' + randomUUID());
760
+ const env = {
761
+ GIT_INDEX_FILE: index,
762
+ GIT_AUTHOR_NAME: 'Engineering Memory',
763
+ GIT_AUTHOR_EMAIL: 'engineering-memory@example.invalid',
764
+ GIT_COMMITTER_NAME: 'Engineering Memory',
765
+ GIT_COMMITTER_EMAIL: 'engineering-memory@example.invalid',
766
+ };
767
+ const git = async (args, options = {}) => {
768
+ const result = await this.runner.run('git', args, { cwd: entry.repoRoot, ...options });
769
+ if (result.exitCode !== 0)
770
+ throw new BridgeRecoveryError('The changes could not be saved (' +
771
+ (result.stderr.trim() || 'git ' + args[0]) +
772
+ '). The folder was left as it was: keep it, or fix the Git error and call worktree.release with deliveryOutcome cancelled again.', 'worktree.release');
773
+ return result.stdout;
774
+ };
775
+ let untracked;
776
+ try {
777
+ await git(['read-tree', 'HEAD'], { env });
778
+ await git(['add', '-A', '--', '.'], { env });
779
+ const tree = (await git(['write-tree'], { env })).trim();
780
+ const commit = (await git([
781
+ 'commit-tree',
782
+ tree,
783
+ '-p',
784
+ 'HEAD',
785
+ '-m',
786
+ 'Changes kept when delivery of ' + entry.externalTaskId + ' was cancelled',
787
+ ], { env })).trim();
788
+ await git(['diff', '--quiet', commit, '--'], { env });
789
+ untracked = (await git(['ls-files', '--others', '--exclude-standard', '-z']))
790
+ .split('\0')
791
+ .filter(Boolean);
792
+ const snapshot = new Set((await git(['ls-tree', '-r', '--name-only', '-z', commit])).split('\0').filter(Boolean));
793
+ if (untracked.some((path) => !snapshot.has(path)))
794
+ throw new BridgeRecoveryError('Not every untracked file reached the saved changes. Keep the folder; nothing was changed.', 'worktree.release');
795
+ await git(['update-ref', ref, commit, '0'.repeat(commit.length)]);
796
+ }
797
+ finally {
798
+ await removeFile(index);
799
+ }
800
+ return { ref, untracked };
801
+ }
802
+ async cleanSnapshot(entry, saved) {
803
+ const refused = (problem) => new BridgeRecoveryError(`The changes are saved in ${saved.ref}, but ${problem}. Nothing is lost: \`${restoreCommand(saved.ref)}\` brings them back. Call worktree.release with deliveryOutcome cancelled again; it asks about the folder as it is now.`, 'worktree.release');
804
+ const clean = async (args) => {
805
+ const result = await this.runner.run('git', args, { cwd: entry.repoRoot });
806
+ if (result.exitCode !== 0)
807
+ throw refused(`Git could not clean the folder (${result.stderr.trim() || 'git ' + args[0]})`);
808
+ };
809
+ await clean(['restore', '--source=HEAD', '--staged', '--worktree', '--', '.']);
810
+ for (let i = 0; i < saved.untracked.length; i += 100)
811
+ await clean(['clean', '-f', '-d', '--', ...saved.untracked.slice(i, i + 100)]);
812
+ const reasons = await this.fileProtection(entry);
813
+ if (reasons.length)
814
+ throw refused('the folder is still protected: ' + reasons.join(', '));
815
+ }
551
816
  async reserve(entry) {
552
817
  await this.git.branchStore(entry.repoRoot).reserve({
553
818
  projectId: entry.projectId,
@@ -599,12 +864,12 @@ export class WorktreePool {
599
864
  }
600
865
  async fileProtection(entry, allowBranchChange = false) {
601
866
  if (!(await pathExists(entry.repoRoot)))
602
- return ['worktree directory missing'];
867
+ return [missingDirectory];
603
868
  try {
604
869
  await this.assertIdentity(entry, allowBranchChange);
605
870
  }
606
871
  catch {
607
- return ['Git identity or reservation changed or unreadable; reconcile this checkout'];
872
+ return [(await this.unreadable(entry)) ? unreadableGit : changedIdentity];
608
873
  }
609
874
  const status = await this.runner.run('git', ['status', '--porcelain=v1', '--untracked-files=all'], { cwd: entry.repoRoot });
610
875
  const reasons = [];
@@ -614,9 +879,9 @@ export class WorktreePool {
614
879
  reservation.decision.externalTaskId !== entry.externalTaskId))
615
880
  reasons.push('another client or legacy task reserved this checkout');
616
881
  if (await this.pendingWork(entry))
617
- reasons.push('task delivery or intent pending');
882
+ reasons.push(pendingTaskWork);
618
883
  if (status.exitCode !== 0 || status.stdout.trim())
619
- reasons.push('uncommitted, staged or untracked files');
884
+ reasons.push(changedFiles);
620
885
  const gitDir = await this.command(entry.repoRoot, ['rev-parse', '--absolute-git-dir']);
621
886
  for (const marker of [
622
887
  'MERGE_HEAD',
@@ -632,12 +897,32 @@ export class WorktreePool {
632
897
  reasons.push('unfinished Git operation: ' + marker);
633
898
  return reasons;
634
899
  }
900
+ outsideActiveRoot(entry, activeRoot, legacyRoot) {
901
+ return entry.managed && key(entry.root ?? legacyRoot) !== activeRoot;
902
+ }
635
903
  async view(entry, policy) {
904
+ const activeRoot = await this.documentsDirectory();
905
+ const outside = this.outsideActiveRoot(entry, key(activeRoot), await this.legacyDocumentsDirectory());
906
+ if (outside && entry.phase === 'released')
907
+ return {
908
+ ...structuredClone(entry),
909
+ activity: 'retired',
910
+ reasons: [
911
+ 'outside the managed root ' + managedBase(activeRoot, 2) + '; not reused',
912
+ ...((await pathExists(entry.repoRoot)) ? [] : [missingDirectory]),
913
+ ],
914
+ };
636
915
  const reasons = await this.fileProtection(entry);
916
+ if (outside)
917
+ reasons.push('legacy location: retires after its current task');
918
+ if (!entry.managed && entry.phase !== 'released')
919
+ reasons.push('the user’s own folder: the pool never reuses it');
637
920
  if (entry.phase === 'creating' || entry.phase === 'quarantined')
638
921
  reasons.push(entry.reason ?? 'allocation requires reconciliation');
639
922
  if (entry.pendingDelivery)
640
923
  reasons.push('delivery pending');
924
+ if (entry.deliveryOutcome === 'cancelled' && reasons.includes(changedFiles))
925
+ reasons.push('changes kept after cancelled delivery');
641
926
  if (entry.owner && (await this.ownerAlive(entry.owner)))
642
927
  reasons.push('owner process still running');
643
928
  const age = this.now() - entry.lastActivityAt;
@@ -707,6 +992,53 @@ export class WorktreePool {
707
992
  }
708
993
  return !current || current === owner.startedAt;
709
994
  }
995
+ async preloadOwnerStarts(pids) {
996
+ const now = Date.now();
997
+ const candidates = [...new Set(pids)].filter((pid) => {
998
+ if (!Number.isSafeInteger(pid) || pid <= 0 || !this.alive(pid))
999
+ return false;
1000
+ const cached = this.startTimes.get(pid);
1001
+ const age = cached ? now - cached.checkedAt : Infinity;
1002
+ return !cached || age < 0 || age > 15000;
1003
+ });
1004
+ if (!candidates.length || process.platform === 'linux')
1005
+ return;
1006
+ const found = new Map();
1007
+ try {
1008
+ if (process.platform === 'win32') {
1009
+ const result = await this.runner.run('powershell.exe', [
1010
+ '-NoProfile',
1011
+ '-NonInteractive',
1012
+ '-Command',
1013
+ 'Get-Process -Id ' +
1014
+ candidates.join(',') +
1015
+ ' -ErrorAction SilentlyContinue | ForEach-Object { "$($_.Id)=$($_.StartTime.ToUniversalTime().Ticks)" }',
1016
+ ], {});
1017
+ if (result.exitCode === 0) {
1018
+ for (const line of result.stdout.split(/\r?\n/)) {
1019
+ const match = /^(\d+)=(\d+)$/.exec(line.trim());
1020
+ if (match)
1021
+ found.set(Number(match[1]), match[2]);
1022
+ }
1023
+ }
1024
+ }
1025
+ else {
1026
+ const result = await this.runner.run('ps', ['-o', 'pid=,lstart=', '-p', candidates.join(',')], {});
1027
+ if (result.exitCode === 0) {
1028
+ for (const line of result.stdout.split(/\r?\n/)) {
1029
+ const match = /^\s*(\d+)\s+(.+)$/.exec(line.replace(/\r$/, ''));
1030
+ if (match)
1031
+ found.set(Number(match[1]), match[2].trim());
1032
+ }
1033
+ }
1034
+ }
1035
+ }
1036
+ catch {
1037
+ return;
1038
+ }
1039
+ for (const pid of candidates)
1040
+ this.startTimes.set(pid, { checkedAt: now, value: found.get(pid) ?? null });
1041
+ }
710
1042
  async processStart(pid) {
711
1043
  if (!Number.isSafeInteger(pid) || pid <= 0)
712
1044
  return null;
@@ -1012,6 +1344,22 @@ function processAlive(pid) {
1012
1344
  return error.code === 'EPERM';
1013
1345
  }
1014
1346
  }
1347
+ const changedFiles = 'uncommitted, staged or untracked files';
1348
+ const pendingTaskWork = 'task delivery or intent pending';
1349
+ const missingDirectory = 'worktree directory missing';
1350
+ const changedIdentity = 'Git identity or reservation changed; reconcile this checkout';
1351
+ const unreadableGit = 'Git metadata missing or unreadable; this folder is no longer a checkout';
1352
+ export function unreadableWorktree(view) {
1353
+ return view.reasons.includes(missingDirectory) || view.reasons.includes(unreadableGit);
1354
+ }
1355
+ function protectedRefusal(reasons, next) {
1356
+ if (reasons.every((reason) => reason === pendingTaskWork))
1357
+ return new BridgeRecoveryError('Engineering Memory still holds an undelivered record for this task: a checkpoint, correction or close waiting in its local outbox, or an unfinished verification or close. The folder was left as it is. Settle it with task.resolve_pending_delivery (session.resume finishes an interrupted verification or close), then call worktree.release again.', 'task.resolve_pending_delivery');
1358
+ return new BridgeRecoveryError('The worktree is protected: ' + reasons.join(', ') + '. ' + next, 'worktree.release');
1359
+ }
1360
+ function restoreCommand(ref) {
1361
+ return `git restore --source=${ref} --worktree -- .`;
1362
+ }
1015
1363
  function refuse(message) {
1016
1364
  return new BridgeRecoveryError(message, 'worktree.reconcile');
1017
1365
  }
@@ -4,6 +4,7 @@ export class NativeCommandRunner {
4
4
  return await new Promise((resolvePromise, reject) => {
5
5
  const child = spawn(command, args, {
6
6
  cwd: options.cwd,
7
+ ...(options.env ? { env: { ...process.env, ...options.env } } : {}),
7
8
  shell: false,
8
9
  windowsHide: true,
9
10
  stdio: ['pipe', 'pipe', 'pipe'],
package/skill/SKILL.md CHANGED
@@ -25,9 +25,9 @@ Do not block independent task work on `memory.propose_revision` drafting, submis
25
25
  6. Use the returned context pack as the engineering authority for the task. Use `memory.query` only for targeted missing context, and `memory.history` to read why the records you are changing became what they are before you design against them.
26
26
  7. Record phase, correction, validation, and handoff checkpoints at the required moments. Declare the task's discretionary decisions on the validation-before checkpoint, and when a task repeats a shape, have the first unit reviewed before writing the rest.
27
27
  8. For a write task, reconcile every changed screen and component. Propose revisions when semantics changed; otherwise record an explicit no-semantic-memory-change reconciliation.
28
- 9. Before validation, read the changed code back against the rules that govern it and record `task.self_review`. Verification refuses without a review of the current diff, and any later edit requires reviewing again.
29
- 10. Run `task.verify` before claiming completion. Write tasks verify the exact Git diff, active lease, and structured command-bound validation evidence. Read-only tasks verify that the current Git diff hash still equals the baseline captured at bootstrap, plus the required discovery, validation, handoff, pinned-context, and synchronization evidence. Run `task.close` only after verification succeeds. The pre-commit gate must confirm the closed task online; a local verification receipt is insufficient.
30
- 11. After closing a task, ask the delivery question every time — commit, commit and push, or either of those with a draft or ready pull request onto a base branch the user names — and do only what they choose. Once a pull request exists, check whether it merges cleanly and ask before resolving a conflict.
28
+ 9. Before validation, read each changed file back against the rules `context.prepare_change` returned for it in `governingRules`, and record `task.self_review` with one entry per changed file naming each of those rules as `follows`, `fixed` or `user_accepted_deviation`. A deviation counts only after the user approves it in the rule-deviation question the bridge asks; matching existing code is not an outcome. Verification refuses without a review of the current diff, and any later edit requires reviewing again.
29
+ 10. Run `task.verify` before claiming completion. Write tasks verify the exact Git diff, active lease, and structured command-bound validation evidence. Read-only tasks verify that the current Git diff hash still equals the baseline captured at bootstrap, plus the required discovery, validation, handoff, pinned-context, and synchronization evidence. Run `task.close` only after verification succeeds. Where the pre-commit hook was installed, it confirms the closed task online; a local verification receipt is insufficient. Without the hook nothing checks the commit, so never claim a hook confirmed closure.
30
+ 11. After closing a task, settle the delivery question — commit, commit and push, or either of those with a draft or ready pull request onto a base branch the user names — naming the branch, remote and URL the close returned, and do only what they choose. A choice the user already made in their own message is the answer; do not ask it again. When the host's reviewer denies a push or pull request they chose, never use an Engineering Memory form or a typed phrase to get past it; follow the Delivery section of questionnaires.md. Once a pull request exists, check whether it merges cleanly and ask before resolving a conflict.
31
31
  12. Never commit, push, publish, deploy, approve a permanent memory revision, or overwrite an existing Git hook without explicit user authorization.
32
32
  13. Never store tokens, passwords, client secrets, raw headers, raw payloads, customer data, or PII in tool inputs, journals, memory, logs, or generated documentation.
33
33