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.
- package/dispatcher/sections.mjs +7 -3
- package/install/codex-approval.mjs +457 -0
- package/install/files.mjs +119 -9
- package/install/installer.mjs +44 -5
- package/install/launcher.mjs +95 -0
- package/package.json +1 -1
- package/runtime/build.json +1 -1
- package/runtime/dist/src/config.js +4 -0
- package/runtime/dist/src/git/git-inspector.js +82 -3
- package/runtime/dist/src/git/verification-gate.js +30 -17
- package/runtime/dist/src/index.js +25 -81
- package/runtime/dist/src/mcp/onboarding-tools.js +3 -16
- package/runtime/dist/src/mcp/questionnaire-tools.js +99 -15
- package/runtime/dist/src/mcp/server.js +14 -7
- package/runtime/dist/src/mcp/tool-annotations.js +111 -0
- package/runtime/dist/src/mcp/tool-definitions.js +148 -28
- package/runtime/dist/src/mcp/tool-host.js +12 -0
- package/runtime/dist/src/mcp/worktree-tools.js +323 -236
- package/runtime/dist/src/project/repository.js +11 -5
- package/runtime/dist/src/runtime/active-context-store.js +3 -0
- package/runtime/dist/src/runtime/branch-preferences.js +0 -19
- package/runtime/dist/src/runtime/bridge-service.js +1163 -231
- package/runtime/dist/src/runtime/create-bridge-service.js +82 -0
- 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 +24 -0
- package/runtime/dist/src/runtime/runtime-host.js +263 -0
- 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 +340 -24
- package/runtime/dist/src/utilities/process.js +1 -0
- package/skill/SKILL.md +3 -3
- package/skill/references/lifecycle.md +44 -19
- package/skill/references/memory-updates.md +9 -3
- package/skill/references/project-onboarding.md +19 -2
- 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
|
-
|
|
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));
|
|
@@ -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,
|
|
348
|
+
async release(projectId, repoRoot, generation, deliveryOutcome) {
|
|
309
349
|
return this.transaction(async (registry) => {
|
|
310
|
-
const entry = this.
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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(
|
|
850
|
+
reasons.push(pendingTaskWork);
|
|
612
851
|
if (status.exitCode !== 0 || status.stdout.trim())
|
|
613
|
-
reasons.push(
|
|
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
|
|
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
|
|