engineering-memory 1.6.2 → 1.6.4

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.
@@ -69,6 +69,9 @@ async function main(argumentList) {
69
69
  skillSource,
70
70
  '--bridge-runtime-source',
71
71
  staged,
72
+ ...(rest.includes('--client-version')
73
+ ? []
74
+ : ['--client-version', await publishedVersion()]),
72
75
  ]);
73
76
  } finally {
74
77
  await rm(staged, { recursive: true, force: true });
@@ -0,0 +1,29 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFile } from 'node:fs/promises';
3
+ import test from 'node:test';
4
+
5
+ const entryPoint = new URL('./engineering-memory.mjs', import.meta.url);
6
+ const manifest = new URL('../package.json', import.meta.url);
7
+
8
+ test('the published entry point tells the installer which version it is', async () => {
9
+ const source = await readFile(entryPoint, 'utf8');
10
+
11
+ assert.match(
12
+ source,
13
+ /'--client-version',\s*await publishedVersion\(\)/,
14
+ 'The installer is never told the version, so every install reports itself as unknown and no update is ever offered',
15
+ );
16
+ });
17
+
18
+ test('the published entry point tells the installer which backend it was built for', async () => {
19
+ const source = await readFile(entryPoint, 'utf8');
20
+
21
+ assert.match(source, /'--api-url',\s*await publishedApiUrl\(\)/);
22
+ });
23
+
24
+ test('the manifest carries a version the entry point can stamp', async () => {
25
+ const declared = JSON.parse(await readFile(manifest, 'utf8'));
26
+
27
+ assert.match(String(declared.version), /^\d+\.\d+\.\d+$/);
28
+ assert.equal(declared.name, 'engineering-memory');
29
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engineering-memory",
3
- "version": "1.6.2",
3
+ "version": "1.6.4",
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",
@@ -91,7 +91,7 @@ const discoverableKindNames = [
91
91
  ];
92
92
  const reconciliationEntry = z.object({
93
93
  resourceId: z.string().min(1),
94
- type: z.enum(['approved_revision', 'no_semantic_memory_change']),
94
+ type: z.enum(['approved_revision', 'no_semantic_memory_change', 'scaffold_applied']),
95
95
  proposalId: z.string().optional(),
96
96
  revisionId: z.string().optional(),
97
97
  reason: z.string().optional(),
@@ -130,6 +130,7 @@ export function registerEngineeringMemoryTools(server, service) {
130
130
  externalTaskId: z.string().min(2),
131
131
  objective: z.string().min(2),
132
132
  taskKind: z.string().min(2),
133
+ workItemKey: z.string().min(1).optional(),
133
134
  mode: z.enum(['write', 'read_only', 'scaffold']).optional(),
134
135
  knownRevisions: z.record(z.string(), z.number().int().min(0)).optional(),
135
136
  }),
@@ -287,7 +288,9 @@ export function registerEngineeringMemoryTools(server, service) {
287
288
  taskId: z.string().min(1),
288
289
  entries: z.array(reconciliationEntry).min(1).max(50).optional(),
289
290
  resourceId: z.string().min(1).optional(),
290
- type: z.enum(['approved_revision', 'no_semantic_memory_change']).optional(),
291
+ type: z
292
+ .enum(['approved_revision', 'no_semantic_memory_change', 'scaffold_applied'])
293
+ .optional(),
291
294
  proposalId: z.string().optional(),
292
295
  revisionId: z.string().optional(),
293
296
  reason: z.string().optional(),
@@ -326,6 +329,12 @@ export function registerEngineeringMemoryTools(server, service) {
326
329
  message: 'No-semantic-change reconciliation requires a reason of at least 8 characters',
327
330
  });
328
331
  }
332
+ if (entry.type === 'scaffold_applied' && (!entry.reason || entry.reason.length < 8)) {
333
+ context.addIssue({
334
+ code: 'custom',
335
+ message: 'Scaffold-applied reconciliation requires a reason of at least 8 characters',
336
+ });
337
+ }
329
338
  }
330
339
  }),
331
340
  }, async (input) => toolResult(await service.taskReconcile(input)));
@@ -13,12 +13,14 @@ export class ApiResponseError extends Error {
13
13
  code;
14
14
  retryable;
15
15
  envelope;
16
- constructor(message, httpStatus, code, retryable, envelope) {
16
+ recovery;
17
+ constructor(message, httpStatus, code, retryable, envelope, recovery = null) {
17
18
  super(message);
18
19
  this.httpStatus = httpStatus;
19
20
  this.code = code;
20
21
  this.retryable = retryable;
21
22
  this.envelope = envelope;
23
+ this.recovery = recovery;
22
24
  this.name = 'ApiResponseError';
23
25
  }
24
26
  }
@@ -106,7 +108,9 @@ export class ApiClient {
106
108
  this.responseSources.set(cached.value, 'stale_cache');
107
109
  return cached.value;
108
110
  }
109
- throw new ApiResponseError(envelope.errorModel?.text ?? envelope.message ?? `Backend returned ${response.status}`, response.status, envelope.errorModel?.code ?? null, response.status === 408 || response.status === 429 || response.status >= 500, envelope);
111
+ const recovery = envelope.errorModel?.recovery ?? null;
112
+ const refusal = envelope.errorModel?.text ?? envelope.message ?? `Backend returned ${response.status}`;
113
+ throw new ApiResponseError(recovery ? `${refusal} Recovery: call ${recovery}.` : refusal, response.status, envelope.errorModel?.code ?? null, response.status === 408 || response.status === 429 || response.status >= 500, envelope, recovery);
110
114
  }
111
115
  if (request.cacheKey) {
112
116
  await this.options.cache.set(cacheKey, response.headers.get('etag'), envelope);
@@ -53,6 +53,7 @@ export class BridgeService {
53
53
  externalTaskId: input.externalTaskId,
54
54
  objective: input.objective,
55
55
  taskKind: input.taskKind,
56
+ workItemKey: input.workItemKey,
56
57
  mode: input.mode,
57
58
  knownRevisions: input.knownRevisions,
58
59
  }, repository.repoRoot);
@@ -81,7 +82,7 @@ export class BridgeService {
81
82
  });
82
83
  }
83
84
  if (!projectId) {
84
- throw new Error('Repository marker does not contain a project binding');
85
+ throw refuse('This repository has a marker that names no project, so there is nothing to bootstrap against.', 'project.resolve');
85
86
  }
86
87
  const checkpointId = deterministicUuid('session.bootstrap', projectId, repository.repoFingerprint, input.externalTaskId);
87
88
  const response = await this.dependencies.client.request(endpoints.sessionBootstrap, {
@@ -92,6 +93,7 @@ export class BridgeService {
92
93
  externalTaskId: persistedBootstrap.externalTaskId,
93
94
  objective: persistedBootstrap.objective,
94
95
  taskKind: persistedBootstrap.taskKind,
96
+ workItemKey: persistedBootstrap.workItemKey,
95
97
  mode: persistedBootstrap.mode ?? 'write',
96
98
  repoFingerprint: repository.repoFingerprint,
97
99
  ...(input.mode === 'read_only'
@@ -116,7 +118,7 @@ export class BridgeService {
116
118
  checkpointEvent.idempotencyKey !== checkpointId ||
117
119
  typeof checkpointPayload?.createdAt !== 'string' ||
118
120
  !isIsoTimestamp(checkpointPayload.createdAt)) {
119
- throw new Error('Session bootstrap response is missing canonical checkpoint data');
121
+ throw refuse('The backend opened the session without the checkpoint the task is anchored to.', 'session.bootstrap');
120
122
  }
121
123
  const pointer = {
122
124
  repoFingerprint: repository.repoFingerprint,
@@ -163,9 +165,16 @@ export class BridgeService {
163
165
  return asJsonValue({ authentication, repository: publicRepository(repository) });
164
166
  }
165
167
  const live = await this.dependencies.activeContexts.list(repository.repoFingerprint);
166
- let pointer = input.taskSlug
167
- ? (live.find((entry) => entry.taskSlug === input.taskSlug) ?? null)
168
- : await this.dependencies.activeContexts.load(repository.repoFingerprint);
168
+ let pointer = null;
169
+ if (input.taskSlug) {
170
+ pointer = live.find((entry) => entry.taskSlug === input.taskSlug) ?? null;
171
+ }
172
+ else if (input.sessionId) {
173
+ pointer = live.find((entry) => entry.sessionId === input.sessionId) ?? null;
174
+ }
175
+ else if (live.length === 1) {
176
+ pointer = live[0];
177
+ }
169
178
  if (!pointer && input.taskSlug) {
170
179
  return asJsonValue({
171
180
  taskChoiceRequired: true,
@@ -174,7 +183,15 @@ export class BridgeService {
174
183
  repository: publicRepository(repository),
175
184
  });
176
185
  }
177
- if (!pointer && !input.sessionId && live.length > 1) {
186
+ if (!pointer && input.sessionId && live.length > 0) {
187
+ return asJsonValue({
188
+ taskChoiceRequired: true,
189
+ requestedSessionId: input.sessionId,
190
+ liveTasks: live.map(describePointer),
191
+ repository: publicRepository(repository),
192
+ });
193
+ }
194
+ if (!pointer && live.length > 1) {
178
195
  return asJsonValue({
179
196
  taskChoiceRequired: true,
180
197
  liveTasks: live.map(describePointer),
@@ -185,14 +202,14 @@ export class BridgeService {
185
202
  const taskSlug = input.taskSlug ?? pointer?.taskSlug;
186
203
  const sessionId = input.sessionId ?? pointer?.sessionId;
187
204
  if (!projectId || !taskSlug || !sessionId) {
188
- throw new Error('No active Engineering Memory task is available for this repository');
205
+ throw refuse('This repository has no live task to resume.', 'session.bootstrap');
189
206
  }
190
207
  if ((pointer &&
191
208
  (pointer.projectId !== projectId ||
192
209
  pointer.taskSlug !== taskSlug ||
193
210
  pointer.sessionId !== sessionId)) ||
194
211
  (repository.projectId && repository.projectId !== projectId)) {
195
- throw new Error('Active task pointer does not match the repository project binding');
212
+ throw refuse('The live task belongs to a different project than the one this repository is bound to.', 'project.resolve');
196
213
  }
197
214
  if (pointer?.closeIntent) {
198
215
  const recoveredClose = await this.retryCloseIntent(repository, pointer);
@@ -230,7 +247,7 @@ export class BridgeService {
230
247
  typeof backendTask.id !== 'string' ||
231
248
  typeof backendSession.id !== 'string' ||
232
249
  backendSession.id !== sessionId) {
233
- throw new Error('Session resume response is missing the active task snapshot');
250
+ throw refuse('The backend resumed the session without the task it belongs to.', 'session.resume');
234
251
  }
235
252
  let localJournal = await this.dependencies.journal.load(projectId, taskSlug);
236
253
  let pendingOutbox = await this.dependencies.outbox.list();
@@ -530,11 +547,11 @@ export class BridgeService {
530
547
  .map((entry) => objectValue(entry))
531
548
  .find((entry) => entry?.projectId === input.projectId);
532
549
  if (!project) {
533
- throw new Error('That project has no repository address recorded, or this account is not a member of it. An organization owner records the address.');
550
+ throw refuse('That project has no repository address recorded, or this account is not a member of it. An organization owner records the address.', 'project.list');
534
551
  }
535
552
  const result = await this.dependencies.repositories.git.clone(String(project.repositoryUrl), input.targetPath);
536
553
  if (!result.cloned) {
537
- throw new Error(`The repository could not be cloned: ${result.reason}. Engineering Memory never handles Git credentials, so this is between the developer's own Git configuration and the host.`);
554
+ throw refuse(`The repository could not be cloned: ${result.reason}. Engineering Memory never handles Git credentials, so this is between the developer's own Git configuration and the host.`, 'project.clone');
538
555
  }
539
556
  return asJsonValue({
540
557
  cloned: true,
@@ -819,7 +836,7 @@ export class BridgeService {
819
836
  return await this.execute(async () => {
820
837
  const entry = await this.dependencies.outbox.get(input.outboxId);
821
838
  if (!entry) {
822
- throw new Error('Pending delivery could not be found');
839
+ throw refuse('There is no pending delivery under that identifier.', 'session.resume');
823
840
  }
824
841
  const metadata = {
825
842
  id: entry.id,
@@ -851,7 +868,7 @@ export class BridgeService {
851
868
  return asJsonValue({ discarded: true, taskId, pendingDelivery: metadata });
852
869
  }
853
870
  if (!entry.lastError?.startsWith('api_409_')) {
854
- throw new Error('Only a confirmed backend version conflict can be rebased');
871
+ throw refuse('This delivery did not fail on a version conflict, so rebasing it is not the repair.', 'session.resume');
855
872
  }
856
873
  if (entry.journalRef) {
857
874
  const taskSnapshot = await this.refreshTaskPointer(taskId, entry.journalRef.projectId);
@@ -868,7 +885,7 @@ export class BridgeService {
868
885
  });
869
886
  }
870
887
  if (entry.operation !== 'memory.propose_revision' || !taskId) {
871
- throw new Error('This delivery cannot be safely rebased and must be discarded or retried');
888
+ throw refuse('This delivery cannot be rebased safely; discard it or retry it as it stands.', 'task.resolve_pending_delivery');
872
889
  }
873
890
  if (!input.proposalRebaseApproved || input.confirmedBaseRevision === undefined) {
874
891
  return asJsonValue({
@@ -882,7 +899,7 @@ export class BridgeService {
882
899
  const projectId = typeof proposal?.projectId === 'string' ? proposal.projectId : null;
883
900
  const queuedBaseRevision = proposal?.baseRevision;
884
901
  if (!projectId || typeof queuedBaseRevision !== 'number') {
885
- throw new Error('Queued proposal cannot establish its project revision contract');
902
+ throw refuse('The queued proposal does not carry the revision it was written against.', 'memory.propose_revision');
886
903
  }
887
904
  if (queuedBaseRevision !== input.confirmedBaseRevision) {
888
905
  return asJsonValue({
@@ -903,7 +920,7 @@ export class BridgeService {
903
920
  const refreshData = objectValue(refreshed.data);
904
921
  const refreshedTask = objectValue(refreshData?.task);
905
922
  if (!refreshedTask || refreshedTask.id !== taskId) {
906
- throw new Error('Context refresh did not return the queued proposal task');
923
+ throw refuse('The refresh did not return the task this queued proposal belongs to.', 'session.resume');
907
924
  }
908
925
  const expectedTaskVersion = numericTaskVersion(refreshedTask.lockVersion);
909
926
  const rebasedBody = cleanJson({ ...proposal, expectedTaskVersion });
@@ -1174,7 +1191,7 @@ export class BridgeService {
1174
1191
  initialProjectProfile: input.initialProjectProfile,
1175
1192
  });
1176
1193
  if ('resourceDiscovery' in input.initialProjectProfile.metadata) {
1177
- throw new Error('Project setup metadata cannot override canonical resource discovery');
1194
+ throw refuse('Project setup metadata cannot override canonical resource discovery', 'project.setup');
1178
1195
  }
1179
1196
  const persistentBody = normalizeRepositoryPaths(body, repository.repoRoot);
1180
1197
  assertSafeToPersist(persistentBody);
@@ -1197,7 +1214,7 @@ export class BridgeService {
1197
1214
  !marker ||
1198
1215
  marker.projectId !== project.id ||
1199
1216
  readDiscoveryUnits(policy).length === 0) {
1200
- throw new Error('Project setup response is not policy-ready');
1217
+ throw refuse('Project setup response is not policy-ready', 'project.setup');
1201
1218
  }
1202
1219
  const markerPath = await this.dependencies.repositories.writeMarker(repository.repoRoot, project.id);
1203
1220
  return asJsonValue({ ...data, markerPath });
@@ -1257,7 +1274,7 @@ export class BridgeService {
1257
1274
  const files = await Promise.all(input.files.map(async (file) => {
1258
1275
  const hash = await hashWorkingTreeFile(repository.repoRoot, file.path);
1259
1276
  if (!hash) {
1260
- throw new Error(`Applied template file is not readable: ${file.path}`);
1277
+ throw refuse(`Applied template file is not readable: ${file.path}`, 'architecture.record_application');
1261
1278
  }
1262
1279
  return { templatePath: file.templatePath, path: file.path, sha256: hash };
1263
1280
  }));
@@ -1317,7 +1334,7 @@ export class BridgeService {
1317
1334
  });
1318
1335
  const project = objectValue(response.data);
1319
1336
  if (!project || project.id !== input.projectId) {
1320
- throw new Error('Project bind response does not match the selected project');
1337
+ throw refuse('Project bind response does not match the selected project', 'project.resolve');
1321
1338
  }
1322
1339
  const markerPath = await this.dependencies.repositories.writeMarker(repository.repoRoot, input.projectId);
1323
1340
  return asJsonValue({
@@ -1574,7 +1591,7 @@ export class BridgeService {
1574
1591
  (entry.operation === 'task.checkpoint' || entry.operation === 'task.record_correction'));
1575
1592
  const blockedEntry = taskEntries.find((entry) => entry.lastError && entry.lastError !== 'backend_unavailable');
1576
1593
  if (blockedEntry && blockedEntry.idempotencyKey !== idempotencyKey) {
1577
- throw new Error('A blocked task delivery must be explicitly resolved before checkpointing');
1594
+ throw refuse('A blocked task delivery must be explicitly resolved before checkpointing', 'task.resolve_pending_delivery');
1578
1595
  }
1579
1596
  const existingExpected = pendingJournal
1580
1597
  ? expectedTaskVersionFromBody(pendingJournal.body)
@@ -1590,7 +1607,7 @@ export class BridgeService {
1590
1607
  correction,
1591
1608
  });
1592
1609
  if (!staged.delivery) {
1593
- throw new Error('Task journal delivery was not materialized');
1610
+ throw refuse('Task journal delivery was not materialized', 'task.resolve_pending_delivery');
1594
1611
  }
1595
1612
  const queued = await this.dependencies.outbox.enqueue({
1596
1613
  operation: staged.delivery.operation,
@@ -1745,10 +1762,10 @@ export class BridgeService {
1745
1762
  const pointer = await this.requireActivePointer(repository.repoFingerprint, taskId);
1746
1763
  if ((projectId && pointer.projectId !== projectId) ||
1747
1764
  (repository.projectId && pointer.projectId !== repository.projectId)) {
1748
- throw new Error('Active task snapshot does not match the repository project');
1765
+ throw refuse('Active task snapshot does not match the repository project', 'session.resume');
1749
1766
  }
1750
1767
  if ((await this.dependencies.outbox.list()).some((entry) => taskIdFromDeliverySafe(entry) === taskId)) {
1751
- throw new Error('Pending task deliveries must be resolved before using the task snapshot');
1768
+ throw refuse('Pending task deliveries must be resolved before using the task snapshot', 'task.resolve_pending_delivery');
1752
1769
  }
1753
1770
  const response = await this.dependencies.client.request(endpoints.sessionResume, {
1754
1771
  method: 'POST',
@@ -1761,7 +1778,7 @@ export class BridgeService {
1761
1778
  const backend = objectValue(response.data);
1762
1779
  const task = objectValue(backend?.task);
1763
1780
  if (!task || task.id !== taskId || backend?.requiresContextRefresh === true) {
1764
- throw new Error('Backend task snapshot is stale or does not match the active task');
1781
+ throw refuse('Backend task snapshot is stale or does not match the active task', 'session.resume');
1765
1782
  }
1766
1783
  const refreshed = {
1767
1784
  ...pointer,
@@ -1784,7 +1801,7 @@ export class BridgeService {
1784
1801
  const backend = objectValue(response.data);
1785
1802
  const task = objectValue(backend?.task);
1786
1803
  if (!task || task.id !== taskId || backend?.requiresContextRefresh === true) {
1787
- throw new Error('Backend task snapshot is stale or does not match the pending delivery');
1804
+ throw refuse('Backend task snapshot is stale or does not match the pending delivery', 'session.resume');
1788
1805
  }
1789
1806
  const refreshed = {
1790
1807
  ...pointer,
@@ -1803,7 +1820,7 @@ export class BridgeService {
1803
1820
  pointer.taskId !== taskId ||
1804
1821
  (blockingConflicts?.length ?? 0) > 0 ||
1805
1822
  lifecycleIntentBlocked) {
1806
- throw new Error('A clean active task snapshot is required for this operation');
1823
+ throw refuse('A clean active task snapshot is required for this operation', 'session.resume');
1807
1824
  }
1808
1825
  return pointer;
1809
1826
  }
@@ -1832,7 +1849,7 @@ export class BridgeService {
1832
1849
  backendTask.projectId !== pointer.projectId ||
1833
1850
  backendSession?.id !== pointer.sessionId ||
1834
1851
  body.pendingOutboxCount !== 0) {
1835
- throw new Error('Verification recovery does not match the durable verification intent');
1852
+ throw refuse('Verification recovery does not match the durable verification intent', 'session.resume');
1836
1853
  }
1837
1854
  if (recovery) {
1838
1855
  const recoveryTaskVersion = numericTaskVersion(recovery.taskVersion);
@@ -1840,12 +1857,12 @@ export class BridgeService {
1840
1857
  recovery.mode !== intent.mode ||
1841
1858
  (recoveryTaskVersion !== expectedTaskVersion &&
1842
1859
  recoveryTaskVersion !== expectedTaskVersion + 1)) {
1843
- throw new Error('Verification recovery does not match the verified backend task');
1860
+ throw refuse('Verification recovery does not match the verified backend task', 'session.resume');
1844
1861
  }
1845
1862
  }
1846
1863
  else if (numericTaskVersion(backendTask.lockVersion) !== expectedTaskVersion ||
1847
1864
  normalizeTaskMode(backendTask.mode) !== intent.mode) {
1848
- throw new Error('Open backend task does not match the durable verification intent');
1865
+ throw refuse('Open backend task does not match the durable verification intent', 'session.resume');
1849
1866
  }
1850
1867
  const currentTaskChanges = intent.mode !== 'read_only'
1851
1868
  ? pointer.changeBaseline
@@ -1853,7 +1870,7 @@ export class BridgeService {
1853
1870
  : null
1854
1871
  : [];
1855
1872
  if (!currentTaskChanges) {
1856
- throw new Error('Verification recovery is missing its locally pinned baseline');
1873
+ throw refuse('Verification recovery is missing its locally pinned baseline', 'session.resume');
1857
1874
  }
1858
1875
  const currentChangedPaths = currentTaskChanges.map((entry) => entry.path).sort();
1859
1876
  const currentPathChanges = intent.mode === 'read_only'
@@ -1863,7 +1880,7 @@ export class BridgeService {
1863
1880
  stableStringify(body.pathChanges ?? null) !== stableStringify(currentPathChanges) ||
1864
1881
  stableStringify(intent.taskChanges.map(changedPathIdentity)) !==
1865
1882
  stableStringify(currentTaskChanges.map(changedPathIdentity))) {
1866
- throw new Error('Current Git state does not match the durable verification intent');
1883
+ throw refuse('Current Git state does not match the durable verification intent', 'session.resume');
1867
1884
  }
1868
1885
  if (intent.mode !== 'read_only') {
1869
1886
  const retryLease = recovery
@@ -1879,13 +1896,13 @@ export class BridgeService {
1879
1896
  retryLease.baselineDiffHash !== body.baselineDiffHash ||
1880
1897
  body.baselineDiffHash !== pointer.changeBaseline?.diffHash ||
1881
1898
  !pathsContainAll(retryLeasePaths, currentChangedPaths)) {
1882
- throw new Error('Backend lease does not match the durable verification intent');
1899
+ throw refuse('Backend lease does not match the durable verification intent', 'context.prepare_change');
1883
1900
  }
1884
1901
  }
1885
1902
  else if (currentChangedPaths.length !== 0 ||
1886
1903
  body.leaseId !== undefined ||
1887
1904
  body.baselineDiffHash !== undefined) {
1888
- throw new Error('Read-only verification recovery contains a write lease');
1905
+ throw refuse('Read-only verification recovery contains a write lease', 'task.verify');
1889
1906
  }
1890
1907
  let response;
1891
1908
  try {
@@ -1903,7 +1920,7 @@ export class BridgeService {
1903
1920
  const responseData = objectValue(response.data);
1904
1921
  if (responseData?.verified !== true || responseData.diffHash !== body.diffHash) {
1905
1922
  await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, pointer.taskId);
1906
- throw new Error('Verification retry did not confirm the durable verification intent');
1923
+ throw refuse('Verification retry did not confirm the durable verification intent', 'session.resume');
1907
1924
  }
1908
1925
  const taskVersion = numericTaskVersion(responseData.taskVersion);
1909
1926
  await this.dependencies.gate.record({
@@ -1922,7 +1939,7 @@ export class BridgeService {
1922
1939
  async retryCloseIntent(repository, pointer) {
1923
1940
  const intent = pointer.closeIntent;
1924
1941
  if (!intent) {
1925
- throw new Error('Durable close intent is unavailable');
1942
+ throw refuse('Durable close intent is unavailable', 'session.resume');
1926
1943
  }
1927
1944
  const body = intent.body;
1928
1945
  const currentTaskChanges = pointer.changeBaseline
@@ -1934,7 +1951,7 @@ export class BridgeService {
1934
1951
  pointer.sessionId !== body.sessionId ||
1935
1952
  stableStringify(intent.taskChanges.map(changedPathIdentity)) !==
1936
1953
  stableStringify(currentTaskChanges.map(changedPathIdentity))) {
1937
- throw new Error('Current state does not match the durable close intent');
1954
+ throw refuse('Current state does not match the durable close intent', 'session.resume');
1938
1955
  }
1939
1956
  return await this.deliverCloseIntent(repository, body, intent.taskChanges, true);
1940
1957
  }
@@ -1956,7 +1973,7 @@ export class BridgeService {
1956
1973
  const closedTask = objectValue(response.data);
1957
1974
  if (closedTask?.closed !== true) {
1958
1975
  await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
1959
- throw new Error('Task close response does not confirm task closure');
1976
+ throw refuse('Task close response does not confirm task closure', 'session.resume');
1960
1977
  }
1961
1978
  const closedTaskVersion = numericTaskVersion(closedTask.taskVersion);
1962
1979
  await this.dependencies.gate.record({
@@ -2025,13 +2042,13 @@ export class BridgeService {
2025
2042
  })()
2026
2043
  : await this.dependencies.activeContexts.findByTaskId(input.taskId, input.projectId, false);
2027
2044
  if (pointer.resumeConflicts.some((conflict) => !isOfflineDevelopmentWarning(conflict))) {
2028
- throw new Error('A clean active task snapshot is required for checkpointing');
2045
+ throw refuse('A clean active task snapshot is required for checkpointing', 'session.resume');
2029
2046
  }
2030
2047
  if (pointer.verificationIntent || pointer.closeIntent) {
2031
- throw new Error('A clean active task snapshot is required for checkpointing');
2048
+ throw refuse('A clean active task snapshot is required for checkpointing', 'session.resume');
2032
2049
  }
2033
2050
  if (pointer.taskSlug.toLowerCase() !== input.taskSlug.toLowerCase()) {
2034
- throw new Error(`Checkpoint task slug ${input.taskSlug} does not match the active task ${pointer.taskSlug}`);
2051
+ throw refuse(`Checkpoint task slug ${input.taskSlug} does not match the active task ${pointer.taskSlug}`, 'session.resume');
2035
2052
  }
2036
2053
  return pointer;
2037
2054
  }
@@ -2080,6 +2097,7 @@ export class BridgeService {
2080
2097
  message: error.message,
2081
2098
  code: error.code ?? error.httpStatus,
2082
2099
  retryable: error.retryable,
2100
+ ...(error.recovery ? { recovery: error.recovery } : {}),
2083
2101
  },
2084
2102
  };
2085
2103
  }
@@ -2102,7 +2120,7 @@ function normalizeChangedPaths(paths) {
2102
2120
  path.startsWith('/') ||
2103
2121
  /^[A-Za-z]:\//.test(path) ||
2104
2122
  path.split('/').includes('..')) {
2105
- throw new Error(`Changed path must be repository-relative: ${path}`);
2123
+ throw refuse(`Changed path must be repository-relative: ${path}`, 'context.prepare_change');
2106
2124
  }
2107
2125
  }
2108
2126
  return [...new Set(normalized)].sort();
@@ -2157,7 +2175,7 @@ function semanticGitStatus(status) {
2157
2175
  function semanticPathStatus(status) {
2158
2176
  const semantic = semanticGitStatus(status);
2159
2177
  if (!['A', 'R', 'C', 'M', 'D', 'T', 'U'].includes(semantic)) {
2160
- throw new Error(`Unsupported semantic Git status: ${status}`);
2178
+ throw refuse(`Unsupported semantic Git status: ${status}`, 'task.verify');
2161
2179
  }
2162
2180
  return semantic;
2163
2181
  }
@@ -2327,6 +2345,7 @@ function publicError(error) {
2327
2345
  httpStatus: error.httpStatus,
2328
2346
  code: error.code,
2329
2347
  retryable: error.retryable,
2348
+ ...(error.recovery ? { recovery: error.recovery } : {}),
2330
2349
  });
2331
2350
  }
2332
2351
  return asJsonValue({
@@ -2341,7 +2360,7 @@ function isBackendUnavailableError(error) {
2341
2360
  function numericSequence(value) {
2342
2361
  const sequence = typeof value === 'number' ? value : Number(value ?? 0);
2343
2362
  if (!Number.isSafeInteger(sequence) || sequence < 0) {
2344
- throw new Error('Backend task sequence is invalid');
2363
+ throw refuse('Backend task sequence is invalid', 'session.resume');
2345
2364
  }
2346
2365
  return sequence;
2347
2366
  }
@@ -2352,7 +2371,7 @@ function isIsoTimestamp(value) {
2352
2371
  function numericTaskVersion(value) {
2353
2372
  const version = typeof value === 'number' ? value : Number(value);
2354
2373
  if (!Number.isSafeInteger(version) || version < 1) {
2355
- throw new Error('Backend task version is invalid');
2374
+ throw refuse('Backend task version is invalid', 'session.resume');
2356
2375
  }
2357
2376
  return version;
2358
2377
  }
@@ -2370,7 +2389,7 @@ function validateOfflineLease(value, sessionId, changedPaths, baselineDiffHash)
2370
2389
  stableStringify(normalizeChangedPaths(leasePaths)) !== stableStringify(changedPaths) ||
2371
2390
  !Number.isFinite(expiresAt) ||
2372
2391
  expiresAt <= Date.now()) {
2373
- throw new Error('Cached change lease is missing, expired or does not match the current Git state');
2392
+ throw refuse('Cached change lease is missing, expired or does not match the current Git state', 'context.prepare_change');
2374
2393
  }
2375
2394
  }
2376
2395
  function isOfflineLeaseUsable(lease, sessionId, changedPaths, baselineDiffHash) {
@@ -2435,7 +2454,7 @@ function isOfflineDevelopmentWarning(value) {
2435
2454
  function taskIdFromDelivery(value) {
2436
2455
  const body = objectValue(value);
2437
2456
  if (!body || typeof body.taskId !== 'string') {
2438
- throw new Error('Pending task delivery does not contain a task identifier');
2457
+ throw refuse('Pending task delivery does not contain a task identifier', 'task.resolve_pending_delivery');
2439
2458
  }
2440
2459
  return body.taskId;
2441
2460
  }
@@ -2496,7 +2515,7 @@ export function newMemoryResourceCandidate(entry, policy) {
2496
2515
  if (policy) {
2497
2516
  const matched = policy.units.filter((unit) => unit.patterns.some((pattern) => globMatches(entry.path, pattern)));
2498
2517
  if (matched.length > 1) {
2499
- throw new Error(`Resource discovery policy is ambiguous for path: ${entry.path}`);
2518
+ throw refuse(`Resource discovery policy is ambiguous for path: ${entry.path}`, 'memory.propose_revision');
2500
2519
  }
2501
2520
  return matched[0] ? [{ path: entry.path, kind: matched[0].kind }] : [];
2502
2521
  }
@@ -2550,7 +2569,7 @@ function readResourceDiscoveryPolicy(snapshot, activeLease) {
2550
2569
  }
2551
2570
  const units = readDiscoveryUnits(discovery);
2552
2571
  if (units.length === 0) {
2553
- throw new Error('Pinned resource discovery policy is incomplete');
2572
+ throw refuse('Pinned resource discovery policy is incomplete', 'context.refresh');
2554
2573
  }
2555
2574
  return { units };
2556
2575
  }
@@ -2587,7 +2606,7 @@ function globMatches(path, pattern) {
2587
2606
  normalizedPattern.startsWith('/') ||
2588
2607
  /^[A-Za-z]:\//.test(normalizedPattern) ||
2589
2608
  normalizedPattern.split('/').includes('..')) {
2590
- throw new Error(`Resource discovery pattern is unsafe: ${pattern}`);
2609
+ throw refuse(`Resource discovery pattern is unsafe: ${pattern}`, 'memory.propose_revision');
2591
2610
  }
2592
2611
  return minimatch(path.replace(/\\/g, '/'), normalizedPattern, { dot: true });
2593
2612
  }
@@ -2597,10 +2616,10 @@ function assertNewResourceEvidence(candidates, evidence) {
2597
2616
  path: normalizeChangedPaths([entry.path])[0],
2598
2617
  }));
2599
2618
  if (normalizedEvidence.some((entry) => entry.resourceKey.trim().length < 2)) {
2600
- throw new Error('New resource evidence requires a memory resource key');
2619
+ throw refuse('New resource evidence requires a memory resource key', 'memory.propose_revision');
2601
2620
  }
2602
2621
  if (new Set(normalizedEvidence.map((entry) => entry.path)).size !== normalizedEvidence.length) {
2603
- throw new Error('New resource evidence contains duplicate paths');
2622
+ throw refuse('New resource evidence contains duplicate paths', 'task.verify');
2604
2623
  }
2605
2624
  const expected = [...candidates].sort((left, right) => left.path.localeCompare(right.path));
2606
2625
  const actual = normalizedEvidence
@@ -2619,27 +2638,27 @@ function assertNewResourceEvidence(candidates, evidence) {
2619
2638
  missing.length ? `missing evidence for ${missing.join(', ')}` : '',
2620
2639
  extra.length ? `evidence for paths the policy does not classify: ${extra.join(', ')}` : '',
2621
2640
  ].filter(Boolean);
2622
- throw new Error(`New screen or shared component paths require exact memory reconciliation. ${details.length ? details.join('; ') : 'The kinds do not match the project discovery policy.'}`);
2641
+ throw refuse(`New screen or shared component paths require exact memory reconciliation. ${details.length ? details.join('; ') : 'The kinds do not match the project discovery policy.'}`, 'task.verify');
2623
2642
  }
2624
2643
  }
2625
2644
  function assertValidationEvidence(validations) {
2626
2645
  if (validations.length === 0) {
2627
- throw new Error('Validation evidence is required');
2646
+ throw refuse('Validation evidence is required', 'task.verify');
2628
2647
  }
2629
2648
  const allowed = new Set(validationIds);
2630
2649
  const seen = new Set();
2631
2650
  for (const validation of validations) {
2632
2651
  if (!allowed.has(validation.validationId)) {
2633
- throw new Error(`Unknown validation evidence: ${validation.validationId}`);
2652
+ throw refuse(`Unknown validation evidence: ${validation.validationId}`, 'task.verify');
2634
2653
  }
2635
2654
  if (seen.has(validation.validationId)) {
2636
- throw new Error(`Duplicate validation evidence: ${validation.validationId}`);
2655
+ throw refuse(`Duplicate validation evidence: ${validation.validationId}`, 'task.verify');
2637
2656
  }
2638
2657
  if (!validation.command.trim()) {
2639
- throw new Error(`Validation command is required: ${validation.validationId}`);
2658
+ throw refuse(`Validation command is required: ${validation.validationId}`, 'task.verify');
2640
2659
  }
2641
2660
  if (!/^[0-9a-f]{64}$/.test(validation.outputHash)) {
2642
- throw new Error(`Validation output hash is invalid: ${validation.validationId}`);
2661
+ throw refuse(`Validation output hash is invalid: ${validation.validationId}`, 'task.verify');
2643
2662
  }
2644
2663
  seen.add(validation.validationId);
2645
2664
  }