engineering-memory 1.11.25 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engineering-memory",
3
- "version": "1.11.25",
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",
@@ -1,3 +1,3 @@
1
1
  {
2
- "gitHead": "487a37fd63becd1520c4e1f5e4ba13ed2de828e2"
2
+ "gitHead": "0f86f662e39b6ace530d89827a5b14018f1b68fe"
3
3
  }
@@ -375,10 +375,10 @@ export class GitInspector {
375
375
  diffHash: sha256(`${head ?? '<unborn>'}\n${stableStringify(hashManifest)}\n`),
376
376
  };
377
377
  }
378
- async manifestAgainst(repoRoot, baseCommit) {
378
+ async manifestAgainst(repoRoot, baseCommit, from = baseCommit) {
379
379
  const root = await this.findRoot(repoRoot);
380
380
  const head = await this.gitValue(root, ['rev-parse', 'HEAD']);
381
- const changedPaths = await this.changedPathsAgainstHead(root, baseCommit);
381
+ const changedPaths = await this.changedPathsAgainstHead(root, from);
382
382
  const hashManifest = canonicalHashManifest(changedPaths);
383
383
  return {
384
384
  repoRoot: root,
@@ -386,9 +386,26 @@ export class GitInspector {
386
386
  baseCommit,
387
387
  changedPaths,
388
388
  worktreeHash: sha256(`${stableStringify(hashManifest)}\n`),
389
- diffHash: sha256(`${baseCommit}\n${head ?? '<unborn>'}\n${stableStringify(hashManifest)}\n`),
389
+ diffHash: sha256(`${from}\n${head ?? '<unborn>'}\n${stableStringify(hashManifest)}\n`),
390
390
  };
391
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
+ }
392
409
  async isAncestor(repoRoot, ancestor, descendant = 'HEAD') {
393
410
  const result = await this.runner.run('git', ['merge-base', '--is-ancestor', ancestor, descendant], { cwd: repoRoot });
394
411
  return result.exitCode === 0;
@@ -403,12 +420,6 @@ export class GitInspector {
403
420
  'refs/remotes/',
404
421
  ]);
405
422
  }
406
- async committedPaths(repoRoot, baseCommit) {
407
- const result = await this.runner.run('git', ['diff', '--name-only', '-z', '--find-renames', baseCommit, 'HEAD', '--'], { cwd: repoRoot });
408
- if (result.exitCode !== 0)
409
- throw new Error(`Git committed diff failed: ${result.stderr.trim()}`);
410
- return result.stdout.split('\0').filter(Boolean).map(normalizeGitPath).sort();
411
- }
412
423
  async stagedManifest(repoRoot) {
413
424
  const root = await this.findRoot(repoRoot);
414
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
- (await this.git.committedPaths(repository.repoRoot, receipt.source.sourceCommit)).every((path) => receipt.taskChanges.some((change) => change.path === path))
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 repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
2416
- const pointer = await this.requireActivePointer(repository.repoFingerprint, input.taskId, false, 'verify');
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 { ...repository, git: await git.manifestAgainst(repository.repoRoot, base) };
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 branch = await this.dependencies.repositories.git.currentBranch(repository.repoRoot);
4885
- 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'}, which is not the task's own work committed on top of its source. Ask the user through the native questionnaire which way to go: switch this checkout back to ${task?.branch ?? 'that commit'} and continue the task, or end it with task.abandon.`, 'task.abandon');
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 this.dependencies.repositories.git.pushDestination(repository.repoRoot), objectValue(closedTask.sourcePublication)?.applicable === true),
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: `The task is closed and nothing has been committed. 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.`,
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: publishApplicable
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) {
@@ -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.