engineering-memory 1.7.0 → 1.9.0

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.
@@ -3,9 +3,11 @@ import { readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { minimatch } from 'minimatch';
5
5
  import { endpoints } from '../config.js';
6
+ import { RepositoryResolver } from '../project/repository.js';
6
7
  import { sha256, stableStringify } from '../utilities/hash.js';
7
8
  import { ApiResponseError, BackendUnavailableError, } from './api-client.js';
8
9
  import { assertSafeToPersist, normalizeRepositoryPaths } from './offline-outbox.js';
10
+ import { BridgeRecoveryError } from './recovery-error.js';
9
11
  export const validationIds = [
10
12
  'format',
11
13
  'static_analysis',
@@ -54,6 +56,7 @@ export class BridgeService {
54
56
  objective: input.objective,
55
57
  taskKind: input.taskKind,
56
58
  workItemKey: input.workItemKey,
59
+ workItemId: input.workItemId,
57
60
  mode: input.mode,
58
61
  knownRevisions: input.knownRevisions,
59
62
  }, repository.repoRoot);
@@ -71,19 +74,28 @@ export class BridgeService {
71
74
  body: { repoFingerprint: repository.repoFingerprint },
72
75
  });
73
76
  const project = objectValue(resolved.data);
74
- const projects = await this.dependencies.client.request(endpoints.projectList);
77
+ const restoreRequired = Boolean(project?.archivedAt);
78
+ const restoreGuidance = archivedProjectGuidance(project);
79
+ const projects = await this.dependencies.client.request(restoreRequired ? `${endpoints.projectList}?includeArchived=true` : endpoints.projectList);
75
80
  return asJsonValue({
76
- projectBindingRequired: true,
81
+ projectBindingRequired: !restoreRequired,
82
+ restoreRequired,
77
83
  candidate: project,
78
84
  requestedProjectId: projectId,
79
85
  repository: publicRepository(repository),
80
86
  projects: projects.data,
81
87
  outbox: { recoveredDeliveries, ...objectOrEmpty(outbox) },
88
+ ...(restoreRequired ? restoreGuidance : {}),
82
89
  });
83
90
  }
84
91
  if (!projectId) {
85
92
  throw refuse('This repository has a marker that names no project, so there is nothing to bootstrap against.', 'project.resolve');
86
93
  }
94
+ const bootstrapMode = persistedBootstrap.mode ?? 'write';
95
+ const decision = bootstrapMode === 'read_only'
96
+ ? null
97
+ : await this.requireBranchDecision(repository, input.externalTaskId);
98
+ const branch = decision?.branch;
87
99
  const checkpointId = deterministicUuid('session.bootstrap', projectId, repository.repoFingerprint, input.externalTaskId);
88
100
  const response = await this.dependencies.client.request(endpoints.sessionBootstrap, {
89
101
  method: 'POST',
@@ -94,11 +106,13 @@ export class BridgeService {
94
106
  objective: persistedBootstrap.objective,
95
107
  taskKind: persistedBootstrap.taskKind,
96
108
  workItemKey: persistedBootstrap.workItemKey,
109
+ workItemId: persistedBootstrap.workItemId,
97
110
  mode: persistedBootstrap.mode ?? 'write',
98
111
  repoFingerprint: repository.repoFingerprint,
99
112
  ...(input.mode === 'read_only'
100
113
  ? { baselineDiffHash: repository.git.worktreeHash }
101
114
  : {}),
115
+ ...(decision ? { branch } : {}),
102
116
  checkpointIdempotencyKey: checkpointId,
103
117
  knownRevisions: persistedBootstrap.knownRevisions,
104
118
  }),
@@ -120,11 +134,20 @@ export class BridgeService {
120
134
  !isIsoTimestamp(checkpointPayload.createdAt)) {
121
135
  throw refuse('The backend opened the session without the checkpoint the task is anchored to.', 'session.bootstrap');
122
136
  }
137
+ if (decision)
138
+ await this.dependencies.repositories.git
139
+ .branchStore(repository.repoRoot)
140
+ .bind(projectId, input.externalTaskId, task.id);
123
141
  const pointer = {
124
142
  repoFingerprint: repository.repoFingerprint,
125
143
  projectId,
126
144
  taskId: task.id,
127
145
  taskSlug: input.externalTaskId,
146
+ worktreeId: sha256(repository.repoRoot),
147
+ ...(decision
148
+ ? { branch: typeof task.branch === 'string' ? task.branch : decision.branch }
149
+ : {}),
150
+ mode: normalizeTaskMode(task.mode),
128
151
  sessionId: session.id,
129
152
  lastSequence: numericSequence(task.lastSequence),
130
153
  taskVersion: numericTaskVersion(task.lockVersion),
@@ -249,6 +272,12 @@ export class BridgeService {
249
272
  backendSession.id !== sessionId) {
250
273
  throw refuse('The backend resumed the session without the task it belongs to.', 'session.resume');
251
274
  }
275
+ await this.assertTaskWorktree(repository, backendTask, pointer);
276
+ if (backendTask.status === 'closed' || backendTask.status === 'abandoned') {
277
+ await this.dependencies.repositories.git
278
+ .branchStore(repository.repoRoot)
279
+ .release(backendTask.id);
280
+ }
252
281
  let localJournal = await this.dependencies.journal.load(projectId, taskSlug);
253
282
  let pendingOutbox = await this.dependencies.outbox.list();
254
283
  const responseSource = this.dependencies.client.getResponseSource(response);
@@ -256,7 +285,7 @@ export class BridgeService {
256
285
  ? await this.retryVerificationRecovery(repository, pointer, backend)
257
286
  : null;
258
287
  if (recoveredVerification) {
259
- pointer = await this.dependencies.activeContexts.load(repository.repoFingerprint);
288
+ pointer = await this.dependencies.activeContexts.loadForTask(repository.repoFingerprint, pointer.taskId);
260
289
  }
261
290
  let hydratedFromBackend = false;
262
291
  let mergedFromBackend = false;
@@ -358,6 +387,13 @@ export class BridgeService {
358
387
  projectId,
359
388
  taskId: backendTask.id,
360
389
  taskSlug,
390
+ worktreeId: pointer?.worktreeId ?? sha256(repository.repoRoot),
391
+ ...(typeof backendTask.branch === 'string'
392
+ ? { branch: backendTask.branch }
393
+ : pointer?.branch !== undefined
394
+ ? { branch: pointer.branch }
395
+ : {}),
396
+ mode: normalizeTaskMode(backendTask.mode),
361
397
  sessionId,
362
398
  lastSequence: resumedLastSequence,
363
399
  taskVersion: resumedTaskVersion,
@@ -405,7 +441,7 @@ export class BridgeService {
405
441
  await this.recoverJournalOutbox();
406
442
  const outbox = await this.flushOutbox();
407
443
  const pendingOutbox = await this.dependencies.outbox.list();
408
- const pointer = await this.dependencies.activeContexts.load(repository.repoFingerprint);
444
+ const pointer = await this.dependencies.activeContexts.loadForSession(repository.repoFingerprint, input.sessionId);
409
445
  if (pointer?.verificationIntent || pointer?.closeIntent) {
410
446
  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');
411
447
  }
@@ -436,6 +472,10 @@ export class BridgeService {
436
472
  ...input.changedPaths,
437
473
  ]);
438
474
  const baselineDiffHash = activeBaseline?.diffHash ?? repository.git.diffHash;
475
+ const decision = pointer &&
476
+ (input.transitionToWrite || (pointer.branch !== undefined && pointer.mode !== 'read_only'))
477
+ ? await this.requireBranchDecision(repository, pointer.taskSlug)
478
+ : null;
439
479
  let transitionTaskVersion;
440
480
  if (input.transitionToWrite) {
441
481
  if (!pointer || pointer.sessionId !== input.sessionId) {
@@ -448,6 +488,7 @@ export class BridgeService {
448
488
  sessionId: input.sessionId,
449
489
  changedPaths,
450
490
  baselineDiffHash,
491
+ ...(decision ? { branch: decision.branch } : {}),
451
492
  ...(input.transitionToWrite
452
493
  ? { transitionToWrite: true, expectedTaskVersion: transitionTaskVersion }
453
494
  : {}),
@@ -492,8 +533,19 @@ export class BridgeService {
492
533
  lastSequence: numericSequence(preparedTask.lastSequence),
493
534
  }
494
535
  : undefined);
495
- const preparedPointer = await this.dependencies.activeContexts.load(repository.repoFingerprint);
536
+ const preparedPointer = await this.dependencies.activeContexts.loadForSession(repository.repoFingerprint, input.sessionId);
496
537
  if (preparedPointer) {
538
+ if (decision) {
539
+ await this.dependencies.repositories.git
540
+ .branchStore(repository.repoRoot)
541
+ .bind(preparedPointer.projectId, preparedPointer.taskSlug, preparedPointer.taskId);
542
+ await this.dependencies.activeContexts.save({
543
+ ...preparedPointer,
544
+ worktreeId: sha256(repository.repoRoot),
545
+ branch: typeof preparedTask?.branch === 'string' ? preparedTask.branch : decision.branch,
546
+ mode: 'write',
547
+ });
548
+ }
497
549
  const preparedData = objectValue(response.data);
498
550
  await this.seedResumeSnapshot(preparedPointer, {
499
551
  ...objectOrEmpty(response.data),
@@ -1003,6 +1055,7 @@ export class BridgeService {
1003
1055
  });
1004
1056
  }
1005
1057
  const taskMode = normalizeTaskMode(snapshotTask.mode);
1058
+ await this.assertTaskWorktree(repository, snapshotTask, pointer);
1006
1059
  const resourceDiscoveryPolicy = readResourceDiscoveryPolicy(snapshot, activeLease);
1007
1060
  const validations = normalizePersistentInput(input.validations, repository.repoRoot);
1008
1061
  if (taskMode === 'read_only') {
@@ -1065,6 +1118,11 @@ export class BridgeService {
1065
1118
  changedPaths: taskMode === 'read_only' ? [] : taskChangedPaths,
1066
1119
  pathChanges: pathChangeManifest,
1067
1120
  diffHash: repository.git.diffHash,
1121
+ ...(taskMode !== 'read_only'
1122
+ ? {
1123
+ branch: await this.dependencies.repositories.git.currentBranch(repository.repoRoot),
1124
+ }
1125
+ : {}),
1068
1126
  ...(taskMode === 'read_only' ? { worktreeHash: repository.git.worktreeHash } : {}),
1069
1127
  validations,
1070
1128
  pendingOutboxCount,
@@ -1098,6 +1156,9 @@ export class BridgeService {
1098
1156
  repoFingerprint: repository.repoFingerprint,
1099
1157
  repoRoot: repository.repoRoot,
1100
1158
  diffHash: repository.git.diffHash,
1159
+ branch: typeof snapshotTask.branch === 'string'
1160
+ ? snapshotTask.branch
1161
+ : (pointer.branch ?? null),
1101
1162
  taskVersion,
1102
1163
  taskChanges,
1103
1164
  });
@@ -1141,11 +1202,17 @@ export class BridgeService {
1141
1202
  const taskChanges = pointer.changeBaseline
1142
1203
  ? manifestDelta(pointer.changeBaseline.changedPaths, repository.git.changedPaths)
1143
1204
  : [];
1205
+ await this.assertTaskWorktree(repository, task, pointer);
1144
1206
  const closeBody = {
1145
1207
  taskId: input.taskId,
1146
1208
  sessionId: pointer.sessionId,
1147
1209
  expectedTaskVersion: numericTaskVersion(task.lockVersion),
1148
1210
  diffHash: repository.git.diffHash,
1211
+ ...(normalizeTaskMode(task.mode) !== 'read_only'
1212
+ ? {
1213
+ branch: await this.dependencies.repositories.git.currentBranch(repository.repoRoot),
1214
+ }
1215
+ : {}),
1149
1216
  };
1150
1217
  await this.dependencies.activeContexts.setCloseIntent(repository.repoFingerprint, {
1151
1218
  body: closeBody,
@@ -1155,6 +1222,111 @@ export class BridgeService {
1155
1222
  });
1156
1223
  });
1157
1224
  }
1225
+ async taskBranch(input) {
1226
+ return await this.execute(async () => {
1227
+ const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1228
+ const authentication = await this.dependencies.browserAuth.ensureAuthenticated();
1229
+ if (authentication)
1230
+ return asJsonValue({ authentication });
1231
+ if (!repository.projectId)
1232
+ throw refuse('Resolve the project before choosing its task branch.', 'project.resolve');
1233
+ if (!input.externalTaskId ||
1234
+ input.externalTaskId.length > 160 ||
1235
+ /[\r\n]/.test(input.externalTaskId))
1236
+ throw refuse('Pass the exact externalTaskId of the task whose branch question was answered.', 'task.branch');
1237
+ if (Boolean(input.name) === (input.keepCurrent === true) ||
1238
+ (input.worktreePath && !input.name))
1239
+ throw refuse('Choose exactly one branch name or keepCurrent. A separate worktree also needs a branch name.', 'task.branch');
1240
+ assertSafeToPersist(cleanJson({ externalTaskId: input.externalTaskId, name: input.name }));
1241
+ const git = this.dependencies.repositories.git;
1242
+ let target = repository;
1243
+ try {
1244
+ if (input.name)
1245
+ await git.validateBranch(repository.repoRoot, input.name);
1246
+ if (input.worktreePath && input.name) {
1247
+ const targetRoot = await git.createWorktree(repository.repoRoot, input.name, input.worktreePath);
1248
+ target = await this.dependencies.repositories.resolve(targetRoot);
1249
+ if (target.projectId && target.projectId !== repository.projectId)
1250
+ throw refuse('The target worktree belongs to a different Engineering Memory project. Choose another worktreePath.', 'task.branch');
1251
+ if (!target.markerPath) {
1252
+ await new RepositoryResolver(git, repository.schemaVersion ?? 2).writeMarker(targetRoot, repository.projectId);
1253
+ target = await this.dependencies.repositories.resolve(targetRoot);
1254
+ }
1255
+ }
1256
+ const branch = input.name ?? (await git.currentBranch(target.repoRoot));
1257
+ const store = git.branchStore(target.repoRoot);
1258
+ await store.reserve({
1259
+ projectId: repository.projectId,
1260
+ externalTaskId: input.externalTaskId,
1261
+ branch,
1262
+ });
1263
+ if (input.name && (await git.currentBranch(target.repoRoot)) !== input.name) {
1264
+ try {
1265
+ if (await git.branchExists(target.repoRoot, input.name))
1266
+ await git.switchBranch(target.repoRoot, input.name);
1267
+ else
1268
+ await git.createBranch(target.repoRoot, input.name);
1269
+ }
1270
+ catch (error) {
1271
+ if ((await git.currentBranch(target.repoRoot)) !== input.name) {
1272
+ await store.cancelUnopened(repository.projectId, input.externalTaskId);
1273
+ throw error;
1274
+ }
1275
+ }
1276
+ }
1277
+ return asJsonValue({
1278
+ branch,
1279
+ kept: input.keepCurrent === true,
1280
+ repoRoot: target.repoRoot,
1281
+ externalTaskId: input.externalTaskId,
1282
+ nextAction: 'Use this repoRoot for session.bootstrap and every subsequent command for this task. The decision survives retries and restarts.',
1283
+ });
1284
+ }
1285
+ catch (error) {
1286
+ if (error instanceof BridgeRecoveryError)
1287
+ throw error;
1288
+ throw refuse(`${error instanceof Error ? error.message : String(error)} Retry task.branch for the selected task; use worktreePath to leave a busy or dirty checkout intact.`, 'task.branch');
1289
+ }
1290
+ });
1291
+ }
1292
+ async requireBranchDecision(repository, externalTaskId) {
1293
+ const git = this.dependencies.repositories.git;
1294
+ const store = git.branchStore(repository.repoRoot);
1295
+ let record = await store.read();
1296
+ if (!record) {
1297
+ const pointer = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, externalTaskId);
1298
+ if (pointer?.worktreeId === sha256(repository.repoRoot) &&
1299
+ pointer.mode !== 'read_only' &&
1300
+ pointer.branch !== undefined) {
1301
+ await store.reserve({
1302
+ projectId: pointer.projectId,
1303
+ externalTaskId,
1304
+ branch: pointer.branch,
1305
+ taskId: pointer.taskId,
1306
+ });
1307
+ record = await store.read();
1308
+ }
1309
+ }
1310
+ if (!record)
1311
+ throw refuse(`Ask the branch question through the native questionnaire for task ${externalTaskId}, then call task.branch with that externalTaskId and a name or keepCurrent. For parallel work choose a separate worktreePath.`, 'task.branch');
1312
+ if (record.decision.projectId !== repository.projectId ||
1313
+ record.decision.externalTaskId !== externalTaskId)
1314
+ throw refuse(`This worktree belongs to task ${record.decision.externalTaskId}. Use task.branch with worktreePath for ${externalTaskId}, then bootstrap in the returned directory.`, 'task.branch');
1315
+ const branch = await git.currentBranch(repository.repoRoot);
1316
+ if (branch !== record.decision.branch)
1317
+ throw refuse(`Task ${externalTaskId} selected ${record.decision.branch ?? 'detached HEAD'}, but this worktree is on ${branch ?? 'detached HEAD'}. Return to the selected branch with task.branch.`, 'task.branch');
1318
+ return record.decision;
1319
+ }
1320
+ async assertTaskWorktree(repository, task, pointer) {
1321
+ if (normalizeTaskMode(task.mode) === 'read_only')
1322
+ return;
1323
+ if (pointer?.worktreeId && pointer.worktreeId !== sha256(repository.repoRoot))
1324
+ throw refuse('This task belongs to a different worktree. Resume and run its commands in the worktree selected for this task.', 'session.resume');
1325
+ const recorded = typeof task.branch === 'string' ? task.branch : pointer?.branch;
1326
+ const current = await this.dependencies.repositories.git.currentBranch(repository.repoRoot);
1327
+ if (recorded && current !== recorded)
1328
+ throw refuse(`This task selected ${recorded}. Call task.branch with externalTaskId ${pointer?.taskSlug ?? String(task.externalTaskId)} and name ${recorded} before continuing.`, 'task.branch');
1329
+ }
1158
1330
  async taskAbandon(input) {
1159
1331
  return await this.execute(async () => {
1160
1332
  return await this.taskExclusive(input.taskId, async () => {
@@ -1167,6 +1339,9 @@ export class BridgeService {
1167
1339
  if (!abandoned || abandoned.abandoned !== true) {
1168
1340
  throw refuse('The backend did not record the abandonment, so the task is still open.', 'session.resume');
1169
1341
  }
1342
+ await this.dependencies.repositories.git
1343
+ .branchStore(repository.repoRoot)
1344
+ .release(input.taskId);
1170
1345
  const forgotten = await this.dependencies.activeContexts.forget(repository.repoFingerprint, input.taskId);
1171
1346
  await this.dependencies.gate.invalidateTask(input.taskId);
1172
1347
  return asJsonValue({
@@ -1224,11 +1399,59 @@ export class BridgeService {
1224
1399
  return await this.execute(async () => {
1225
1400
  const response = await this.dependencies.client.request(endpoints.projectMemberAdd(input.projectId), {
1226
1401
  method: 'POST',
1227
- body: { email: input.email, role: input.role },
1402
+ body: cleanJson({
1403
+ email: input.email,
1404
+ role: input.role,
1405
+ ...(input.discipline !== undefined ? { discipline: input.discipline } : {}),
1406
+ }),
1228
1407
  });
1229
1408
  return asJsonValue({ membership: response.data });
1230
1409
  });
1231
1410
  }
1411
+ async projectMemberList(input) {
1412
+ return await this.execute(async () => {
1413
+ const response = await this.dependencies.client.request(endpoints.projectMemberList(input.projectId));
1414
+ return asJsonValue({ members: response.data });
1415
+ });
1416
+ }
1417
+ async auditList(input) {
1418
+ return await this.execute(async () => {
1419
+ const query = new URLSearchParams();
1420
+ if (input.organizationId) {
1421
+ query.set('organizationId', input.organizationId);
1422
+ }
1423
+ if (input.projectId)
1424
+ query.set('projectId', input.projectId);
1425
+ if (input.beforeCreatedAt) {
1426
+ query.set('beforeCreatedAt', input.beforeCreatedAt);
1427
+ }
1428
+ if (input.beforeId)
1429
+ query.set('beforeId', input.beforeId);
1430
+ if (input.limit !== undefined)
1431
+ query.set('limit', String(input.limit));
1432
+ const response = await this.dependencies.client.request(`${endpoints.auditList}?${query.toString()}`);
1433
+ return asJsonValue({ page: response.data });
1434
+ });
1435
+ }
1436
+ async organizationMemberUpsert(input) {
1437
+ return await this.execute(async () => {
1438
+ const response = await this.dependencies.client.request(endpoints.organizationMemberUpsert(input.organizationId), {
1439
+ method: 'PUT',
1440
+ body: cleanJson({
1441
+ email: input.email,
1442
+ role: input.role,
1443
+ discipline: input.discipline,
1444
+ }),
1445
+ });
1446
+ return asJsonValue({ membership: response.data });
1447
+ });
1448
+ }
1449
+ async organizationMemberList(input) {
1450
+ return await this.execute(async () => {
1451
+ const response = await this.dependencies.client.request(endpoints.organizationMemberList(input.organizationId));
1452
+ return asJsonValue({ members: response.data });
1453
+ });
1454
+ }
1232
1455
  async organizationList() {
1233
1456
  return await this.execute(async () => {
1234
1457
  const authentication = await this.dependencies.browserAuth.ensureAuthenticated();
@@ -1243,6 +1466,33 @@ export class BridgeService {
1243
1466
  });
1244
1467
  });
1245
1468
  }
1469
+ async workItemList(input) {
1470
+ return await this.execute(async () => {
1471
+ const query = new URLSearchParams();
1472
+ if (input.status)
1473
+ query.set('status', input.status);
1474
+ if (input.priority)
1475
+ query.set('priority', input.priority);
1476
+ if (input.assigneeUserId) {
1477
+ query.set('assigneeUserId', input.assigneeUserId);
1478
+ }
1479
+ if (input.includeArchived)
1480
+ query.set('includeArchived', 'true');
1481
+ const suffix = query.size > 0 ? `?${query.toString()}` : '';
1482
+ const response = await this.dependencies.client.request(`${endpoints.workItemList(input.projectId)}${suffix}`);
1483
+ return asJsonValue({
1484
+ selectionRequired: true,
1485
+ questionnaire: 'Select the project work item to run in this chat',
1486
+ ...objectOrEmpty(response.data),
1487
+ });
1488
+ });
1489
+ }
1490
+ async workItemGet(input) {
1491
+ return await this.execute(async () => {
1492
+ const response = await this.dependencies.client.request(endpoints.workItemGet(input.projectId, input.workItemId));
1493
+ return asJsonValue({ workItem: response.data });
1494
+ });
1495
+ }
1246
1496
  async architecturePlan(input) {
1247
1497
  return await this.execute(async () => {
1248
1498
  const body = cleanJson(input);
@@ -1297,20 +1547,48 @@ export class BridgeService {
1297
1547
  return asJsonValue({ organization: response.data });
1298
1548
  });
1299
1549
  }
1300
- async projectList() {
1550
+ async projectList(input = {}) {
1301
1551
  return await this.execute(async () => {
1302
1552
  const authentication = await this.dependencies.browserAuth.ensureAuthenticated();
1303
1553
  if (authentication) {
1304
1554
  return asJsonValue({ authentication });
1305
1555
  }
1306
- const response = await this.dependencies.client.request(endpoints.projectList);
1556
+ const response = await this.dependencies.client.request(input.includeArchived
1557
+ ? `${endpoints.projectList}?includeArchived=true`
1558
+ : endpoints.projectList);
1307
1559
  return asJsonValue({
1308
1560
  selectionRequired: true,
1309
1561
  questionnaire: 'Select an existing project or choose new project setup',
1310
1562
  projects: response.data,
1563
+ ...(input.includeArchived
1564
+ ? {
1565
+ archivedProjectActions: (Array.isArray(response.data) ? response.data : [])
1566
+ .map((entry) => objectValue(entry))
1567
+ .filter((project) => Boolean(project?.archivedAt))
1568
+ .map((project) => archivedProjectGuidance(project)),
1569
+ }
1570
+ : {}),
1311
1571
  });
1312
1572
  });
1313
1573
  }
1574
+ async projectArchive(input) {
1575
+ return await this.execute(async () => {
1576
+ const response = await this.dependencies.client.request(endpoints.projectArchive(input.projectId), {
1577
+ method: 'DELETE',
1578
+ body: { expectedVersion: input.expectedVersion },
1579
+ });
1580
+ return asJsonValue({ project: response.data, archived: true });
1581
+ });
1582
+ }
1583
+ async projectRestore(input) {
1584
+ return await this.execute(async () => {
1585
+ const response = await this.dependencies.client.request(endpoints.projectRestore(input.projectId), {
1586
+ method: 'POST',
1587
+ body: { expectedVersion: input.expectedVersion },
1588
+ });
1589
+ return asJsonValue({ project: response.data, restored: true });
1590
+ });
1591
+ }
1314
1592
  async projectResolve(input) {
1315
1593
  return await this.execute(async () => {
1316
1594
  const authentication = await this.dependencies.browserAuth.ensureAuthenticated();
@@ -1355,12 +1633,16 @@ export class BridgeService {
1355
1633
  body: { repoFingerprint: repository.repoFingerprint },
1356
1634
  });
1357
1635
  const project = objectValue(response.data);
1636
+ const restoreRequired = Boolean(project?.archivedAt);
1637
+ const restoreGuidance = archivedProjectGuidance(project);
1358
1638
  return asJsonValue({
1359
- resolved: project !== null,
1639
+ resolved: project !== null && !restoreRequired,
1360
1640
  project,
1361
1641
  markerPath: null,
1362
1642
  repository: publicRepository(repository),
1363
1643
  selectionRequired: project === null,
1644
+ restoreRequired,
1645
+ ...(restoreRequired ? restoreGuidance : {}),
1364
1646
  });
1365
1647
  });
1366
1648
  }
@@ -1391,21 +1673,50 @@ export class BridgeService {
1391
1673
  const client = await this.clientUpdate(authenticated);
1392
1674
  const shipped = objectValue(objectValue(client)?.shippedKnowledge);
1393
1675
  const incomplete = shipped && (shipped.failure !== null || Number(shipped.live) < Number(shipped.expected));
1676
+ const projectId = repository.projectId ?? decision?.projectId ?? null;
1677
+ const liveTasks = authenticated && state === 'bound'
1678
+ ? (await this.dependencies.activeContexts.list(repository.repoFingerprint)).map(describePointer)
1679
+ : [];
1680
+ const workItems = authenticated && state === 'bound' && projectId
1681
+ ? await this.actionableWorkItems(projectId)
1682
+ : [];
1683
+ const taskSelectionRequired = liveTasks.length > 0 || workItems.length > 0;
1394
1684
  return asJsonValue({
1395
1685
  authenticated,
1396
1686
  repository: publicRepository(repository),
1397
1687
  decision: state,
1398
- projectId: repository.projectId ?? decision?.projectId ?? null,
1688
+ projectId,
1689
+ liveTasks,
1690
+ workItems,
1691
+ taskSelectionRequired,
1399
1692
  client,
1400
- ...(incomplete
1693
+ ...(incomplete && state === 'bound'
1401
1694
  ? {
1402
1695
  shippedKnowledgeWarning: 'The backend is not serving all of the knowledge this release ships. Tell the user before relying on the rules: some are missing from the running server, so a review against them is incomplete. The counts and the reason are in client.shippedKnowledge.',
1403
1696
  }
1404
1697
  : {}),
1405
- nextAction: entryNextAction(authenticated, state),
1698
+ nextAction: taskSelectionRequired
1699
+ ? 'Present the live runs and actionable project work items, then ask which one this chat should handle. Resume only the run they choose, or bootstrap a separate run with the chosen workItemId. Another live task never blocks opening this one.'
1700
+ : entryNextAction(authenticated, state),
1406
1701
  });
1407
1702
  });
1408
1703
  }
1704
+ async actionableWorkItems(projectId) {
1705
+ try {
1706
+ const response = await this.dependencies.client.request(`${endpoints.workItemList(projectId)}?limit=100`);
1707
+ const items = objectValue(response.data)?.items;
1708
+ if (!Array.isArray(items))
1709
+ return [];
1710
+ const actionable = new Set(['backlog', 'ready', 'in_progress', 'in_review']);
1711
+ return items.filter((entry) => {
1712
+ const item = objectValue(entry);
1713
+ return item ? actionable.has(String(item.status)) : false;
1714
+ });
1715
+ }
1716
+ catch {
1717
+ return [];
1718
+ }
1719
+ }
1409
1720
  async clientUpdate(authenticated) {
1410
1721
  const installed = this.dependencies.clientVersion;
1411
1722
  if (!authenticated) {
@@ -1929,6 +2240,7 @@ export class BridgeService {
1929
2240
  repoFingerprint: pointer.repoFingerprint,
1930
2241
  repoRoot: repository.repoRoot,
1931
2242
  diffHash: repository.git.diffHash,
2243
+ branch: typeof backendTask?.branch === 'string' ? backendTask.branch : (pointer.branch ?? null),
1932
2244
  taskVersion,
1933
2245
  taskChanges: intent.taskChanges,
1934
2246
  });
@@ -1957,6 +2269,9 @@ export class BridgeService {
1957
2269
  }
1958
2270
  async deliverCloseIntent(repository, body, taskChanges, recoveredAfterResponseLoss) {
1959
2271
  const touchedContract = taskChanges.some((change) => /\.controller\.[tj]s$/.test(change.path));
2272
+ const currentBranch = await this.dependencies.repositories.git.currentBranch(repository.repoRoot);
2273
+ if (typeof body.branch === 'string' && body.branch !== currentBranch)
2274
+ throw refuse(`Return to the branch ${body.branch} recorded in this close request before retrying.`, 'task.branch');
1960
2275
  let response;
1961
2276
  try {
1962
2277
  response = await this.dependencies.client.request(endpoints.taskClose, {
@@ -1982,11 +2297,19 @@ export class BridgeService {
1982
2297
  repoFingerprint: repository.repoFingerprint,
1983
2298
  repoRoot: repository.repoRoot,
1984
2299
  diffHash: String(body.diffHash),
2300
+ branch: typeof closedTask.branch === 'string'
2301
+ ? closedTask.branch
2302
+ : typeof body.branch === 'string'
2303
+ ? body.branch
2304
+ : null,
1985
2305
  taskClosed: true,
1986
2306
  taskVersion: closedTaskVersion,
1987
2307
  taskChanges,
1988
2308
  });
1989
2309
  await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
2310
+ await this.dependencies.repositories.git
2311
+ .branchStore(repository.repoRoot)
2312
+ .release(String(body.taskId));
1990
2313
  return asJsonValue({
1991
2314
  ...closedTask,
1992
2315
  repository: publicRepository(repository),
@@ -2107,7 +2430,7 @@ export class BridgeService {
2107
2430
  kind: 'bridge_error',
2108
2431
  message: error instanceof Error ? error.message : 'Bridge operation failed',
2109
2432
  retryable: false,
2110
- ...(error instanceof RefusalError ? { recovery: error.recovery } : {}),
2433
+ ...(error instanceof BridgeRecoveryError ? { recovery: error.recovery } : {}),
2111
2434
  },
2112
2435
  };
2113
2436
  }
@@ -2300,6 +2623,26 @@ function objectValue(value) {
2300
2623
  function objectOrEmpty(value) {
2301
2624
  return objectValue(value ?? undefined) ?? {};
2302
2625
  }
2626
+ function archivedProjectGuidance(project) {
2627
+ const projectId = typeof project?.id === 'string' ? project.id : null;
2628
+ const expectedVersion = typeof project?.lockVersion === 'number' ? project.lockVersion : null;
2629
+ if (project?.role === 'owner') {
2630
+ return {
2631
+ projectId,
2632
+ expectedVersion,
2633
+ requiredRole: 'owner',
2634
+ recovery: 'project.restore',
2635
+ nextAction: 'Call project.restore with this project id and lockVersion before continuing.',
2636
+ };
2637
+ }
2638
+ return {
2639
+ projectId,
2640
+ expectedVersion,
2641
+ requiredRole: 'owner',
2642
+ recovery: 'project.member_list',
2643
+ nextAction: 'Call project.member_list with this project id, identify an owner, and ask that owner to restore the archived project.',
2644
+ };
2645
+ }
2303
2646
  function slugify(value) {
2304
2647
  return value
2305
2648
  .trim()
@@ -2404,15 +2747,8 @@ function isOfflineLeaseUsable(lease, sessionId, changedPaths, baselineDiffHash)
2404
2747
  return false;
2405
2748
  }
2406
2749
  }
2407
- class RefusalError extends Error {
2408
- recovery;
2409
- constructor(message, recovery) {
2410
- super(message);
2411
- this.recovery = recovery;
2412
- }
2413
- }
2414
2750
  function refuse(message, recovery) {
2415
- return new RefusalError(`${message} Recovery: call ${recovery}.`, recovery);
2751
+ return new BridgeRecoveryError(message, recovery);
2416
2752
  }
2417
2753
  function short(value) {
2418
2754
  return typeof value === 'string' && value.length > 0 ? value.slice(0, 12) : 'none';
@@ -1,6 +1,7 @@
1
1
  import { join } from 'node:path';
2
2
  import { readJson, removeFile, writeJson } from '../utilities/files.js';
3
3
  import { sha256 } from '../utilities/hash.js';
4
+ import { BridgeRecoveryError } from './recovery-error.js';
4
5
  export class PrincipalStateGuard {
5
6
  stateRoot;
6
7
  credentials;
@@ -31,7 +32,7 @@ export class PrincipalStateGuard {
31
32
  return;
32
33
  }
33
34
  if ((await this.outbox.list()).length > 0) {
34
- throw new Error('Pending offline work belongs to a different authenticated principal');
35
+ throw new BridgeRecoveryError('Pending offline work belongs to a different authenticated principal.', 'task.resolve_pending_delivery');
35
36
  }
36
37
  await this.clearPrincipalState();
37
38
  await writeJson(this.ownerPath, {
@@ -44,7 +45,7 @@ export class PrincipalStateGuard {
44
45
  async clearAfterLogout() {
45
46
  await this.exclusive(async () => {
46
47
  if ((await this.outbox.list()).length > 0) {
47
- throw new Error('Pending offline work must be resolved before logout');
48
+ throw new BridgeRecoveryError('Pending offline work must be resolved before logout.', 'task.resolve_pending_delivery');
48
49
  }
49
50
  await this.clearPrincipalState();
50
51
  await this.credentials.clear();