engineering-memory 1.8.0 → 1.10.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.
@@ -32,6 +32,7 @@ export const backendRecoveryOperationNames = [
32
32
  'task.verify',
33
33
  'task.close',
34
34
  'task.abandon',
35
+ 'task.branch',
35
36
  'task.resolve_pending_delivery',
36
37
  ];
37
38
  const backendRecoveryOperations = new Set(backendRecoveryOperationNames);
@@ -67,7 +67,7 @@ export class BridgeService {
67
67
  const recoveredDeliveries = await this.recoverJournalOutbox();
68
68
  const outbox = await this.flushOutbox();
69
69
  let projectId = repository.projectId;
70
- if (!repository.markerPath) {
70
+ if (!repository.bindingPath) {
71
71
  const resolved = await this.dependencies.client.request(endpoints.projectResolve, {
72
72
  method: 'POST',
73
73
  body: { repoFingerprint: repository.repoFingerprint },
@@ -88,8 +88,13 @@ export class BridgeService {
88
88
  });
89
89
  }
90
90
  if (!projectId) {
91
- throw refuse('This repository has a marker that names no project, so there is nothing to bootstrap against.', 'project.resolve');
91
+ throw refuse('This repository has no selected project. Select its project before bootstrapping.', 'project.resolve');
92
92
  }
93
+ const bootstrapMode = persistedBootstrap.mode ?? 'write';
94
+ const decision = bootstrapMode === 'read_only'
95
+ ? null
96
+ : await this.requireBranchDecision(repository, input.externalTaskId);
97
+ const branch = decision?.branch;
93
98
  const checkpointId = deterministicUuid('session.bootstrap', projectId, repository.repoFingerprint, input.externalTaskId);
94
99
  const response = await this.dependencies.client.request(endpoints.sessionBootstrap, {
95
100
  method: 'POST',
@@ -106,6 +111,7 @@ export class BridgeService {
106
111
  ...(input.mode === 'read_only'
107
112
  ? { baselineDiffHash: repository.git.worktreeHash }
108
113
  : {}),
114
+ ...(decision ? { branch } : {}),
109
115
  checkpointIdempotencyKey: checkpointId,
110
116
  knownRevisions: persistedBootstrap.knownRevisions,
111
117
  }),
@@ -127,11 +133,20 @@ export class BridgeService {
127
133
  !isIsoTimestamp(checkpointPayload.createdAt)) {
128
134
  throw refuse('The backend opened the session without the checkpoint the task is anchored to.', 'session.bootstrap');
129
135
  }
136
+ if (decision)
137
+ await this.dependencies.repositories.git
138
+ .branchStore(repository.repoRoot)
139
+ .bind(projectId, input.externalTaskId, task.id);
130
140
  const pointer = {
131
141
  repoFingerprint: repository.repoFingerprint,
132
142
  projectId,
133
143
  taskId: task.id,
134
144
  taskSlug: input.externalTaskId,
145
+ worktreeId: sha256(repository.repoRoot),
146
+ ...(decision
147
+ ? { branch: typeof task.branch === 'string' ? task.branch : decision.branch }
148
+ : {}),
149
+ mode: normalizeTaskMode(task.mode),
135
150
  sessionId: session.id,
136
151
  lastSequence: numericSequence(task.lastSequence),
137
152
  taskVersion: numericTaskVersion(task.lockVersion),
@@ -256,6 +271,12 @@ export class BridgeService {
256
271
  backendSession.id !== sessionId) {
257
272
  throw refuse('The backend resumed the session without the task it belongs to.', 'session.resume');
258
273
  }
274
+ await this.assertTaskWorktree(repository, backendTask, pointer);
275
+ if (backendTask.status === 'closed' || backendTask.status === 'abandoned') {
276
+ await this.dependencies.repositories.git
277
+ .branchStore(repository.repoRoot)
278
+ .release(backendTask.id);
279
+ }
259
280
  let localJournal = await this.dependencies.journal.load(projectId, taskSlug);
260
281
  let pendingOutbox = await this.dependencies.outbox.list();
261
282
  const responseSource = this.dependencies.client.getResponseSource(response);
@@ -365,6 +386,13 @@ export class BridgeService {
365
386
  projectId,
366
387
  taskId: backendTask.id,
367
388
  taskSlug,
389
+ worktreeId: pointer?.worktreeId ?? sha256(repository.repoRoot),
390
+ ...(typeof backendTask.branch === 'string'
391
+ ? { branch: backendTask.branch }
392
+ : pointer?.branch !== undefined
393
+ ? { branch: pointer.branch }
394
+ : {}),
395
+ mode: normalizeTaskMode(backendTask.mode),
368
396
  sessionId,
369
397
  lastSequence: resumedLastSequence,
370
398
  taskVersion: resumedTaskVersion,
@@ -443,6 +471,10 @@ export class BridgeService {
443
471
  ...input.changedPaths,
444
472
  ]);
445
473
  const baselineDiffHash = activeBaseline?.diffHash ?? repository.git.diffHash;
474
+ const decision = pointer &&
475
+ (input.transitionToWrite || (pointer.branch !== undefined && pointer.mode !== 'read_only'))
476
+ ? await this.requireBranchDecision(repository, pointer.taskSlug)
477
+ : null;
446
478
  let transitionTaskVersion;
447
479
  if (input.transitionToWrite) {
448
480
  if (!pointer || pointer.sessionId !== input.sessionId) {
@@ -455,6 +487,7 @@ export class BridgeService {
455
487
  sessionId: input.sessionId,
456
488
  changedPaths,
457
489
  baselineDiffHash,
490
+ ...(decision ? { branch: decision.branch } : {}),
458
491
  ...(input.transitionToWrite
459
492
  ? { transitionToWrite: true, expectedTaskVersion: transitionTaskVersion }
460
493
  : {}),
@@ -501,6 +534,17 @@ export class BridgeService {
501
534
  : undefined);
502
535
  const preparedPointer = await this.dependencies.activeContexts.loadForSession(repository.repoFingerprint, input.sessionId);
503
536
  if (preparedPointer) {
537
+ if (decision) {
538
+ await this.dependencies.repositories.git
539
+ .branchStore(repository.repoRoot)
540
+ .bind(preparedPointer.projectId, preparedPointer.taskSlug, preparedPointer.taskId);
541
+ await this.dependencies.activeContexts.save({
542
+ ...preparedPointer,
543
+ worktreeId: sha256(repository.repoRoot),
544
+ branch: typeof preparedTask?.branch === 'string' ? preparedTask.branch : decision.branch,
545
+ mode: 'write',
546
+ });
547
+ }
504
548
  const preparedData = objectValue(response.data);
505
549
  await this.seedResumeSnapshot(preparedPointer, {
506
550
  ...objectOrEmpty(response.data),
@@ -1010,6 +1054,7 @@ export class BridgeService {
1010
1054
  });
1011
1055
  }
1012
1056
  const taskMode = normalizeTaskMode(snapshotTask.mode);
1057
+ await this.assertTaskWorktree(repository, snapshotTask, pointer);
1013
1058
  const resourceDiscoveryPolicy = readResourceDiscoveryPolicy(snapshot, activeLease);
1014
1059
  const validations = normalizePersistentInput(input.validations, repository.repoRoot);
1015
1060
  if (taskMode === 'read_only') {
@@ -1072,6 +1117,11 @@ export class BridgeService {
1072
1117
  changedPaths: taskMode === 'read_only' ? [] : taskChangedPaths,
1073
1118
  pathChanges: pathChangeManifest,
1074
1119
  diffHash: repository.git.diffHash,
1120
+ ...(taskMode !== 'read_only'
1121
+ ? {
1122
+ branch: await this.dependencies.repositories.git.currentBranch(repository.repoRoot),
1123
+ }
1124
+ : {}),
1075
1125
  ...(taskMode === 'read_only' ? { worktreeHash: repository.git.worktreeHash } : {}),
1076
1126
  validations,
1077
1127
  pendingOutboxCount,
@@ -1105,6 +1155,9 @@ export class BridgeService {
1105
1155
  repoFingerprint: repository.repoFingerprint,
1106
1156
  repoRoot: repository.repoRoot,
1107
1157
  diffHash: repository.git.diffHash,
1158
+ branch: typeof snapshotTask.branch === 'string'
1159
+ ? snapshotTask.branch
1160
+ : (pointer.branch ?? null),
1108
1161
  taskVersion,
1109
1162
  taskChanges,
1110
1163
  });
@@ -1148,11 +1201,17 @@ export class BridgeService {
1148
1201
  const taskChanges = pointer.changeBaseline
1149
1202
  ? manifestDelta(pointer.changeBaseline.changedPaths, repository.git.changedPaths)
1150
1203
  : [];
1204
+ await this.assertTaskWorktree(repository, task, pointer);
1151
1205
  const closeBody = {
1152
1206
  taskId: input.taskId,
1153
1207
  sessionId: pointer.sessionId,
1154
1208
  expectedTaskVersion: numericTaskVersion(task.lockVersion),
1155
1209
  diffHash: repository.git.diffHash,
1210
+ ...(normalizeTaskMode(task.mode) !== 'read_only'
1211
+ ? {
1212
+ branch: await this.dependencies.repositories.git.currentBranch(repository.repoRoot),
1213
+ }
1214
+ : {}),
1156
1215
  };
1157
1216
  await this.dependencies.activeContexts.setCloseIntent(repository.repoFingerprint, {
1158
1217
  body: closeBody,
@@ -1162,6 +1221,109 @@ export class BridgeService {
1162
1221
  });
1163
1222
  });
1164
1223
  }
1224
+ async taskBranch(input) {
1225
+ return await this.execute(async () => {
1226
+ const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1227
+ const authentication = await this.dependencies.browserAuth.ensureAuthenticated();
1228
+ if (authentication)
1229
+ return asJsonValue({ authentication });
1230
+ if (!repository.projectId)
1231
+ throw refuse('Resolve the project before choosing its task branch.', 'project.resolve');
1232
+ if (!input.externalTaskId ||
1233
+ input.externalTaskId.length > 160 ||
1234
+ /[\r\n]/.test(input.externalTaskId))
1235
+ throw refuse('Pass the exact externalTaskId of the task whose branch question was answered.', 'task.branch');
1236
+ if (Boolean(input.name) === (input.keepCurrent === true) ||
1237
+ (input.worktreePath && !input.name))
1238
+ throw refuse('Choose exactly one branch name or keepCurrent. A separate worktree also needs a branch name.', 'task.branch');
1239
+ assertSafeToPersist(cleanJson({ externalTaskId: input.externalTaskId, name: input.name }));
1240
+ const git = this.dependencies.repositories.git;
1241
+ let target = repository;
1242
+ try {
1243
+ if (input.name)
1244
+ await git.validateBranch(repository.repoRoot, input.name);
1245
+ if (input.worktreePath && input.name) {
1246
+ const targetRoot = await git.createWorktree(repository.repoRoot, input.name, input.worktreePath);
1247
+ target = await this.dependencies.repositories.resolve(targetRoot);
1248
+ if (target.projectId && target.projectId !== repository.projectId)
1249
+ throw refuse('The target worktree belongs to a different Engineering Memory project. Choose another worktreePath.', 'task.branch');
1250
+ if (!target.bindingPath || target.repoFingerprint !== repository.repoFingerprint)
1251
+ throw refuse('The worktree did not inherit the selected local project binding.', 'project.resolve');
1252
+ }
1253
+ const branch = input.name ?? (await git.currentBranch(target.repoRoot));
1254
+ const store = git.branchStore(target.repoRoot);
1255
+ await store.reserve({
1256
+ projectId: repository.projectId,
1257
+ externalTaskId: input.externalTaskId,
1258
+ branch,
1259
+ });
1260
+ if (input.name && (await git.currentBranch(target.repoRoot)) !== input.name) {
1261
+ try {
1262
+ if (await git.branchExists(target.repoRoot, input.name))
1263
+ await git.switchBranch(target.repoRoot, input.name);
1264
+ else
1265
+ await git.createBranch(target.repoRoot, input.name);
1266
+ }
1267
+ catch (error) {
1268
+ if ((await git.currentBranch(target.repoRoot)) !== input.name) {
1269
+ await store.cancelUnopened(repository.projectId, input.externalTaskId);
1270
+ throw error;
1271
+ }
1272
+ }
1273
+ }
1274
+ return asJsonValue({
1275
+ branch,
1276
+ kept: input.keepCurrent === true,
1277
+ repoRoot: target.repoRoot,
1278
+ externalTaskId: input.externalTaskId,
1279
+ nextAction: 'Use this repoRoot for session.bootstrap and every subsequent command for this task. The decision survives retries and restarts.',
1280
+ });
1281
+ }
1282
+ catch (error) {
1283
+ if (error instanceof BridgeRecoveryError)
1284
+ throw error;
1285
+ 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');
1286
+ }
1287
+ });
1288
+ }
1289
+ async requireBranchDecision(repository, externalTaskId) {
1290
+ const git = this.dependencies.repositories.git;
1291
+ const store = git.branchStore(repository.repoRoot);
1292
+ let record = await store.read();
1293
+ if (!record) {
1294
+ const pointer = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, externalTaskId);
1295
+ if (pointer?.worktreeId === sha256(repository.repoRoot) &&
1296
+ pointer.mode !== 'read_only' &&
1297
+ pointer.branch !== undefined) {
1298
+ await store.reserve({
1299
+ projectId: pointer.projectId,
1300
+ externalTaskId,
1301
+ branch: pointer.branch,
1302
+ taskId: pointer.taskId,
1303
+ });
1304
+ record = await store.read();
1305
+ }
1306
+ }
1307
+ if (!record)
1308
+ 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');
1309
+ if (record.decision.projectId !== repository.projectId ||
1310
+ record.decision.externalTaskId !== externalTaskId)
1311
+ 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');
1312
+ const branch = await git.currentBranch(repository.repoRoot);
1313
+ if (branch !== record.decision.branch)
1314
+ 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');
1315
+ return record.decision;
1316
+ }
1317
+ async assertTaskWorktree(repository, task, pointer) {
1318
+ if (normalizeTaskMode(task.mode) === 'read_only')
1319
+ return;
1320
+ if (pointer?.worktreeId && pointer.worktreeId !== sha256(repository.repoRoot))
1321
+ throw refuse('This task belongs to a different worktree. Resume and run its commands in the worktree selected for this task.', 'session.resume');
1322
+ const recorded = typeof task.branch === 'string' ? task.branch : pointer?.branch;
1323
+ const current = await this.dependencies.repositories.git.currentBranch(repository.repoRoot);
1324
+ if (recorded && current !== recorded)
1325
+ throw refuse(`This task selected ${recorded}. Call task.branch with externalTaskId ${pointer?.taskSlug ?? String(task.externalTaskId)} and name ${recorded} before continuing.`, 'task.branch');
1326
+ }
1165
1327
  async taskAbandon(input) {
1166
1328
  return await this.execute(async () => {
1167
1329
  return await this.taskExclusive(input.taskId, async () => {
@@ -1174,6 +1336,9 @@ export class BridgeService {
1174
1336
  if (!abandoned || abandoned.abandoned !== true) {
1175
1337
  throw refuse('The backend did not record the abandonment, so the task is still open.', 'session.resume');
1176
1338
  }
1339
+ await this.dependencies.repositories.git
1340
+ .branchStore(repository.repoRoot)
1341
+ .release(input.taskId);
1177
1342
  const forgotten = await this.dependencies.activeContexts.forget(repository.repoFingerprint, input.taskId);
1178
1343
  await this.dependencies.gate.invalidateTask(input.taskId);
1179
1344
  return asJsonValue({
@@ -1223,8 +1388,8 @@ export class BridgeService {
1223
1388
  readDiscoveryUnits(policy).length === 0) {
1224
1389
  throw refuse('Project setup response is not policy-ready', 'project.setup');
1225
1390
  }
1226
- const markerPath = await this.dependencies.repositories.writeMarker(repository.repoRoot, project.id);
1227
- return asJsonValue({ ...data, markerPath });
1391
+ const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, project.id);
1392
+ return asJsonValue({ ...data, marker: null, markerPath: null, bindingPath });
1228
1393
  });
1229
1394
  }
1230
1395
  async projectMemberAdd(input) {
@@ -1325,6 +1490,77 @@ export class BridgeService {
1325
1490
  return asJsonValue({ workItem: response.data });
1326
1491
  });
1327
1492
  }
1493
+ async workItemCreate(input) {
1494
+ return this.execute(async () => {
1495
+ const response = await this.dependencies.client.request(endpoints.workItemList(input.projectId), { method: 'POST', body: cleanJson(input.data) });
1496
+ return asJsonValue(response.data);
1497
+ });
1498
+ }
1499
+ async workItemUpdate(input) {
1500
+ return this.execute(async () => {
1501
+ const response = await this.dependencies.client.request(endpoints.workItemGet(input.projectId, input.workItemId), { method: 'PATCH', body: cleanJson(input.data) });
1502
+ return asJsonValue(response.data);
1503
+ });
1504
+ }
1505
+ async workItemArchive(input) {
1506
+ return this.execute(async () => {
1507
+ const response = await this.dependencies.client.request(endpoints.workItemGet(input.projectId, input.workItemId), { method: 'DELETE', body: cleanJson({ expectedVersion: input.expectedVersion }) });
1508
+ return asJsonValue(response.data);
1509
+ });
1510
+ }
1511
+ async workItemRestore(input) {
1512
+ return this.execute(async () => {
1513
+ const response = await this.dependencies.client.request(endpoints.workItemRestore(input.projectId, input.workItemId), { method: 'POST', body: cleanJson({ expectedVersion: input.expectedVersion }) });
1514
+ return asJsonValue(response.data);
1515
+ });
1516
+ }
1517
+ async workItemRecordTest(input) {
1518
+ return this.execute(async () => {
1519
+ const response = await this.dependencies.client.request(endpoints.workItemTests(input.projectId, input.workItemId), { method: 'POST', body: cleanJson(input.data) });
1520
+ return asJsonValue(response.data);
1521
+ });
1522
+ }
1523
+ async workItemConfirmPlan(input) {
1524
+ return this.execute(async () => {
1525
+ const response = await this.dependencies.client.request(endpoints.workItemPlan(input.projectId, input.workItemId), { method: 'PUT', body: cleanJson(input.data) });
1526
+ return asJsonValue(response.data);
1527
+ });
1528
+ }
1529
+ async projectUpdate(input) {
1530
+ return this.execute(async () => {
1531
+ const response = await this.dependencies.client.request(endpoints.projectUpdate(input.projectId), { method: 'PATCH', body: cleanJson(input.data) });
1532
+ return asJsonValue(response.data);
1533
+ });
1534
+ }
1535
+ async workItemRuns(input) {
1536
+ return this.execute(async () => {
1537
+ const query = new URLSearchParams({
1538
+ offset: String(input.offset ?? 0),
1539
+ limit: String(input.limit ?? 50),
1540
+ });
1541
+ const response = await this.dependencies.client.request(`${endpoints.workItemRuns(input.projectId, input.workItemId)}?${query}`);
1542
+ return asJsonValue(response.data);
1543
+ });
1544
+ }
1545
+ async workItemTestResults(input) {
1546
+ return this.execute(async () => {
1547
+ const query = new URLSearchParams({
1548
+ offset: String(input.offset ?? 0),
1549
+ limit: String(input.limit ?? 50),
1550
+ });
1551
+ const response = await this.dependencies.client.request(`${endpoints.workItemTests(input.projectId, input.workItemId)}?${query}`);
1552
+ return asJsonValue(response.data);
1553
+ });
1554
+ }
1555
+ async workItemPlan(input) {
1556
+ return this.execute(async () => {
1557
+ const response = await this.dependencies.client.request(endpoints.workItemPlan(input.projectId, input.workItemId));
1558
+ return asJsonValue({
1559
+ ...objectOrEmpty(response.data),
1560
+ nextAction: 'Read the current and authorized linked project contracts. Decide whether web, mobile, backend or multiple projects need changes. If additional scope is needed, explain why and obtain the native questionnaire answer before work_item.confirm_plan. Reuse an already confirmed scope and shared branch; each repository has its own worktree and task lifecycle.',
1561
+ });
1562
+ });
1563
+ }
1328
1564
  async architecturePlan(input) {
1329
1565
  return await this.execute(async () => {
1330
1566
  const body = cleanJson(input);
@@ -1373,6 +1609,15 @@ export class BridgeService {
1373
1609
  return asJsonValue({ application: response.data });
1374
1610
  });
1375
1611
  }
1612
+ async organizationUpdate(input) {
1613
+ return this.execute(async () => {
1614
+ const response = await this.dependencies.client.request(endpoints.organizationUpdate(input.organizationId), {
1615
+ method: 'PATCH',
1616
+ body: cleanJson({ name: input.name, expectedName: input.expectedName }),
1617
+ });
1618
+ return asJsonValue(response.data);
1619
+ });
1620
+ }
1376
1621
  async organizationCreate(input) {
1377
1622
  return await this.execute(async () => {
1378
1623
  const response = await this.dependencies.client.request(endpoints.organizationCreate, { method: 'POST', body: cleanJson(input) });
@@ -1446,17 +1691,14 @@ export class BridgeService {
1446
1691
  if (!project || project.id !== input.projectId) {
1447
1692
  throw refuse('Project bind response does not match the selected project', 'project.resolve');
1448
1693
  }
1449
- const markerPath = await this.dependencies.repositories.writeMarker(repository.repoRoot, input.projectId);
1694
+ const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, input.projectId);
1450
1695
  return asJsonValue({
1451
1696
  resolved: true,
1452
1697
  bound: true,
1453
1698
  project,
1454
- markerPath,
1455
- repository: publicRepository({
1456
- ...repository,
1457
- markerPath,
1458
- projectId: input.projectId,
1459
- }),
1699
+ markerPath: null,
1700
+ bindingPath,
1701
+ repository: publicRepository(await this.dependencies.repositories.resolve(repository.repoRoot)),
1460
1702
  selectionRequired: false,
1461
1703
  });
1462
1704
  }
@@ -1501,11 +1743,11 @@ export class BridgeService {
1501
1743
  }
1502
1744
  const decision = await this.dependencies.repositoryDecisions.load(repository.repoFingerprint);
1503
1745
  const authenticated = authentication.authenticated === true;
1504
- const state = decision?.state ?? (repository.projectId ? 'bound' : 'none');
1746
+ const state = decision?.state === 'disabled' ? 'disabled' : repository.bindingPath ? 'bound' : 'none';
1505
1747
  const client = await this.clientUpdate(authenticated);
1506
1748
  const shipped = objectValue(objectValue(client)?.shippedKnowledge);
1507
1749
  const incomplete = shipped && (shipped.failure !== null || Number(shipped.live) < Number(shipped.expected));
1508
- const projectId = repository.projectId ?? decision?.projectId ?? null;
1750
+ const projectId = repository.projectId;
1509
1751
  const liveTasks = authenticated && state === 'bound'
1510
1752
  ? (await this.dependencies.activeContexts.list(repository.repoFingerprint)).map(describePointer)
1511
1753
  : [];
@@ -2072,6 +2314,7 @@ export class BridgeService {
2072
2314
  repoFingerprint: pointer.repoFingerprint,
2073
2315
  repoRoot: repository.repoRoot,
2074
2316
  diffHash: repository.git.diffHash,
2317
+ branch: typeof backendTask?.branch === 'string' ? backendTask.branch : (pointer.branch ?? null),
2075
2318
  taskVersion,
2076
2319
  taskChanges: intent.taskChanges,
2077
2320
  });
@@ -2100,6 +2343,9 @@ export class BridgeService {
2100
2343
  }
2101
2344
  async deliverCloseIntent(repository, body, taskChanges, recoveredAfterResponseLoss) {
2102
2345
  const touchedContract = taskChanges.some((change) => /\.controller\.[tj]s$/.test(change.path));
2346
+ const currentBranch = await this.dependencies.repositories.git.currentBranch(repository.repoRoot);
2347
+ if (typeof body.branch === 'string' && body.branch !== currentBranch)
2348
+ throw refuse(`Return to the branch ${body.branch} recorded in this close request before retrying.`, 'task.branch');
2103
2349
  let response;
2104
2350
  try {
2105
2351
  response = await this.dependencies.client.request(endpoints.taskClose, {
@@ -2125,11 +2371,19 @@ export class BridgeService {
2125
2371
  repoFingerprint: repository.repoFingerprint,
2126
2372
  repoRoot: repository.repoRoot,
2127
2373
  diffHash: String(body.diffHash),
2374
+ branch: typeof closedTask.branch === 'string'
2375
+ ? closedTask.branch
2376
+ : typeof body.branch === 'string'
2377
+ ? body.branch
2378
+ : null,
2128
2379
  taskClosed: true,
2129
2380
  taskVersion: closedTaskVersion,
2130
2381
  taskChanges,
2131
2382
  });
2132
2383
  await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
2384
+ await this.dependencies.repositories.git
2385
+ .branchStore(repository.repoRoot)
2386
+ .release(String(body.taskId));
2133
2387
  return asJsonValue({
2134
2388
  ...closedTask,
2135
2389
  repository: publicRepository(repository),
@@ -2363,6 +2617,7 @@ function publicRepository(repository) {
2363
2617
  return asJsonValue({
2364
2618
  repoRoot: repository.repoRoot,
2365
2619
  markerPath: repository.markerPath,
2620
+ bindingPath: repository.bindingPath,
2366
2621
  projectId: repository.projectId,
2367
2622
  schemaVersion: repository.schemaVersion,
2368
2623
  repoFingerprint: repository.repoFingerprint,
@@ -0,0 +1,111 @@
1
+ import { NativeCommandRunner } from '../utilities/process.js';
2
+ import { assertSafeToPersist } from './offline-outbox.js';
3
+ import { BridgeRecoveryError } from './recovery-error.js';
4
+ const decisionRef = 'refs/worktree/engineering-memory-task';
5
+ export class TaskBranchStore {
6
+ repoRoot;
7
+ runner;
8
+ constructor(repoRoot, runner = new NativeCommandRunner()) {
9
+ this.repoRoot = repoRoot;
10
+ this.runner = runner;
11
+ }
12
+ async read() {
13
+ const reference = await this.runner.run('git', ['rev-parse', '--verify', '--quiet', decisionRef], { cwd: this.repoRoot });
14
+ if (reference.exitCode === 1)
15
+ return null;
16
+ if (reference.exitCode !== 0)
17
+ throw new BridgeRecoveryError('Git could not read the worktree task reservation. Retry the branch operation.', 'task.branch');
18
+ const oid = reference.stdout.trim();
19
+ const blob = await this.runner.run('git', ['cat-file', 'blob', oid], { cwd: this.repoRoot });
20
+ if (blob.exitCode !== 0)
21
+ throw new BridgeRecoveryError('Git could not read the recorded branch decision. Retry the branch operation.', 'task.branch');
22
+ let decision;
23
+ try {
24
+ decision = JSON.parse(blob.stdout);
25
+ }
26
+ catch {
27
+ throw new BridgeRecoveryError('The worktree branch record is invalid. Use a separate worktree to continue this task.', 'task.branch');
28
+ }
29
+ if (!decision)
30
+ throw new BridgeRecoveryError('The worktree branch record is invalid. Use a separate worktree to continue this task.', 'task.branch');
31
+ if (typeof decision.projectId !== 'string' ||
32
+ typeof decision.externalTaskId !== 'string' ||
33
+ !(decision.branch === null || typeof decision.branch === 'string')) {
34
+ throw new BridgeRecoveryError('The worktree branch record is invalid. Use a separate worktree to continue this task.', 'task.branch');
35
+ }
36
+ return { oid, decision };
37
+ }
38
+ async reserve(decision) {
39
+ assertSafeToPersist({ ...decision });
40
+ const existing = await this.read();
41
+ if (existing) {
42
+ this.assertSame(existing.decision, decision);
43
+ return;
44
+ }
45
+ const oid = await this.hash(decision);
46
+ const written = await this.runner.run('git', ['update-ref', '--no-deref', decisionRef, oid, '0'.repeat(oid.length)], { cwd: this.repoRoot });
47
+ if (written.exitCode !== 0) {
48
+ const winner = await this.read();
49
+ if (winner) {
50
+ this.assertSame(winner.decision, decision);
51
+ return;
52
+ }
53
+ throw new BridgeRecoveryError('Another branch operation is in progress. Retry the same task.branch operation.', 'task.branch');
54
+ }
55
+ }
56
+ async bind(projectId, externalTaskId, taskId) {
57
+ const current = await this.read();
58
+ if (!current ||
59
+ current.decision.projectId !== projectId ||
60
+ current.decision.externalTaskId !== externalTaskId)
61
+ throw new BridgeRecoveryError('The worktree reservation changed while opening the task. Resume it before editing.', 'session.resume');
62
+ if (current.decision.taskId === taskId)
63
+ return;
64
+ const oid = await this.hash({ ...current.decision, taskId });
65
+ const result = await this.runner.run('git', ['update-ref', '--no-deref', decisionRef, oid, current.oid], { cwd: this.repoRoot });
66
+ if (result.exitCode !== 0) {
67
+ const latest = await this.read();
68
+ if (latest?.decision.taskId !== taskId)
69
+ throw new BridgeRecoveryError('The worktree reservation changed while opening the task. Resume it before editing.', 'session.resume');
70
+ }
71
+ }
72
+ async cancelUnopened(projectId, externalTaskId) {
73
+ const current = await this.read();
74
+ if (!current ||
75
+ current.decision.taskId ||
76
+ current.decision.projectId !== projectId ||
77
+ current.decision.externalTaskId !== externalTaskId)
78
+ return;
79
+ const result = await this.runner.run('git', ['update-ref', '--no-deref', '-d', decisionRef, current.oid], { cwd: this.repoRoot });
80
+ if (result.exitCode !== 0 && (await this.read())?.oid === current.oid)
81
+ throw new BridgeRecoveryError('The failed branch operation could not release its reservation. Retry task.branch with the same task identifier.', 'task.branch');
82
+ }
83
+ async release(taskId) {
84
+ const current = await this.read();
85
+ if (!current || current.decision.taskId !== taskId)
86
+ return;
87
+ const result = await this.runner.run('git', ['update-ref', '--no-deref', '-d', decisionRef, current.oid], { cwd: this.repoRoot });
88
+ if (result.exitCode !== 0 && (await this.read())?.decision.taskId === taskId) {
89
+ throw new BridgeRecoveryError('The task has finished but its worktree reservation could not be released. Resume the task to retry cleanup.', 'session.resume');
90
+ }
91
+ }
92
+ assertSame(current, requested) {
93
+ if (current.projectId !== requested.projectId ||
94
+ current.externalTaskId !== requested.externalTaskId) {
95
+ throw new BridgeRecoveryError(`This worktree belongs to task ${current.externalTaskId}. Resume that task, or call task.branch with worktreePath and a branch name for the new task.`, 'task.branch');
96
+ }
97
+ if (current.branch !== requested.branch) {
98
+ throw new BridgeRecoveryError(`This task selected ${current.branch ?? 'detached HEAD'}. Return to that branch before continuing.`, 'task.branch');
99
+ }
100
+ }
101
+ async hash(decision) {
102
+ const result = await this.runner.run('git', ['hash-object', '-w', '--stdin'], {
103
+ cwd: this.repoRoot,
104
+ input: JSON.stringify(decision),
105
+ });
106
+ if (result.exitCode !== 0)
107
+ throw new BridgeRecoveryError('Git could not save the branch decision. Retry the same branch operation.', 'task.branch');
108
+ return result.stdout.trim();
109
+ }
110
+ }
111
+ //# sourceMappingURL=task-branch-store.js.map
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: engineering-memory
3
- description: Enforces the Engineering Memory lifecycle for Codex and Claude in repositories bound by .engineering-memory/project.json. Use before answering anything about a bound repository, including a question about what it has or where its design lives, and for every planning, implementation, review, diagnosis, rework, Figma, component, screen, service, model, navigation, localization, storage, validation, or Git task in one.
3
+ description: Enforces the Engineering Memory lifecycle for Codex and Claude in repositories bound in user-local Engineering Memory state. Use before answering anything about a bound repository, including a question about what it has or where its design lives, and for every planning, implementation, review, diagnosis, rework, Figma, component, screen, service, model, navigation, localization, storage, validation, or Git task in one.
4
4
  ---
5
5
 
6
6
  # Engineering Memory
@@ -11,7 +11,9 @@ Read [lifecycle.md](references/lifecycle.md) before acting in a bound repository
11
11
 
12
12
  Mandatory behavior:
13
13
 
14
- 1. Locate `.engineering-memory/project.json` from the working directory toward the repository root. When it exists, the project's knowledge is in the backend and not in the working tree, so answer nothing about the project before bootstrapping — a file search that finds no design link, no screen record and no rule is reporting what the repository lacks, not what the project knows.
14
+ Use the host's native questionnaire for every question to the user, including implementation choices, names, clarification, branch/worktree decisions and delivery. In Codex use request_user_input when available; in Claude use AskUserQuestion. Never replace the questionnaire with a chat instruction such as 'type this', 'reply yes', or 'write X if you want Y'. Do not open a survey web page. If the required native control is unavailable or prohibited for that kind of question, follow the host's tool restrictions, explain the limitation, and continue only work already authorized; do not fabricate a survey or silently choose an answer. Existing answers remain valid through retries and handoffs.
15
+
16
+ 1. Discover the repository binding through `session.entry`. Bindings live in the user-level Engineering Memory state directory, outside the repository and installed runtime. No project settings file is required. For a bound repository, the project's knowledge is in the backend, so answer nothing about it before bootstrapping; the absence of local design files or records says nothing about its stored knowledge.
15
17
  2. Call `session.entry` before answering anything in a repository, and act on what it reports before the message itself: sign in when it says so, ask for organization and project when nothing has been decided, and stay completely silent about Engineering Memory in a repository where the user switched it off. Record every one of those answers with `session.set_decision`, and only ever from something the user actually said.
16
18
  3. Before planning or editing, call `session.bootstrap`. After compaction, a new chat, interruption, or handoff, call `session.resume` first.
17
19
  4. Open the task in `read_only` mode for review, diagnosis, planning, or reporting that does not authorize writes; use `scaffold` mode only to apply organization architecture templates to a new project; otherwise use `write` mode. Do read-only discovery, then record the discovery checkpoint.
@@ -25,6 +27,6 @@ Mandatory behavior:
25
27
  12. Never commit, push, publish, deploy, approve a permanent memory revision, or overwrite an existing Git hook without explicit user authorization.
26
28
  13. Never store tokens, passwords, client secrets, raw headers, raw payloads, customer data, or PII in tool inputs, journals, memory, logs, or generated documentation.
27
29
 
28
- If the repository is unbound, do not silently create or attach a project. Use the native questionnaire workflow. If the user selects task-only skip, do not create a marker or memory records.
30
+ If the repository is unbound, do not silently create or attach a project. Use the native questionnaire workflow. If the user selects task-only skip, do not create a binding or memory records.
29
31
 
30
32
  If the backend is unavailable, use only an unexpired bridge-provided cached context. Checkpoints may enter the local outbox, but verification, task close, and commit remain blocked until synchronization succeeds.