engineering-memory 1.11.21 → 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.
@@ -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
- if (!input.resume && (await this.git.branchExists(input.repoRoot, input.branch)))
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
- if (checkout)
227
- throw refuse('This branch already has a checkout. Resume its task instead of creating or resetting the branch.');
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 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');
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
- await this.settleDelivered(registry, project.entries);
257
- let reusable;
289
+ let reusable = holding;
258
290
  const activeRoot = key(await this.documentsDirectory());
259
291
  const legacyRoot = await this.legacyDocumentsDirectory();
260
- for (const candidate of project.entries
261
- .filter((e) => e.managed &&
262
- e.commonDir === commonDir &&
263
- !this.outsideActiveRoot(e, activeRoot, legacyRoot))
264
- .sort((a, b) => a.slot - b.slot)) {
265
- if (candidate.pendingDelivery ||
266
- ['creating', 'quarantined'].includes(candidate.phase) ||
267
- (candidate.owner && (await this.ownerAlive(candidate.owner))))
268
- continue;
269
- const view = await this.view(candidate, policy);
270
- if (view.activity === 'available') {
271
- reusable = candidate;
272
- break;
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;
@@ -3,7 +3,10 @@ import { performance } from 'node:perf_hooks';
3
3
  import { sha256 } from './hash.js';
4
4
  import { BridgeRecoveryError } from '../runtime/recovery-error.js';
5
5
  export async function localMutex(identity, work, timeoutMs = 60000) {
6
- const port = 20000 + (parseInt(sha256(identity).slice(0, 8), 16) % 30000);
6
+ const hash = sha256(identity);
7
+ const address = process.platform === 'win32'
8
+ ? { path: String.raw `\\.\pipe\engineering-memory-` + hash }
9
+ : { host: '127.0.0.1', port: 20000 + (parseInt(hash.slice(0, 8), 16) % 30000) };
7
10
  const deadline = performance.now() + timeoutMs;
8
11
  let server;
9
12
  for (;;) {
@@ -11,7 +14,7 @@ export async function localMutex(identity, work, timeoutMs = 60000) {
11
14
  try {
12
15
  await new Promise((resolve, reject) => {
13
16
  server.once('error', reject);
14
- server.listen({ host: '127.0.0.1', port, exclusive: true }, () => {
17
+ server.listen({ ...address, exclusive: true }, () => {
15
18
  server.removeListener('error', reject);
16
19
  resolve();
17
20
  });
@@ -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. 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, call memory.publish_task after the
288
- already-authorized commit is created, with the closed task id and repoRoot; otherwise there is
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.