engineering-memory 1.6.3 → 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.3",
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,
@@ -200,14 +202,14 @@ export class BridgeService {
200
202
  const taskSlug = input.taskSlug ?? pointer?.taskSlug;
201
203
  const sessionId = input.sessionId ?? pointer?.sessionId;
202
204
  if (!projectId || !taskSlug || !sessionId) {
203
- 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');
204
206
  }
205
207
  if ((pointer &&
206
208
  (pointer.projectId !== projectId ||
207
209
  pointer.taskSlug !== taskSlug ||
208
210
  pointer.sessionId !== sessionId)) ||
209
211
  (repository.projectId && repository.projectId !== projectId)) {
210
- 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');
211
213
  }
212
214
  if (pointer?.closeIntent) {
213
215
  const recoveredClose = await this.retryCloseIntent(repository, pointer);
@@ -245,7 +247,7 @@ export class BridgeService {
245
247
  typeof backendTask.id !== 'string' ||
246
248
  typeof backendSession.id !== 'string' ||
247
249
  backendSession.id !== sessionId) {
248
- 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');
249
251
  }
250
252
  let localJournal = await this.dependencies.journal.load(projectId, taskSlug);
251
253
  let pendingOutbox = await this.dependencies.outbox.list();
@@ -545,11 +547,11 @@ export class BridgeService {
545
547
  .map((entry) => objectValue(entry))
546
548
  .find((entry) => entry?.projectId === input.projectId);
547
549
  if (!project) {
548
- 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');
549
551
  }
550
552
  const result = await this.dependencies.repositories.git.clone(String(project.repositoryUrl), input.targetPath);
551
553
  if (!result.cloned) {
552
- 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');
553
555
  }
554
556
  return asJsonValue({
555
557
  cloned: true,
@@ -834,7 +836,7 @@ export class BridgeService {
834
836
  return await this.execute(async () => {
835
837
  const entry = await this.dependencies.outbox.get(input.outboxId);
836
838
  if (!entry) {
837
- throw new Error('Pending delivery could not be found');
839
+ throw refuse('There is no pending delivery under that identifier.', 'session.resume');
838
840
  }
839
841
  const metadata = {
840
842
  id: entry.id,
@@ -866,7 +868,7 @@ export class BridgeService {
866
868
  return asJsonValue({ discarded: true, taskId, pendingDelivery: metadata });
867
869
  }
868
870
  if (!entry.lastError?.startsWith('api_409_')) {
869
- 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');
870
872
  }
871
873
  if (entry.journalRef) {
872
874
  const taskSnapshot = await this.refreshTaskPointer(taskId, entry.journalRef.projectId);
@@ -883,7 +885,7 @@ export class BridgeService {
883
885
  });
884
886
  }
885
887
  if (entry.operation !== 'memory.propose_revision' || !taskId) {
886
- 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');
887
889
  }
888
890
  if (!input.proposalRebaseApproved || input.confirmedBaseRevision === undefined) {
889
891
  return asJsonValue({
@@ -897,7 +899,7 @@ export class BridgeService {
897
899
  const projectId = typeof proposal?.projectId === 'string' ? proposal.projectId : null;
898
900
  const queuedBaseRevision = proposal?.baseRevision;
899
901
  if (!projectId || typeof queuedBaseRevision !== 'number') {
900
- 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');
901
903
  }
902
904
  if (queuedBaseRevision !== input.confirmedBaseRevision) {
903
905
  return asJsonValue({
@@ -918,7 +920,7 @@ export class BridgeService {
918
920
  const refreshData = objectValue(refreshed.data);
919
921
  const refreshedTask = objectValue(refreshData?.task);
920
922
  if (!refreshedTask || refreshedTask.id !== taskId) {
921
- 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');
922
924
  }
923
925
  const expectedTaskVersion = numericTaskVersion(refreshedTask.lockVersion);
924
926
  const rebasedBody = cleanJson({ ...proposal, expectedTaskVersion });
@@ -1189,7 +1191,7 @@ export class BridgeService {
1189
1191
  initialProjectProfile: input.initialProjectProfile,
1190
1192
  });
1191
1193
  if ('resourceDiscovery' in input.initialProjectProfile.metadata) {
1192
- throw new Error('Project setup metadata cannot override canonical resource discovery');
1194
+ throw refuse('Project setup metadata cannot override canonical resource discovery', 'project.setup');
1193
1195
  }
1194
1196
  const persistentBody = normalizeRepositoryPaths(body, repository.repoRoot);
1195
1197
  assertSafeToPersist(persistentBody);
@@ -1212,7 +1214,7 @@ export class BridgeService {
1212
1214
  !marker ||
1213
1215
  marker.projectId !== project.id ||
1214
1216
  readDiscoveryUnits(policy).length === 0) {
1215
- throw new Error('Project setup response is not policy-ready');
1217
+ throw refuse('Project setup response is not policy-ready', 'project.setup');
1216
1218
  }
1217
1219
  const markerPath = await this.dependencies.repositories.writeMarker(repository.repoRoot, project.id);
1218
1220
  return asJsonValue({ ...data, markerPath });
@@ -1272,7 +1274,7 @@ export class BridgeService {
1272
1274
  const files = await Promise.all(input.files.map(async (file) => {
1273
1275
  const hash = await hashWorkingTreeFile(repository.repoRoot, file.path);
1274
1276
  if (!hash) {
1275
- 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');
1276
1278
  }
1277
1279
  return { templatePath: file.templatePath, path: file.path, sha256: hash };
1278
1280
  }));
@@ -1332,7 +1334,7 @@ export class BridgeService {
1332
1334
  });
1333
1335
  const project = objectValue(response.data);
1334
1336
  if (!project || project.id !== input.projectId) {
1335
- 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');
1336
1338
  }
1337
1339
  const markerPath = await this.dependencies.repositories.writeMarker(repository.repoRoot, input.projectId);
1338
1340
  return asJsonValue({
@@ -1589,7 +1591,7 @@ export class BridgeService {
1589
1591
  (entry.operation === 'task.checkpoint' || entry.operation === 'task.record_correction'));
1590
1592
  const blockedEntry = taskEntries.find((entry) => entry.lastError && entry.lastError !== 'backend_unavailable');
1591
1593
  if (blockedEntry && blockedEntry.idempotencyKey !== idempotencyKey) {
1592
- 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');
1593
1595
  }
1594
1596
  const existingExpected = pendingJournal
1595
1597
  ? expectedTaskVersionFromBody(pendingJournal.body)
@@ -1605,7 +1607,7 @@ export class BridgeService {
1605
1607
  correction,
1606
1608
  });
1607
1609
  if (!staged.delivery) {
1608
- throw new Error('Task journal delivery was not materialized');
1610
+ throw refuse('Task journal delivery was not materialized', 'task.resolve_pending_delivery');
1609
1611
  }
1610
1612
  const queued = await this.dependencies.outbox.enqueue({
1611
1613
  operation: staged.delivery.operation,
@@ -1760,10 +1762,10 @@ export class BridgeService {
1760
1762
  const pointer = await this.requireActivePointer(repository.repoFingerprint, taskId);
1761
1763
  if ((projectId && pointer.projectId !== projectId) ||
1762
1764
  (repository.projectId && pointer.projectId !== repository.projectId)) {
1763
- 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');
1764
1766
  }
1765
1767
  if ((await this.dependencies.outbox.list()).some((entry) => taskIdFromDeliverySafe(entry) === taskId)) {
1766
- 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');
1767
1769
  }
1768
1770
  const response = await this.dependencies.client.request(endpoints.sessionResume, {
1769
1771
  method: 'POST',
@@ -1776,7 +1778,7 @@ export class BridgeService {
1776
1778
  const backend = objectValue(response.data);
1777
1779
  const task = objectValue(backend?.task);
1778
1780
  if (!task || task.id !== taskId || backend?.requiresContextRefresh === true) {
1779
- 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');
1780
1782
  }
1781
1783
  const refreshed = {
1782
1784
  ...pointer,
@@ -1799,7 +1801,7 @@ export class BridgeService {
1799
1801
  const backend = objectValue(response.data);
1800
1802
  const task = objectValue(backend?.task);
1801
1803
  if (!task || task.id !== taskId || backend?.requiresContextRefresh === true) {
1802
- 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');
1803
1805
  }
1804
1806
  const refreshed = {
1805
1807
  ...pointer,
@@ -1818,7 +1820,7 @@ export class BridgeService {
1818
1820
  pointer.taskId !== taskId ||
1819
1821
  (blockingConflicts?.length ?? 0) > 0 ||
1820
1822
  lifecycleIntentBlocked) {
1821
- 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');
1822
1824
  }
1823
1825
  return pointer;
1824
1826
  }
@@ -1847,7 +1849,7 @@ export class BridgeService {
1847
1849
  backendTask.projectId !== pointer.projectId ||
1848
1850
  backendSession?.id !== pointer.sessionId ||
1849
1851
  body.pendingOutboxCount !== 0) {
1850
- 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');
1851
1853
  }
1852
1854
  if (recovery) {
1853
1855
  const recoveryTaskVersion = numericTaskVersion(recovery.taskVersion);
@@ -1855,12 +1857,12 @@ export class BridgeService {
1855
1857
  recovery.mode !== intent.mode ||
1856
1858
  (recoveryTaskVersion !== expectedTaskVersion &&
1857
1859
  recoveryTaskVersion !== expectedTaskVersion + 1)) {
1858
- 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');
1859
1861
  }
1860
1862
  }
1861
1863
  else if (numericTaskVersion(backendTask.lockVersion) !== expectedTaskVersion ||
1862
1864
  normalizeTaskMode(backendTask.mode) !== intent.mode) {
1863
- 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');
1864
1866
  }
1865
1867
  const currentTaskChanges = intent.mode !== 'read_only'
1866
1868
  ? pointer.changeBaseline
@@ -1868,7 +1870,7 @@ export class BridgeService {
1868
1870
  : null
1869
1871
  : [];
1870
1872
  if (!currentTaskChanges) {
1871
- throw new Error('Verification recovery is missing its locally pinned baseline');
1873
+ throw refuse('Verification recovery is missing its locally pinned baseline', 'session.resume');
1872
1874
  }
1873
1875
  const currentChangedPaths = currentTaskChanges.map((entry) => entry.path).sort();
1874
1876
  const currentPathChanges = intent.mode === 'read_only'
@@ -1878,7 +1880,7 @@ export class BridgeService {
1878
1880
  stableStringify(body.pathChanges ?? null) !== stableStringify(currentPathChanges) ||
1879
1881
  stableStringify(intent.taskChanges.map(changedPathIdentity)) !==
1880
1882
  stableStringify(currentTaskChanges.map(changedPathIdentity))) {
1881
- 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');
1882
1884
  }
1883
1885
  if (intent.mode !== 'read_only') {
1884
1886
  const retryLease = recovery
@@ -1894,13 +1896,13 @@ export class BridgeService {
1894
1896
  retryLease.baselineDiffHash !== body.baselineDiffHash ||
1895
1897
  body.baselineDiffHash !== pointer.changeBaseline?.diffHash ||
1896
1898
  !pathsContainAll(retryLeasePaths, currentChangedPaths)) {
1897
- 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');
1898
1900
  }
1899
1901
  }
1900
1902
  else if (currentChangedPaths.length !== 0 ||
1901
1903
  body.leaseId !== undefined ||
1902
1904
  body.baselineDiffHash !== undefined) {
1903
- throw new Error('Read-only verification recovery contains a write lease');
1905
+ throw refuse('Read-only verification recovery contains a write lease', 'task.verify');
1904
1906
  }
1905
1907
  let response;
1906
1908
  try {
@@ -1918,7 +1920,7 @@ export class BridgeService {
1918
1920
  const responseData = objectValue(response.data);
1919
1921
  if (responseData?.verified !== true || responseData.diffHash !== body.diffHash) {
1920
1922
  await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, pointer.taskId);
1921
- 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');
1922
1924
  }
1923
1925
  const taskVersion = numericTaskVersion(responseData.taskVersion);
1924
1926
  await this.dependencies.gate.record({
@@ -1937,7 +1939,7 @@ export class BridgeService {
1937
1939
  async retryCloseIntent(repository, pointer) {
1938
1940
  const intent = pointer.closeIntent;
1939
1941
  if (!intent) {
1940
- throw new Error('Durable close intent is unavailable');
1942
+ throw refuse('Durable close intent is unavailable', 'session.resume');
1941
1943
  }
1942
1944
  const body = intent.body;
1943
1945
  const currentTaskChanges = pointer.changeBaseline
@@ -1949,7 +1951,7 @@ export class BridgeService {
1949
1951
  pointer.sessionId !== body.sessionId ||
1950
1952
  stableStringify(intent.taskChanges.map(changedPathIdentity)) !==
1951
1953
  stableStringify(currentTaskChanges.map(changedPathIdentity))) {
1952
- 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');
1953
1955
  }
1954
1956
  return await this.deliverCloseIntent(repository, body, intent.taskChanges, true);
1955
1957
  }
@@ -1971,7 +1973,7 @@ export class BridgeService {
1971
1973
  const closedTask = objectValue(response.data);
1972
1974
  if (closedTask?.closed !== true) {
1973
1975
  await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
1974
- throw new Error('Task close response does not confirm task closure');
1976
+ throw refuse('Task close response does not confirm task closure', 'session.resume');
1975
1977
  }
1976
1978
  const closedTaskVersion = numericTaskVersion(closedTask.taskVersion);
1977
1979
  await this.dependencies.gate.record({
@@ -2040,13 +2042,13 @@ export class BridgeService {
2040
2042
  })()
2041
2043
  : await this.dependencies.activeContexts.findByTaskId(input.taskId, input.projectId, false);
2042
2044
  if (pointer.resumeConflicts.some((conflict) => !isOfflineDevelopmentWarning(conflict))) {
2043
- 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');
2044
2046
  }
2045
2047
  if (pointer.verificationIntent || pointer.closeIntent) {
2046
- 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');
2047
2049
  }
2048
2050
  if (pointer.taskSlug.toLowerCase() !== input.taskSlug.toLowerCase()) {
2049
- 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');
2050
2052
  }
2051
2053
  return pointer;
2052
2054
  }
@@ -2095,6 +2097,7 @@ export class BridgeService {
2095
2097
  message: error.message,
2096
2098
  code: error.code ?? error.httpStatus,
2097
2099
  retryable: error.retryable,
2100
+ ...(error.recovery ? { recovery: error.recovery } : {}),
2098
2101
  },
2099
2102
  };
2100
2103
  }
@@ -2117,7 +2120,7 @@ function normalizeChangedPaths(paths) {
2117
2120
  path.startsWith('/') ||
2118
2121
  /^[A-Za-z]:\//.test(path) ||
2119
2122
  path.split('/').includes('..')) {
2120
- throw new Error(`Changed path must be repository-relative: ${path}`);
2123
+ throw refuse(`Changed path must be repository-relative: ${path}`, 'context.prepare_change');
2121
2124
  }
2122
2125
  }
2123
2126
  return [...new Set(normalized)].sort();
@@ -2172,7 +2175,7 @@ function semanticGitStatus(status) {
2172
2175
  function semanticPathStatus(status) {
2173
2176
  const semantic = semanticGitStatus(status);
2174
2177
  if (!['A', 'R', 'C', 'M', 'D', 'T', 'U'].includes(semantic)) {
2175
- throw new Error(`Unsupported semantic Git status: ${status}`);
2178
+ throw refuse(`Unsupported semantic Git status: ${status}`, 'task.verify');
2176
2179
  }
2177
2180
  return semantic;
2178
2181
  }
@@ -2342,6 +2345,7 @@ function publicError(error) {
2342
2345
  httpStatus: error.httpStatus,
2343
2346
  code: error.code,
2344
2347
  retryable: error.retryable,
2348
+ ...(error.recovery ? { recovery: error.recovery } : {}),
2345
2349
  });
2346
2350
  }
2347
2351
  return asJsonValue({
@@ -2356,7 +2360,7 @@ function isBackendUnavailableError(error) {
2356
2360
  function numericSequence(value) {
2357
2361
  const sequence = typeof value === 'number' ? value : Number(value ?? 0);
2358
2362
  if (!Number.isSafeInteger(sequence) || sequence < 0) {
2359
- throw new Error('Backend task sequence is invalid');
2363
+ throw refuse('Backend task sequence is invalid', 'session.resume');
2360
2364
  }
2361
2365
  return sequence;
2362
2366
  }
@@ -2367,7 +2371,7 @@ function isIsoTimestamp(value) {
2367
2371
  function numericTaskVersion(value) {
2368
2372
  const version = typeof value === 'number' ? value : Number(value);
2369
2373
  if (!Number.isSafeInteger(version) || version < 1) {
2370
- throw new Error('Backend task version is invalid');
2374
+ throw refuse('Backend task version is invalid', 'session.resume');
2371
2375
  }
2372
2376
  return version;
2373
2377
  }
@@ -2385,7 +2389,7 @@ function validateOfflineLease(value, sessionId, changedPaths, baselineDiffHash)
2385
2389
  stableStringify(normalizeChangedPaths(leasePaths)) !== stableStringify(changedPaths) ||
2386
2390
  !Number.isFinite(expiresAt) ||
2387
2391
  expiresAt <= Date.now()) {
2388
- 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');
2389
2393
  }
2390
2394
  }
2391
2395
  function isOfflineLeaseUsable(lease, sessionId, changedPaths, baselineDiffHash) {
@@ -2450,7 +2454,7 @@ function isOfflineDevelopmentWarning(value) {
2450
2454
  function taskIdFromDelivery(value) {
2451
2455
  const body = objectValue(value);
2452
2456
  if (!body || typeof body.taskId !== 'string') {
2453
- 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');
2454
2458
  }
2455
2459
  return body.taskId;
2456
2460
  }
@@ -2511,7 +2515,7 @@ export function newMemoryResourceCandidate(entry, policy) {
2511
2515
  if (policy) {
2512
2516
  const matched = policy.units.filter((unit) => unit.patterns.some((pattern) => globMatches(entry.path, pattern)));
2513
2517
  if (matched.length > 1) {
2514
- 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');
2515
2519
  }
2516
2520
  return matched[0] ? [{ path: entry.path, kind: matched[0].kind }] : [];
2517
2521
  }
@@ -2565,7 +2569,7 @@ function readResourceDiscoveryPolicy(snapshot, activeLease) {
2565
2569
  }
2566
2570
  const units = readDiscoveryUnits(discovery);
2567
2571
  if (units.length === 0) {
2568
- throw new Error('Pinned resource discovery policy is incomplete');
2572
+ throw refuse('Pinned resource discovery policy is incomplete', 'context.refresh');
2569
2573
  }
2570
2574
  return { units };
2571
2575
  }
@@ -2602,7 +2606,7 @@ function globMatches(path, pattern) {
2602
2606
  normalizedPattern.startsWith('/') ||
2603
2607
  /^[A-Za-z]:\//.test(normalizedPattern) ||
2604
2608
  normalizedPattern.split('/').includes('..')) {
2605
- throw new Error(`Resource discovery pattern is unsafe: ${pattern}`);
2609
+ throw refuse(`Resource discovery pattern is unsafe: ${pattern}`, 'memory.propose_revision');
2606
2610
  }
2607
2611
  return minimatch(path.replace(/\\/g, '/'), normalizedPattern, { dot: true });
2608
2612
  }
@@ -2612,10 +2616,10 @@ function assertNewResourceEvidence(candidates, evidence) {
2612
2616
  path: normalizeChangedPaths([entry.path])[0],
2613
2617
  }));
2614
2618
  if (normalizedEvidence.some((entry) => entry.resourceKey.trim().length < 2)) {
2615
- 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');
2616
2620
  }
2617
2621
  if (new Set(normalizedEvidence.map((entry) => entry.path)).size !== normalizedEvidence.length) {
2618
- throw new Error('New resource evidence contains duplicate paths');
2622
+ throw refuse('New resource evidence contains duplicate paths', 'task.verify');
2619
2623
  }
2620
2624
  const expected = [...candidates].sort((left, right) => left.path.localeCompare(right.path));
2621
2625
  const actual = normalizedEvidence
@@ -2634,27 +2638,27 @@ function assertNewResourceEvidence(candidates, evidence) {
2634
2638
  missing.length ? `missing evidence for ${missing.join(', ')}` : '',
2635
2639
  extra.length ? `evidence for paths the policy does not classify: ${extra.join(', ')}` : '',
2636
2640
  ].filter(Boolean);
2637
- 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');
2638
2642
  }
2639
2643
  }
2640
2644
  function assertValidationEvidence(validations) {
2641
2645
  if (validations.length === 0) {
2642
- throw new Error('Validation evidence is required');
2646
+ throw refuse('Validation evidence is required', 'task.verify');
2643
2647
  }
2644
2648
  const allowed = new Set(validationIds);
2645
2649
  const seen = new Set();
2646
2650
  for (const validation of validations) {
2647
2651
  if (!allowed.has(validation.validationId)) {
2648
- throw new Error(`Unknown validation evidence: ${validation.validationId}`);
2652
+ throw refuse(`Unknown validation evidence: ${validation.validationId}`, 'task.verify');
2649
2653
  }
2650
2654
  if (seen.has(validation.validationId)) {
2651
- throw new Error(`Duplicate validation evidence: ${validation.validationId}`);
2655
+ throw refuse(`Duplicate validation evidence: ${validation.validationId}`, 'task.verify');
2652
2656
  }
2653
2657
  if (!validation.command.trim()) {
2654
- throw new Error(`Validation command is required: ${validation.validationId}`);
2658
+ throw refuse(`Validation command is required: ${validation.validationId}`, 'task.verify');
2655
2659
  }
2656
2660
  if (!/^[0-9a-f]{64}$/.test(validation.outputHash)) {
2657
- throw new Error(`Validation output hash is invalid: ${validation.validationId}`);
2661
+ throw refuse(`Validation output hash is invalid: ${validation.validationId}`, 'task.verify');
2658
2662
  }
2659
2663
  seen.add(validation.validationId);
2660
2664
  }