engineering-memory 1.11.10 → 1.11.11

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.
@@ -8,6 +8,7 @@ Call \`session.entry\` before you answer anything in a repository, whatever the
8
8
  - No decision recorded: ask which organization and then which project, whatever the message was. The other answer is switching Engineering Memory off in this repository, and it is remembered with \`session.set_decision\`.
9
9
  - Switched off: say nothing about Engineering Memory, offer nothing, hint at nothing, however useful it would be for what was asked. They turned it off; raising it again is what this prevents. Record it back on only when they ask.
10
10
  - Bound: continue into the lifecycle.
11
+ - Project lives elsewhere: binding the chosen project is refused because its code moved to another repository, or \`session.entry\` reports the project moved away from this checkout. Follow the skill's lifecycle reference: a project owner moves the project here with \`project.move_repository\`, anyone else is told that an owner has to. Never create a second project for moved code and never bind a different one to get past the refusal.
11
12
 
12
13
  \`session.entry\` also reports this client's version. When it says an update is available, offer once to run \`npx engineering-memory@latest install\` and tell the user that the running bridge switches to the new release by itself on its next call, so nothing needs restarting; only a bridge older than 1.11.9 needs the client restarted once after that install. If they decline, record it with \`session.decline_update\` and never raise that version again. When it says an update is required, there is no choice to offer: say that this client is older than the backend supports, give the command, and do not carry on as though the lifecycle works.
13
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engineering-memory",
3
- "version": "1.11.10",
3
+ "version": "1.11.11",
4
4
  "description": "Installs the Engineering Memory skill and its local MCP bridge. Sign in after installing; your organization and project are resolved from your account.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -1,3 +1,3 @@
1
1
  {
2
- "gitHead": "875304dd11735e1bf2817ae59e75b54186e5071a"
2
+ "gitHead": "d515ff32869833254d52677e2b9927fd3925295c"
3
3
  }
@@ -67,6 +67,7 @@ export const endpoints = {
67
67
  projectList: '/projects',
68
68
  projectResolve: '/projects/resolve',
69
69
  projectBind: (projectId) => `/projects/${projectId}/bind`,
70
+ projectRepository: (projectId) => `/projects/${projectId}/repository`,
70
71
  projectUpdate: (projectId) => `/projects/${projectId}`,
71
72
  workItemRuns: (projectId, workItemId) => `/projects/${projectId}/work-items/${workItemId}/runs`,
72
73
  workItemRestore: (projectId, workItemId) => `/projects/${projectId}/work-items/${workItemId}/restore`,
@@ -4,6 +4,7 @@ import { isAbsolute, relative, resolve, sep } from 'node:path';
4
4
  import { assertManagedPath, canonicalPath } from '../utilities/files.js';
5
5
  import { sha256, stableStringify } from '../utilities/hash.js';
6
6
  import { NativeCommandRunner } from '../utilities/process.js';
7
+ import { restrictedValueKind } from '../runtime/privacy-detector.js';
7
8
  import { TaskBranchStore } from '../runtime/task-branch-store.js';
8
9
  export const temporaryScaffoldingMarker = 'ENGINEERING-MEMORY-TEMPORARY';
9
10
  export const temporaryScaffoldingPattern = `${temporaryScaffoldingMarker}:`;
@@ -35,6 +36,18 @@ export class GitInspector {
35
36
  }
36
37
  return sha256(identity);
37
38
  }
39
+ async remoteIdentity(repoRoot) {
40
+ const remote = await this.gitValue(repoRoot, ['config', '--get', 'remote.origin.url']);
41
+ if (!remote)
42
+ return null;
43
+ const identity = canonicalRemoteIdentity(remote);
44
+ return !identity.startsWith('local/') &&
45
+ identity.length <= 500 &&
46
+ /^[a-z0-9][a-z0-9.-]*(:\d{1,5})?\/[^\s@\\]+$/.test(identity) &&
47
+ !restrictedValueKind(identity, 'remoteIdentity')
48
+ ? identity
49
+ : null;
50
+ }
38
51
  branchStore(repoRoot) {
39
52
  return new TaskBranchStore(repoRoot, this.runner);
40
53
  }
@@ -72,11 +85,13 @@ export class GitInspector {
72
85
  throw new Error('The worktree directory already belongs to another repository or branch. Choose a different directory');
73
86
  }
74
87
  const exists = await this.branchExists(repoRoot, name);
88
+ const lost = (await this.lostRegistration(repoRoot, path, name)) ? ['-f'] : [];
75
89
  const args = exists
76
- ? ['worktree', 'add', '--', path, name]
90
+ ? ['worktree', 'add', ...lost, '--', path, name]
77
91
  : [
78
92
  'worktree',
79
93
  'add',
94
+ ...lost,
80
95
  '--no-track',
81
96
  '-b',
82
97
  name,
@@ -89,6 +104,22 @@ export class GitInspector {
89
104
  throw new Error(`Git could not create the worktree: ${result.stderr.trim()}`);
90
105
  return await this.findRoot(path);
91
106
  }
107
+ async lostRegistration(repoRoot, path, name) {
108
+ const listed = await this.runner.run('git', ['worktree', 'list', '--porcelain'], {
109
+ cwd: repoRoot,
110
+ });
111
+ if (listed.exitCode !== 0)
112
+ return false;
113
+ const registered = listed.stdout.split(/\r?\n\r?\n/).map((block) => ({
114
+ path: /^worktree (.+)$/m.exec(block)?.[1] ?? '',
115
+ branch: /^branch refs\/heads\/(.+)$/m.exec(block)?.[1] ?? null,
116
+ }));
117
+ const same = (left, right) => process.platform === 'win32'
118
+ ? resolve(left).toLowerCase() === resolve(right).toLowerCase()
119
+ : resolve(left) === resolve(right);
120
+ return (registered.some((entry) => entry.path && same(entry.path, path)) &&
121
+ !registered.some((entry) => entry.branch === name && !same(entry.path, path)));
122
+ }
92
123
  async fetchBranchCommit(repoRoot, remote, branch) {
93
124
  await this.validateBranch(repoRoot, branch);
94
125
  if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/.test(remote))
@@ -133,7 +133,114 @@ const onboardingInput = z.strictObject({
133
133
  projectId: uuid.optional(),
134
134
  reworkBehavior: z.enum(['preserve', 'redesign']).optional(),
135
135
  });
136
+ const moveCopy = {
137
+ tr: {
138
+ ask: (name) => `"${name}" projesini bu depoya taşıyalım mı?`,
139
+ from: 'Projenin şu anki deposu',
140
+ to: 'Bu klasörün deposu',
141
+ unknown: 'adresi kayıtlı değil',
142
+ keeps: (tasks, records, items) => `Proje olduğu gibi devam eder: ${tasks} görev, ${records} hafıza kaydı ve ${items} iş kalemi projede kalır, hiçbir şey silinmez ve yeniden öğrenilmez.`,
143
+ after: 'Eski depo projenin geçmişinde saklanır. Hâlâ eski depoda çalışan ekip arkadaşları açık görevlerini bitirebilir, yeni görevi yeni depodan açar. Proje ileride yeniden taşınabilir.',
144
+ approve: 'Projeyi bu depoya taşı',
145
+ decline: 'Taşıma',
146
+ },
147
+ en: {
148
+ ask: (name) => `Move the project "${name}" to this repository?`,
149
+ from: 'The project lives in',
150
+ to: 'This folder points at',
151
+ unknown: 'address not recorded',
152
+ keeps: (tasks, records, items) => `The project continues as it is: ${tasks} tasks, ${records} memory records and ${items} work items stay with it; nothing is deleted or learned again.`,
153
+ after: 'The old repository is kept in the project history. Teammates still working in it can finish their open tasks and start new ones from the new repository. The project can be moved again later.',
154
+ approve: 'Move the project here',
155
+ decline: 'Do not move',
156
+ },
157
+ };
136
158
  export function registerOnboardingTools(server, service) {
159
+ server.registerTool('project.move_repository', {
160
+ description: 'Move an existing project to the repository this checkout points at, after its code moved (another host, a new remote, a history restart). Project owners only. Opens ONE durable native form naming the old and the new repository; on approval the project keeps every task, memory record, work item and open session, the old repository is kept in its history, and this checkout is bound. Use it when project.resolve with bind refuses with recovery project.move_repository; never create a second project for moved code. Reconsider a declined move with a new decisionAttempt; retries keep the same attempt.',
161
+ inputSchema: z.strictObject({
162
+ repoRoot,
163
+ projectId: uuid,
164
+ decisionAttempt: z.number().int().min(1).max(10000).optional(),
165
+ language: z.enum(['tr', 'en']).default('en'),
166
+ presentation: z.enum(['host_native']).optional(),
167
+ }),
168
+ }, async (input, context) => {
169
+ const preflight = await service.projectRepositoryMovePreflight(input);
170
+ if (!preflight.ok)
171
+ return output(preflight);
172
+ const flight = preflight.data;
173
+ if (flight.authentication)
174
+ return output(preflight);
175
+ if (flight.alreadyHere || !flight.history.current)
176
+ return output(await service.projectResolve({
177
+ repoRoot: input.repoRoot,
178
+ bind: true,
179
+ projectId: input.projectId,
180
+ }));
181
+ if (flight.history.role !== 'owner')
182
+ return output({
183
+ ok: false,
184
+ error: {
185
+ kind: 'bridge_error',
186
+ message: 'Only a project owner can move a project to another repository. List the members with project.member_list and ask an owner to run project.move_repository from a checkout of the new repository. Nothing was changed.',
187
+ retryable: false,
188
+ recovery: 'project.member_list',
189
+ },
190
+ });
191
+ const copy = moveCopy[input.language];
192
+ const questionnaireId = 'project-move-' +
193
+ sha256(stableStringify({
194
+ projectId: input.projectId,
195
+ from: flight.history.current.repoFingerprint,
196
+ to: flight.checkout.repoFingerprint,
197
+ version: flight.history.lockVersion,
198
+ ...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
199
+ }));
200
+ const form = await askQuestionnaire(server, service, {
201
+ repoRoot: input.repoRoot,
202
+ questionnaireId,
203
+ presentation: input.presentation,
204
+ language: input.language,
205
+ message: [
206
+ copy.ask(flight.history.name),
207
+ `${copy.from}: ${flight.history.current.remoteIdentity ?? copy.unknown}`,
208
+ `${copy.to}: ${flight.checkout.remoteIdentity ?? copy.unknown}`,
209
+ copy.keeps(flight.history.records.tasks, flight.history.records.memoryRecords, flight.history.records.workItems),
210
+ copy.after,
211
+ ].join('\n'),
212
+ options: [
213
+ { id: 'approve', label: copy.approve },
214
+ { id: 'decline', label: copy.decline },
215
+ ],
216
+ }, context, [], {
217
+ tool: 'project.move_repository',
218
+ ...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
219
+ retryArguments: { projectId: input.projectId, language: input.language },
220
+ });
221
+ const record = await service.questionnaireResume({
222
+ repoRoot: input.repoRoot,
223
+ questionnaireId,
224
+ });
225
+ if (record.status !== 'answered')
226
+ return form;
227
+ if (record.answer?.choice !== 'approve')
228
+ return output({
229
+ ok: true,
230
+ data: {
231
+ moved: false,
232
+ status: 'declined',
233
+ nextAction: 'The project was not moved and nothing changed. Tell the user in one line. If they later want the move, call project.move_repository with decisionAttempt ' +
234
+ ((input.decisionAttempt ?? 0) + 1) +
235
+ '.',
236
+ },
237
+ });
238
+ return output(await service.projectMoveRepository({
239
+ repoRoot: input.repoRoot,
240
+ projectId: input.projectId,
241
+ expectedVersion: flight.history.lockVersion,
242
+ }));
243
+ });
137
244
  server.registerTool('memory.source_targets', {
138
245
  description: 'Read bounded Git ref candidates without assuming environment roles from their names. Confirm only the chosen committed sources with native memory.sync_start questionnaires; an explicit ref can select a source outside this preview.',
139
246
  inputSchema: z.object({ repoRoot: z.string().optional() }),
@@ -84,6 +84,7 @@ export const toolAnnotations = {
84
84
  'project.setup': write,
85
85
  'project.list': read,
86
86
  'project.resolve': repeatableWrite, // binds an explicitly selected project
87
+ 'project.move_repository': write,
87
88
  'project.archive': destructive,
88
89
  'project.restore': write,
89
90
  'project.member_add': write,
@@ -114,6 +114,7 @@ export const engineeringMemoryToolNames = [
114
114
  'project.setup',
115
115
  'project.list',
116
116
  'project.resolve',
117
+ 'project.move_repository',
117
118
  'project.archive',
118
119
  'project.restore',
119
120
  'project.member_add',
@@ -772,7 +773,7 @@ export function registerEngineeringMemoryTools(server, service) {
772
773
  inputSchema: z.object({ includeArchived: z.boolean().optional() }),
773
774
  }, async (input) => toolResult(await service.projectList(input)));
774
775
  server.registerTool('project.resolve', {
775
- description: 'Resolve the current repository fingerprint or bind an explicitly selected project, then store the user-local binding only after backend authorization succeeds.',
776
+ description: 'Resolve the project this checkout belongs to, or bind an explicitly selected project, then store the user-local binding only after backend authorization succeeds. A result with projectMoved means this checkout points at a repository the project left; a bind refused with recovery project.move_repository means the project lives in another repository and an owner can move it here.',
776
777
  inputSchema: z.object({
777
778
  repoRoot: optionalRepoRoot,
778
779
  bind: z.boolean().optional(),
@@ -1,8 +1,9 @@
1
1
  import { basename } from 'node:path';
2
2
  import * as z from 'zod/v4';
3
- import { cancelledDeliveryQuestionId, } from '../runtime/bridge-service.js';
3
+ import { cancelledDeliveryQuestionId, forgetUnreadableQuestionId, } from '../runtime/bridge-service.js';
4
4
  import { configuredBase, sourceLabel, taskStartChoice, taskStartDefinition, } from '../runtime/task-start.js';
5
5
  import { sha256, stableStringify } from '../utilities/hash.js';
6
+ import { unreadableWorktree } from '../runtime/worktree-pool.js';
6
7
  import { askQuestionnaire } from './questionnaire-tools.js';
7
8
  const repo = z.string().min(1);
8
9
  const branch = z.string().min(1).max(255);
@@ -336,18 +337,89 @@ export function registerWorktreeTools(server, service) {
336
337
  inputSchema: locationSchema,
337
338
  }, async (input) => output(await service.worktreeOperation('list', input)));
338
339
  server.registerTool('worktree.reconcile', {
339
- description: 'Reconcile Git with the local registry, including unregistered legacy checkouts. Use releaseUnowned only on a selected unreserved recovered checkout after prior work has finished; a native source-bound confirmation and fresh safety checks precede reuse. Use retireLegacy only when the user wants old-location slots gone: each retired folder is removed with git worktree remove after its own native approval, and the branch stays. Otherwise never delete files or reset branches.',
340
+ description: 'Reconcile Git with the local registry, including unregistered legacy checkouts. Use releaseUnowned only on a selected unreserved recovered checkout after prior work has finished; a native source-bound confirmation and fresh safety checks precede reuse. Use retireLegacy only when the user wants old-location slots gone: each retired folder is removed with git worktree remove after its own native approval, and the branch stays. Use forgetUnreadable with the externalTaskId of a checkout that is gone or can no longer be read as a Git worktree, called from a working checkout of the same project: after its own native approval Engineering Memory stops tracking that entry, so it no longer counts toward the limit, and no file in the folder is read, moved or deleted. Otherwise never delete files or reset branches.',
340
341
  inputSchema: z.strictObject({
341
342
  repoRoot: repo,
342
343
  releaseUnowned: z.boolean().optional(),
343
344
  retireLegacy: z.boolean().optional(),
345
+ forgetUnreadable: z.strictObject({ externalTaskId: z.string().min(2).max(160) }).optional(),
344
346
  decisionAttempt: z.number().int().min(0).default(0),
345
347
  language: z.enum(['tr', 'en']).default('en'),
346
348
  }),
347
349
  }, async (input, context) => {
348
350
  const inspection = await service.worktreeOperation('reconcile', input);
349
- if (!inspection.ok || (!input.releaseUnowned && !input.retireLegacy))
351
+ if (!inspection.ok ||
352
+ (!input.releaseUnowned && !input.retireLegacy && !input.forgetUnreadable))
350
353
  return output(inspection);
354
+ if (input.forgetUnreadable) {
355
+ const item = inspection.data.items.find((entry) => entry.externalTaskId === input.forgetUnreadable.externalTaskId);
356
+ if (!item || item.activity === 'retired' || !unreadableWorktree(item))
357
+ return output({
358
+ ok: false,
359
+ error: {
360
+ kind: 'bridge_error',
361
+ retryable: false,
362
+ recovery: 'worktree.list',
363
+ message: 'Only a checkout that is missing or can no longer be read as a Git worktree is forgotten. Call worktree.list to see this task: a readable one is freed with worktree.release, and a retired slot is removed with worktree.reconcile and retireLegacy.',
364
+ },
365
+ });
366
+ const questionnaireId = forgetUnreadableQuestionId(item, input.decisionAttempt);
367
+ const folder = basename(item.repoRoot);
368
+ const tr = input.language === 'tr';
369
+ const form = await askQuestionnaire(server, service, {
370
+ repoRoot: input.repoRoot,
371
+ questionnaireId,
372
+ language: input.language,
373
+ allowFreeText: false,
374
+ message: tr
375
+ ? item.externalTaskId +
376
+ ' görevinin ' +
377
+ folder +
378
+ ' klasörü artık bir Git çalışma kopyası olarak okunamıyor. Engineering Memory bu klasörü takip etmeyi bıraksın mı? Klasördeki hiçbir dosyaya dokunulmaz, hiçbiri silinmez.' +
379
+ (item.pendingDelivery
380
+ ? ' Bu görev teslimi sonuçlanmadan kapandı; teslim edilmemiş iş varsa yalnızca bu klasörde duruyor.'
381
+ : '')
382
+ : 'The ' +
383
+ folder +
384
+ ' folder of task ' +
385
+ item.externalTaskId +
386
+ ' can no longer be read as a Git checkout. Should Engineering Memory stop tracking it? No file in the folder is touched or deleted.' +
387
+ (item.pendingDelivery
388
+ ? ' That task closed without its delivery being settled, so any undelivered work exists only in that folder.'
389
+ : ''),
390
+ options: [
391
+ {
392
+ id: 'forget',
393
+ label: tr
394
+ ? 'Takip etmeyi bırak; dosyalara dokunma'
395
+ : 'Stop tracking it; leave the files alone',
396
+ },
397
+ { id: 'keep', label: tr ? 'Takip etmeye devam et' : 'Keep tracking it' },
398
+ ],
399
+ }, context, [], {
400
+ tool: 'worktree.reconcile',
401
+ ...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
402
+ retryArguments: json({
403
+ forgetUnreadable: input.forgetUnreadable,
404
+ language: input.language,
405
+ }),
406
+ });
407
+ const decision = await service.questionnaireResume({
408
+ repoRoot: input.repoRoot,
409
+ questionnaireId,
410
+ });
411
+ if (decision.status !== 'answered')
412
+ return form;
413
+ if (decision.answer?.choice !== 'forget')
414
+ return output({ ok: true, data: json({ kept: item.externalTaskId }) });
415
+ return output(await service.worktreeOperation('forget_unreadable', {
416
+ repoRoot: input.repoRoot,
417
+ externalTaskId: item.externalTaskId,
418
+ generation: item.generation,
419
+ decisionAttempt: input.decisionAttempt,
420
+ decisionId: questionnaireId,
421
+ }));
422
+ }
351
423
  if (input.retireLegacy) {
352
424
  const items = inspection.data.items.filter((item) => item.activity === 'retired');
353
425
  const retired = [];
@@ -69,11 +69,23 @@ export class RepositoryResolver {
69
69
  legacyFingerprints: binding ? [] : [await this.git.fingerprint(repoRoot, 1)],
70
70
  };
71
71
  }
72
- async writeBinding(repoRoot, projectId, fingerprint) {
72
+ async checkoutFingerprints(repoRoot) {
73
+ return [
74
+ ...new Set([
75
+ await this.git.fingerprint(repoRoot, 2),
76
+ await this.git.fingerprint(repoRoot, 1),
77
+ ]),
78
+ ];
79
+ }
80
+ async writeBinding(repoRoot, projectId, fingerprint, livesIn) {
73
81
  assertProjectId(projectId);
74
- const repository = await this.resolve(repoRoot, projectId);
82
+ const root = await this.git.findRoot(repoRoot);
83
+ const home = livesIn !== undefined &&
84
+ /^[0-9a-f]{64}$/.test(fingerprint) &&
85
+ (await this.checkoutFingerprints(root)).includes(livesIn);
86
+ const repository = await this.resolve(root, home ? undefined : projectId);
75
87
  const older = repository.legacyFingerprints.includes(fingerprint);
76
- if (fingerprint !== repository.repoFingerprint && !older) {
88
+ if (fingerprint !== repository.repoFingerprint && !older && !home) {
77
89
  throw new Error('The confirmed project fingerprint does not identify this repository');
78
90
  }
79
91
  const repositoryKey = await this.keyFor(repository.repoRoot);
@@ -15,6 +15,7 @@ export const backendRecoveryOperationNames = [
15
15
  'project.resolve',
16
16
  'project.list',
17
17
  'project.restore',
18
+ 'project.move_repository',
18
19
  'project.member_list',
19
20
  'organization.list',
20
21
  'project.member_add',
@@ -1,3 +1,4 @@
1
+ import { unreadableWorktree, } from './worktree-pool.js';
1
2
  import { validWorktreePolicy } from './worktree-policy.js';
2
3
  import { resolveTaskBase } from './branch-preferences.js';
3
4
  import { randomUUID } from 'node:crypto';
@@ -263,13 +264,7 @@ export class BridgeService {
263
264
  });
264
265
  let projectId = repository.projectId;
265
266
  if (!repository.bindingPath) {
266
- const resolved = await this.dependencies.client.request(endpoints.projectResolve, {
267
- method: 'POST',
268
- body: {
269
- repoFingerprint: repository.repoFingerprint,
270
- legacyFingerprints: repository.legacyFingerprints,
271
- },
272
- });
267
+ const resolved = await this.dependencies.client.request(endpoints.projectResolve, { method: 'POST', body: await this.checkoutIdentity(repository) });
273
268
  const project = objectValue(resolved.data);
274
269
  const restoreRequired = Boolean(project?.archivedAt);
275
270
  const restoreGuidance = archivedProjectGuidance(project);
@@ -283,6 +278,7 @@ export class BridgeService {
283
278
  projects: projects.data,
284
279
  outbox: { recoveredDeliveries, ...objectOrEmpty(outbox) },
285
280
  ...(restoreRequired ? restoreGuidance : {}),
281
+ ...movedProjectGuidance(project),
286
282
  });
287
283
  }
288
284
  if (!projectId) {
@@ -308,7 +304,7 @@ export class BridgeService {
308
304
  this.poolAllocations.set(repository.repoRoot, poolAllocation);
309
305
  }
310
306
  const checkpointId = deterministicUuid('session.bootstrap', projectId, repository.repoFingerprint, input.externalTaskId);
311
- const response = await this.dependencies.client.request(endpoints.sessionBootstrap, {
307
+ const response = await this.requestWithNewerFields(endpoints.sessionBootstrap, ['checkoutFingerprints'], {
312
308
  method: 'POST',
313
309
  idempotencyKey: checkpointId,
314
310
  body: cleanJson({
@@ -321,6 +317,7 @@ export class BridgeService {
321
317
  workItemId: persistedBootstrap.workItemId,
322
318
  mode: persistedBootstrap.mode ?? 'write',
323
319
  repoFingerprint: repository.repoFingerprint,
320
+ checkoutFingerprints: await this.dependencies.repositories.checkoutFingerprints(repository.repoRoot),
324
321
  ...(input.mode === 'read_only'
325
322
  ? { baselineDiffHash: repository.git.worktreeHash }
326
323
  : {}),
@@ -2305,15 +2302,19 @@ export class BridgeService {
2305
2302
  ? {
2306
2303
  nextAction: 'worktree.reconcile with retireLegacy when the user wants the old folder removed; it is never reused.',
2307
2304
  }
2308
- : item.phase === 'delivery'
2305
+ : unreadableWorktree(item)
2309
2306
  ? {
2310
- nextAction: 'Ask the user whether it was delivered; call worktree.release with deliveryOutcome delivered or cancelled (the product asks the user what happens to the folder).',
2307
+ nextAction: `This checkout can no longer be read as a Git worktree. worktree.reconcile with forgetUnreadable {"externalTaskId": "${item.externalTaskId}"} asks the user whether Engineering Memory stops tracking it; no file in the folder is touched.`,
2311
2308
  }
2312
- : !item.managed && item.phase !== 'released'
2309
+ : item.phase === 'delivery'
2313
2310
  ? {
2314
- nextAction: 'The user’s own folder, held by this task. Resume the task here to continue it. For another task here, task.branch asks whether this one should move out; a closed or abandoned task is let go when the next task starts here.',
2311
+ nextAction: 'Ask the user whether it was delivered; call worktree.release with deliveryOutcome delivered or cancelled (the product asks the user what happens to the folder).',
2315
2312
  }
2316
- : {}),
2313
+ : !item.managed && item.phase !== 'released'
2314
+ ? {
2315
+ nextAction: 'The user’s own folder, held by this task. Resume the task here to continue it. For another task here, task.branch asks whether this one should move out; a closed or abandoned task is let go when the next task starts here.',
2316
+ }
2317
+ : {}),
2317
2318
  }));
2318
2319
  const retired = items.some((item) => item.activity === 'retired');
2319
2320
  const selected = await pool.forPath(projectId, repository.repoRoot);
@@ -2363,6 +2364,42 @@ export class BridgeService {
2363
2364
  await pool.releaseUnowned(projectId, repository.repoRoot, input.generation, input.sourceCommit);
2364
2365
  return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
2365
2366
  }
2367
+ if (operation === 'forget_unreadable') {
2368
+ const entry = input.externalTaskId
2369
+ ? await pool.forSlug(projectId, input.externalTaskId)
2370
+ : null;
2371
+ if (!entry || entry.generation !== input.generation || !input.decisionId)
2372
+ throw refuse('The unreadable checkout changed after it was asked about. Call worktree.list again before forgetting it.', 'worktree.list');
2373
+ if (input.decisionId !== forgetUnreadableQuestionId(entry, input.decisionAttempt ?? 0))
2374
+ throw refuse('The decision does not match this task and checkout. Repeat worktree.reconcile with forgetUnreadable.', 'worktree.reconcile');
2375
+ const decision = await this.questionnaireResume({
2376
+ repoRoot: repository.repoRoot,
2377
+ questionnaireId: input.decisionId,
2378
+ });
2379
+ if (decision.status !== 'answered' || decision.answer?.choice !== 'forget')
2380
+ throw refuse('A checkout stops being tracked only after the user approves it in the native worktree.reconcile form.', 'worktree.reconcile');
2381
+ const forgotten = await pool.forgetUnreadable(projectId, entry.externalTaskId, entry.generation);
2382
+ this.poolAllocations.delete(forgotten.repoRoot);
2383
+ const folderPresent = await lstat(forgotten.repoRoot).then(() => true, () => false);
2384
+ const pointer = await this.dependencies.activeContexts.loadForSlug(forgotten.repoFingerprint, forgotten.externalTaskId);
2385
+ if (pointer?.worktreeId === sha256(forgotten.repoRoot))
2386
+ await this.parkPointer(pointer);
2387
+ return asJsonValue({
2388
+ operation,
2389
+ complete: true,
2390
+ forgotten: {
2391
+ externalTaskId: forgotten.externalTaskId,
2392
+ repoRoot: forgotten.repoRoot,
2393
+ branch: forgotten.branch,
2394
+ pendingDelivery: forgotten.pendingDelivery,
2395
+ },
2396
+ filesTouched: false,
2397
+ folderPresent,
2398
+ nextAction: folderPresent
2399
+ ? `Engineering Memory no longer tracks that folder and it no longer counts toward the worktree limit. Nothing in it was deleted or changed; the task itself stays open. Tell the user that its files are still on disk in that folder, and start its next worktree with task.branch when they want to continue.`
2400
+ : `Engineering Memory no longer tracks that folder and it no longer counts toward the worktree limit. The folder was already gone before this call, so anything in it that was never committed is not on disk any more${forgotten.pendingDelivery ? '; this task closed with its delivery unsettled, so say that plainly' : ''}. Whatever was committed is still on${forgotten.branch ? ' branch ' + forgotten.branch : ' its branch'}. The task itself stays open; start its next worktree with task.branch when the user wants to continue.`,
2401
+ });
2402
+ }
2366
2403
  if (operation === 'retire') {
2367
2404
  const entry = input.worktreePath ? await pool.forPath(projectId, input.worktreePath) : null;
2368
2405
  if (!entry ||
@@ -2857,8 +2894,8 @@ export class BridgeService {
2857
2894
  organizationId: input.organizationId,
2858
2895
  projectName: input.projectName,
2859
2896
  projectSlug: input.projectSlug ?? slugify(input.projectName),
2860
- repoFingerprint: repository.repoFingerprint,
2861
- legacyFingerprints: repository.legacyFingerprints,
2897
+ ...(await this.checkoutIdentity(repository)),
2898
+ remoteIdentity: await this.dependencies.repositories.git.remoteIdentity(repository.repoRoot),
2862
2899
  framework: input.framework,
2863
2900
  developmentArea: input.developmentArea,
2864
2901
  figmaConfig: input.figmaConfig,
@@ -2869,7 +2906,7 @@ export class BridgeService {
2869
2906
  }
2870
2907
  const persistentBody = normalizeRepositoryPaths(body, repository.repoRoot);
2871
2908
  assertSafeToPersist(persistentBody);
2872
- const response = await this.dependencies.client.request(endpoints.projectSetup, {
2909
+ const response = await this.requestWithNewerFields(endpoints.projectSetup, ['remoteIdentity'], {
2873
2910
  method: 'POST',
2874
2911
  body: persistentBody,
2875
2912
  });
@@ -2912,7 +2949,7 @@ export class BridgeService {
2912
2949
  else if (stableStringify(current.branches) !== stableStringify(choice.gitPreferences))
2913
2950
  throw refuse('The project already has different shared branch preferences. Review them before changing the project settings.', 'project.git_preferences');
2914
2951
  }
2915
- const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, project.id, project.repoFingerprint);
2952
+ const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, project.id, project.repoFingerprint, livesIn(project));
2916
2953
  return asJsonValue({ ...data, marker: null, markerPath: null, bindingPath });
2917
2954
  });
2918
2955
  }
@@ -3219,12 +3256,12 @@ export class BridgeService {
3219
3256
  repository: publicRepository(repository),
3220
3257
  });
3221
3258
  }
3222
- const response = await this.dependencies.client.request(endpoints.projectBind(input.projectId), {
3259
+ const response = await this.requestWithNewerFields(endpoints.projectBind(input.projectId), ['remoteIdentity'], {
3223
3260
  method: 'POST',
3224
- body: {
3225
- repoFingerprint: repository.repoFingerprint,
3226
- legacyFingerprints: repository.legacyFingerprints,
3227
- },
3261
+ body: cleanJson({
3262
+ ...(await this.checkoutIdentity(repository)),
3263
+ remoteIdentity: await this.dependencies.repositories.git.remoteIdentity(repository.repoRoot),
3264
+ }),
3228
3265
  });
3229
3266
  const project = objectValue(response.data);
3230
3267
  if (!project ||
@@ -3232,7 +3269,7 @@ export class BridgeService {
3232
3269
  typeof project.repoFingerprint !== 'string') {
3233
3270
  throw refuse('Project bind response does not match the selected project', 'project.resolve');
3234
3271
  }
3235
- const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, input.projectId, project.repoFingerprint);
3272
+ const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, input.projectId, project.repoFingerprint, livesIn(project));
3236
3273
  return asJsonValue({
3237
3274
  resolved: true,
3238
3275
  bound: true,
@@ -3245,25 +3282,101 @@ export class BridgeService {
3245
3282
  }
3246
3283
  const response = await this.dependencies.client.request(endpoints.projectResolve, {
3247
3284
  method: 'POST',
3248
- body: {
3249
- repoFingerprint: repository.repoFingerprint,
3250
- legacyFingerprints: repository.legacyFingerprints,
3251
- },
3285
+ body: await this.checkoutIdentity(repository),
3252
3286
  });
3253
3287
  const project = objectValue(response.data);
3254
3288
  const restoreRequired = Boolean(project?.archivedAt);
3255
3289
  const restoreGuidance = archivedProjectGuidance(project);
3290
+ const moved = project?.checkout === 'retired';
3256
3291
  return asJsonValue({
3257
- resolved: project !== null && !restoreRequired,
3292
+ resolved: project !== null && !restoreRequired && !moved,
3258
3293
  project,
3259
3294
  markerPath: null,
3260
3295
  repository: publicRepository(repository),
3261
3296
  selectionRequired: project === null,
3262
3297
  restoreRequired,
3263
3298
  ...(restoreRequired ? restoreGuidance : {}),
3299
+ ...movedProjectGuidance(project),
3300
+ });
3301
+ });
3302
+ }
3303
+ async projectRepositoryMovePreflight(input) {
3304
+ return await this.execute(async () => {
3305
+ const authentication = await this.dependencies.browserAuth.ensureAuthenticated();
3306
+ if (authentication)
3307
+ return asJsonValue({ authentication });
3308
+ const repository = await this.dependencies.repositories.resolve(input.repoRoot);
3309
+ const history = await this.dependencies.client.request(endpoints.projectRepository(input.projectId));
3310
+ const identity = await this.checkoutIdentity(repository);
3311
+ const current = objectValue(objectValue(history.data)?.current);
3312
+ return asJsonValue({
3313
+ history: history.data,
3314
+ checkout: {
3315
+ ...identity,
3316
+ remoteIdentity: await this.dependencies.repositories.git.remoteIdentity(repository.repoRoot),
3317
+ },
3318
+ alreadyHere: typeof current?.repoFingerprint === 'string' &&
3319
+ [identity.repoFingerprint, ...identity.legacyFingerprints].includes(current.repoFingerprint),
3320
+ });
3321
+ });
3322
+ }
3323
+ async projectMoveRepository(input) {
3324
+ return await this.execute(async () => {
3325
+ const repository = await this.dependencies.repositories.resolve(input.repoRoot);
3326
+ const response = await this.dependencies.client.request(endpoints.projectRepository(input.projectId), {
3327
+ method: 'POST',
3328
+ body: cleanJson({
3329
+ ...(await this.checkoutIdentity(repository)),
3330
+ remoteIdentity: await this.dependencies.repositories.git.remoteIdentity(repository.repoRoot),
3331
+ expectedVersion: input.expectedVersion,
3332
+ }),
3333
+ });
3334
+ const project = objectValue(response.data);
3335
+ if (!project ||
3336
+ project.id !== input.projectId ||
3337
+ typeof project.repoFingerprint !== 'string' ||
3338
+ !livesIn(project)) {
3339
+ throw refuse('Project move response does not match the selected project', 'project.move_repository');
3340
+ }
3341
+ const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, input.projectId, project.repoFingerprint, livesIn(project));
3342
+ return asJsonValue({
3343
+ moved: true,
3344
+ bound: true,
3345
+ project,
3346
+ bindingPath,
3347
+ repository: publicRepository(await this.dependencies.repositories.resolve(repository.repoRoot)),
3348
+ nextAction: 'The project now lives in this repository and this checkout is bound to it. Every task, memory record and work item stayed with the project. Continue with session.bootstrap or session.resume.',
3264
3349
  });
3265
3350
  });
3266
3351
  }
3352
+ async checkoutIdentity(repository) {
3353
+ const [current, ...older] = await this.dependencies.repositories.checkoutFingerprints(repository.repoRoot);
3354
+ return {
3355
+ repoFingerprint: current,
3356
+ legacyFingerprints: [
3357
+ ...new Set([...older, repository.repoFingerprint, ...repository.legacyFingerprints]),
3358
+ ]
3359
+ .filter((fingerprint) => fingerprint !== current)
3360
+ .slice(0, 4),
3361
+ };
3362
+ }
3363
+ async requestWithNewerFields(path, newer, options) {
3364
+ try {
3365
+ return await this.dependencies.client.request(path, options);
3366
+ }
3367
+ catch (error) {
3368
+ const body = objectValue(options.body);
3369
+ if (!body ||
3370
+ !(error instanceof ApiResponseError) ||
3371
+ error.httpStatus !== 400 ||
3372
+ !newer.some((name) => error.message.includes(`property ${name} should not exist`)))
3373
+ throw error;
3374
+ return await this.dependencies.client.request(path, {
3375
+ ...options,
3376
+ body: Object.fromEntries(Object.entries(body).filter(([name]) => !newer.includes(name))),
3377
+ });
3378
+ }
3379
+ }
3267
3380
  async sessionEntry(input = {}) {
3268
3381
  return await this.execute(async () => {
3269
3382
  const timer = startPhaseTimer('session.entry');
@@ -3301,7 +3414,7 @@ export class BridgeService {
3301
3414
  const livePointers = authenticated && state === 'bound'
3302
3415
  ? this.dependencies.activeContexts.list(repository.repoFingerprint)
3303
3416
  : Promise.resolve([]);
3304
- const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires] = await Promise.all([
3417
+ const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires, moved,] = await Promise.all([
3305
3418
  this.clientUpdate(authenticated),
3306
3419
  livePointers,
3307
3420
  authenticated && state === 'bound' && projectId
@@ -3319,6 +3432,9 @@ export class BridgeService {
3319
3432
  this.checkoutTask(repository.repoFingerprint, repository.repoRoot),
3320
3433
  ]).then(([pointers, workedTask]) => this.questionnaireOverview(repository.repoRoot, workedTask, new Set(pointers.map((pointer) => pointer.taskSlug))))
3321
3434
  : Promise.resolve({ mine: [], other: [], deferred: [] }),
3435
+ authenticated && state === 'bound' && projectId
3436
+ ? this.movedAway(repository, projectId)
3437
+ : Promise.resolve({}),
3322
3438
  ]);
3323
3439
  timer.mark('independent_lookups');
3324
3440
  const liveTasks = liveTasksRaw.map(describePointer);
@@ -3355,19 +3471,35 @@ export class BridgeService {
3355
3471
  ? { deferredTaskStarts: pendingQuestionnaires.deferred }
3356
3472
  : {}),
3357
3473
  client,
3474
+ ...moved,
3358
3475
  ...(incomplete && state === 'bound'
3359
3476
  ? {
3360
3477
  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.',
3361
3478
  }
3362
3479
  : {}),
3363
- nextAction: pendingQuestionnaires.mine.length > 0
3364
- ? 'Resume only the pending questionnaire for this work with questionnaire.resume before taking action that depends on its answer. Unrelated pending questions never block opening a separate task. Never infer consent from a timeout, dismissal, interrupted turn or missing answer.'
3365
- : taskSelectionRequired
3366
- ? 'Present the live runs and actionable project work items, then ask which one this chat should handle. Resume only the run they choose, or start a separate run for the chosen work item: task.branch first for write work, then session.bootstrap with its workItemId in the returned repoRoot. Another live task never blocks opening this one.'
3367
- : entryNextAction(authenticated, state),
3480
+ nextAction: typeof moved.nextAction === 'string'
3481
+ ? moved.nextAction
3482
+ : pendingQuestionnaires.mine.length > 0
3483
+ ? 'Resume only the pending questionnaire for this work with questionnaire.resume before taking action that depends on its answer. Unrelated pending questions never block opening a separate task. Never infer consent from a timeout, dismissal, interrupted turn or missing answer.'
3484
+ : taskSelectionRequired
3485
+ ? 'Present the live runs and actionable project work items, then ask which one this chat should handle. Resume only the run they choose, or start a separate run for the chosen work item: task.branch first for write work, then session.bootstrap with its workItemId in the returned repoRoot. Another live task never blocks opening this one.'
3486
+ : entryNextAction(authenticated, state),
3368
3487
  });
3369
3488
  });
3370
3489
  }
3490
+ async movedAway(repository, projectId) {
3491
+ try {
3492
+ const response = await this.dependencies.client.request(endpoints.projectResolve, {
3493
+ method: 'POST',
3494
+ body: await this.checkoutIdentity(repository),
3495
+ });
3496
+ const project = objectValue(response.data);
3497
+ return project?.id === projectId ? movedProjectGuidance(project, true) : {};
3498
+ }
3499
+ catch {
3500
+ return {};
3501
+ }
3502
+ }
3371
3503
  async actionableWorkItems(projectId) {
3372
3504
  try {
3373
3505
  const response = await this.dependencies.client.request(`${endpoints.workItemList(projectId)}?limit=100`);
@@ -4682,6 +4814,26 @@ function archivedProjectGuidance(project) {
4682
4814
  nextAction: 'Call project.member_list with this project id, identify an owner, and ask that owner to restore the archived project.',
4683
4815
  };
4684
4816
  }
4817
+ function livesIn(project) {
4818
+ const repository = objectValue(project?.repository);
4819
+ return typeof repository?.repoFingerprint === 'string' ? repository.repoFingerprint : undefined;
4820
+ }
4821
+ function movedProjectGuidance(project, bound = false) {
4822
+ if (project?.checkout !== 'retired')
4823
+ return {};
4824
+ const home = objectValue(project.repository)?.remoteIdentity;
4825
+ return {
4826
+ projectMoved: true,
4827
+ movedTo: typeof home === 'string' ? home : null,
4828
+ nextAction: bound
4829
+ ? `The project "${String(project.name)}" no longer lives in the repository this checkout points at` +
4830
+ (typeof home === 'string' ? `; it moved to ${home}` : '') +
4831
+ '. Tell the user before anything else. A task already open here can be resumed and finished. Do not call task.branch or open a new task here: new work starts from a checkout of the new repository, where the project is selected again. A project owner calls project.move_repository from here only when the code really lives in this repository again.'
4832
+ : `This checkout points at a repository the project "${String(project.name)}" left` +
4833
+ (typeof home === 'string' ? `; it now lives in ${home}` : '') +
4834
+ '. Tell the user. To keep working on that project, point this checkout at its new repository (or clone it) and select the project again. A project owner calls project.move_repository only when the code of the project really lives in this repository again. Do not create a second project for it.',
4835
+ };
4836
+ }
4685
4837
  function slugify(value) {
4686
4838
  return value
4687
4839
  .trim()
@@ -4791,6 +4943,17 @@ function isOfflineLeaseUsable(lease, sessionId, changedPaths, baselineDiffHash)
4791
4943
  return false;
4792
4944
  }
4793
4945
  }
4946
+ export function forgetUnreadableQuestionId(entry, decisionAttempt) {
4947
+ return ('worktree-forget-unreadable-' +
4948
+ sha256(stableStringify({
4949
+ projectId: entry.projectId,
4950
+ externalTaskId: entry.externalTaskId,
4951
+ repoRoot: entry.repoRoot,
4952
+ generation: entry.generation,
4953
+ pendingDelivery: entry.pendingDelivery,
4954
+ decisionAttempt,
4955
+ })));
4956
+ }
4794
4957
  export function cancelledDeliveryQuestionId(entry, clean) {
4795
4958
  return ('worktree-delivery-cancel-' +
4796
4959
  sha256(entry.generation + ':' + (clean ? 'clean' : entry.managed ? 'modified' : 'own-folder')));
@@ -283,6 +283,10 @@ export class WorktreePool {
283
283
  return this.transaction(async (registry) => structuredClone(registry.projects[projectId]?.entries.find((e) => key(e.repoRoot) === key(repoRoot)) ??
284
284
  null));
285
285
  }
286
+ async forSlug(projectId, externalTaskId) {
287
+ return this.transaction(async (registry) => structuredClone(registry.projects[projectId]?.entries.find((e) => e.externalTaskId === externalTaskId) ??
288
+ null));
289
+ }
286
290
  async validateCommit(projectId, taskId, repoRoot, generation) {
287
291
  return this.transaction(async (registry) => {
288
292
  const entry = registry.projects[projectId]?.entries.find((e) => key(e.repoRoot) === key(repoRoot));
@@ -457,6 +461,34 @@ export class WorktreePool {
457
461
  await this.save(registry);
458
462
  });
459
463
  }
464
+ async forgetUnreadable(projectId, externalTaskId, generation) {
465
+ return this.transaction(async (registry) => {
466
+ const project = registry.projects[projectId];
467
+ const entry = project?.entries.find((e) => e.externalTaskId === externalTaskId && e.generation === generation);
468
+ if (!project || !entry)
469
+ throw refuse('This is no longer the same registry entry for that task. List the checkouts again with worktree.list before forgetting one.');
470
+ if (await this.ownedElsewhere(entry))
471
+ throw refuse('Another live client still owns this task. Stop or pause it there first; worktree.list shows the checkout meanwhile.');
472
+ if (!(await this.unreadable(entry)))
473
+ throw new BridgeRecoveryError('This checkout can still be read as a Git worktree, so it is not forgotten. Settle its delivery and free it with worktree.release.', 'worktree.release');
474
+ project.entries = project.entries.filter((e) => e !== entry);
475
+ await this.save(registry);
476
+ return structuredClone(entry);
477
+ });
478
+ }
479
+ async unreadable(entry) {
480
+ if (!(await pathExists(entry.repoRoot)))
481
+ return true;
482
+ try {
483
+ if (key(await this.git.findRoot(entry.repoRoot)) !== key(await canonicalPath(entry.repoRoot)))
484
+ return true;
485
+ await this.commonDirectory(entry.repoRoot);
486
+ return false;
487
+ }
488
+ catch {
489
+ return true;
490
+ }
491
+ }
460
492
  async releaseUnowned(projectId, repoRoot, generation, sourceCommit) {
461
493
  await this.transaction(async (registry) => {
462
494
  const entry = registry.projects[projectId]?.entries.find((e) => key(e.repoRoot) === key(repoRoot));
@@ -832,12 +864,12 @@ export class WorktreePool {
832
864
  }
833
865
  async fileProtection(entry, allowBranchChange = false) {
834
866
  if (!(await pathExists(entry.repoRoot)))
835
- return ['worktree directory missing'];
867
+ return [missingDirectory];
836
868
  try {
837
869
  await this.assertIdentity(entry, allowBranchChange);
838
870
  }
839
871
  catch {
840
- return ['Git identity or reservation changed or unreadable; reconcile this checkout'];
872
+ return [(await this.unreadable(entry)) ? unreadableGit : changedIdentity];
841
873
  }
842
874
  const status = await this.runner.run('git', ['status', '--porcelain=v1', '--untracked-files=all'], { cwd: entry.repoRoot });
843
875
  const reasons = [];
@@ -877,7 +909,7 @@ export class WorktreePool {
877
909
  activity: 'retired',
878
910
  reasons: [
879
911
  'outside the managed root ' + managedBase(activeRoot, 2) + '; not reused',
880
- ...((await pathExists(entry.repoRoot)) ? [] : ['worktree directory missing']),
912
+ ...((await pathExists(entry.repoRoot)) ? [] : [missingDirectory]),
881
913
  ],
882
914
  };
883
915
  const reasons = await this.fileProtection(entry);
@@ -1314,6 +1346,12 @@ function processAlive(pid) {
1314
1346
  }
1315
1347
  const changedFiles = 'uncommitted, staged or untracked files';
1316
1348
  const pendingTaskWork = 'task delivery or intent pending';
1349
+ const missingDirectory = 'worktree directory missing';
1350
+ const changedIdentity = 'Git identity or reservation changed; reconcile this checkout';
1351
+ const unreadableGit = 'Git metadata missing or unreadable; this folder is no longer a checkout';
1352
+ export function unreadableWorktree(view) {
1353
+ return view.reasons.includes(missingDirectory) || view.reasons.includes(unreadableGit);
1354
+ }
1317
1355
  function protectedRefusal(reasons, next) {
1318
1356
  if (reasons.every((reason) => reason === pendingTaskWork))
1319
1357
  return new BridgeRecoveryError('Engineering Memory still holds an undelivered record for this task: a checkpoint, correction or close waiting in its local outbox, or an unfinished verification or close. The folder was left as it is. Settle it with task.resolve_pending_delivery (session.resume finishes an interrupted verification or close), then call worktree.release again.', 'task.resolve_pending_delivery');
@@ -6,6 +6,14 @@ Sign-in decides nothing beyond who the user is. The organization and the project
6
6
 
7
7
  Use the binding returned by `session.entry` as the authority. The bridge stores the selected project and repository identity under `~/.engineering-memory/origins/<api-hash>/project-bindings/`, outside the installed runtime and working tree. It imports a legacy `.engineering-memory/project.json` once, preserving its schema and exact backend fingerprint for existing tasks and receipts. It leaves that file unchanged; after migration the file is optional and may be removed through the repository's normal Git workflow. New bindings never create it. A checkout that never imported the marker still finds a project bound under the older identity: the bridge presents that identity alongside the current one and stores whichever the backend confirms. Never infer a selection from a directory or remote, and never edit binding records by hand. Separate clones or a changed remote require a new explicit selection; worktrees of the same checkout share the local binding.
8
8
 
9
+ A project is not tied to one repository forever. When its code moves — to another host, under a new remote, into a history that starts again from one commit — the project moves with it and keeps everything: tasks, memory, work items, open sessions, the local state of every checkout. The old repository stays in the project's history. Recognise the three shapes this takes and never answer any of them by creating a second project:
10
+
11
+ - Binding the selected project is refused with recovery `project.move_repository`. The project lives in another repository and the user is one of its owners. Call `project.move_repository` with the project id; it opens one native form that names both repositories and what stays with the project, and on approval moves the project and binds this checkout. A declined form changes nothing.
12
+ - The same refusal arrives with recovery `project.member_list`. Moving needs project-owner authority, which the organization owner also holds. Check the roles, say that an owner has to run the move from a checkout of the new repository, and continue only work that does not need the binding. Do not name a person.
13
+ - `session.entry`, `project.resolve` or bootstrap reports `projectMoved`, or bootstrap refuses with "Project moved". This checkout still points at a repository the project has left; the message names where it lives now. Tasks already open here can be resumed and finished. New work starts from the new repository: the user points this checkout's origin at it, or clones it, and selects the project again. An owner calls `project.move_repository` from here only when the code truly lives in this repository again.
14
+
15
+ After a move nothing else changes for the agent. The binding keeps reporting the identity the project has always had, so resume, verification, receipts and the commit gate behave as they did before.
16
+
9
17
  An archived project is recoverable state, not a missing or conflicting binding. When an owner receives `project.restore`, use the `projectId` and `expectedVersion` in its data and call that operation before retrying. A non-owner receives `project.member_list` instead: check the available roles and explain that project-owner authority is required to restore this exact project before retrying. Do not name a person or disclose contact details. Use `project.list` with `includeArchived: true` when an owner must first select the archived project. Never create or bind a replacement project to escape archive state.
10
18
 
11
19
  For a bound repository, call `session.bootstrap` before producing a plan or changing files. Supply the current repository root, project ID, task ID or stable local task slug, objective, task kind, task mode, and current Git diff hash. `externalTaskId`, `objective`, `taskKind` and `workItemKey` are each one line with a fixed maximum length (160/240/80/120 characters); the bridge refuses an over-length or multi-line value locally, in milliseconds, before any Git or backend work, and names the limit in the refusal — put detail that does not fit in checkpoints, not in the objective. Use `read_only` for review, diagnosis, planning, or reporting without write authority; use `scaffold` when the task applies organization architecture templates to a new project; use `write` when the request authorizes repository changes. The bridge records the read-only Git diff hash as the immutable task baseline, including a pre-existing dirty worktree. Use the returned task ID, task version, context session ID, project profile, engineering rules, prior task documents, quality gates, and current deviations; `deferredResources` names what did not fit, each with the `revisionId` to read it by.
@@ -295,6 +303,13 @@ ownership generation. Cancellation leaves it protected. Git reservation, dirty f
295
303
  Git operations and pending work are checked again before release. A changed source requires a new
296
304
  inspection and answer. This recovery never deletes files or branches or interrupts another agent.
297
305
 
306
+ When a recorded checkout is gone or can no longer be read as a Git worktree — its `.git` was
307
+ deleted, or the repository it was linked to was removed — `worktree.list` names `worktree.reconcile`
308
+ with `forgetUnreadable` and that task's `externalTaskId`, called from a working checkout of the same
309
+ project. After its own native approval Engineering Memory only stops tracking that entry, so it
310
+ stops counting toward the limit; no file in the folder is moved or deleted, the branch and the task
311
+ stay, and a readable checkout is still freed with `worktree.release`.
312
+
298
313
  If the running client predates the pool tools, inspect `git worktree list` and the existing task
299
314
  reservations before any explicit allocation. Do not treat an unavailable tool or an empty registry
300
315
  as an empty filesystem. Preserve uncertain checkouts and report the client limitation.
@@ -119,6 +119,8 @@ Once the organization is chosen, call `project.list` and offer that organization
119
119
 
120
120
  The normal list omits archived projects. If repository entry, setup, binding or task open reports `project.restore`, use the project id and expected version returned with that refusal and restore it before continuing. If it reports `project.member_list`, the current member cannot restore the project: call that operation to check the available roles and explain that the versioned restore requires project-owner authority. Do not identify a person, reconstruct an identity from an account identifier or disclose contact details. If an owner does not yet have the project id and expected version, call `project.list` with `includeArchived: true` and let them select the archived project. Archive state never authorizes creating a duplicate project.
121
121
 
122
+ When the user picks an existing project and binding it is refused because the project lives in another repository, do not offer to create a new project for the same code. An owner is asked once, through the form `project.move_repository` opens, whether the project moves to this repository; the form already says which repository it leaves, which one it joins and how many tasks, memory records and work items stay with it, so add no second question of your own. Anyone else is told that a project owner runs the move, and is not asked anything.
123
+
122
124
  Ask both questions again whenever a chat starts in a repository whose binding you have not confirmed in this session.
123
125
 
124
126
  ## Switching Organization or Project
@@ -359,5 +361,12 @@ safety checks still decide whether reuse is possible. Cancellation or choosing t
359
361
  release it. Resume the same form via the same operation. A changed source/generation needs a fresh
360
362
  decision, and an old answer cannot release a newly owned task.
361
363
 
364
+ A checkout that is gone, or that can no longer be read as a Git worktree, is left with
365
+ `worktree.reconcile` and `forgetUnreadable: {"externalTaskId": "..."}` from a working checkout of the
366
+ same project: its native question names the task and the folder name, says that Engineering Memory
367
+ only stops tracking it and touches no file, and warns when that task closed with its delivery
368
+ unsettled. Cancelling or keeping it leaves it tracked, and a readable checkout is refused there and
369
+ freed with `worktree.release` instead.
370
+
362
371
  If the user later reconsiders a preserved legacy checkout, repeat `worktree.reconcile` with a new
363
372
  `decisionAttempt`; never reinterpret the previous preserve answer as approval.