engineering-memory 1.10.0 → 1.10.1

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.10.0",
3
+ "version": "1.10.1",
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,4 @@
1
+ import { sha256 } from '../utilities/hash.js';
1
2
  import { readdir } from 'node:fs/promises';
2
3
  import { join } from 'node:path';
3
4
  import { RepositoryResolver } from '../project/repository.js';
@@ -22,8 +23,10 @@ export class VerificationGate {
22
23
  }
23
24
  const verifiedAt = new Date();
24
25
  const taskChanges = await this.git.taskManifest(input.repoRoot, input.taskChanges);
25
- await writeJson(this.pathFor(input.repoFingerprint), {
26
- schemaVersion: 4,
26
+ const worktreeId = worktreeIdentity(await this.git.findRoot(input.repoRoot));
27
+ await writeJson(this.pathFor(input.repoFingerprint, worktreeId, input.taskId), {
28
+ schemaVersion: 5,
29
+ worktreeId,
27
30
  taskId: input.taskId,
28
31
  projectId: input.projectId,
29
32
  repoFingerprint: input.repoFingerprint,
@@ -39,16 +42,12 @@ export class VerificationGate {
39
42
  }, this.root);
40
43
  }
41
44
  async verify(repoRoot) {
42
- const [repository, stagedChanges, pending, scaffolding] = await Promise.all([
45
+ const [repository, stagedChanges, scaffolding] = await Promise.all([
43
46
  this.repositories.resolve(repoRoot),
44
47
  this.git.stagedManifest(repoRoot),
45
- this.outbox.list(),
46
48
  this.git.temporaryScaffolding(repoRoot),
47
49
  ]);
48
50
  const { repoFingerprint: fingerprint, git: manifest } = repository;
49
- if (pending.length > 0) {
50
- return { allowed: false, reason: 'offline_outbox_pending', diffHash: manifest.diffHash };
51
- }
52
51
  const staged = scaffolding.filter((match) => match.staged);
53
52
  if (staged.length > 0) {
54
53
  return {
@@ -58,10 +57,29 @@ export class VerificationGate {
58
57
  details: staged.map((match) => `${match.path}:${match.line}`),
59
58
  };
60
59
  }
61
- const receipt = await readJson(this.pathFor(fingerprint), this.root);
60
+ const branch = await this.git.currentBranch(repository.repoRoot);
61
+ const candidates = await this.receiptsFor(fingerprint, worktreeIdentity(repository.repoRoot));
62
+ const relevant = candidates.filter((candidate) => !repository.projectId || candidate.projectId === repository.projectId);
63
+ relevant.sort((a, b) => b.verifiedAt.localeCompare(a.verifiedAt) || a.taskId.localeCompare(b.taskId));
64
+ const matchesBranch = (candidate) => !candidate.branch || candidate.branch === branch;
65
+ const receipt = relevant.find((candidate) => candidate.taskClosed &&
66
+ matchesBranch(candidate) &&
67
+ candidate.diffHash === manifest.diffHash &&
68
+ isTaskManifest(candidate.taskChanges) &&
69
+ stagedChanges.every((entry) => belongsToTask(entry, candidate.taskChanges))) ??
70
+ relevant.find(matchesBranch) ??
71
+ relevant[0];
62
72
  if (!receipt) {
63
73
  return { allowed: false, reason: 'task_verify_required', diffHash: manifest.diffHash };
64
74
  }
75
+ if ((await this.outbox.listForTask(receipt.taskId)).length > 0) {
76
+ return {
77
+ allowed: false,
78
+ reason: 'offline_outbox_pending',
79
+ diffHash: manifest.diffHash,
80
+ taskId: receipt.taskId,
81
+ };
82
+ }
65
83
  if (!receipt.taskClosed) {
66
84
  return {
67
85
  allowed: false,
@@ -70,7 +88,7 @@ export class VerificationGate {
70
88
  taskId: receipt.taskId,
71
89
  };
72
90
  }
73
- if (receipt.schemaVersion !== 4 ||
91
+ if (![4, 5].includes(receipt.schemaVersion) ||
74
92
  !receipt.projectId ||
75
93
  typeof receipt.taskVersion !== 'number' ||
76
94
  !Number.isSafeInteger(receipt.taskVersion) ||
@@ -83,7 +101,6 @@ export class VerificationGate {
83
101
  taskId: receipt.taskId,
84
102
  };
85
103
  }
86
- const branch = await this.git.currentBranch(repoRoot);
87
104
  if (receipt.branch && branch !== receipt.branch) {
88
105
  return {
89
106
  allowed: false,
@@ -191,10 +208,31 @@ export class VerificationGate {
191
208
  await removeFile(join(this.root, entry.name), this.root);
192
209
  }
193
210
  }
194
- pathFor(repoFingerprint) {
195
- return join(this.root, `${safeSegment(repoFingerprint)}.json`);
211
+ async receiptsFor(repoFingerprint, worktreeId) {
212
+ await ensureManagedDirectory(this.root, this.root);
213
+ const prefix = safeSegment(repoFingerprint);
214
+ const entries = await readdir(this.root, { withFileTypes: true });
215
+ const receipts = [];
216
+ for (const entry of entries) {
217
+ if (entry.name !== `${prefix}.json` &&
218
+ !(entry.name.startsWith(`${prefix}-`) && entry.name.endsWith('.json')))
219
+ continue;
220
+ if (!entry.isFile() || entry.isSymbolicLink())
221
+ throw new Error(`Unsafe verification gate entry: ${entry.name}`);
222
+ const receipt = await readJson(join(this.root, entry.name), this.root);
223
+ if (receipt?.repoFingerprint === repoFingerprint &&
224
+ (entry.name === `${prefix}.json` || receipt.worktreeId === worktreeId))
225
+ receipts.push(receipt);
226
+ }
227
+ return receipts;
228
+ }
229
+ pathFor(repoFingerprint, worktreeId, taskId) {
230
+ return join(this.root, `${safeSegment(repoFingerprint)}-${worktreeId}-${safeSegment(taskId)}.json`);
196
231
  }
197
232
  }
233
+ function worktreeIdentity(repoRoot) {
234
+ return sha256(process.platform === 'win32' ? repoRoot.toLowerCase() : repoRoot);
235
+ }
198
236
  function asObject(value) {
199
237
  return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
200
238
  }
@@ -487,6 +487,7 @@ export function registerEngineeringMemoryTools(server, service) {
487
487
  projectName: z.string().min(2),
488
488
  projectSlug: z.string().min(2).optional(),
489
489
  framework: z.string().optional(),
490
+ developmentArea: z.enum(['backend', 'web', 'mobile']).nullable().optional(),
490
491
  figmaConfig: jsonObject,
491
492
  initialProjectProfile: z.object({
492
493
  title: z.string().min(2),
@@ -701,6 +702,7 @@ export function registerEngineeringMemoryTools(server, service) {
701
702
  expectedVersion: z.number().int().min(1),
702
703
  name: z.string().min(2).max(160).optional(),
703
704
  framework: z.string().min(2).max(80).nullable().optional(),
705
+ developmentArea: z.enum(['backend', 'web', 'mobile']).nullable().optional(),
704
706
  repositoryUrl: z.string().url().nullable().optional(),
705
707
  workItemPrefix: z
706
708
  .string()
@@ -7,6 +7,7 @@ export const backendRecoveryOperationNames = [
7
7
  'organization.member_list',
8
8
  'organization.member_upsert',
9
9
  'project.setup',
10
+ 'project.update',
10
11
  'project.resolve',
11
12
  'project.list',
12
13
  'project.restore',
@@ -248,7 +248,7 @@ export class BridgeService {
248
248
  });
249
249
  }
250
250
  const recoveredDeliveries = await this.recoverJournalOutbox(projectId, taskSlug);
251
- const outbox = await this.flushOutbox();
251
+ const outbox = await this.flushOutbox(pointer?.taskId);
252
252
  const afterSequence = input.afterSequence ?? 0;
253
253
  const response = await this.dependencies.client.request(endpoints.sessionResume, {
254
254
  method: 'POST',
@@ -278,7 +278,7 @@ export class BridgeService {
278
278
  .release(backendTask.id);
279
279
  }
280
280
  let localJournal = await this.dependencies.journal.load(projectId, taskSlug);
281
- let pendingOutbox = await this.dependencies.outbox.list();
281
+ let pendingOutbox = await this.dependencies.outbox.listForTask(backendTask.id);
282
282
  const responseSource = this.dependencies.client.getResponseSource(response);
283
283
  const recoveredVerification = responseSource !== 'stale_cache' && pointer
284
284
  ? await this.retryVerificationRecovery(repository, pointer, backend)
@@ -306,7 +306,7 @@ export class BridgeService {
306
306
  documents: backendDocuments,
307
307
  });
308
308
  blockedJournalEventIds = hydration.blockedEventIds;
309
- const entries = await this.dependencies.outbox.list();
309
+ const entries = await this.dependencies.outbox.listForTask(backendTask.id);
310
310
  for (const eventId of hydration.synchronizedEventIds) {
311
311
  const entry = entries.find((value) => value.journalRef?.eventId === eventId);
312
312
  if (entry) {
@@ -337,7 +337,7 @@ export class BridgeService {
337
337
  }
338
338
  }
339
339
  localJournal = await this.dependencies.journal.load(projectId, taskSlug);
340
- pendingOutbox = await this.dependencies.outbox.list();
340
+ pendingOutbox = await this.dependencies.outbox.listForTask(backendTask.id);
341
341
  hydratedFromBackend = !hadLocalProjection;
342
342
  mergedFromBackend = hadLocalProjection;
343
343
  }
@@ -437,10 +437,12 @@ export class BridgeService {
437
437
  async contextPrepareChange(input) {
438
438
  return await this.execute(async () => {
439
439
  const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
440
- await this.recoverJournalOutbox();
441
- const outbox = await this.flushOutbox();
442
- const pendingOutbox = await this.dependencies.outbox.list();
443
440
  const pointer = await this.dependencies.activeContexts.loadForSession(repository.repoFingerprint, input.sessionId);
441
+ if (!pointer)
442
+ throw refuse('Resume this task before requesting a change lease.', 'session.resume');
443
+ await this.recoverJournalOutbox(pointer.projectId, pointer.taskSlug);
444
+ const outbox = await this.flushOutbox(pointer.taskId);
445
+ const pendingOutbox = await this.dependencies.outbox.listForTask(pointer.taskId);
444
446
  if (pointer?.verificationIntent || pointer?.closeIntent) {
445
447
  throw refuse('A verification or close intent from an earlier session is still unsettled, so a new lease would be taken against the wrong state.', 'session.resume');
446
448
  }
@@ -992,8 +994,9 @@ export class BridgeService {
992
994
  return await this.execute(async () => {
993
995
  return await this.taskExclusive(input.taskId, async () => {
994
996
  await this.recoverJournalOutbox();
995
- const outbox = await this.flushOutbox();
996
- const pendingOutboxCount = (await this.dependencies.outbox.list()).length;
997
+ const outbox = await this.flushOutbox(input.taskId);
998
+ const pendingOutboxCount = (await this.dependencies.outbox.listForTask(input.taskId))
999
+ .length;
997
1000
  const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
998
1001
  if (pendingOutboxCount > 0) {
999
1002
  return asJsonValue({
@@ -1359,6 +1362,7 @@ export class BridgeService {
1359
1362
  projectSlug: input.projectSlug ?? slugify(input.projectName),
1360
1363
  repoFingerprint: repository.repoFingerprint,
1361
1364
  framework: input.framework,
1365
+ developmentArea: input.developmentArea,
1362
1366
  figmaConfig: input.figmaConfig,
1363
1367
  initialProjectProfile: input.initialProjectProfile,
1364
1368
  });
@@ -2085,11 +2089,17 @@ export class BridgeService {
2085
2089
  return asJsonValue({ queued: true, outboxId: queued.id, idempotencyKey });
2086
2090
  }
2087
2091
  }
2088
- async flushOutbox() {
2089
- const entries = await this.dependencies.outbox.list();
2092
+ async flushOutbox(taskId) {
2093
+ const entries = taskId
2094
+ ? await this.dependencies.outbox.listForTask(taskId)
2095
+ : await this.dependencies.outbox.list();
2090
2096
  const synchronized = [];
2091
2097
  const blocked = [];
2098
+ const blockedTasks = new Set();
2092
2099
  for (const entry of entries) {
2100
+ const deliveryTaskId = taskIdFromDeliverySafe(entry);
2101
+ if (blockedTasks.has(deliveryTaskId))
2102
+ continue;
2093
2103
  try {
2094
2104
  const response = await this.dependencies.client.request(entry.path, {
2095
2105
  method: entry.method,
@@ -2115,7 +2125,7 @@ export class BridgeService {
2115
2125
  await this.dependencies.journal.markDeliveryAttempt(entry.journalRef.projectId, entry.journalRef.taskSlug, entry.journalRef.eventId, errorKind);
2116
2126
  }
2117
2127
  blocked.push({ id: entry.id, error: errorKind });
2118
- break;
2128
+ blockedTasks.add(deliveryTaskId);
2119
2129
  }
2120
2130
  }
2121
2131
  return asJsonValue({
@@ -81,6 +81,12 @@ export class OfflineOutbox {
81
81
  }));
82
82
  return values.filter((entry) => entry !== null).sort(compareEntries);
83
83
  }
84
+ async listForTask(taskId) {
85
+ return (await this.list()).filter((entry) => {
86
+ const body = entry.body;
87
+ return (body !== null && typeof body === 'object' && !Array.isArray(body) && body.taskId === taskId);
88
+ });
89
+ }
84
90
  async markAttempt(id, errorKind) {
85
91
  await this.exclusive(id, async () => {
86
92
  const path = this.pathFor(id);
@@ -184,4 +184,4 @@ When a pull request has been opened, keep going: check whether it merges cleanly
184
184
 
185
185
  ## Offline Behavior
186
186
 
187
- An unexpired cached context pack may be used for implementation. The bridge records checkpoints in its outbox. Permanent proposal review, strict verification, task close, and Git commit remain blocked until the backend is reachable and the outbox is synchronized.
187
+ An unexpired cached context pack may be used for implementation. The bridge records checkpoints in its outbox. Permanent proposal review, strict verification, task close, and Git commit require the backend and synchronization of the relevant task deliveries. An unrelated task or repository queue does not block this task; account switching and logout still require all deliveries to be resolved. Commit receipts are retained per task and worktree and still require an online attestation for the selected closed task and exact staged content.