engineering-memory 1.11.8 → 1.11.10

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 (39) hide show
  1. package/dispatcher/sections.mjs +7 -3
  2. package/install/codex-approval.mjs +457 -0
  3. package/install/files.mjs +119 -9
  4. package/install/installer.mjs +44 -5
  5. package/install/launcher.mjs +95 -0
  6. package/package.json +1 -1
  7. package/runtime/build.json +1 -1
  8. package/runtime/dist/src/config.js +4 -0
  9. package/runtime/dist/src/git/git-inspector.js +82 -3
  10. package/runtime/dist/src/git/verification-gate.js +30 -17
  11. package/runtime/dist/src/index.js +25 -81
  12. package/runtime/dist/src/mcp/onboarding-tools.js +3 -16
  13. package/runtime/dist/src/mcp/questionnaire-tools.js +99 -15
  14. package/runtime/dist/src/mcp/server.js +14 -7
  15. package/runtime/dist/src/mcp/tool-annotations.js +111 -0
  16. package/runtime/dist/src/mcp/tool-definitions.js +148 -28
  17. package/runtime/dist/src/mcp/tool-host.js +12 -0
  18. package/runtime/dist/src/mcp/worktree-tools.js +323 -236
  19. package/runtime/dist/src/project/repository.js +11 -5
  20. package/runtime/dist/src/runtime/active-context-store.js +3 -0
  21. package/runtime/dist/src/runtime/branch-preferences.js +0 -19
  22. package/runtime/dist/src/runtime/bridge-service.js +1163 -231
  23. package/runtime/dist/src/runtime/create-bridge-service.js +82 -0
  24. package/runtime/dist/src/runtime/offline-outbox.js +33 -158
  25. package/runtime/dist/src/runtime/phase-timer.js +26 -0
  26. package/runtime/dist/src/runtime/privacy-detector.js +263 -0
  27. package/runtime/dist/src/runtime/questionnaire-store.js +210 -49
  28. package/runtime/dist/src/runtime/recovery-error.js +3 -1
  29. package/runtime/dist/src/runtime/runtime-entry.js +24 -0
  30. package/runtime/dist/src/runtime/runtime-host.js +263 -0
  31. package/runtime/dist/src/runtime/task-branch-store.js +17 -1
  32. package/runtime/dist/src/runtime/task-start.js +281 -0
  33. package/runtime/dist/src/runtime/worktree-pool.js +340 -24
  34. package/runtime/dist/src/utilities/process.js +1 -0
  35. package/skill/SKILL.md +3 -3
  36. package/skill/references/lifecycle.md +44 -19
  37. package/skill/references/memory-updates.md +9 -3
  38. package/skill/references/project-onboarding.md +19 -2
  39. package/skill/references/questionnaires.md +77 -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));
@@ -239,6 +273,12 @@ export class WorktreePool {
239
273
  .flatMap((p) => p.entries)
240
274
  .find((e) => e.taskId === taskId && e.phase !== 'released') ?? null));
241
275
  }
276
+ owns(entry) {
277
+ return (entry.phase !== 'released' &&
278
+ entry.owner !== null &&
279
+ entry.owner.id === this.ownerId &&
280
+ entry.owner.pid === process.pid);
281
+ }
242
282
  async forPath(projectId, repoRoot) {
243
283
  return this.transaction(async (registry) => structuredClone(registry.projects[projectId]?.entries.find((e) => key(e.repoRoot) === key(repoRoot)) ??
244
284
  null));
@@ -305,21 +345,115 @@ export class WorktreePool {
305
345
  await this.save(registry);
306
346
  });
307
347
  }
308
- async release(projectId, repoRoot, generation, delivered) {
348
+ async release(projectId, repoRoot, generation, deliveryOutcome) {
309
349
  return this.transaction(async (registry) => {
310
- const entry = this.owned(registry, projectId, repoRoot, generation);
311
- const reasons = await this.fileProtection(entry);
312
- if (reasons.length)
313
- throw refuse('The worktree is protected: ' +
314
- reasons.join(', ') +
315
- '. Finish or preserve the work before release.');
316
- if (entry.pendingDelivery && !delivered)
317
- throw refuse('Delivery is pending. Complete delivery before safely releasing this worktree.');
318
- await this.releaseReservation(entry);
319
- entry.pendingDelivery = false;
320
- entry.phase = 'released';
321
- entry.owner = null;
322
- entry.generation = randomUUID();
350
+ const entry = await this.deliveryOwned(registry, projectId, repoRoot, generation);
351
+ if (entry.pendingDelivery && !deliveryOutcome)
352
+ 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');
353
+ if (entry.managed) {
354
+ const reasons = await this.fileProtection(entry);
355
+ if (reasons.length)
356
+ 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.');
357
+ }
358
+ if (deliveryOutcome)
359
+ entry.deliveryOutcome = deliveryOutcome;
360
+ await this.releaseEntry(registry, entry);
361
+ });
362
+ }
363
+ async cancelDelivery(projectId, repoRoot, generation, choice) {
364
+ return this.transaction(async (registry) => {
365
+ const entry = await this.deliveryOwned(registry, projectId, repoRoot, generation);
366
+ if (!entry.pendingDelivery)
367
+ throw new BridgeRecoveryError('This worktree is not waiting for delivery, so there is no delivery to cancel. Release it with worktree.release.', 'worktree.release');
368
+ if (choice === 'keep') {
369
+ entry.deliveryOutcome = 'cancelled';
370
+ entry.cancelledAt = new Date(this.now()).toISOString();
371
+ entry.phase = 'paused';
372
+ entry.pendingDelivery = false;
373
+ entry.owner = null;
374
+ entry.generation = randomUUID();
375
+ await this.save(registry);
376
+ return { choice };
377
+ }
378
+ let preservedRef;
379
+ if (entry.managed) {
380
+ const reasons = (await this.fileProtection(entry)).filter((reason) => choice !== 'save_and_release' || reason !== changedFiles);
381
+ if (reasons.length)
382
+ 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.');
383
+ if (choice === 'save_and_release') {
384
+ const saved = await this.snapshot(entry);
385
+ preservedRef = entry.preservedRef = saved.ref;
386
+ await this.save(registry);
387
+ await this.cleanSnapshot(entry, saved);
388
+ }
389
+ }
390
+ entry.deliveryOutcome = 'cancelled';
391
+ entry.cancelledAt = new Date(this.now()).toISOString();
392
+ await this.releaseEntry(registry, entry);
393
+ return preservedRef
394
+ ? { choice, preservedRef, restoreCommand: restoreCommand(preservedRef) }
395
+ : { choice };
396
+ });
397
+ }
398
+ async ownedElsewhere(entry) {
399
+ return Boolean(entry.owner && entry.owner.id !== this.ownerId && (await this.ownerAlive(entry.owner)));
400
+ }
401
+ async moveOut(projectId, repoRoot, generation, oid) {
402
+ await this.transaction(async (registry) => {
403
+ const project = registry.projects[projectId];
404
+ const entry = project?.entries.find((e) => key(e.repoRoot) === key(repoRoot));
405
+ if (!project ||
406
+ !entry ||
407
+ entry.managed ||
408
+ entry.phase === 'released' ||
409
+ entry.generation !== generation)
410
+ 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');
411
+ if (await this.ownedElsewhere(entry))
412
+ 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');
413
+ if (entry.pendingDelivery)
414
+ 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');
415
+ if (oid)
416
+ await this.git.branchStore(entry.repoRoot).detach({
417
+ projectId,
418
+ externalTaskId: entry.externalTaskId,
419
+ branch: entry.branch,
420
+ ...(entry.taskId ? { taskId: entry.taskId } : {}),
421
+ }, oid);
422
+ else
423
+ await this.releaseReservation(entry);
424
+ project.entries = project.entries.filter((e) => e !== entry);
425
+ await this.save(registry);
426
+ });
427
+ }
428
+ async retire(projectId, repoRoot, path, generation) {
429
+ const commonDir = await this.commonDirectory(repoRoot);
430
+ const activeRoot = key(await this.documentsDirectory());
431
+ const legacyRoot = await this.legacyDocumentsDirectory();
432
+ await this.transaction(async (registry) => {
433
+ const project = registry.projects[projectId];
434
+ const entry = project?.entries.find((e) => key(e.repoRoot) === key(path));
435
+ if (!project ||
436
+ !entry ||
437
+ entry.generation !== generation ||
438
+ entry.phase !== 'released' ||
439
+ entry.commonDir !== commonDir ||
440
+ !this.outsideActiveRoot(entry, activeRoot, legacyRoot))
441
+ throw refuse('This folder is no longer the same retired slot. List the worktrees again before removing it.');
442
+ if (await pathExists(entry.repoRoot)) {
443
+ const reasons = await this.fileProtection(entry);
444
+ if (reasons.length)
445
+ throw refuse('The retired folder is protected: ' +
446
+ reasons.join(', ') +
447
+ '. It stays listed as retired and is never reused; nothing was removed.');
448
+ const removed = await this.runner.run('git', ['worktree', 'remove', entry.repoRoot], {
449
+ cwd: repoRoot,
450
+ });
451
+ if (removed.exitCode !== 0)
452
+ throw refuse('Git refused to remove the retired folder: ' +
453
+ removed.stderr.trim() +
454
+ '. It stays listed as retired and is never reused; nothing was removed.');
455
+ }
456
+ project.entries = project.entries.filter((e) => e !== entry);
323
457
  await this.save(registry);
324
458
  });
325
459
  }
@@ -394,6 +528,9 @@ export class WorktreePool {
394
528
  if (!recoveredProject)
395
529
  return;
396
530
  project = recoveredProject;
531
+ const activeRootKey = key(await this.documentsDirectory());
532
+ const legacyRootDir = await this.legacyDocumentsDirectory();
533
+ await this.preloadOwnerStarts(project.entries.filter((entry) => entry.owner).map((entry) => entry.owner.pid));
397
534
  for (const entry of project.entries.filter((e) => !discoverOnly && e.commonDir === commonDir)) {
398
535
  if (entry.owner && entry.owner.id !== this.ownerId && (await this.ownerAlive(entry.owner)))
399
536
  continue;
@@ -450,6 +587,9 @@ export class WorktreePool {
450
587
  for (const entry of project.entries.filter((e) => !discoverOnly && e.commonDir === commonDir)) {
451
588
  if (entry.owner && (await this.ownerAlive(entry.owner)) && entry.owner.id !== this.ownerId)
452
589
  continue;
590
+ if (entry.phase === 'released' &&
591
+ this.outsideActiveRoot(entry, activeRootKey, legacyRootDir))
592
+ continue;
453
593
  try {
454
594
  if (entry.recoveredWithoutReservation &&
455
595
  !entry.owner &&
@@ -542,6 +682,105 @@ export class WorktreePool {
542
682
  throw refuse('Worktree ownership has changed or was paused. Resume the task and use its returned repoRoot before any further write.');
543
683
  return entry;
544
684
  }
685
+ async deliveryOwned(registry, projectId, repoRoot, generation) {
686
+ const entry = registry.projects[projectId]?.entries.find((e) => key(e.repoRoot) === key(repoRoot));
687
+ if (!entry ||
688
+ entry.phase !== 'delivery' ||
689
+ entry.generation !== generation ||
690
+ entry.owner?.id === this.ownerId)
691
+ return this.owned(registry, projectId, repoRoot, generation);
692
+ await this.requireTakeover(entry);
693
+ await this.assertIdentity(entry);
694
+ entry.owner = {
695
+ id: this.ownerId,
696
+ pid: process.pid,
697
+ generation,
698
+ ...((await this.ownerStart()) ? { startedAt: (await this.ownerStart()) } : {}),
699
+ };
700
+ return entry;
701
+ }
702
+ async releaseEntry(registry, entry) {
703
+ await this.releaseReservation(entry);
704
+ if (entry.managed) {
705
+ entry.pendingDelivery = false;
706
+ entry.phase = 'released';
707
+ entry.owner = null;
708
+ entry.generation = randomUUID();
709
+ }
710
+ else {
711
+ const project = registry.projects[entry.projectId];
712
+ project.entries = project.entries.filter((e) => e !== entry);
713
+ }
714
+ await this.save(registry);
715
+ }
716
+ async snapshot(entry) {
717
+ const gitDir = await this.command(entry.repoRoot, ['rev-parse', '--absolute-git-dir']);
718
+ const saved = 'refs/engineering-memory/preserved/' +
719
+ entry.externalTaskId.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+/, '') +
720
+ '/' +
721
+ entry.generation;
722
+ let ref = saved;
723
+ for (let attempt = 2; (await this.runner.run('git', ['rev-parse', '--verify', '--quiet', ref], {
724
+ cwd: entry.repoRoot,
725
+ })).exitCode === 0; attempt++)
726
+ ref = saved + '-' + attempt;
727
+ const index = join(gitDir, 'engineering-memory-preserve-' + randomUUID());
728
+ const env = {
729
+ GIT_INDEX_FILE: index,
730
+ GIT_AUTHOR_NAME: 'Engineering Memory',
731
+ GIT_AUTHOR_EMAIL: 'engineering-memory@example.invalid',
732
+ GIT_COMMITTER_NAME: 'Engineering Memory',
733
+ GIT_COMMITTER_EMAIL: 'engineering-memory@example.invalid',
734
+ };
735
+ const git = async (args, options = {}) => {
736
+ const result = await this.runner.run('git', args, { cwd: entry.repoRoot, ...options });
737
+ if (result.exitCode !== 0)
738
+ throw new BridgeRecoveryError('The changes could not be saved (' +
739
+ (result.stderr.trim() || 'git ' + args[0]) +
740
+ '). The folder was left as it was: keep it, or fix the Git error and call worktree.release with deliveryOutcome cancelled again.', 'worktree.release');
741
+ return result.stdout;
742
+ };
743
+ let untracked;
744
+ try {
745
+ await git(['read-tree', 'HEAD'], { env });
746
+ await git(['add', '-A', '--', '.'], { env });
747
+ const tree = (await git(['write-tree'], { env })).trim();
748
+ const commit = (await git([
749
+ 'commit-tree',
750
+ tree,
751
+ '-p',
752
+ 'HEAD',
753
+ '-m',
754
+ 'Changes kept when delivery of ' + entry.externalTaskId + ' was cancelled',
755
+ ], { env })).trim();
756
+ await git(['diff', '--quiet', commit, '--'], { env });
757
+ untracked = (await git(['ls-files', '--others', '--exclude-standard', '-z']))
758
+ .split('\0')
759
+ .filter(Boolean);
760
+ const snapshot = new Set((await git(['ls-tree', '-r', '--name-only', '-z', commit])).split('\0').filter(Boolean));
761
+ if (untracked.some((path) => !snapshot.has(path)))
762
+ throw new BridgeRecoveryError('Not every untracked file reached the saved changes. Keep the folder; nothing was changed.', 'worktree.release');
763
+ await git(['update-ref', ref, commit, '0'.repeat(commit.length)]);
764
+ }
765
+ finally {
766
+ await removeFile(index);
767
+ }
768
+ return { ref, untracked };
769
+ }
770
+ async cleanSnapshot(entry, saved) {
771
+ 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');
772
+ const clean = async (args) => {
773
+ const result = await this.runner.run('git', args, { cwd: entry.repoRoot });
774
+ if (result.exitCode !== 0)
775
+ throw refused(`Git could not clean the folder (${result.stderr.trim() || 'git ' + args[0]})`);
776
+ };
777
+ await clean(['restore', '--source=HEAD', '--staged', '--worktree', '--', '.']);
778
+ for (let i = 0; i < saved.untracked.length; i += 100)
779
+ await clean(['clean', '-f', '-d', '--', ...saved.untracked.slice(i, i + 100)]);
780
+ const reasons = await this.fileProtection(entry);
781
+ if (reasons.length)
782
+ throw refused('the folder is still protected: ' + reasons.join(', '));
783
+ }
545
784
  async reserve(entry) {
546
785
  await this.git.branchStore(entry.repoRoot).reserve({
547
786
  projectId: entry.projectId,
@@ -608,9 +847,9 @@ export class WorktreePool {
608
847
  reservation.decision.externalTaskId !== entry.externalTaskId))
609
848
  reasons.push('another client or legacy task reserved this checkout');
610
849
  if (await this.pendingWork(entry))
611
- reasons.push('task delivery or intent pending');
850
+ reasons.push(pendingTaskWork);
612
851
  if (status.exitCode !== 0 || status.stdout.trim())
613
- reasons.push('uncommitted, staged or untracked files');
852
+ reasons.push(changedFiles);
614
853
  const gitDir = await this.command(entry.repoRoot, ['rev-parse', '--absolute-git-dir']);
615
854
  for (const marker of [
616
855
  'MERGE_HEAD',
@@ -626,12 +865,32 @@ export class WorktreePool {
626
865
  reasons.push('unfinished Git operation: ' + marker);
627
866
  return reasons;
628
867
  }
868
+ outsideActiveRoot(entry, activeRoot, legacyRoot) {
869
+ return entry.managed && key(entry.root ?? legacyRoot) !== activeRoot;
870
+ }
629
871
  async view(entry, policy) {
872
+ const activeRoot = await this.documentsDirectory();
873
+ const outside = this.outsideActiveRoot(entry, key(activeRoot), await this.legacyDocumentsDirectory());
874
+ if (outside && entry.phase === 'released')
875
+ return {
876
+ ...structuredClone(entry),
877
+ activity: 'retired',
878
+ reasons: [
879
+ 'outside the managed root ' + managedBase(activeRoot, 2) + '; not reused',
880
+ ...((await pathExists(entry.repoRoot)) ? [] : ['worktree directory missing']),
881
+ ],
882
+ };
630
883
  const reasons = await this.fileProtection(entry);
884
+ if (outside)
885
+ reasons.push('legacy location: retires after its current task');
886
+ if (!entry.managed && entry.phase !== 'released')
887
+ reasons.push('the user’s own folder: the pool never reuses it');
631
888
  if (entry.phase === 'creating' || entry.phase === 'quarantined')
632
889
  reasons.push(entry.reason ?? 'allocation requires reconciliation');
633
890
  if (entry.pendingDelivery)
634
891
  reasons.push('delivery pending');
892
+ if (entry.deliveryOutcome === 'cancelled' && reasons.includes(changedFiles))
893
+ reasons.push('changes kept after cancelled delivery');
635
894
  if (entry.owner && (await this.ownerAlive(entry.owner)))
636
895
  reasons.push('owner process still running');
637
896
  const age = this.now() - entry.lastActivityAt;
@@ -701,6 +960,53 @@ export class WorktreePool {
701
960
  }
702
961
  return !current || current === owner.startedAt;
703
962
  }
963
+ async preloadOwnerStarts(pids) {
964
+ const now = Date.now();
965
+ const candidates = [...new Set(pids)].filter((pid) => {
966
+ if (!Number.isSafeInteger(pid) || pid <= 0 || !this.alive(pid))
967
+ return false;
968
+ const cached = this.startTimes.get(pid);
969
+ const age = cached ? now - cached.checkedAt : Infinity;
970
+ return !cached || age < 0 || age > 15000;
971
+ });
972
+ if (!candidates.length || process.platform === 'linux')
973
+ return;
974
+ const found = new Map();
975
+ try {
976
+ if (process.platform === 'win32') {
977
+ const result = await this.runner.run('powershell.exe', [
978
+ '-NoProfile',
979
+ '-NonInteractive',
980
+ '-Command',
981
+ 'Get-Process -Id ' +
982
+ candidates.join(',') +
983
+ ' -ErrorAction SilentlyContinue | ForEach-Object { "$($_.Id)=$($_.StartTime.ToUniversalTime().Ticks)" }',
984
+ ], {});
985
+ if (result.exitCode === 0) {
986
+ for (const line of result.stdout.split(/\r?\n/)) {
987
+ const match = /^(\d+)=(\d+)$/.exec(line.trim());
988
+ if (match)
989
+ found.set(Number(match[1]), match[2]);
990
+ }
991
+ }
992
+ }
993
+ else {
994
+ const result = await this.runner.run('ps', ['-o', 'pid=,lstart=', '-p', candidates.join(',')], {});
995
+ if (result.exitCode === 0) {
996
+ for (const line of result.stdout.split(/\r?\n/)) {
997
+ const match = /^\s*(\d+)\s+(.+)$/.exec(line.replace(/\r$/, ''));
998
+ if (match)
999
+ found.set(Number(match[1]), match[2].trim());
1000
+ }
1001
+ }
1002
+ }
1003
+ }
1004
+ catch {
1005
+ return;
1006
+ }
1007
+ for (const pid of candidates)
1008
+ this.startTimes.set(pid, { checkedAt: now, value: found.get(pid) ?? null });
1009
+ }
704
1010
  async processStart(pid) {
705
1011
  if (!Number.isSafeInteger(pid) || pid <= 0)
706
1012
  return null;
@@ -1006,6 +1312,16 @@ function processAlive(pid) {
1006
1312
  return error.code === 'EPERM';
1007
1313
  }
1008
1314
  }
1315
+ const changedFiles = 'uncommitted, staged or untracked files';
1316
+ const pendingTaskWork = 'task delivery or intent pending';
1317
+ function protectedRefusal(reasons, next) {
1318
+ if (reasons.every((reason) => reason === pendingTaskWork))
1319
+ 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');
1320
+ return new BridgeRecoveryError('The worktree is protected: ' + reasons.join(', ') + '. ' + next, 'worktree.release');
1321
+ }
1322
+ function restoreCommand(ref) {
1323
+ return `git restore --source=${ref} --worktree -- .`;
1324
+ }
1009
1325
  function refuse(message) {
1010
1326
  return new BridgeRecoveryError(message, 'worktree.reconcile');
1011
1327
  }
@@ -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