engineering-memory 1.11.24 → 1.11.26
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/package.json +1 -1
- package/runtime/build.json +1 -1
- package/runtime/dist/src/git/git-inspector.js +24 -11
- package/runtime/dist/src/git/verification-gate.js +2 -3
- package/runtime/dist/src/mcp/delivery-tools.js +11 -0
- package/runtime/dist/src/runtime/bridge-service.js +51 -14
- package/runtime/dist/src/runtime/create-bridge-service.js +15 -3
- package/runtime/dist/src/runtime/task-branch-store.js +67 -8
- package/runtime/dist/src/runtime/worktree-pool.js +5 -3
- package/skill/references/lifecycle.md +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "engineering-memory",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.26",
|
|
4
4
|
"description": "Installs the Engineering Memory skill and its local MCP bridge. Sign in after installing; your organization and project are resolved from your account.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
package/runtime/build.json
CHANGED
|
@@ -10,8 +10,10 @@ export const temporaryScaffoldingMarker = 'ENGINEERING-MEMORY-TEMPORARY';
|
|
|
10
10
|
export const temporaryScaffoldingPattern = `${temporaryScaffoldingMarker}:`;
|
|
11
11
|
export class GitInspector {
|
|
12
12
|
runner;
|
|
13
|
-
|
|
13
|
+
knownReservations;
|
|
14
|
+
constructor(runner = new NativeCommandRunner(), knownReservations) {
|
|
14
15
|
this.runner = runner;
|
|
16
|
+
this.knownReservations = knownReservations;
|
|
15
17
|
}
|
|
16
18
|
async findRoot(startPath) {
|
|
17
19
|
const result = await this.runner.run('git', ['rev-parse', '--show-toplevel'], {
|
|
@@ -49,7 +51,7 @@ export class GitInspector {
|
|
|
49
51
|
: null;
|
|
50
52
|
}
|
|
51
53
|
branchStore(repoRoot) {
|
|
52
|
-
return new TaskBranchStore(repoRoot, this.runner);
|
|
54
|
+
return new TaskBranchStore(repoRoot, this.runner, this.knownReservations);
|
|
53
55
|
}
|
|
54
56
|
async validateBranch(repoRoot, name) {
|
|
55
57
|
const result = await this.runner.run('git', ['check-ref-format', '--branch', name], {
|
|
@@ -373,10 +375,10 @@ export class GitInspector {
|
|
|
373
375
|
diffHash: sha256(`${head ?? '<unborn>'}\n${stableStringify(hashManifest)}\n`),
|
|
374
376
|
};
|
|
375
377
|
}
|
|
376
|
-
async manifestAgainst(repoRoot, baseCommit) {
|
|
378
|
+
async manifestAgainst(repoRoot, baseCommit, from = baseCommit) {
|
|
377
379
|
const root = await this.findRoot(repoRoot);
|
|
378
380
|
const head = await this.gitValue(root, ['rev-parse', 'HEAD']);
|
|
379
|
-
const changedPaths = await this.changedPathsAgainstHead(root,
|
|
381
|
+
const changedPaths = await this.changedPathsAgainstHead(root, from);
|
|
380
382
|
const hashManifest = canonicalHashManifest(changedPaths);
|
|
381
383
|
return {
|
|
382
384
|
repoRoot: root,
|
|
@@ -384,9 +386,26 @@ export class GitInspector {
|
|
|
384
386
|
baseCommit,
|
|
385
387
|
changedPaths,
|
|
386
388
|
worktreeHash: sha256(`${stableStringify(hashManifest)}\n`),
|
|
387
|
-
diffHash: sha256(`${
|
|
389
|
+
diffHash: sha256(`${from}\n${head ?? '<unborn>'}\n${stableStringify(hashManifest)}\n`),
|
|
388
390
|
};
|
|
389
391
|
}
|
|
392
|
+
async taskBase(repoRoot, baseCommit, branch) {
|
|
393
|
+
const listed = await this.runner.run('git', ['rev-list', '--boundary', 'HEAD', '--not', baseCommit, `--exclude=*/${branch}`, '--remotes'], { cwd: repoRoot });
|
|
394
|
+
if (listed.exitCode !== 0)
|
|
395
|
+
return baseCommit;
|
|
396
|
+
const upstream = [];
|
|
397
|
+
for (const line of listed.stdout.split(/\r?\n/)) {
|
|
398
|
+
if (line.startsWith('-') && (await this.isAncestor(repoRoot, baseCommit, line.slice(1))))
|
|
399
|
+
upstream.push(line.slice(1));
|
|
400
|
+
}
|
|
401
|
+
if (upstream.length === 0)
|
|
402
|
+
return baseCommit;
|
|
403
|
+
const newest = await this.runner.run('git', ['merge-base', '--independent', ...upstream], {
|
|
404
|
+
cwd: repoRoot,
|
|
405
|
+
});
|
|
406
|
+
const tips = newest.exitCode === 0 ? newest.stdout.split(/\r?\n/).filter(Boolean) : [];
|
|
407
|
+
return tips.length === 1 ? tips[0] : baseCommit;
|
|
408
|
+
}
|
|
390
409
|
async isAncestor(repoRoot, ancestor, descendant = 'HEAD') {
|
|
391
410
|
const result = await this.runner.run('git', ['merge-base', '--is-ancestor', ancestor, descendant], { cwd: repoRoot });
|
|
392
411
|
return result.exitCode === 0;
|
|
@@ -401,12 +420,6 @@ export class GitInspector {
|
|
|
401
420
|
'refs/remotes/',
|
|
402
421
|
]);
|
|
403
422
|
}
|
|
404
|
-
async committedPaths(repoRoot, baseCommit) {
|
|
405
|
-
const result = await this.runner.run('git', ['diff', '--name-only', '-z', '--find-renames', baseCommit, 'HEAD', '--'], { cwd: repoRoot });
|
|
406
|
-
if (result.exitCode !== 0)
|
|
407
|
-
throw new Error(`Git committed diff failed: ${result.stderr.trim()}`);
|
|
408
|
-
return result.stdout.split('\0').filter(Boolean).map(normalizeGitPath).sort();
|
|
409
|
-
}
|
|
410
423
|
async stagedManifest(repoRoot) {
|
|
411
424
|
const root = await this.findRoot(repoRoot);
|
|
412
425
|
let result = await this.runner.run('git', ['diff', '--cached', '--name-status', '-z', '--find-renames', '--find-copies', 'HEAD', '--'], { cwd: root });
|
|
@@ -155,9 +155,8 @@ export class VerificationGate {
|
|
|
155
155
|
receipt.source &&
|
|
156
156
|
actualSource &&
|
|
157
157
|
actualSource.sourceCommit !== receipt.source.sourceCommit &&
|
|
158
|
-
(await this.git.isAncestor(repository.repoRoot, receipt.source.sourceCommit))
|
|
159
|
-
|
|
160
|
-
? await this.git.manifestAgainst(repository.repoRoot, receipt.source.sourceCommit)
|
|
158
|
+
(await this.git.isAncestor(repository.repoRoot, receipt.source.sourceCommit))
|
|
159
|
+
? await this.git.manifestAgainst(repository.repoRoot, receipt.source.sourceCommit, await this.git.taskBase(repository.repoRoot, receipt.source.sourceCommit, receipt.branch))
|
|
161
160
|
: null;
|
|
162
161
|
const committedAhead = ahead?.diffHash === receipt.diffHash;
|
|
163
162
|
const current = ahead && committedAhead ? ahead : manifest;
|
|
@@ -39,6 +39,7 @@ const closedSchema = z.object({
|
|
|
39
39
|
pushUrl: z.string().nullable(),
|
|
40
40
|
headCommit: z.string().nullable(),
|
|
41
41
|
}),
|
|
42
|
+
alreadyDelivered: z.object({ commit: z.string(), ref: z.string() }).optional(),
|
|
42
43
|
}),
|
|
43
44
|
deliveryRecord: z
|
|
44
45
|
.object({
|
|
@@ -119,6 +120,16 @@ export function registerDeliveryTools(server, service) {
|
|
|
119
120
|
const data = parsed.data;
|
|
120
121
|
if (data.deliveryContext.mode === 'read_only')
|
|
121
122
|
return output(closed);
|
|
123
|
+
const delivered = data.delivery.alreadyDelivered;
|
|
124
|
+
if (delivered)
|
|
125
|
+
return output({
|
|
126
|
+
ok: true,
|
|
127
|
+
data: {
|
|
128
|
+
...closed.data,
|
|
129
|
+
deliveryStatus: 'delivered',
|
|
130
|
+
nextAction: `Everything this task changed was already committed and pushed by hand: ${delivered.ref.replace(/^refs\/remotes\//, '')} contains ${delivered.commit.slice(0, 12)}. There is nothing left to deliver, so no delivery question is asked, and the delivery is recorded for the project. Call worktree.release in this folder with deliveryOutcome delivered to settle the folder.`,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
122
133
|
const destination = data.delivery.destination;
|
|
123
134
|
if (!destination.branch)
|
|
124
135
|
return output({
|
|
@@ -1105,7 +1105,7 @@ export class BridgeService {
|
|
|
1105
1105
|
body: {
|
|
1106
1106
|
sessionId: input.sessionId,
|
|
1107
1107
|
repoFingerprint: repository.repoFingerprint,
|
|
1108
|
-
source: (await this.checkoutSource(repository, pointer.source)) ?? null,
|
|
1108
|
+
source: (await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source)) ?? null,
|
|
1109
1109
|
afterSequence: 0,
|
|
1110
1110
|
},
|
|
1111
1111
|
})).data);
|
|
@@ -2388,6 +2388,7 @@ export class BridgeService {
|
|
|
2388
2388
|
taskChanges,
|
|
2389
2389
|
});
|
|
2390
2390
|
await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, input.taskId);
|
|
2391
|
+
await this.dependencies.activeContexts.updateTaskSnapshot(input.taskId, taskVersion, pointer.lastSequence);
|
|
2391
2392
|
}
|
|
2392
2393
|
else {
|
|
2393
2394
|
await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, input.taskId);
|
|
@@ -2412,11 +2413,12 @@ export class BridgeService {
|
|
|
2412
2413
|
}
|
|
2413
2414
|
async validationWaiverSubject(input) {
|
|
2414
2415
|
return await this.execute(async () => {
|
|
2415
|
-
const
|
|
2416
|
-
const pointer = await this.requireActivePointer(
|
|
2416
|
+
const checkout = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
|
|
2417
|
+
const pointer = await this.requireActivePointer(checkout.repoFingerprint, input.taskId, false, 'verify');
|
|
2417
2418
|
if (!pointer.changeBaseline) {
|
|
2418
2419
|
throw refuse('This task has no local change baseline, so what it changed cannot be measured. The baseline is restored from the task record.', 'session.resume');
|
|
2419
2420
|
}
|
|
2421
|
+
const repository = await this.taskRepository(checkout, pointer);
|
|
2420
2422
|
const response = await this.dependencies.client.request(endpoints.sessionResume, {
|
|
2421
2423
|
method: 'POST',
|
|
2422
2424
|
body: {
|
|
@@ -4866,10 +4868,12 @@ export class BridgeService {
|
|
|
4866
4868
|
!pointer.changeBaseline ||
|
|
4867
4869
|
typeof pointer.branch !== 'string' ||
|
|
4868
4870
|
(await git.currentBranch(repository.repoRoot)) !== pointer.branch ||
|
|
4869
|
-
!(await git.isAncestor(repository.repoRoot, base))
|
|
4870
|
-
!pathsContainAll(pointer.changeBaseline.leasePaths, await git.committedPaths(repository.repoRoot, base)))
|
|
4871
|
+
!(await git.isAncestor(repository.repoRoot, base)))
|
|
4871
4872
|
return repository;
|
|
4872
|
-
return {
|
|
4873
|
+
return {
|
|
4874
|
+
...repository,
|
|
4875
|
+
git: await git.manifestAgainst(repository.repoRoot, base, await git.taskBase(repository.repoRoot, base, pointer.branch)),
|
|
4876
|
+
};
|
|
4873
4877
|
}
|
|
4874
4878
|
async checkoutSource(repository, expected, task) {
|
|
4875
4879
|
const current = await this.dependencies.repositories.git.sourceIdentity(repository.repoRoot);
|
|
@@ -4881,8 +4885,15 @@ export class BridgeService {
|
|
|
4881
4885
|
repository.git.baseCommit === expected.sourceCommit &&
|
|
4882
4886
|
repository.git.head === current.sourceCommit)
|
|
4883
4887
|
return { ...expected };
|
|
4884
|
-
const
|
|
4885
|
-
|
|
4888
|
+
const git = this.dependencies.repositories.git;
|
|
4889
|
+
const branch = await git.currentBranch(repository.repoRoot);
|
|
4890
|
+
const rewritten = branch !== null &&
|
|
4891
|
+
branch === task?.branch &&
|
|
4892
|
+
!(await git.isAncestor(repository.repoRoot, expected.sourceCommit));
|
|
4893
|
+
const way = rewritten
|
|
4894
|
+
? `${branch} no longer contains that commit: it was rewritten onto another history (a rebase, reset or amend). Nothing of the task is lost and nothing has to be abandoned: once ${branch} contains that commit again, for example after rebasing it back onto the branch it started from if the user wants that, call session.resume and the task continues where it stopped.`
|
|
4895
|
+
: `Nothing of the task is lost: switch this checkout back to ${task?.branch ?? 'a branch that contains that commit'} and call session.resume, and the task continues where it stopped. Commits, merges and rebases made on the task branch itself stay the task's work.`;
|
|
4896
|
+
throw refuse(`The checkout no longer matches this task source. ${task?.taskSlug ? 'Task ' + task.taskSlug : 'This task'} was opened${task?.branch ? ' on ' + task.branch : ''} at commit ${expected.sourceCommit.slice(0, 12)}; this checkout is on ${branch ?? 'detached HEAD'} at ${current?.sourceCommit.slice(0, 12) ?? 'no commit'}. ${way}`, 'session.resume');
|
|
4886
4897
|
}
|
|
4887
4898
|
if (current && repository.git.head !== current.sourceCommit) {
|
|
4888
4899
|
throw refuse('The Git source changed during the request. Retry session.resume with a stable checkout.', 'session.resume');
|
|
@@ -5170,6 +5181,19 @@ export class BridgeService {
|
|
|
5170
5181
|
await this.dependencies.repositories.git
|
|
5171
5182
|
.branchStore(repository.repoRoot)
|
|
5172
5183
|
.release(String(body.taskId));
|
|
5184
|
+
const git = this.dependencies.repositories.git;
|
|
5185
|
+
const head = repository.git.head;
|
|
5186
|
+
const onBranch = Boolean(head && head !== sourceFromContext(body.source)?.sourceCommit);
|
|
5187
|
+
const clean = onBranch && (await git.manifest(repository.repoRoot)).changedPaths.length === 0;
|
|
5188
|
+
const onRemote = clean && head ? await git.remoteRefContaining(repository.repoRoot, head) : null;
|
|
5189
|
+
if (onRemote && head && repository.projectId && closedTask.deliveryRecord)
|
|
5190
|
+
await this.reportDeliveryOutcome({
|
|
5191
|
+
projectId: repository.projectId,
|
|
5192
|
+
taskId: String(body.taskId),
|
|
5193
|
+
outcome: 'delivered',
|
|
5194
|
+
commit: head,
|
|
5195
|
+
pushed: true,
|
|
5196
|
+
});
|
|
5173
5197
|
return asJsonValue({
|
|
5174
5198
|
...closedTask,
|
|
5175
5199
|
repository: publicRepository(repository),
|
|
@@ -5180,7 +5204,7 @@ export class BridgeService {
|
|
|
5180
5204
|
diffHash: String(body.diffHash),
|
|
5181
5205
|
mode: normalizeTaskMode(closedPointer?.mode),
|
|
5182
5206
|
},
|
|
5183
|
-
delivery: deliveryQuestion(touchedContract, await
|
|
5207
|
+
delivery: deliveryQuestion(touchedContract, await git.pushDestination(repository.repoRoot), objectValue(closedTask.sourcePublication)?.applicable === true, { onBranch, clean, onRemote }),
|
|
5184
5208
|
});
|
|
5185
5209
|
}
|
|
5186
5210
|
async seedResumeSnapshot(pointer, backendPatch) {
|
|
@@ -5591,9 +5615,24 @@ function entryNextAction(authenticated, decision) {
|
|
|
5591
5615
|
}
|
|
5592
5616
|
return 'Ask which organization and then which project, whatever the user asked for, listing what they already have with the option to create a new one last. Switching Engineering Memory off in this repository is the other answer, and it is remembered.';
|
|
5593
5617
|
}
|
|
5594
|
-
function deliveryQuestion(touchedContract, destination, publishApplicable) {
|
|
5618
|
+
function deliveryQuestion(touchedContract, destination, publishApplicable, committed) {
|
|
5595
5619
|
const { branch, remote, pushUrl, remoteDefaultBranch, headCommit } = destination;
|
|
5620
|
+
const afterCommit = publishApplicable
|
|
5621
|
+
? "After the authorized delivery, call worktree.release in this folder with deliveryOutcome delivered: it publishes this task's source memory and then records the delivery for the project. Its sourcePublication says whether publication happened; call memory.publish_task only when it names a reason you can fix. A task without a worktree to release calls memory.publish_task after the commit instead."
|
|
5622
|
+
: 'This task has nothing to publish (see sourcePublication on the close response); do not call memory.publish_task.';
|
|
5623
|
+
if (committed.onRemote && headCommit)
|
|
5624
|
+
return asJsonValue({
|
|
5625
|
+
required: false,
|
|
5626
|
+
destination,
|
|
5627
|
+
alreadyDelivered: { commit: headCommit, ref: committed.onRemote },
|
|
5628
|
+
afterCommit,
|
|
5629
|
+
});
|
|
5596
5630
|
const onto = branch ? `branch ${branch}` : 'a detached HEAD, which is on no branch';
|
|
5631
|
+
const state = committed.clean
|
|
5632
|
+
? `Everything this task changed is already committed on ${onto}; nothing is left to commit.`
|
|
5633
|
+
: committed.onBranch
|
|
5634
|
+
? `Part of this task's work is already committed on ${onto}; the rest is not committed yet.`
|
|
5635
|
+
: 'The task is closed and nothing has been committed.';
|
|
5597
5636
|
const target = branch && remote ? `${remote} (${pushUrl ?? 'no push URL'}) branch ${branch}` : null;
|
|
5598
5637
|
const head = headCommit ? `, on top of ${headCommit.slice(0, 12)}` : '';
|
|
5599
5638
|
const pushed = target ? `, and a push goes to ${target}` : '';
|
|
@@ -5609,7 +5648,7 @@ function deliveryQuestion(touchedContract, destination, publishApplicable) {
|
|
|
5609
5648
|
: '');
|
|
5610
5649
|
return asJsonValue({
|
|
5611
5650
|
required: true,
|
|
5612
|
-
question:
|
|
5651
|
+
question: `${state} The commit goes on ${onto}${head}${pushed}. If the user already said in their own message which of these they want, that is the answer; do not ask it again. Otherwise ask, and do only what they choose.`,
|
|
5613
5652
|
destination,
|
|
5614
5653
|
options: [
|
|
5615
5654
|
{ id: 'commit', label: `Commit on ${onto}` },
|
|
@@ -5631,9 +5670,7 @@ function deliveryQuestion(touchedContract, destination, publishApplicable) {
|
|
|
5631
5670
|
}
|
|
5632
5671
|
: {}),
|
|
5633
5672
|
afterPullRequest: 'Do not end the turn once a pull request exists. Check whether it merges cleanly, report the conflicting files if it does not, and ask whether to resolve them before touching anything.',
|
|
5634
|
-
afterCommit
|
|
5635
|
-
? "After the authorized delivery, call worktree.release in this folder with deliveryOutcome delivered: it publishes this task's source memory and then records the delivery for the project. Its sourcePublication says whether publication happened; call memory.publish_task only when it names a reason you can fix. A task without a worktree to release calls memory.publish_task after the commit instead."
|
|
5636
|
-
: 'This task has nothing to publish (see sourcePublication on the close response); do not call memory.publish_task.',
|
|
5673
|
+
afterCommit,
|
|
5637
5674
|
});
|
|
5638
5675
|
}
|
|
5639
5676
|
function cleanJson(value) {
|
|
@@ -46,9 +46,20 @@ export function createBridgeService(options = {}) {
|
|
|
46
46
|
liveHeader: (path) => live.header(path),
|
|
47
47
|
});
|
|
48
48
|
const live = new LiveSignals(client);
|
|
49
|
-
const git = new GitInspector();
|
|
50
|
-
const outbox = new OfflineOutbox(stateRoot);
|
|
51
49
|
const activeContexts = new ActiveContextStore(stateRoot);
|
|
50
|
+
const knownReservations = async (repoRoot) => (await activeContexts.list((await repositories.resolveIdentity(repoRoot)).repoFingerprint)).flatMap((pointer) => pointer.branch === undefined
|
|
51
|
+
? []
|
|
52
|
+
: [
|
|
53
|
+
{
|
|
54
|
+
projectId: pointer.projectId,
|
|
55
|
+
externalTaskId: pointer.taskSlug,
|
|
56
|
+
branch: pointer.branch,
|
|
57
|
+
taskId: pointer.taskId,
|
|
58
|
+
},
|
|
59
|
+
]);
|
|
60
|
+
const git = new GitInspector(undefined, knownReservations);
|
|
61
|
+
const repositories = new RepositoryResolver(git, config.markerSchemaVersion, stateRoot);
|
|
62
|
+
const outbox = new OfflineOutbox(stateRoot);
|
|
52
63
|
const repositoryDecisions = new RepositoryDecisionStore(stateRoot);
|
|
53
64
|
const updateChoices = new UpdateChoiceStore(stateRoot);
|
|
54
65
|
const shadowNotices = new ShadowNoticeStore(stateRoot);
|
|
@@ -64,6 +75,7 @@ export function createBridgeService(options = {}) {
|
|
|
64
75
|
(await outbox.listForTask(entry.taskId)).length);
|
|
65
76
|
},
|
|
66
77
|
onDeliveryOutcome: async (report) => service?.reportDeliveryOutcome(report),
|
|
78
|
+
knownReservations,
|
|
67
79
|
});
|
|
68
80
|
const gate = new VerificationGate(stateRoot, git, outbox, undefined, worktreePool);
|
|
69
81
|
const principalState = new PrincipalStateGuard(stateRoot, credentials, cache, outbox, activeContexts, gate);
|
|
@@ -76,7 +88,7 @@ export function createBridgeService(options = {}) {
|
|
|
76
88
|
client,
|
|
77
89
|
credentials,
|
|
78
90
|
browserAuth: new BrowserAuthCoordinator(client, credentials, () => languages.read(sha256('anonymous'))),
|
|
79
|
-
repositories
|
|
91
|
+
repositories,
|
|
80
92
|
journal: new JournalStore(stateRoot),
|
|
81
93
|
outbox,
|
|
82
94
|
gate,
|
|
@@ -1,13 +1,18 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { NativeCommandRunner } from '../utilities/process.js';
|
|
2
3
|
import { assertSafeToPersist } from './offline-outbox.js';
|
|
3
4
|
import { BridgeRecoveryError } from './recovery-error.js';
|
|
4
5
|
const decisionRef = 'refs/worktree/engineering-memory-task';
|
|
6
|
+
const keptRef = (oid) => 'refs/engineering-memory/task-reservations/' + oid;
|
|
7
|
+
const kept = new Set();
|
|
5
8
|
export class TaskBranchStore {
|
|
6
9
|
repoRoot;
|
|
7
10
|
runner;
|
|
8
|
-
|
|
11
|
+
known;
|
|
12
|
+
constructor(repoRoot, runner = new NativeCommandRunner(), known) {
|
|
9
13
|
this.repoRoot = repoRoot;
|
|
10
14
|
this.runner = runner;
|
|
15
|
+
this.known = known;
|
|
11
16
|
}
|
|
12
17
|
async read() {
|
|
13
18
|
const reference = await this.runner.run('git', ['rev-parse', '--verify', '--quiet', decisionRef], { cwd: this.repoRoot });
|
|
@@ -17,8 +22,12 @@ export class TaskBranchStore {
|
|
|
17
22
|
throw new BridgeRecoveryError('Git could not read the worktree task reservation. Retry the branch operation.', 'task.branch');
|
|
18
23
|
const oid = reference.stdout.trim();
|
|
19
24
|
const blob = await this.runner.run('git', ['cat-file', 'blob', oid], { cwd: this.repoRoot });
|
|
20
|
-
if (blob.exitCode !== 0)
|
|
21
|
-
|
|
25
|
+
if (blob.exitCode !== 0) {
|
|
26
|
+
const found = await this.runner.run('git', ['cat-file', '-e', oid], { cwd: this.repoRoot });
|
|
27
|
+
if (found.exitCode !== 1 || !this.known)
|
|
28
|
+
throw new BridgeRecoveryError('Git could not read the recorded branch decision. Retry the branch operation.', 'task.branch');
|
|
29
|
+
return await this.restore(oid, await this.known(this.repoRoot));
|
|
30
|
+
}
|
|
22
31
|
let decision;
|
|
23
32
|
try {
|
|
24
33
|
decision = JSON.parse(blob.stdout);
|
|
@@ -33,6 +42,7 @@ export class TaskBranchStore {
|
|
|
33
42
|
!(decision.branch === null || typeof decision.branch === 'string')) {
|
|
34
43
|
throw new BridgeRecoveryError('The worktree branch record is invalid. Use a separate worktree to continue this task.', 'task.branch');
|
|
35
44
|
}
|
|
45
|
+
await this.keep(oid);
|
|
36
46
|
return { oid, decision };
|
|
37
47
|
}
|
|
38
48
|
async reserve(decision) {
|
|
@@ -43,7 +53,10 @@ export class TaskBranchStore {
|
|
|
43
53
|
return;
|
|
44
54
|
}
|
|
45
55
|
const oid = await this.hash(decision);
|
|
46
|
-
const written = await this.
|
|
56
|
+
const written = await this.update([
|
|
57
|
+
`create ${decisionRef} ${oid}`,
|
|
58
|
+
`update ${keptRef(oid)} ${oid}`,
|
|
59
|
+
]);
|
|
47
60
|
if (written.exitCode !== 0) {
|
|
48
61
|
const winner = await this.read();
|
|
49
62
|
if (winner) {
|
|
@@ -62,7 +75,11 @@ export class TaskBranchStore {
|
|
|
62
75
|
if (current.decision.taskId === taskId)
|
|
63
76
|
return;
|
|
64
77
|
const oid = await this.hash({ ...current.decision, taskId });
|
|
65
|
-
const result = await this.
|
|
78
|
+
const result = await this.update([
|
|
79
|
+
`update ${decisionRef} ${oid} ${current.oid}`,
|
|
80
|
+
`update ${keptRef(oid)} ${oid}`,
|
|
81
|
+
`delete ${keptRef(current.oid)}`,
|
|
82
|
+
]);
|
|
66
83
|
if (result.exitCode !== 0) {
|
|
67
84
|
const latest = await this.read();
|
|
68
85
|
if (latest?.decision.taskId !== taskId)
|
|
@@ -76,7 +93,7 @@ export class TaskBranchStore {
|
|
|
76
93
|
current.decision.projectId !== projectId ||
|
|
77
94
|
current.decision.externalTaskId !== externalTaskId)
|
|
78
95
|
return;
|
|
79
|
-
const result = await this.
|
|
96
|
+
const result = await this.remove(current.oid);
|
|
80
97
|
if (result.exitCode !== 0 && (await this.read())?.oid === current.oid)
|
|
81
98
|
throw new BridgeRecoveryError('The failed branch operation could not release its reservation. Retry task.branch with the same task identifier.', 'task.branch');
|
|
82
99
|
}
|
|
@@ -84,7 +101,7 @@ export class TaskBranchStore {
|
|
|
84
101
|
const current = await this.read();
|
|
85
102
|
if (!current || current.decision.taskId !== taskId)
|
|
86
103
|
return;
|
|
87
|
-
const result = await this.
|
|
104
|
+
const result = await this.remove(current.oid);
|
|
88
105
|
if (result.exitCode !== 0 && (await this.read())?.decision.taskId === taskId) {
|
|
89
106
|
throw new BridgeRecoveryError('The task has finished but its worktree reservation could not be released. Resume the task to retry cleanup.', 'session.resume');
|
|
90
107
|
}
|
|
@@ -98,7 +115,7 @@ export class TaskBranchStore {
|
|
|
98
115
|
current.decision.externalTaskId !== expected.externalTaskId ||
|
|
99
116
|
current.decision.taskId !== expected.taskId)
|
|
100
117
|
throw changed();
|
|
101
|
-
const result = await this.
|
|
118
|
+
const result = await this.remove(oid);
|
|
102
119
|
if (result.exitCode !== 0) {
|
|
103
120
|
if ((await this.read())?.oid !== oid)
|
|
104
121
|
throw changed();
|
|
@@ -114,6 +131,48 @@ export class TaskBranchStore {
|
|
|
114
131
|
throw new BridgeRecoveryError(`This task selected ${current.branch ?? 'detached HEAD'}. Return to that branch before continuing.`, 'task.branch');
|
|
115
132
|
}
|
|
116
133
|
}
|
|
134
|
+
async restore(oid, known) {
|
|
135
|
+
for (const task of known) {
|
|
136
|
+
const unopened = {
|
|
137
|
+
projectId: task.projectId,
|
|
138
|
+
externalTaskId: task.externalTaskId,
|
|
139
|
+
branch: task.branch,
|
|
140
|
+
};
|
|
141
|
+
for (const decision of [{ ...unopened, taskId: task.taskId }, unopened]) {
|
|
142
|
+
const body = Buffer.from(JSON.stringify(decision));
|
|
143
|
+
const id = createHash(oid.length === 64 ? 'sha256' : 'sha1')
|
|
144
|
+
.update(`blob ${body.length}\0`)
|
|
145
|
+
.update(body)
|
|
146
|
+
.digest('hex');
|
|
147
|
+
if (id !== oid)
|
|
148
|
+
continue;
|
|
149
|
+
await this.hash(decision);
|
|
150
|
+
await this.keep(oid);
|
|
151
|
+
return { oid, decision };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if ((await this.remove(oid)).exitCode !== 0)
|
|
155
|
+
throw new BridgeRecoveryError('Git could not read the recorded branch decision. Retry the branch operation.', 'task.branch');
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
async keep(oid) {
|
|
159
|
+
if (kept.has(this.repoRoot + '\n' + oid))
|
|
160
|
+
return;
|
|
161
|
+
const result = await this.runner.run('git', ['update-ref', keptRef(oid), oid], {
|
|
162
|
+
cwd: this.repoRoot,
|
|
163
|
+
});
|
|
164
|
+
if (result.exitCode === 0)
|
|
165
|
+
kept.add(this.repoRoot + '\n' + oid);
|
|
166
|
+
}
|
|
167
|
+
async remove(oid) {
|
|
168
|
+
return await this.update([`delete ${decisionRef} ${oid}`, `delete ${keptRef(oid)}`]);
|
|
169
|
+
}
|
|
170
|
+
async update(commands) {
|
|
171
|
+
return await this.runner.run('git', ['update-ref', '--no-deref', '--stdin'], {
|
|
172
|
+
cwd: this.repoRoot,
|
|
173
|
+
input: commands.join('\n') + '\n',
|
|
174
|
+
});
|
|
175
|
+
}
|
|
117
176
|
async hash(decision) {
|
|
118
177
|
const result = await this.runner.run('git', ['hash-object', '-w', '--stdin'], {
|
|
119
178
|
cwd: this.repoRoot,
|
|
@@ -131,7 +131,7 @@ export class WorktreePool {
|
|
|
131
131
|
this.stateRoot = stateRoot;
|
|
132
132
|
this.ownerId = options.ownerId ?? randomUUID();
|
|
133
133
|
this.runner = options.runner ?? new NativeCommandRunner();
|
|
134
|
-
this.git = new GitInspector(this.runner);
|
|
134
|
+
this.git = new GitInspector(this.runner, options.knownReservations);
|
|
135
135
|
this.now = options.now ?? Date.now;
|
|
136
136
|
this.alive = options.processAlive ?? processAlive;
|
|
137
137
|
this.documents = options.documentsRoot;
|
|
@@ -1169,9 +1169,11 @@ export class WorktreePool {
|
|
|
1169
1169
|
}
|
|
1170
1170
|
async assertIdentity(entry, allowBranchChange = false) {
|
|
1171
1171
|
if (key(await this.git.findRoot(entry.repoRoot)) !== key(await canonicalPath(entry.repoRoot)) ||
|
|
1172
|
-
(await this.commonDirectory(entry.repoRoot)) !== entry.commonDir
|
|
1173
|
-
(!allowBranchChange && (await this.git.currentBranch(entry.repoRoot)) !== entry.branch))
|
|
1172
|
+
(await this.commonDirectory(entry.repoRoot)) !== entry.commonDir)
|
|
1174
1173
|
throw refuse('The worktree Git identity changed. Reconcile it before continuing; the task was not moved or reset.');
|
|
1174
|
+
const branch = allowBranchChange ? entry.branch : await this.git.currentBranch(entry.repoRoot);
|
|
1175
|
+
if (branch !== entry.branch)
|
|
1176
|
+
throw new BridgeRecoveryError(`This folder is on ${branch ?? 'a detached HEAD'}, but its task works on ${entry.branch ?? 'a detached HEAD'}. Nothing of the task is lost: switch this folder back to ${entry.branch ?? 'the commit the task started from'} and call session.resume, and the task continues where it stopped.`, 'session.resume');
|
|
1175
1177
|
const reservation = await this.git.branchStore(entry.repoRoot).read();
|
|
1176
1178
|
if (reservation &&
|
|
1177
1179
|
(reservation.decision.projectId !== entry.projectId ||
|
|
@@ -214,6 +214,10 @@ proved against the current commit. When it cannot prove it, resume reports
|
|
|
214
214
|
list, and do not edit the pointer file: say what happened, and let the user decide between
|
|
215
215
|
reopening the task and abandoning it.
|
|
216
216
|
|
|
217
|
+
## Work committed, merged or rebased by hand
|
|
218
|
+
|
|
219
|
+
Anything Engineering Memory does in Git the developer may do by hand while the task is open: commit on the task branch, merge the base branch into it, rebase it, push it. None of that loses the task, and none of it is a reason to abandon or reset anything. The task's changes are measured the way a pull request of the task branch shows them: against the newest commit the branch took in from the remote's other branches, so a merged-in `develop` is not counted as the task's work, while the task's own commits and its uncommitted files still are. When the checkout is on another branch or a detached HEAD, the refusal names the task branch to switch back to; switch back and call `session.resume`, and the task continues where it stopped. Do not call `worktree.reconcile` for it: that quarantines the folder. When the task branch itself was rewritten so that it no longer contains the commit the task started from (a rebase onto another history, a reset, an amend of that commit), the refusal says so; once that commit is back in the branch, `session.resume` continues the task. When everything the task changed is already committed and a remote branch contains it, `task.close` asks no delivery question: it reports `deliveryStatus: 'delivered'` and records the delivery for the project, and `worktree.release` with `deliveryOutcome:'delivered'` settles the folder.
|
|
220
|
+
|
|
217
221
|
## Change Preparation
|
|
218
222
|
|
|
219
223
|
Skip change preparation for a read-only task. Do not request an edit lease, send changed paths, record `pre_edit`, or reconcile code resources in that mode. If the user later authorizes a change, call `context.prepare_change` with `transitionToWrite: true`, the bridge-owned current task version, intended paths, and current baseline diff. Continue only after the bridge returns the updated write task and lease.
|
|
@@ -280,6 +284,8 @@ When `context.prepare_change`'s `requirements` list a path under `mappings`, dra
|
|
|
280
284
|
|
|
281
285
|
Record `handoff_before`, then call `task.verify`. For a write task, verify the exact changed paths, current diff hash, validations, session, and lease. For a read-only task, send no changed paths, lease, or write baseline; the bridge supplies the current Git diff hash and the backend compares it with the baseline captured at bootstrap while also verifying bootstrap, discovery, validation-before, validation-after, handoff, pinned read receipts, and synchronized outbox evidence. If verification fails, the refusal lists every unmet requirement at once; resolve all of them, then verify again. When `session.resume` reports `context_refresh_required`, call `context.refresh`, then `context.prepare_change` for the same paths; resume alone never reactivates a stale session, and the task baseline does not change. `context.refresh` sends in full only the revisions that changed since the session pinned them and lists the rest in `unchangedResources`; reread one of those with `memory.read_revisions` only when its text is no longer in view. Do not state that the task is complete while verification is failing, and do not carry on writing code with the failure unaddressed — a task that never verifies never closes, and everything that depends on closing, including the commit gate, silently never happens.
|
|
282
286
|
|
|
287
|
+
A verified task is not frozen. When a change is needed after verification — a product manager's update, a fix the user asks for, a correction — call `context.prepare_change` for the paths it touches: that reopens the task and keeps everything recorded so far. A correction, a checkpoint or a self review reopens it as well. `task.reconcile` and `memory.propose_revision` on a verified task refuse with `Task already verified` and recovery `context.prepare_change`; call it, then repeat the refused call. Make the change, review it with `task.self_review`, run the validations again with `validation_before`, `validation_after` and `handoff_before` recorded around them, and verify again before `task.close`.
|
|
288
|
+
|
|
283
289
|
When a task adds a structure the system did not have — a cache, a broker, a read replica, a second deployable, a projection, an event store — name it in `introducedStructures` at verification. The backend refuses any the project profile has not recorded under `architecture.adopted`, with the pressure it relieves, and refuses with the stated reason any the project recorded as deliberately declined. Recording it is a project profile revision like any other: propose, have the user approve, reconcile. Do not reach for the structure first and record it afterwards — the point of the record is that somebody decided.
|
|
284
290
|
|
|
285
291
|
A new screen or component fails verification until its memory exists, which takes four steps in order: `memory.propose_revision` for each new path, the user's explicit approval, `memory.review_proposal`, then `task.reconcile`. The error names the paths. Walk the chain rather than retrying the same verification.
|