engineering-memory 1.10.0 → 1.10.2

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.2",
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()
@@ -59,19 +59,24 @@ export class RepositoryResolver {
59
59
  projectId: explicitProjectId ?? binding?.projectId ?? null,
60
60
  schemaVersion: binding?.schemaVersion ?? null,
61
61
  repoFingerprint,
62
+ legacyFingerprints: binding ? [] : [await this.git.fingerprint(repoRoot, 1)],
62
63
  git,
63
64
  };
64
65
  }
65
- async writeBinding(repoRoot, projectId) {
66
+ async writeBinding(repoRoot, projectId, fingerprint) {
66
67
  assertProjectId(projectId);
67
68
  const repository = await this.resolve(repoRoot, projectId);
69
+ const older = repository.legacyFingerprints.includes(fingerprint);
70
+ if (fingerprint !== repository.repoFingerprint && !older) {
71
+ throw new Error('The confirmed project fingerprint does not identify this repository');
72
+ }
68
73
  const repositoryKey = await this.keyFor(repository.repoRoot);
69
74
  const bindingPath = this.pathFor(repositoryKey);
70
75
  await writeJson(bindingPath, {
71
76
  projectId,
72
- schemaVersion: repository.schemaVersion ?? this.markerSchemaVersion,
77
+ schemaVersion: older ? 1 : (repository.schemaVersion ?? this.markerSchemaVersion),
73
78
  repositoryKey,
74
- repoFingerprint: repository.repoFingerprint,
79
+ repoFingerprint: fingerprint,
75
80
  }, this.stateRoot);
76
81
  return bindingPath;
77
82
  }
@@ -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',
@@ -70,7 +70,10 @@ export class BridgeService {
70
70
  if (!repository.bindingPath) {
71
71
  const resolved = await this.dependencies.client.request(endpoints.projectResolve, {
72
72
  method: 'POST',
73
- body: { repoFingerprint: repository.repoFingerprint },
73
+ body: {
74
+ repoFingerprint: repository.repoFingerprint,
75
+ legacyFingerprints: repository.legacyFingerprints,
76
+ },
74
77
  });
75
78
  const project = objectValue(resolved.data);
76
79
  const restoreRequired = Boolean(project?.archivedAt);
@@ -248,7 +251,7 @@ export class BridgeService {
248
251
  });
249
252
  }
250
253
  const recoveredDeliveries = await this.recoverJournalOutbox(projectId, taskSlug);
251
- const outbox = await this.flushOutbox();
254
+ const outbox = await this.flushOutbox(pointer?.taskId);
252
255
  const afterSequence = input.afterSequence ?? 0;
253
256
  const response = await this.dependencies.client.request(endpoints.sessionResume, {
254
257
  method: 'POST',
@@ -278,7 +281,7 @@ export class BridgeService {
278
281
  .release(backendTask.id);
279
282
  }
280
283
  let localJournal = await this.dependencies.journal.load(projectId, taskSlug);
281
- let pendingOutbox = await this.dependencies.outbox.list();
284
+ let pendingOutbox = await this.dependencies.outbox.listForTask(backendTask.id);
282
285
  const responseSource = this.dependencies.client.getResponseSource(response);
283
286
  const recoveredVerification = responseSource !== 'stale_cache' && pointer
284
287
  ? await this.retryVerificationRecovery(repository, pointer, backend)
@@ -306,7 +309,7 @@ export class BridgeService {
306
309
  documents: backendDocuments,
307
310
  });
308
311
  blockedJournalEventIds = hydration.blockedEventIds;
309
- const entries = await this.dependencies.outbox.list();
312
+ const entries = await this.dependencies.outbox.listForTask(backendTask.id);
310
313
  for (const eventId of hydration.synchronizedEventIds) {
311
314
  const entry = entries.find((value) => value.journalRef?.eventId === eventId);
312
315
  if (entry) {
@@ -337,7 +340,7 @@ export class BridgeService {
337
340
  }
338
341
  }
339
342
  localJournal = await this.dependencies.journal.load(projectId, taskSlug);
340
- pendingOutbox = await this.dependencies.outbox.list();
343
+ pendingOutbox = await this.dependencies.outbox.listForTask(backendTask.id);
341
344
  hydratedFromBackend = !hadLocalProjection;
342
345
  mergedFromBackend = hadLocalProjection;
343
346
  }
@@ -437,10 +440,12 @@ export class BridgeService {
437
440
  async contextPrepareChange(input) {
438
441
  return await this.execute(async () => {
439
442
  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
443
  const pointer = await this.dependencies.activeContexts.loadForSession(repository.repoFingerprint, input.sessionId);
444
+ if (!pointer)
445
+ throw refuse('Resume this task before requesting a change lease.', 'session.resume');
446
+ await this.recoverJournalOutbox(pointer.projectId, pointer.taskSlug);
447
+ const outbox = await this.flushOutbox(pointer.taskId);
448
+ const pendingOutbox = await this.dependencies.outbox.listForTask(pointer.taskId);
444
449
  if (pointer?.verificationIntent || pointer?.closeIntent) {
445
450
  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
451
  }
@@ -992,8 +997,9 @@ export class BridgeService {
992
997
  return await this.execute(async () => {
993
998
  return await this.taskExclusive(input.taskId, async () => {
994
999
  await this.recoverJournalOutbox();
995
- const outbox = await this.flushOutbox();
996
- const pendingOutboxCount = (await this.dependencies.outbox.list()).length;
1000
+ const outbox = await this.flushOutbox(input.taskId);
1001
+ const pendingOutboxCount = (await this.dependencies.outbox.listForTask(input.taskId))
1002
+ .length;
997
1003
  const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
998
1004
  if (pendingOutboxCount > 0) {
999
1005
  return asJsonValue({
@@ -1358,7 +1364,9 @@ export class BridgeService {
1358
1364
  projectName: input.projectName,
1359
1365
  projectSlug: input.projectSlug ?? slugify(input.projectName),
1360
1366
  repoFingerprint: repository.repoFingerprint,
1367
+ legacyFingerprints: repository.legacyFingerprints,
1361
1368
  framework: input.framework,
1369
+ developmentArea: input.developmentArea,
1362
1370
  figmaConfig: input.figmaConfig,
1363
1371
  initialProjectProfile: input.initialProjectProfile,
1364
1372
  });
@@ -1380,6 +1388,7 @@ export class BridgeService {
1380
1388
  const marker = objectValue(data?.marker);
1381
1389
  if (!project ||
1382
1390
  typeof project.id !== 'string' ||
1391
+ typeof project.repoFingerprint !== 'string' ||
1383
1392
  !profileResource ||
1384
1393
  !profileRevision ||
1385
1394
  !policy ||
@@ -1388,7 +1397,7 @@ export class BridgeService {
1388
1397
  readDiscoveryUnits(policy).length === 0) {
1389
1398
  throw refuse('Project setup response is not policy-ready', 'project.setup');
1390
1399
  }
1391
- const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, project.id);
1400
+ const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, project.id, project.repoFingerprint);
1392
1401
  return asJsonValue({ ...data, marker: null, markerPath: null, bindingPath });
1393
1402
  });
1394
1403
  }
@@ -1685,13 +1694,18 @@ export class BridgeService {
1685
1694
  }
1686
1695
  const response = await this.dependencies.client.request(endpoints.projectBind(input.projectId), {
1687
1696
  method: 'POST',
1688
- body: { repoFingerprint: repository.repoFingerprint },
1697
+ body: {
1698
+ repoFingerprint: repository.repoFingerprint,
1699
+ legacyFingerprints: repository.legacyFingerprints,
1700
+ },
1689
1701
  });
1690
1702
  const project = objectValue(response.data);
1691
- if (!project || project.id !== input.projectId) {
1703
+ if (!project ||
1704
+ project.id !== input.projectId ||
1705
+ typeof project.repoFingerprint !== 'string') {
1692
1706
  throw refuse('Project bind response does not match the selected project', 'project.resolve');
1693
1707
  }
1694
- const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, input.projectId);
1708
+ const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, input.projectId, project.repoFingerprint);
1695
1709
  return asJsonValue({
1696
1710
  resolved: true,
1697
1711
  bound: true,
@@ -1704,7 +1718,10 @@ export class BridgeService {
1704
1718
  }
1705
1719
  const response = await this.dependencies.client.request(endpoints.projectResolve, {
1706
1720
  method: 'POST',
1707
- body: { repoFingerprint: repository.repoFingerprint },
1721
+ body: {
1722
+ repoFingerprint: repository.repoFingerprint,
1723
+ legacyFingerprints: repository.legacyFingerprints,
1724
+ },
1708
1725
  });
1709
1726
  const project = objectValue(response.data);
1710
1727
  const restoreRequired = Boolean(project?.archivedAt);
@@ -2085,11 +2102,17 @@ export class BridgeService {
2085
2102
  return asJsonValue({ queued: true, outboxId: queued.id, idempotencyKey });
2086
2103
  }
2087
2104
  }
2088
- async flushOutbox() {
2089
- const entries = await this.dependencies.outbox.list();
2105
+ async flushOutbox(taskId) {
2106
+ const entries = taskId
2107
+ ? await this.dependencies.outbox.listForTask(taskId)
2108
+ : await this.dependencies.outbox.list();
2090
2109
  const synchronized = [];
2091
2110
  const blocked = [];
2111
+ const blockedTasks = new Set();
2092
2112
  for (const entry of entries) {
2113
+ const deliveryTaskId = taskIdFromDeliverySafe(entry);
2114
+ if (blockedTasks.has(deliveryTaskId))
2115
+ continue;
2093
2116
  try {
2094
2117
  const response = await this.dependencies.client.request(entry.path, {
2095
2118
  method: entry.method,
@@ -2115,7 +2138,7 @@ export class BridgeService {
2115
2138
  await this.dependencies.journal.markDeliveryAttempt(entry.journalRef.projectId, entry.journalRef.taskSlug, entry.journalRef.eventId, errorKind);
2116
2139
  }
2117
2140
  blocked.push({ id: entry.id, error: errorKind });
2118
- break;
2141
+ blockedTasks.add(deliveryTaskId);
2119
2142
  }
2120
2143
  }
2121
2144
  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);
@@ -4,7 +4,7 @@
4
4
 
5
5
  Sign-in decides nothing beyond who the user is. The organization and the project are chosen after it, through the questionnaires in `questionnaires.md`, and both listings end with an option to create a new one. Ask for both whenever this session has not already confirmed them, and ask again the moment the user says they want to change either — changing the organization always means choosing the project again.
6
6
 
7
- Use the binding returned by `session.entry` as the authority. The bridge stores the selected project and repository identity under `~/.engineering-memory/origins/<api-hash>/project-bindings/`, outside the installed runtime and working tree. It imports a legacy `.engineering-memory/project.json` once, preserving its schema and exact backend fingerprint for existing tasks and receipts. It leaves that file unchanged; after migration the file is optional and may be removed through the repository's normal Git workflow. New bindings never create it. Never infer a selection from a directory or remote, and never edit binding records by hand. Separate clones or a changed remote require a new explicit selection; worktrees of the same checkout share the local binding.
7
+ Use the binding returned by `session.entry` as the authority. The bridge stores the selected project and repository identity under `~/.engineering-memory/origins/<api-hash>/project-bindings/`, outside the installed runtime and working tree. It imports a legacy `.engineering-memory/project.json` once, preserving its schema and exact backend fingerprint for existing tasks and receipts. It leaves that file unchanged; after migration the file is optional and may be removed through the repository's normal Git workflow. New bindings never create it. A checkout that never imported the marker still finds a project bound under the older identity: the bridge presents that identity alongside the current one and stores whichever the backend confirms. Never infer a selection from a directory or remote, and never edit binding records by hand. Separate clones or a changed remote require a new explicit selection; worktrees of the same checkout share the local binding.
8
8
 
9
9
  An archived project is recoverable state, not a missing or conflicting binding. When an owner receives `project.restore`, use the `projectId` and `expectedVersion` in its data and call that operation before retrying. A non-owner receives `project.member_list` instead: list the members, identify a project owner and explain that the owner must restore the exact project before this session can retry. Use `project.list` with `includeArchived: true` when an owner must first select the archived project. Never create or bind a replacement project to escape archive state.
10
10
 
@@ -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.