engineering-memory 1.11.22 → 1.11.23
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/config.js +6 -0
- package/runtime/dist/src/git/verification-gate.js +2 -0
- package/runtime/dist/src/localization/catalogue.generated.js +22 -0
- package/runtime/dist/src/mcp/delivery-tools.js +226 -22
- package/runtime/dist/src/mcp/tool-annotations.js +3 -0
- package/runtime/dist/src/mcp/tool-definitions.js +22 -1
- package/runtime/dist/src/mcp/worktree-tools.js +16 -2
- package/runtime/dist/src/runtime/api-client.js +3 -0
- package/runtime/dist/src/runtime/branch-preferences.js +15 -0
- package/runtime/dist/src/runtime/bridge-service.js +168 -10
- package/runtime/dist/src/runtime/create-bridge-service.js +4 -2
- package/runtime/dist/src/runtime/questionnaire-store.js +7 -0
- package/runtime/dist/src/runtime/task-start.js +45 -15
- package/runtime/dist/src/runtime/worktree-pool.js +97 -20
- package/skill/references/lifecycle.md +26 -3
|
@@ -12,6 +12,7 @@ import { BridgeRecoveryError } from './recovery-error.js';
|
|
|
12
12
|
import { planWorktreeFiles, ignoredRuntimeFiles, createWorktreeStage, stageWorktreeFiles, publishWorktreeFiles, discardStagedWorktreeFiles, } from './worktree-preparation.js';
|
|
13
13
|
import { WorktreeFileIssue, WorktreeFileStatus, } from './worktree-readiness-types.js';
|
|
14
14
|
import { openWorktreeInEditor, WorktreeEditorStatus } from './worktree-editor.js';
|
|
15
|
+
const cancelReasons = { keep: 'kept', release: 'released', save_and_release: 'saved' };
|
|
15
16
|
const allocationSchema = z.object({
|
|
16
17
|
projectId: z.string().min(1),
|
|
17
18
|
repoFingerprint: z.string().regex(/^[a-f0-9]{64}$/),
|
|
@@ -125,6 +126,7 @@ export class WorktreePool {
|
|
|
125
126
|
overrideProblem;
|
|
126
127
|
before = new Map();
|
|
127
128
|
pendingWork;
|
|
129
|
+
onDeliveryOutcome;
|
|
128
130
|
constructor(stateRoot, options = {}) {
|
|
129
131
|
this.stateRoot = stateRoot;
|
|
130
132
|
this.ownerId = options.ownerId ?? randomUUID();
|
|
@@ -135,6 +137,7 @@ export class WorktreePool {
|
|
|
135
137
|
this.documents = options.documentsRoot;
|
|
136
138
|
this.legacyDocuments = options.legacyDocumentsRoot ?? options.documentsRoot;
|
|
137
139
|
this.pendingWork = options.pendingWork ?? (async () => false);
|
|
140
|
+
this.onDeliveryOutcome = options.onDeliveryOutcome ?? (async () => undefined);
|
|
138
141
|
this.registryPath = join(stateRoot, 'worktree-pool.json');
|
|
139
142
|
}
|
|
140
143
|
async allocate(input, policy) {
|
|
@@ -220,11 +223,34 @@ export class WorktreePool {
|
|
|
220
223
|
(await this.git.sourceIdentity(input.repoRoot, input.baseCommit))?.sourceCommit !==
|
|
221
224
|
input.baseCommit)
|
|
222
225
|
throw new BridgeRecoveryError('The chosen base commit is unavailable in this clone. Make a fresh task.branch source choice; no allocation was recorded.', 'task.branch');
|
|
223
|
-
|
|
226
|
+
await this.settleDelivered(registry, project.entries);
|
|
227
|
+
if (!input.resume &&
|
|
228
|
+
!input.continued &&
|
|
229
|
+
(await this.git.branchExists(input.repoRoot, input.branch)))
|
|
224
230
|
throw refuse('That branch already exists. Resume its recorded task or choose a new branch name; the selected base was not substituted.');
|
|
225
231
|
const checkout = (await this.gitPaths(input.repoRoot)).find((p) => p.branch === input.branch);
|
|
226
|
-
|
|
227
|
-
|
|
232
|
+
const alreadyHere = input.continued &&
|
|
233
|
+
input.inPlace &&
|
|
234
|
+
checkout &&
|
|
235
|
+
key(checkout.path) === key(await this.git.findRoot(input.repoRoot));
|
|
236
|
+
const holding = checkout && input.continued && !alreadyHere
|
|
237
|
+
? project.entries.find((e) => e.managed &&
|
|
238
|
+
e.phase === 'released' &&
|
|
239
|
+
e.commonDir === commonDir &&
|
|
240
|
+
key(e.repoRoot) === key(checkout.path))
|
|
241
|
+
: undefined;
|
|
242
|
+
if (checkout && !holding && !alreadyHere)
|
|
243
|
+
throw refuse(input.continued
|
|
244
|
+
? `Branch ${input.branch} is checked out in ${checkout.path}. Continue the task that works there, or free that folder, then call task.branch with a new decisionAttempt.`
|
|
245
|
+
: 'This branch already has a checkout. Resume its task instead of creating or resetting the branch.');
|
|
246
|
+
if (holding) {
|
|
247
|
+
const view = await this.view(holding, policy);
|
|
248
|
+
if (view.activity !== 'available')
|
|
249
|
+
throw refuse(`Branch ${input.branch} is checked out in the released folder ${holding.repoRoot}, which is not free (${view.reasons.join(', ')}). Settle that folder, then call task.branch with a new decisionAttempt.`);
|
|
250
|
+
await this.command(holding.repoRoot, ['switch', '--detach']);
|
|
251
|
+
}
|
|
252
|
+
if (input.continued && !alreadyHere)
|
|
253
|
+
await this.moveBranch(input.repoRoot, input.branch, input.baseCommit);
|
|
228
254
|
if (input.inPlace) {
|
|
229
255
|
const root = await this.git.findRoot(input.repoRoot);
|
|
230
256
|
const held = project.entries.find((e) => key(e.repoRoot) === key(root) && e.phase !== 'released');
|
|
@@ -241,37 +267,44 @@ export class WorktreePool {
|
|
|
241
267
|
await this.save(registry);
|
|
242
268
|
await this.reserve(entry);
|
|
243
269
|
try {
|
|
270
|
+
if (alreadyHere)
|
|
271
|
+
await this.command(root, [
|
|
272
|
+
'merge',
|
|
273
|
+
'--ff-only',
|
|
274
|
+
'--no-overwrite-ignore',
|
|
275
|
+
input.baseCommit,
|
|
276
|
+
]);
|
|
244
277
|
await this.checkout(entry);
|
|
245
278
|
}
|
|
246
279
|
catch {
|
|
247
280
|
await this.git.branchStore(root).cancelUnopened(entry.projectId, entry.externalTaskId);
|
|
248
281
|
project.entries = project.entries.filter((e) => e !== entry);
|
|
249
282
|
await this.save(registry);
|
|
250
|
-
throw new BridgeRecoveryError('Git could not switch this folder to the
|
|
283
|
+
throw new BridgeRecoveryError('Git could not switch this folder to the task 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');
|
|
251
284
|
}
|
|
252
285
|
entry.phase = 'working';
|
|
253
286
|
await this.save(registry);
|
|
254
287
|
return structuredClone(entry);
|
|
255
288
|
}
|
|
256
|
-
|
|
257
|
-
let reusable;
|
|
289
|
+
let reusable = holding;
|
|
258
290
|
const activeRoot = key(await this.documentsDirectory());
|
|
259
291
|
const legacyRoot = await this.legacyDocumentsDirectory();
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
292
|
+
if (!reusable)
|
|
293
|
+
for (const candidate of project.entries
|
|
294
|
+
.filter((e) => e.managed &&
|
|
295
|
+
e.commonDir === commonDir &&
|
|
296
|
+
!this.outsideActiveRoot(e, activeRoot, legacyRoot))
|
|
297
|
+
.sort((a, b) => a.slot - b.slot)) {
|
|
298
|
+
if (candidate.pendingDelivery ||
|
|
299
|
+
['creating', 'quarantined'].includes(candidate.phase) ||
|
|
300
|
+
(candidate.owner && (await this.ownerAlive(candidate.owner))))
|
|
301
|
+
continue;
|
|
302
|
+
const view = await this.view(candidate, policy);
|
|
303
|
+
if (view.activity === 'available') {
|
|
304
|
+
reusable = candidate;
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
273
307
|
}
|
|
274
|
-
}
|
|
275
308
|
entry = reusable;
|
|
276
309
|
if (entry) {
|
|
277
310
|
const old = structuredClone(entry);
|
|
@@ -565,9 +598,20 @@ export class WorktreePool {
|
|
|
565
598
|
if (reasons.length)
|
|
566
599
|
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.');
|
|
567
600
|
}
|
|
601
|
+
const taskId = deliveryOutcome && entry.pendingDelivery ? entry.taskId : undefined;
|
|
602
|
+
const head = taskId && (await this.git.sourceIdentity(entry.repoRoot))?.sourceCommit;
|
|
603
|
+
const pushed = Boolean(head && (await this.git.remoteRefContaining(entry.repoRoot, head)));
|
|
568
604
|
if (deliveryOutcome)
|
|
569
605
|
entry.deliveryOutcome = deliveryOutcome;
|
|
570
606
|
await this.releaseEntry(registry, entry);
|
|
607
|
+
if (taskId && head && head !== entry.baseCommit)
|
|
608
|
+
await this.onDeliveryOutcome({
|
|
609
|
+
projectId,
|
|
610
|
+
taskId,
|
|
611
|
+
outcome: 'delivered',
|
|
612
|
+
commit: head,
|
|
613
|
+
pushed,
|
|
614
|
+
}).catch(() => undefined);
|
|
571
615
|
});
|
|
572
616
|
}
|
|
573
617
|
async cancelDelivery(projectId, repoRoot, generation, choice) {
|
|
@@ -575,6 +619,16 @@ export class WorktreePool {
|
|
|
575
619
|
const entry = await this.deliveryOwned(registry, projectId, repoRoot, generation);
|
|
576
620
|
if (!entry.pendingDelivery)
|
|
577
621
|
throw new BridgeRecoveryError('This worktree is not waiting for delivery, so there is no delivery to cancel. Release it with worktree.release.', 'worktree.release');
|
|
622
|
+
const taskId = entry.taskId;
|
|
623
|
+
const report = async () => {
|
|
624
|
+
if (taskId)
|
|
625
|
+
await this.onDeliveryOutcome({
|
|
626
|
+
projectId,
|
|
627
|
+
taskId,
|
|
628
|
+
outcome: 'cancelled',
|
|
629
|
+
reason: cancelReasons[choice],
|
|
630
|
+
}).catch(() => undefined);
|
|
631
|
+
};
|
|
578
632
|
if (choice === 'keep') {
|
|
579
633
|
entry.deliveryOutcome = 'cancelled';
|
|
580
634
|
entry.cancelledAt = new Date(this.now()).toISOString();
|
|
@@ -583,6 +637,7 @@ export class WorktreePool {
|
|
|
583
637
|
entry.owner = null;
|
|
584
638
|
entry.generation = randomUUID();
|
|
585
639
|
await this.save(registry);
|
|
640
|
+
await report();
|
|
586
641
|
return { choice };
|
|
587
642
|
}
|
|
588
643
|
let preservedRef;
|
|
@@ -600,6 +655,7 @@ export class WorktreePool {
|
|
|
600
655
|
entry.deliveryOutcome = 'cancelled';
|
|
601
656
|
entry.cancelledAt = new Date(this.now()).toISOString();
|
|
602
657
|
await this.releaseEntry(registry, entry);
|
|
658
|
+
await report();
|
|
603
659
|
return preservedRef
|
|
604
660
|
? { choice, preservedRef, restoreCommand: restoreCommand(preservedRef) }
|
|
605
661
|
: { choice };
|
|
@@ -968,6 +1024,14 @@ export class WorktreePool {
|
|
|
968
1024
|
entry.deliveredCommit = head;
|
|
969
1025
|
entry.deliveredRef = ref;
|
|
970
1026
|
await this.releaseEntry(registry, entry);
|
|
1027
|
+
if (entry.taskId)
|
|
1028
|
+
await this.onDeliveryOutcome({
|
|
1029
|
+
projectId: entry.projectId,
|
|
1030
|
+
taskId: entry.taskId,
|
|
1031
|
+
outcome: 'delivered',
|
|
1032
|
+
commit: head,
|
|
1033
|
+
pushed: true,
|
|
1034
|
+
}).catch(() => undefined);
|
|
971
1035
|
}
|
|
972
1036
|
}
|
|
973
1037
|
async releaseEntry(registry, entry) {
|
|
@@ -1076,6 +1140,19 @@ export class WorktreePool {
|
|
|
1076
1140
|
if (result.exitCode !== 0)
|
|
1077
1141
|
throw refuse('Git could not select the task branch. Reconcile the interrupted allocation; no reset was attempted.');
|
|
1078
1142
|
}
|
|
1143
|
+
async moveBranch(repoRoot, branch, commit) {
|
|
1144
|
+
const current = await this.git.sourceIdentity(repoRoot, 'refs/heads/' + branch);
|
|
1145
|
+
if (current?.sourceCommit === commit)
|
|
1146
|
+
return;
|
|
1147
|
+
const result = await this.runner.run('git', [
|
|
1148
|
+
'update-ref',
|
|
1149
|
+
'refs/heads/' + branch,
|
|
1150
|
+
commit,
|
|
1151
|
+
current?.sourceCommit ?? '0'.repeat(commit.length),
|
|
1152
|
+
], { cwd: repoRoot });
|
|
1153
|
+
if (result.exitCode !== 0)
|
|
1154
|
+
throw refuse(`Git could not bring branch ${branch} to its newest commit. Call task.branch with a new decisionAttempt; nothing was allocated.`);
|
|
1155
|
+
}
|
|
1079
1156
|
async repairCreating(entry) {
|
|
1080
1157
|
if (entry.phase !== 'creating' || !(await pathExists(entry.repoRoot)))
|
|
1081
1158
|
return;
|
|
@@ -40,7 +40,7 @@ Renew `task.heartbeat` using the exact returned task, path and ownership generat
|
|
|
40
40
|
|
|
41
41
|
Use `task.pause` when work stops or is handed to another client. It preserves task state and files, invalidates the old owner, and allows safe clean directories to be reused. It is not `task.abandon`. Resume reacquires the original directory when possible or uses the retained task branch in another safe directory. `worktree.reconcile` recovers interrupted allocation and validated local ownership records against Git. Never edit the registry, force checkout, stash/reset/clean, remove branches, kill a client or delete a worktree to bypass a refusal.
|
|
42
42
|
|
|
43
|
-
`task.close` retains a managed checkout while changes or delivery remain pending. After delivery, release with `deliveryOutcome:'delivered'` and the current generation. Once the task's commits are on a remote branch the pool settles the folder by itself; `worktree.release` then answers `alreadyReleased`, `session.resume` of the closed task answers `folderReleased`, and nothing further is needed. When the user decides not to deliver, call `worktree.release` with `deliveryOutcome:'cancelled'`; the product asks what happens to the folder and saves any changes to a ref. Never run `git restore` or `git clean` yourself to free a worktree, and never report a delivery that did not happen. Dirty/staged/untracked files, pending outbox/intents and unfinished Git operations prevent release. An old release cannot affect a reused directory. User-owned existing directories are never added to automatic cleanup. When the user wants to work in the main folder, they answer that in `task.branch`'s start form; when an open task holds the folder, that option names the task that would move out. A closed or abandoned task stops holding the folder by itself; a task still running in another client keeps it until it is paused there. Never edit or delete the reservation ref by hand.
|
|
43
|
+
`task.close` retains a managed checkout while changes or delivery remain pending. After delivery, release with `deliveryOutcome:'delivered'` and the current generation; that release publishes the task's source memory first and reports it as `sourcePublication`. A skipped publication names its reason and never undoes the delivery. Once the task's commits are on a remote branch the pool settles the folder by itself; `worktree.release` then answers `alreadyReleased`, `session.resume` of the closed task answers `folderReleased`, and nothing further is needed. When the user decides not to deliver, call `worktree.release` with `deliveryOutcome:'cancelled'`; the product asks what happens to the folder and saves any changes to a ref. Never run `git restore` or `git clean` yourself to free a worktree, and never report a delivery that did not happen. Dirty/staged/untracked files, pending outbox/intents and unfinished Git operations prevent release. An old release cannot affect a reused directory. User-owned existing directories are never added to automatic cleanup. When the user wants to work in the main folder, they answer that in `task.branch`'s start form; when an open task holds the folder, that option names the task that would move out. A closed or abandoned task stops holding the folder by itself; a task still running in another client keeps it until it is paused there. Never edit or delete the reservation ref by hand.
|
|
44
44
|
|
|
45
45
|
Read-only tasks allocate no worktree. If the user authorizes writing, settle `task.branch` for that same externalTaskId, use the allocated repoRoot, then call `context.prepare_change` with `transitionToWrite`. Its pinned source must still match; choosing a different source requires a new task, never rewriting the original task's source identity. Existing lease, discipline, source-memory, verify and commit gates remain mandatory.
|
|
46
46
|
|
|
@@ -91,6 +91,27 @@ The catalogue returns `items`, `total`, `offset` and `limit`. Page with `offset`
|
|
|
91
91
|
|
|
92
92
|
Session entry uses the backend's actionable selection. An empty page remains empty, and a failed lookup offers no work items; never retry an unfiltered list to classify statuses locally. Reuse existing user choices. Any unresolved workflow choice must use the native questionnaire.
|
|
93
93
|
|
|
94
|
+
### Starting work on a work item
|
|
95
|
+
|
|
96
|
+
When a write task starts on a work item — `session.bootstrap` in write or scaffold mode, or `context.prepare_change` with `transitionToWrite` — the project's rule for the "work started" event is applied and the response carries `workStarted`. Its `nextAction` is written for you; relay it in the user's language:
|
|
97
|
+
|
|
98
|
+
- `moved`: say in one line which stage the item moved from and to, for example that KAN-12 moved from Todo to InProgress.
|
|
99
|
+
- `confirmation_required`: the project asks before this move. Ask the user with the native questionnaire under the task's decision mode; when they agree, call `work_item.update` with the given status slug and `expectedVersion`. A refusal for a changed version means someone moved the item meanwhile: read it again rather than retrying.
|
|
100
|
+
- `unchanged` with `target_meaning_missing`: the target stage has no written meaning, and Engineering Memory never moves work into a stage it cannot explain. Mention it once; an owner can describe the stage with `work_item.describe_status`.
|
|
101
|
+
- `unchanged` for any other reason: the item was already there, is past the start, or the project ignores the event. Say nothing unless asked.
|
|
102
|
+
|
|
103
|
+
Only work waiting to be started moves: a stage of the new category, or the stage the project sends failed tests to. Work in progress, review, QA, parked or done is never moved back.
|
|
104
|
+
|
|
105
|
+
### Work item comments
|
|
106
|
+
|
|
107
|
+
Work that came back from testing carries the reason as a comment. Read `work_item.comments` before starting a round on it. A tester, product manager or developer writes a reason with `work_item.comment`; comments are permanent, so a wrong one is corrected by a new one. Keep credentials, personal data and production payloads out of them.
|
|
108
|
+
|
|
109
|
+
A new round on a work item starts with `task.branch` and its `workItemId`: the branch question then offers continuing the branch its earlier rounds used, and says so when the previous round's delivery is still open, because that round's last commits may not be on origin yet. A planned item keeps its plan branch.
|
|
110
|
+
|
|
111
|
+
### Delivery records
|
|
112
|
+
|
|
113
|
+
Every closed write task has a delivery record that the whole project sees: whether it was answered, delivered or concluded, and by whom. `task.close` records the delivery answer there. When another member answered first, the response shows their answer; the choice made here still decides the Git action here, so tell the user both. `session.entry` lists open deliveries as `openDeliveries`: mention them in one line. For one whose `folderHere` is set, `task.close` with its `taskId` in that folder asks and performs the delivery. Any other one is answered or concluded with `task.delivery` when the user asks — for example when the files are no longer reachable ("conclude without delivery"). The worktree pool reports delivered and cancelled outcomes itself; never report a delivery by hand.
|
|
114
|
+
|
|
94
115
|
### Administration, product management and QA
|
|
95
116
|
|
|
96
117
|
Administrative authority and work discipline are independent. Organization admins can manage projects, assignments, repository URLs and work items in their organization. A global admin has those rights only in organizations they actively belong to. Ordinary users need a live explicit project assignment. Active organization administrators, including a global admin with live membership in that organization, inherit contributor access to its projects without a separate project grant. Fullstack/backend/web/mobile/frontend disciplines still govern implementation, while project overrides do not grant administration.
|
|
@@ -284,8 +305,10 @@ current. Select linkedSources explicitly by accessible project id, commit and tr
|
|
|
284
305
|
do not guess the counterpart's source from this repository's branch. Unselected or unknown linked
|
|
285
306
|
sources are not verified endpoint contracts.
|
|
286
307
|
|
|
287
|
-
If task.close reported sourcePublication.applicable,
|
|
288
|
-
|
|
308
|
+
If task.close reported sourcePublication.applicable, worktree.release with deliveryOutcome delivered
|
|
309
|
+
publishes the source memory after the already-authorized commit and reports the result as
|
|
310
|
+
sourcePublication. Call memory.publish_task with the closed task id and repoRoot yourself only when
|
|
311
|
+
that result names a reason you can fix, or when the task has no worktree to release. Otherwise there is
|
|
289
312
|
nothing to publish, for one of two distinct reasons publish_task's reason field names: a project
|
|
290
313
|
with no inspected source snapshot at all has legacy-unverified coverage, while a source-aware task
|
|
291
314
|
whose commit and tree were never resolved to an inspected snapshot has unknown coverage.
|