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.
- package/dispatcher/sections.mjs +7 -2
- package/install/codex-approval.mjs +457 -0
- package/install/files.mjs +5 -1
- package/install/installer.mjs +24 -1
- package/package.json +1 -1
- package/runtime/build.json +1 -1
- package/runtime/dist/src/config.js +2 -0
- package/runtime/dist/src/git/git-inspector.js +114 -4
- package/runtime/dist/src/git/verification-gate.js +30 -17
- package/runtime/dist/src/mcp/onboarding-tools.js +110 -16
- package/runtime/dist/src/mcp/questionnaire-tools.js +99 -15
- package/runtime/dist/src/mcp/tool-annotations.js +112 -0
- package/runtime/dist/src/mcp/tool-definitions.js +150 -29
- package/runtime/dist/src/mcp/worktree-tools.js +395 -236
- package/runtime/dist/src/project/repository.js +26 -8
- package/runtime/dist/src/runtime/active-context-store.js +3 -0
- package/runtime/dist/src/runtime/api-client.js +1 -0
- package/runtime/dist/src/runtime/branch-preferences.js +0 -19
- package/runtime/dist/src/runtime/bridge-service.js +1320 -246
- package/runtime/dist/src/runtime/offline-outbox.js +33 -158
- package/runtime/dist/src/runtime/phase-timer.js +26 -0
- package/runtime/dist/src/runtime/privacy-detector.js +263 -0
- package/runtime/dist/src/runtime/questionnaire-store.js +210 -49
- package/runtime/dist/src/runtime/recovery-error.js +3 -1
- package/runtime/dist/src/runtime/runtime-entry.js +8 -0
- package/runtime/dist/src/runtime/runtime-host.js +1 -3
- package/runtime/dist/src/runtime/task-branch-store.js +17 -1
- package/runtime/dist/src/runtime/task-start.js +281 -0
- package/runtime/dist/src/runtime/worktree-pool.js +374 -26
- package/runtime/dist/src/utilities/process.js +1 -0
- package/skill/SKILL.md +3 -3
- package/skill/references/lifecycle.md +59 -19
- package/skill/references/memory-updates.md +9 -3
- package/skill/references/project-onboarding.md +19 -2
- 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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (
|
|
156
|
-
throw
|
|
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 &&
|
|
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
|
|
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,
|
|
352
|
+
async release(projectId, repoRoot, generation, deliveryOutcome) {
|
|
315
353
|
return this.transaction(async (registry) => {
|
|
316
|
-
const entry = this.
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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 [
|
|
867
|
+
return [missingDirectory];
|
|
603
868
|
try {
|
|
604
869
|
await this.assertIdentity(entry, allowBranchChange);
|
|
605
870
|
}
|
|
606
871
|
catch {
|
|
607
|
-
return [
|
|
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(
|
|
882
|
+
reasons.push(pendingTaskWork);
|
|
618
883
|
if (status.exitCode !== 0 || status.stdout.trim())
|
|
619
|
-
reasons.push(
|
|
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
|
|
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.
|
|
30
|
-
11. After closing a task,
|
|
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
|
|