engineering-memory 1.11.9 → 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.
Files changed (35) hide show
  1. package/dispatcher/sections.mjs +7 -2
  2. package/install/codex-approval.mjs +457 -0
  3. package/install/files.mjs +5 -1
  4. package/install/installer.mjs +24 -1
  5. package/package.json +1 -1
  6. package/runtime/build.json +1 -1
  7. package/runtime/dist/src/config.js +2 -0
  8. package/runtime/dist/src/git/git-inspector.js +114 -4
  9. package/runtime/dist/src/git/verification-gate.js +30 -17
  10. package/runtime/dist/src/mcp/onboarding-tools.js +110 -16
  11. package/runtime/dist/src/mcp/questionnaire-tools.js +99 -15
  12. package/runtime/dist/src/mcp/tool-annotations.js +112 -0
  13. package/runtime/dist/src/mcp/tool-definitions.js +150 -29
  14. package/runtime/dist/src/mcp/worktree-tools.js +395 -236
  15. package/runtime/dist/src/project/repository.js +26 -8
  16. package/runtime/dist/src/runtime/active-context-store.js +3 -0
  17. package/runtime/dist/src/runtime/api-client.js +1 -0
  18. package/runtime/dist/src/runtime/branch-preferences.js +0 -19
  19. package/runtime/dist/src/runtime/bridge-service.js +1320 -246
  20. package/runtime/dist/src/runtime/offline-outbox.js +33 -158
  21. package/runtime/dist/src/runtime/phase-timer.js +26 -0
  22. package/runtime/dist/src/runtime/privacy-detector.js +263 -0
  23. package/runtime/dist/src/runtime/questionnaire-store.js +210 -49
  24. package/runtime/dist/src/runtime/recovery-error.js +3 -1
  25. package/runtime/dist/src/runtime/runtime-entry.js +8 -0
  26. package/runtime/dist/src/runtime/runtime-host.js +1 -3
  27. package/runtime/dist/src/runtime/task-branch-store.js +17 -1
  28. package/runtime/dist/src/runtime/task-start.js +281 -0
  29. package/runtime/dist/src/runtime/worktree-pool.js +374 -26
  30. package/runtime/dist/src/utilities/process.js +1 -0
  31. package/skill/SKILL.md +3 -3
  32. package/skill/references/lifecycle.md +59 -19
  33. package/skill/references/memory-updates.md +9 -3
  34. package/skill/references/project-onboarding.md +19 -2
  35. package/skill/references/questionnaires.md +86 -15
@@ -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';
@@ -8,10 +9,12 @@ import { ProjectIntake } from '../project/project-intake.js';
8
9
  import { canonicalPath } from '../utilities/files.js';
9
10
  import { minimatch } from 'minimatch';
10
11
  import { endpoints } from '../config.js';
12
+ import { startPhaseTimer } from './phase-timer.js';
11
13
  import { sha256, stableStringify } from '../utilities/hash.js';
12
14
  import { ApiResponseError, BackendUnavailableError, } from './api-client.js';
13
15
  import { assertSafeToPersist, normalizeRepositoryPaths } from './offline-outbox.js';
14
16
  import { principalFingerprint } from './principal-state.js';
17
+ import { sameHolder, suggestedBranchNames, taskStartChoice } from './task-start.js';
15
18
  import { BridgeRecoveryError } from './recovery-error.js';
16
19
  export const validationIds = [
17
20
  'format',
@@ -38,6 +41,7 @@ export class BridgeService {
38
41
  deliveryQueues = new Map();
39
42
  deliveryWaiters = [];
40
43
  activeDeliveries = 0;
44
+ workedTasks = new Map();
41
45
  constructor(dependencies) {
42
46
  this.dependencies = dependencies;
43
47
  this.deferDeliveries = dependencies.deferDeliveries ?? true;
@@ -45,10 +49,22 @@ export class BridgeService {
45
49
  managesWorktrees() {
46
50
  return Boolean(this.dependencies.worktreePool);
47
51
  }
48
- async questionnaireAsk(input, previousDefinitions = []) {
52
+ async questionnaireAsk(input, previousDefinitions = [], owner) {
49
53
  const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
50
- const { repoRoot: _repoRoot, preparation: _preparation, ...definition } = input;
51
- return await this.dependencies.questionnaires.ask(scope, definition, previousDefinitions);
54
+ const { repoRoot: _repoRoot, preparation: _preparation, presentation: _presentation, ...definition } = input;
55
+ const askingTask = owner || input.preparation
56
+ ? null
57
+ : await this.checkoutTask(scope.repoFingerprint, await this.dependencies.repositories.git.findRoot(input.repoRoot ?? process.cwd()));
58
+ return await this.dependencies.questionnaires.ask(scope, definition, previousDefinitions, owner ?? (askingTask ? { tool: 'questionnaire.ask', externalTaskId: askingTask } : undefined));
59
+ }
60
+ async checkoutTask(repoFingerprint, repoRoot) {
61
+ const worktreeId = sha256(await canonicalPath(repoRoot));
62
+ const task = this.workedTasks.get(worktreeId);
63
+ if (!task)
64
+ return null;
65
+ return (await this.dependencies.activeContexts.list(repoFingerprint)).some((pointer) => pointer.taskSlug === task && pointer.worktreeId === worktreeId)
66
+ ? task
67
+ : null;
52
68
  }
53
69
  async questionnaireResume(input) {
54
70
  const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
@@ -66,18 +82,38 @@ export class BridgeService {
66
82
  if (stableStringify(scope) !== stableStringify(input.scope)) {
67
83
  throw refuse('The account or repository binding changed while the questionnaire was open.', 'session.entry');
68
84
  }
69
- return await this.dependencies.questionnaires.accept(scope, input.questionnaireId, input.requestKey, input.answer, input.answerSource);
85
+ return await this.acceptWithScope(scope, input.questionnaireId, input.requestKey, input.answer, input.answerSource);
86
+ }
87
+ async acceptWithScope(scope, questionnaireId, requestKey, answer, answerSource) {
88
+ return this.dependencies.questionnaires.accept(scope, questionnaireId, requestKey, answer, answerSource);
89
+ }
90
+ retryHint(record, repoRoot, attemptOffset = 0) {
91
+ if (!record.owner || record.owner.tool === 'questionnaire.ask')
92
+ return undefined;
93
+ const attempt = (record.owner.decisionAttempt ?? 0) + attemptOffset;
94
+ return {
95
+ tool: record.owner.tool,
96
+ arguments: {
97
+ ...record.owner.retryArguments,
98
+ repoRoot,
99
+ ...(record.owner.externalTaskId ? { externalTaskId: record.owner.externalTaskId } : {}),
100
+ ...(attempt > 0 ? { decisionAttempt: attempt } : {}),
101
+ },
102
+ };
70
103
  }
71
104
  async questionnaireAnswerFromHost(input) {
72
- const record = await this.questionnaireResume(input);
105
+ const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
106
+ const record = await this.dependencies.questionnaires.get(scope, input.questionnaireId);
107
+ if (!record)
108
+ throw refuse('No questionnaire exists for this account and repository binding.', 'session.entry');
73
109
  if (record.requestKey !== input.requestKey || record.contentHash !== input.contentHash) {
74
110
  throw refuse('The host answer belongs to a different question or content. Read the original with questionnaire.resume; do not reuse this answer for a changed decision.', 'questionnaire.resume');
75
111
  }
76
- return this.questionnaireAccept({
77
- ...input,
78
- scope: record.scope,
79
- answerSource: { kind: 'host_native_relay', hostTool: input.hostTool },
80
- });
112
+ const resolved = await this.acceptWithScope(scope, input.questionnaireId, input.requestKey, (input.answer ?? input.answers), { kind: 'host_native_relay', hostTool: input.hostTool });
113
+ return {
114
+ ...resolved,
115
+ retry: this.retryHint(resolved.record, input.repoRoot ?? process.cwd()),
116
+ };
81
117
  }
82
118
  async questionnaireScope(repoRoot, preparation = false) {
83
119
  await this.dependencies.principalState?.ensure();
@@ -99,7 +135,7 @@ export class BridgeService {
99
135
  }
100
136
  let repository;
101
137
  try {
102
- repository = await this.dependencies.repositories.resolve(lookup);
138
+ repository = await this.dependencies.repositories.resolveIdentity(lookup);
103
139
  }
104
140
  catch (error) {
105
141
  if (!preparation ||
@@ -128,18 +164,64 @@ export class BridgeService {
128
164
  projectId: preparation ? null : repository.projectId,
129
165
  };
130
166
  }
131
- async pendingQuestionnaires(repoRoot) {
167
+ async questionnaireOverview(repoRoot, currentExternalTaskId, liveTaskSlugs = new Set()) {
132
168
  const scope = await this.questionnaireScope(repoRoot);
133
- return (await this.dependencies.questionnaires.pending(scope)).map((record) => ({
134
- questionnaireId: record.questionnaireId,
135
- message: record.message,
136
- options: record.options,
137
- allowFreeText: record.allowFreeText,
138
- ...(record.textField ? { textField: record.textField } : {}),
139
- createdAt: record.createdAt,
140
- status: record.status,
141
- nextAction: 'Call questionnaire.resume with this questionnaireId. No answer, elapsed time, dismissal or interruption is consent. If the user explicitly cancels this decision, call questionnaire.withdraw. Unrelated pending decisions never block independently authorized work.',
169
+ const records = await this.dependencies.questionnaires.list(scope);
170
+ const mine = [];
171
+ const other = [];
172
+ for (const record of records.filter((item) => item.status === 'pending')) {
173
+ const ownerTask = record.owner?.externalTaskId;
174
+ if (!ownerTask || ownerTask === currentExternalTaskId)
175
+ mine.push(asJsonValue({
176
+ questionnaireId: record.questionnaireId,
177
+ message: record.message,
178
+ ...(record.questions
179
+ ? {
180
+ questions: record.questions,
181
+ ...(record.terminalChoices ? { terminalChoices: record.terminalChoices } : {}),
182
+ }
183
+ : {
184
+ options: record.options,
185
+ allowFreeText: record.allowFreeText,
186
+ ...(record.textField ? { textField: record.textField } : {}),
187
+ }),
188
+ createdAt: record.createdAt,
189
+ status: record.status,
190
+ nextAction: 'Call questionnaire.resume with this questionnaireId. No answer, elapsed time, dismissal or interruption is consent. If the user explicitly cancels this decision, call questionnaire.withdraw. Unrelated pending decisions never block independently authorized work.',
191
+ }));
192
+ else
193
+ other.push(asJsonValue({
194
+ questionnaireId: record.questionnaireId,
195
+ ownerTask,
196
+ createdAt: record.createdAt,
197
+ summary: record.message.slice(0, 120),
198
+ nextAction: `A pending question of task ${ownerTask}. Resume it with questionnaire.resume only in the chat working on ${ownerTask}, or once the user picks ${ownerTask} here; otherwise leave it alone.`,
199
+ }));
200
+ }
201
+ const latestStart = new Map();
202
+ for (const record of records) {
203
+ const task = record.owner?.tool === 'task.branch' ? record.owner.externalTaskId : undefined;
204
+ const current = task ? latestStart.get(task) : undefined;
205
+ if (task &&
206
+ record.status !== 'withdrawn' &&
207
+ (!current || (record.owner?.decisionAttempt ?? 0) >= (current.owner?.decisionAttempt ?? 0)))
208
+ latestStart.set(task, record);
209
+ }
210
+ const deferred = [...latestStart.entries()]
211
+ .filter(([task, record]) => !liveTaskSlugs.has(task) &&
212
+ (taskStartChoice(record)?.status === 'deferred' ||
213
+ (record.questionnaireId.startsWith('task-branch-fallback-') &&
214
+ record.status === 'answered' &&
215
+ record.answer?.choice === 'defer')))
216
+ .sort(([, left], [, right]) => (left.answeredAt ?? left.createdAt).localeCompare(right.answeredAt ?? right.createdAt))
217
+ .slice(-5)
218
+ .map(([externalTaskId, record]) => asJsonValue({
219
+ externalTaskId,
220
+ deferredAt: record.answeredAt ?? record.createdAt,
221
+ reconsider: this.retryHint(record, repoRoot, 1),
222
+ nextAction: `Starting ${externalTaskId} is on hold. Mention it only if the user brings that task up, and call reconsider only when they ask to start it.`,
142
223
  }));
224
+ return { mine, other, deferred };
143
225
  }
144
226
  deliverInBackground(taskId) {
145
227
  if (!this.deferDeliveries)
@@ -182,13 +264,7 @@ export class BridgeService {
182
264
  });
183
265
  let projectId = repository.projectId;
184
266
  if (!repository.bindingPath) {
185
- const resolved = await this.dependencies.client.request(endpoints.projectResolve, {
186
- method: 'POST',
187
- body: {
188
- repoFingerprint: repository.repoFingerprint,
189
- legacyFingerprints: repository.legacyFingerprints,
190
- },
191
- });
267
+ const resolved = await this.dependencies.client.request(endpoints.projectResolve, { method: 'POST', body: await this.checkoutIdentity(repository) });
192
268
  const project = objectValue(resolved.data);
193
269
  const restoreRequired = Boolean(project?.archivedAt);
194
270
  const restoreGuidance = archivedProjectGuidance(project);
@@ -202,6 +278,7 @@ export class BridgeService {
202
278
  projects: projects.data,
203
279
  outbox: { recoveredDeliveries, ...objectOrEmpty(outbox) },
204
280
  ...(restoreRequired ? restoreGuidance : {}),
281
+ ...movedProjectGuidance(project),
205
282
  });
206
283
  }
207
284
  if (!projectId) {
@@ -227,7 +304,7 @@ export class BridgeService {
227
304
  this.poolAllocations.set(repository.repoRoot, poolAllocation);
228
305
  }
229
306
  const checkpointId = deterministicUuid('session.bootstrap', projectId, repository.repoFingerprint, input.externalTaskId);
230
- const response = await this.dependencies.client.request(endpoints.sessionBootstrap, {
307
+ const response = await this.requestWithNewerFields(endpoints.sessionBootstrap, ['checkoutFingerprints'], {
231
308
  method: 'POST',
232
309
  idempotencyKey: checkpointId,
233
310
  body: cleanJson({
@@ -240,6 +317,7 @@ export class BridgeService {
240
317
  workItemId: persistedBootstrap.workItemId,
241
318
  mode: persistedBootstrap.mode ?? 'write',
242
319
  repoFingerprint: repository.repoFingerprint,
320
+ checkoutFingerprints: await this.dependencies.repositories.checkoutFingerprints(repository.repoRoot),
243
321
  ...(input.mode === 'read_only'
244
322
  ? { baselineDiffHash: repository.git.worktreeHash }
245
323
  : {}),
@@ -274,6 +352,10 @@ export class BridgeService {
274
352
  poolAllocation.taskId = task.id;
275
353
  this.poolAllocations.set(repository.repoRoot, poolAllocation);
276
354
  }
355
+ const replayedPointer = existingTask?.taskId === task.id ? existingTask : null;
356
+ const recordedBaselineDiffHash = typeof data.changeBaselineDiffHash === 'string' ? data.changeBaselineDiffHash : undefined;
357
+ const repairedBaseline = repairChangeBaseline(replayedPointer?.changeBaseline, recordedBaselineDiffHash, repository.git.head);
358
+ const replayedBaseline = repairedBaseline ?? replayedPointer?.changeBaseline;
277
359
  const pointer = {
278
360
  repoFingerprint: repository.repoFingerprint,
279
361
  projectId,
@@ -289,9 +371,21 @@ export class BridgeService {
289
371
  sessionId: session.id,
290
372
  lastSequence: numericSequence(task.lastSequence),
291
373
  taskVersion: numericTaskVersion(task.lockVersion),
292
- resumeConflicts: [],
374
+ resumeConflicts: [
375
+ ...new Set([
376
+ ...(replayedPointer?.resumeConflicts.filter((conflict) => contextRefreshConflicts.includes(conflict)) ?? []),
377
+ ...(data.requiresContextRefresh === true ? ['context_refresh_required'] : []),
378
+ ...(recordedBaselineDiffHash &&
379
+ replayedBaseline &&
380
+ replayedBaseline.diffHash !== recordedBaselineDiffHash
381
+ ? ['task_change_baseline_recaptured']
382
+ : []),
383
+ ]),
384
+ ],
385
+ ...(repairedBaseline ? { changeBaseline: repairedBaseline } : {}),
293
386
  };
294
387
  await this.dependencies.activeContexts.save(pointer);
388
+ this.workedTasks.set(sha256(repository.repoRoot), input.externalTaskId);
295
389
  await this.dependencies.journal.apply({
296
390
  eventId: checkpointId,
297
391
  taskId: task.id,
@@ -312,9 +406,10 @@ export class BridgeService {
312
406
  await this.seedResumeSnapshot(pointer, data);
313
407
  return asJsonValue({
314
408
  ...data,
409
+ checkpoint: checkpointView(checkpoint),
315
410
  repository: publicRepository(repository),
316
- localJournal: await this.dependencies.journal.load(projectId, input.externalTaskId),
317
- outbox: { recoveredDeliveries, ...objectOrEmpty(outbox) },
411
+ localJournal: journalSummary(await this.dependencies.journal.load(projectId, input.externalTaskId)),
412
+ outbox: { recoveredDeliveries, ...outboxSummary(outbox) },
318
413
  });
319
414
  });
320
415
  }
@@ -328,12 +423,12 @@ export class BridgeService {
328
423
  nextAction: entryNextAction(false, 'disabled'),
329
424
  });
330
425
  }
331
- const pendingQuestionnaires = await this.pendingQuestionnaires(repository.repoRoot);
426
+ const earlyPendingQuestionnaires = await this.questionnaireOverview(repository.repoRoot, input.taskSlug ?? null);
332
427
  const signedIn = (await this.dependencies.browserAuth.status()).authenticated;
333
- if (!signedIn && pendingQuestionnaires.length > 0) {
428
+ if (!signedIn && earlyPendingQuestionnaires.mine.length > 0) {
334
429
  return asJsonValue({
335
430
  authenticated: false,
336
- pendingQuestionnaires,
431
+ ...pendingQuestionnairesFields(earlyPendingQuestionnaires),
337
432
  repository: publicRepository(repository),
338
433
  nextAction: 'Resume only the pending sign-in questionnaire for this work. Signing in is still required before accessing project memory.',
339
434
  });
@@ -357,7 +452,7 @@ export class BridgeService {
357
452
  return asJsonValue({
358
453
  taskChoiceRequired: true,
359
454
  requestedTaskSlug: input.taskSlug,
360
- pendingQuestionnaires,
455
+ ...pendingQuestionnairesFields(earlyPendingQuestionnaires),
361
456
  liveTasks: live.map(describePointer),
362
457
  repository: publicRepository(repository),
363
458
  });
@@ -366,7 +461,7 @@ export class BridgeService {
366
461
  return asJsonValue({
367
462
  taskChoiceRequired: true,
368
463
  requestedSessionId: input.sessionId,
369
- pendingQuestionnaires,
464
+ ...pendingQuestionnairesFields(earlyPendingQuestionnaires),
370
465
  liveTasks: live.map(describePointer),
371
466
  repository: publicRepository(repository),
372
467
  });
@@ -374,7 +469,7 @@ export class BridgeService {
374
469
  if (!pointer && live.length > 1) {
375
470
  return asJsonValue({
376
471
  taskChoiceRequired: true,
377
- pendingQuestionnaires,
472
+ ...pendingQuestionnairesFields(earlyPendingQuestionnaires),
378
473
  liveTasks: live.map(describePointer),
379
474
  repository: publicRepository(repository),
380
475
  });
@@ -382,10 +477,13 @@ export class BridgeService {
382
477
  const projectId = input.projectId ?? repository.projectId ?? pointer?.projectId;
383
478
  const taskSlug = input.taskSlug ?? pointer?.taskSlug;
384
479
  const sessionId = input.sessionId ?? pointer?.sessionId;
480
+ const pendingQuestionnaires = taskSlug && taskSlug !== (input.taskSlug ?? null)
481
+ ? await this.questionnaireOverview(repository.repoRoot, taskSlug)
482
+ : earlyPendingQuestionnaires;
385
483
  if (!projectId || !taskSlug || !sessionId) {
386
- if (pendingQuestionnaires.length > 0) {
484
+ if (pendingQuestionnaires.mine.length > 0) {
387
485
  return asJsonValue({
388
- pendingQuestionnaires,
486
+ ...pendingQuestionnairesFields(pendingQuestionnaires),
389
487
  repository: publicRepository(repository),
390
488
  nextAction: 'Resume only the pending questionnaire for this work. Unrelated pending questions never block a separate task; continue independently authorized work.',
391
489
  });
@@ -405,7 +503,17 @@ export class BridgeService {
405
503
  this.dependencies.worktreePool
406
504
  ? await this.dependencies.worktreePool.find(projectId, taskSlug, repository.repoRoot)
407
505
  : null;
408
- if (pointer && this.dependencies.worktreePool && (pointer.worktreeGeneration || adoptable)) {
506
+ const parked = pointer &&
507
+ !pointer.worktreeId &&
508
+ !pointer.worktreeGeneration &&
509
+ !pointer.closedAt &&
510
+ normalizeTaskMode(pointer.mode) !== 'read_only' &&
511
+ typeof pointer.branch === 'string' &&
512
+ (await this.dependencies.repositories.git.currentBranch(repository.repoRoot)) !==
513
+ pointer.branch;
514
+ if (pointer &&
515
+ this.dependencies.worktreePool &&
516
+ (pointer.worktreeGeneration || adoptable || parked)) {
409
517
  const pool = this.dependencies.worktreePool;
410
518
  const previous = adoptable ?? (await pool.find(projectId, taskSlug, repository.repoRoot));
411
519
  if (previous && previous.repoRoot !== repository.repoRoot)
@@ -416,10 +524,14 @@ export class BridgeService {
416
524
  sessionId,
417
525
  nextAction: 'Call session.resume from this returned repoRoot and use it for every subsequent command.',
418
526
  });
419
- const sourceCommit = previous?.baseCommit ?? pointer.source?.sourceCommit;
420
527
  const branch = pointer.worktreeGeneration
421
528
  ? pointer.branch
422
529
  : (previous?.branch ?? pointer.branch);
530
+ const sourceCommit = previous?.baseCommit ??
531
+ pointer.source?.sourceCommit ??
532
+ (parked && branch
533
+ ? (await this.dependencies.repositories.git.sourceIdentity(repository.repoRoot, 'refs/heads/' + branch))?.sourceCommit
534
+ : undefined);
423
535
  if ((!sourceCommit && !(previous && previous.baseCommit === null && !previous.managed)) ||
424
536
  branch === undefined)
425
537
  throw refuse('The task branch and base must be restored before resuming its worktree.', 'worktree.reconcile');
@@ -455,12 +567,13 @@ export class BridgeService {
455
567
  });
456
568
  await this.dependencies.gate.rebindWorktreeOwnership(repository.repoRoot, pointer.taskId, oldGeneration, allocation.generation);
457
569
  }
570
+ const checkout = await this.taskRepository(repository, pointer);
458
571
  if (pointer?.closeIntent) {
459
- const recoveredClose = await this.retryCloseIntent(repository, pointer);
572
+ const recoveredClose = await this.retryCloseIntent(checkout, pointer);
460
573
  return asJsonValue({
461
574
  backend: { close: recoveredClose },
462
575
  worktree: (await this.ownedAllocation(projectId, repository.repoRoot)) ?? null,
463
- pendingQuestionnaires,
576
+ ...pendingQuestionnairesFields(pendingQuestionnaires),
464
577
  repository: publicRepository(repository),
465
578
  localJournal: await this.dependencies.journal.load(projectId, taskSlug),
466
579
  synchronization: {
@@ -474,7 +587,7 @@ export class BridgeService {
474
587
  const recoveredDeliveries = await this.recoverJournalOutbox(projectId, taskSlug);
475
588
  const outbox = await this.flushOutbox(pointer?.taskId);
476
589
  const afterSequence = input.afterSequence ?? 0;
477
- const source = await this.checkoutSource(repository, pointer?.source);
590
+ const source = await this.checkoutSource(checkout, pointer?.source, pointer ?? undefined);
478
591
  const response = await this.dependencies.client.request(endpoints.sessionResume, {
479
592
  method: 'POST',
480
593
  cacheKey: resumeCacheKey(sessionId, repository.repoFingerprint, afterSequence),
@@ -512,7 +625,7 @@ export class BridgeService {
512
625
  let pendingOutbox = await this.dependencies.outbox.listForTask(backendTask.id);
513
626
  const responseSource = this.dependencies.client.getResponseSource(response);
514
627
  const recoveredVerification = responseSource !== 'stale_cache' && pointer
515
- ? await this.retryVerificationRecovery(repository, pointer, backend)
628
+ ? await this.retryVerificationRecovery(checkout, pointer, backend)
516
629
  : null;
517
630
  if (recoveredVerification) {
518
631
  pointer = await this.dependencies.activeContexts.loadForTask(repository.repoFingerprint, pointer.taskId);
@@ -576,6 +689,11 @@ export class BridgeService {
576
689
  ? await this.dependencies.journal.readDocuments(projectId, taskSlug)
577
690
  : {};
578
691
  const conflicts = resumeConflicts(backend, backendTask, pointer, localJournal, localDocuments, pendingOutbox, responseSource);
692
+ if (backend.requiresContextRefresh === true)
693
+ conflicts.push('context_refresh_required');
694
+ if (pointer?.resumeConflicts.includes('approved_memory_revision_requires_refresh'))
695
+ conflicts.push('approved_memory_revision_requires_refresh');
696
+ const contextRefreshRequired = conflicts.some((conflict) => contextRefreshConflicts.includes(conflict));
579
697
  const resumedLease = objectValue(backend.activeLease);
580
698
  const resumeIdentityMatches = pointer !== null &&
581
699
  pointer.repoFingerprint === repository.repoFingerprint &&
@@ -607,11 +725,12 @@ export class BridgeService {
607
725
  const resumedTaskVersion = responseSource === 'stale_cache' && pointer
608
726
  ? Math.max(pointer.taskVersion, numericTaskVersion(backendTask.lockVersion))
609
727
  : numericTaskVersion(backendTask.lockVersion);
610
- const hasBlockingConflict = conflicts.some((conflict) => !isOfflineDevelopmentWarning(conflict));
728
+ const hasBlockingConflict = conflicts.some((conflict) => !isOfflineDevelopmentWarning(conflict) && !contextRefreshConflicts.includes(conflict));
611
729
  if (pointer && (!resumeIdentityMatches || hasBlockingConflict)) {
612
730
  await this.dependencies.activeContexts.save({ ...pointer, resumeConflicts: conflicts });
613
731
  }
614
732
  else {
733
+ this.workedTasks.set(sha256(repository.repoRoot), taskSlug);
615
734
  await this.dependencies.activeContexts.save({
616
735
  repoFingerprint: repository.repoFingerprint,
617
736
  projectId,
@@ -637,22 +756,33 @@ export class BridgeService {
637
756
  ? { verificationIntent: pointer.verificationIntent }
638
757
  : {}),
639
758
  ...(pointer?.closeIntent ? { closeIntent: pointer.closeIntent } : {}),
759
+ ...(backendTask.status === 'closed'
760
+ ? { closedAt: pointer?.closedAt ?? new Date().toISOString() }
761
+ : {}),
640
762
  });
641
763
  }
764
+ const backendFresh = responseSource !== 'stale_cache';
642
765
  return asJsonValue({
643
- backend,
766
+ backend: resumeBackendView(backend, backendFresh || !localJournal.projection),
767
+ requirements: backend.requirements ?? null,
768
+ ...(contextRefreshRequired
769
+ ? {
770
+ staleReason: backend.staleReason ?? null,
771
+ nextAction: contextRefreshAction(backend.staleReason, resumedBaseline),
772
+ }
773
+ : {}),
644
774
  repository: publicRepository(repository),
645
775
  worktree: (await this.ownedAllocation(projectId, repository.repoRoot)) ?? null,
646
- localJournal,
647
- pendingQuestionnaires,
776
+ localJournal: backendFresh ? journalSummary(localJournal) : localJournal,
777
+ ...pendingQuestionnairesFields(pendingQuestionnaires),
648
778
  synchronization: {
649
779
  backendSource: responseSource,
650
- backendFresh: responseSource !== 'stale_cache',
780
+ backendFresh,
651
781
  hydratedFromBackend,
652
782
  mergedFromBackend,
653
783
  blockedJournalEventIds,
654
784
  recoveredDeliveries,
655
- outbox,
785
+ outbox: outboxSummary(outbox),
656
786
  pendingOutboxCount: pendingOutbox.length,
657
787
  pendingOutbox: pendingOutbox.map((entry) => ({
658
788
  id: entry.id,
@@ -670,6 +800,15 @@ export class BridgeService {
670
800
  (conflicts.length === 0 || offlineDevelopmentAllowed),
671
801
  offlineDevelopmentOnly: offlineDevelopmentAllowed,
672
802
  recoveredVerification,
803
+ ...(checkout.git.baseCommit
804
+ ? {
805
+ taskWorkCommitted: {
806
+ baseCommit: checkout.git.baseCommit,
807
+ headCommit: checkout.git.head,
808
+ branch: pointer?.branch ?? null,
809
+ },
810
+ }
811
+ : {}),
673
812
  },
674
813
  });
675
814
  });
@@ -720,9 +859,44 @@ export class BridgeService {
720
859
  conflicts: [...new Set(blockingConflicts)],
721
860
  outbox,
722
861
  repository: publicRepository(repository),
862
+ ...(blockingConflicts.some((conflict) => contextRefreshConflicts.includes(conflict))
863
+ ? { nextAction: contextRefreshAction(undefined, pointer.changeBaseline) }
864
+ : {}),
723
865
  });
724
866
  }
725
- const activeBaseline = pointer?.sessionId === input.sessionId ? pointer.changeBaseline : undefined;
867
+ let activeBaseline = pointer?.sessionId === input.sessionId ? pointer.changeBaseline : undefined;
868
+ if (!activeBaseline) {
869
+ const snapshot = objectValue((await this.dependencies.client.request(endpoints.sessionResume, {
870
+ method: 'POST',
871
+ cacheKey: resumeCacheKey(input.sessionId, repository.repoFingerprint, 0),
872
+ allowStaleOnUnavailable: true,
873
+ body: {
874
+ sessionId: input.sessionId,
875
+ repoFingerprint: repository.repoFingerprint,
876
+ source: (await this.checkoutSource(repository, pointer.source)) ?? null,
877
+ afterSequence: 0,
878
+ },
879
+ })).data);
880
+ const recordedBaselineDiffHash = typeof snapshot?.changeBaselineDiffHash === 'string'
881
+ ? snapshot.changeBaselineDiffHash
882
+ : undefined;
883
+ if (recordedBaselineDiffHash) {
884
+ activeBaseline = repairChangeBaseline(undefined, recordedBaselineDiffHash, repository.git.head);
885
+ if (!activeBaseline) {
886
+ return asJsonValue({
887
+ editLeaseGranted: false,
888
+ conflicts: ['task_change_baseline_recaptured'],
889
+ outbox,
890
+ repository: publicRepository(repository),
891
+ nextAction: `This task started from baseline ${short(recordedBaselineDiffHash)}, which cannot be proved against the current commit, so a lease taken now would measure only part of the work. Say what happened and let the user decide between reopening the task and abandoning it.`,
892
+ });
893
+ }
894
+ await this.dependencies.activeContexts.save({
895
+ ...pointer,
896
+ changeBaseline: activeBaseline,
897
+ });
898
+ }
899
+ }
726
900
  const changedPaths = normalizeChangedPaths([
727
901
  ...(activeBaseline?.leasePaths ?? []),
728
902
  ...input.changedPaths,
@@ -750,7 +924,7 @@ export class BridgeService {
750
924
  sessionId: input.sessionId,
751
925
  changedPaths,
752
926
  baselineDiffHash,
753
- source: await this.checkoutSource(repository, pointer?.source),
927
+ source: await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer),
754
928
  ...(decision ? { branch: decision.branch } : {}),
755
929
  ...(input.transitionToWrite
756
930
  ? { transitionToWrite: true, expectedTaskVersion: transitionTaskVersion }
@@ -962,13 +1136,14 @@ export class BridgeService {
962
1136
  if (pointer && repository && pointer.mode !== 'read_only')
963
1137
  await this.assertPoolPointer(repository, pointer);
964
1138
  const source = repository
965
- ? await this.checkoutSource(repository, pointer?.source)
1139
+ ? await this.checkoutSource(await this.taskRepository(repository, pointer), pointer?.source, pointer ?? undefined)
966
1140
  : undefined;
967
1141
  const response = await this.dependencies.client.request(endpoints.contextRefresh, {
968
1142
  method: 'POST',
969
1143
  body: cleanJson({ sessionId: input.sessionId, source }),
970
1144
  });
971
- await this.dependencies.activeContexts.clearSessionConflict(input.sessionId, 'approved_memory_revision_requires_refresh');
1145
+ for (const conflict of contextRefreshConflicts)
1146
+ await this.dependencies.activeContexts.clearSessionConflict(input.sessionId, conflict);
972
1147
  return asJsonValue({
973
1148
  ...objectOrEmpty(response.data),
974
1149
  requiresPrepareChange: true,
@@ -984,7 +1159,9 @@ export class BridgeService {
984
1159
  cacheKey: sha256(`memory.query\n${stableStringify(body)}`),
985
1160
  allowStaleOnUnavailable: true,
986
1161
  });
987
- return asJsonValue({ resources: response.data });
1162
+ return Array.isArray(response.data)
1163
+ ? asJsonValue({ resources: response.data })
1164
+ : response.data;
988
1165
  });
989
1166
  }
990
1167
  async memoryHistory(input) {
@@ -992,15 +1169,31 @@ export class BridgeService {
992
1169
  const body = cleanJson({
993
1170
  sessionId: input.sessionId,
994
1171
  resourceKeys: input.resourceKeys,
1172
+ resourceIds: input.resourceIds,
995
1173
  paths: input.paths,
1174
+ required: input.required,
996
1175
  depth: input.depth,
997
1176
  });
998
- const response = await this.dependencies.client.request(endpoints.memoryHistory, {
999
- method: 'POST',
1000
- body,
1001
- });
1177
+ let response;
1178
+ try {
1179
+ response = await this.dependencies.client.request(endpoints.memoryHistory, {
1180
+ method: 'POST',
1181
+ body,
1182
+ });
1183
+ }
1184
+ catch (error) {
1185
+ if (backendRejectsField(error, ['required', 'resourceIds']))
1186
+ throw refuse('This Engineering Memory server is older than this client and does not accept required or resourceIds in memory.history yet. Call memory.history with the resourceKeys or paths of the records this change touches.', 'memory.history');
1187
+ throw error;
1188
+ }
1189
+ const data = objectValue(response.data);
1002
1190
  const relatedBranches = await this.branchTaskReferences(input.repoRoot ?? process.cwd(), input.paths ?? []);
1003
- return asJsonValue({ history: response.data, relatedBranches });
1191
+ return asJsonValue({
1192
+ history: Array.isArray(response.data) ? response.data : (data?.histories ?? []),
1193
+ uncoveredPaths: data?.uncoveredPaths ?? [],
1194
+ historyReadsRequired: data?.historyReadsRequired ?? [],
1195
+ relatedBranches,
1196
+ });
1004
1197
  });
1005
1198
  }
1006
1199
  async branchTaskReferences(repoRoot, paths) {
@@ -1341,7 +1534,7 @@ export class BridgeService {
1341
1534
  async memoryListProposals(input) {
1342
1535
  return await this.execute(async () => {
1343
1536
  const response = await this.dependencies.client.request(endpoints.memoryListProposals, { method: 'POST', body: cleanJson(input) });
1344
- return asJsonValue({ proposals: response.data });
1537
+ return asJsonValue(response.data);
1345
1538
  });
1346
1539
  }
1347
1540
  async memoryReviewProposal(input) {
@@ -1416,16 +1609,25 @@ export class BridgeService {
1416
1609
  }
1417
1610
  return [...pending];
1418
1611
  }
1612
+ ruleDeviationQuestions(input) {
1613
+ const language = input.language ?? 'en';
1614
+ return reviewedFiles(input).flatMap(({ path, rules }) => rules.filter(isDeviation).map((rule) => ({
1615
+ definition: ruleDeviationQuestion(input.taskId, path, rule, language),
1616
+ previousDefinitions: ['en', 'tr']
1617
+ .filter((other) => other !== language)
1618
+ .map((other) => ruleDeviationQuestion(input.taskId, path, rule, other)),
1619
+ })));
1620
+ }
1419
1621
  async taskSelfReview(input) {
1420
1622
  return await this.execute(async () => {
1421
- const findings = normalizePersistentInput(input.findings, input.repoRoot ?? process.cwd());
1422
- assertSafeToPersist(cleanJson({ findings }));
1423
- const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1623
+ const files = await this.approvedDeviations(input);
1624
+ assertSafeToPersist(cleanJson({ files }));
1625
+ const checkout = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1626
+ const repository = await this.taskRepository(checkout, await this.dependencies.activeContexts.loadForTask(checkout.repoFingerprint, input.taskId));
1424
1627
  if (this.deferDeliveries) {
1425
1628
  return this.enqueueTaskReceipt('task.self_review', endpoints.taskSelfReview, input.taskId, repository.repoRoot, cleanJson({
1426
1629
  diffHash: repository.git.diffHash,
1427
- reviewedResourceIds: [...new Set(input.reviewedResourceIds)],
1428
- findings,
1630
+ files,
1429
1631
  }));
1430
1632
  }
1431
1633
  const snapshot = await this.activeTaskSnapshot(input.taskId, undefined, input.repoRoot);
@@ -1435,20 +1637,50 @@ export class BridgeService {
1435
1637
  taskId: input.taskId,
1436
1638
  expectedTaskVersion: snapshot.taskVersion,
1437
1639
  diffHash: repository.git.diffHash,
1438
- reviewedResourceIds: input.reviewedResourceIds,
1439
- findings,
1640
+ files,
1440
1641
  }),
1441
1642
  });
1442
1643
  await this.captureTaskSnapshot(asJsonValue({ task: response.data }));
1443
1644
  return asJsonValue({
1444
1645
  selfReview: {
1445
1646
  diffHash: repository.git.diffHash,
1446
- reviewedResourceIds: input.reviewedResourceIds,
1447
- findings: findings.length,
1647
+ files: files.map(({ path, rules }) => ({
1648
+ path,
1649
+ rules: rules.map(({ resourceId, outcome }) => ({ resourceId, outcome })),
1650
+ })),
1448
1651
  },
1449
1652
  });
1450
1653
  });
1451
1654
  }
1655
+ async approvedDeviations(input) {
1656
+ const files = reviewedFiles(input);
1657
+ if (!files.some(({ rules }) => rules.some(isDeviation)))
1658
+ return files;
1659
+ const scope = await this.questionnaireScope(input.repoRoot);
1660
+ return await Promise.all(files.map(async ({ path, rules }) => ({
1661
+ path,
1662
+ rules: await Promise.all(rules.map(async (rule) => {
1663
+ if (!isDeviation(rule))
1664
+ return rule;
1665
+ const questionnaireId = ruleDeviationQuestionnaireId(input.taskId, path, rule);
1666
+ const question = await this.dependencies.questionnaires.get(scope, questionnaireId);
1667
+ const choice = question?.status === 'answered' ? question.answer?.choice : undefined;
1668
+ if (choice === 'change')
1669
+ throw refuse(`The user chose to change ${path} so it follows ${rule.resourceKey}. Change the code, then record task.self_review again with that rule as fixed.`, 'task.self_review');
1670
+ if (choice !== 'approve' || !question?.answeredAt)
1671
+ throw refuse(`The user has not answered whether ${path} may keep deviating from ${rule.resourceKey}. Call the task.self_review tool so it asks the rule-deviation question natively, then retry.`, 'task.self_review');
1672
+ return {
1673
+ ...rule,
1674
+ questionnaireId,
1675
+ deviationApproval: {
1676
+ questionnaireId,
1677
+ answeredAt: question.answeredAt,
1678
+ hostTool: question.answerSource?.hostTool ?? null,
1679
+ },
1680
+ };
1681
+ })),
1682
+ })));
1683
+ }
1452
1684
  async taskReconcile(input) {
1453
1685
  return await this.execute(async () => {
1454
1686
  const repoRoot = input.repoRoot ?? process.cwd();
@@ -1567,7 +1799,7 @@ export class BridgeService {
1567
1799
  method: 'POST',
1568
1800
  body: cleanJson({
1569
1801
  sessionId: pointer.sessionId,
1570
- source: await this.checkoutSource(repository, pointer.source),
1802
+ source: await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer),
1571
1803
  }),
1572
1804
  });
1573
1805
  const refreshData = objectValue(refreshed.data);
@@ -1592,13 +1824,18 @@ export class BridgeService {
1592
1824
  }
1593
1825
  async taskVerify(input) {
1594
1826
  return await this.execute(async () => {
1827
+ const timer = startPhaseTimer('task.verify');
1595
1828
  return await this.taskExclusive(input.taskId, async () => {
1596
1829
  await this.recoverJournalOutbox();
1830
+ timer.mark('recover_journal_outbox');
1597
1831
  const outbox = await this.flushOutbox(input.taskId);
1832
+ timer.mark('flush_outbox');
1598
1833
  const pendingOutboxCount = (await this.dependencies.outbox.listForTask(input.taskId))
1599
1834
  .length;
1600
- const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1835
+ let repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1836
+ timer.mark('resolve_repository');
1601
1837
  if (pendingOutboxCount > 0) {
1838
+ timer.finish();
1602
1839
  return asJsonValue({
1603
1840
  verified: false,
1604
1841
  reason: 'pending_outbox',
@@ -1608,6 +1845,7 @@ export class BridgeService {
1608
1845
  });
1609
1846
  }
1610
1847
  const pointer = await this.requireActivePointer(repository.repoFingerprint, input.taskId, false, 'verify');
1848
+ repository = await this.taskRepository(repository, pointer);
1611
1849
  const pendingCorrectionScopes = await this.pendingCorrectionScopes(pointer.projectId, pointer.taskSlug);
1612
1850
  if (pendingCorrectionScopes.length > 0) {
1613
1851
  return asJsonValue({
@@ -1640,7 +1878,7 @@ export class BridgeService {
1640
1878
  body: {
1641
1879
  sessionId: input.sessionId,
1642
1880
  repoFingerprint: repository.repoFingerprint,
1643
- source: (await this.checkoutSource(repository, pointer.source)) ?? null,
1881
+ source: (await this.checkoutSource(repository, pointer.source, pointer)) ?? null,
1644
1882
  afterSequence: 0,
1645
1883
  },
1646
1884
  });
@@ -1661,6 +1899,10 @@ export class BridgeService {
1661
1899
  await this.assertTaskWorktree(repository, snapshotTask, pointer);
1662
1900
  const resourceDiscoveryPolicy = readResourceDiscoveryPolicy(snapshot, activeLease);
1663
1901
  const validations = normalizePersistentInput(input.validations, repository.repoRoot);
1902
+ const waivers = normalizePersistentInput(input.waivers ?? [], repository.repoRoot);
1903
+ if (snapshot?.requiresContextRefresh === true) {
1904
+ throw refuse(contextRefreshAction(snapshot.staleReason, pointer.changeBaseline), 'context.refresh');
1905
+ }
1664
1906
  if (taskMode === 'read_only') {
1665
1907
  if (input.leaseId || input.changedPaths?.length) {
1666
1908
  throw refuse('This task is read-only, so it has no lease and no changed paths to report.', 'task.verify');
@@ -1668,48 +1910,114 @@ export class BridgeService {
1668
1910
  }
1669
1911
  else {
1670
1912
  const baseline = pointer.changeBaseline;
1671
- const leaseChangedPaths = Array.isArray(activeLease?.changedPaths)
1672
- ? activeLease.changedPaths.filter((path) => typeof path === 'string')
1673
- : [];
1674
1913
  if (!baseline) {
1675
1914
  throw refuse('This task has no local change baseline, so what it changed cannot be measured. The baseline is restored from the task record.', 'session.resume');
1676
1915
  }
1677
- if (activeLease?.baselineDiffHash !== baseline.diffHash) {
1678
- throw refuse(`The active lease was taken against baseline ${short(activeLease?.baselineDiffHash)} but this task started from ${short(baseline.diffHash)}, so the lease must be taken again against the task's own baseline.`, 'context.prepare_change');
1916
+ if (!activeLease) {
1917
+ throw refuse(`There is no active lease (the last one was consumed or expired). Call context.prepare_change for ${baseline.leasePaths.join(', ') || 'the paths this task changes'}; the task baseline stays ${short(baseline.diffHash)}.`, 'context.prepare_change');
1918
+ }
1919
+ if (activeLease.baselineDiffHash !== baseline.diffHash) {
1920
+ throw refuse(`The active lease was taken against baseline ${short(activeLease.baselineDiffHash)} but this task started from ${short(baseline.diffHash)}, so the lease must be taken again against the task's own baseline.`, 'context.prepare_change');
1679
1921
  }
1680
- const wholeTreeDelta = manifestDelta(baseline.changedPaths, repository.git.changedPaths);
1681
- const leased = new Set(normalizeChangedPaths([...baseline.leasePaths, ...leaseChangedPaths]));
1682
- taskChanges = wholeTreeDelta.filter((entry) => leased.has(normalizeChangedPaths([entry.path])[0]));
1683
- foreignChangedPaths = wholeTreeDelta
1684
- .filter((entry) => !leased.has(normalizeChangedPaths([entry.path])[0]))
1922
+ const changeSet = taskChangeSet(baseline, activeLease, repository.git.changedPaths);
1923
+ taskChanges = changeSet.taskChanges;
1924
+ foreignChangedPaths = changeSet.wholeTreeDelta
1925
+ .filter((entry) => !changeSet.leased.has(normalizeChangedPaths([entry.path])[0]))
1685
1926
  .map((entry) => entry.path)
1686
1927
  .sort();
1687
1928
  taskChangedPaths = taskChanges.map((entry) => entry.path).sort();
1688
- if (input.changedPaths &&
1689
- stableStringify(normalizeChangedPaths(input.changedPaths)) !==
1690
- stableStringify(taskChangedPaths)) {
1691
- throw refuse(`Reported changed paths do not match the actual Git manifest. ${describePathDisagreement(normalizeChangedPaths(input.changedPaths), taskChangedPaths)} The manifest is the difference between the tree this task started from and the tree now; report exactly what it says. A manifest that looks short means the baseline has moved, and resuming repairs it.`, 'session.resume');
1929
+ const reported = input.changedPaths
1930
+ ? normalizeChangedPaths(input.changedPaths)
1931
+ : taskChangedPaths;
1932
+ const treePaths = new Set(normalizeChangedPaths(changeSet.wholeTreeDelta.map((entry) => entry.path)));
1933
+ const outsideLease = [
1934
+ ...new Set([
1935
+ ...reported.filter((path) => treePaths.has(path) && !changeSet.leased.has(path)),
1936
+ ...taskChangedPaths.filter((path) => !pathsContainAll(baseline.leasePaths, [path]) ||
1937
+ !pathsContainAll(changeSet.leaseChangedPaths, [path])),
1938
+ ]),
1939
+ ].sort();
1940
+ const notChanged = reported.filter((path) => !treePaths.has(path));
1941
+ const unreported = taskChangedPaths.filter((path) => !reported.includes(path));
1942
+ const baselineMoved = typeof snapshot?.changeBaselineDiffHash === 'string' &&
1943
+ snapshot.changeBaselineDiffHash !== baseline.diffHash;
1944
+ const pathFindings = [
1945
+ ...(outsideLease.length > 0
1946
+ ? [
1947
+ `These changed paths are outside the current lease: ${samplePaths(outsideLease)}. Call context.prepare_change with them; the lease expands and the task baseline stays ${short(baseline.diffHash)}.`,
1948
+ ]
1949
+ : []),
1950
+ ...(notChanged.length > 0
1951
+ ? [
1952
+ `These reported paths do not differ from the tree this task started from (baseline ${short(baseline.diffHash)}): ${samplePaths(notChanged)}. ${baselineMoved
1953
+ ? 'The baseline has moved from the one the task recorded; resuming repairs it.'
1954
+ : 'Report only the paths the manifest lists.'}`,
1955
+ ]
1956
+ : []),
1957
+ ...(unreported.length > 0
1958
+ ? [
1959
+ `This task changed these paths but did not report them: ${samplePaths(unreported)}. Report every path the manifest lists.`,
1960
+ ]
1961
+ : []),
1962
+ ];
1963
+ if (pathFindings.length > 0) {
1964
+ throw refuse(pathFindings.join(' '), outsideLease.length > 0
1965
+ ? 'context.prepare_change'
1966
+ : notChanged.length > 0 && baselineMoved
1967
+ ? 'session.resume'
1968
+ : 'task.verify', {
1969
+ outsideLease,
1970
+ notChanged,
1971
+ unreported,
1972
+ changedPathsOutsideThisTask: foreignChangedPaths,
1973
+ });
1692
1974
  }
1693
1975
  if (taskChangedPaths.length === 0) {
1694
1976
  throw refuse('The manifest shows this task changed nothing. Either the work is not in the working tree, or the baseline has moved to equal it.', 'session.resume');
1695
1977
  }
1696
- if (!activeLease || !input.leaseId || activeLease.id !== input.leaseId) {
1697
- throw refuse(`Verification needs the lease the backend currently holds. It has ${short(activeLease?.id)} and you sent ${short(input.leaseId)}.`, 'context.prepare_change');
1698
- }
1699
- const unleased = taskChangedPaths.filter((path) => !pathsContainAll(baseline.leasePaths, [path]) ||
1700
- !pathsContainAll(leaseChangedPaths, [path]));
1701
- if (unleased.length > 0) {
1702
- throw refuse(`These changed paths are outside the current lease: ${unleased.slice(0, 5).join(', ')}${unleased.length > 5 ? `, and ${unleased.length - 5} more` : ''}. The lease must cover every path this task changed.`, 'context.prepare_change');
1978
+ if (!input.leaseId || activeLease.id !== input.leaseId) {
1979
+ throw refuse(`Verification needs the lease the backend currently holds. It has ${short(activeLease.id)} and you sent ${short(input.leaseId)}.`, 'context.prepare_change');
1703
1980
  }
1704
- assertNewResourceEvidence(taskChanges.flatMap((entry) => newMemoryResourceCandidate(entry, resourceDiscoveryPolicy)), input.newResources ?? []);
1705
1981
  }
1706
1982
  assertSafeToPersist(cleanJson(validations));
1707
- assertValidationEvidence(validations);
1983
+ assertSafeToPersist(cleanJson(waivers));
1984
+ const evidenceFindings = [
1985
+ ...(taskMode === 'read_only'
1986
+ ? []
1987
+ : newResourceEvidenceFindings(taskChanges.flatMap((entry) => newMemoryResourceCandidate(entry, resourceDiscoveryPolicy)), input.newResources ?? [])),
1988
+ ...validationEvidenceFindings(validations),
1989
+ ];
1990
+ if (evidenceFindings.length > 0) {
1991
+ throw refuse(evidenceFindings.length === 1
1992
+ ? evidenceFindings[0]
1993
+ : `Verification found ${evidenceFindings.length} unmet requirements: ${evidenceFindings
1994
+ .map((finding, index) => `(${index + 1}) ${finding}`)
1995
+ .join(' ')}`, 'task.verify', { findings: evidenceFindings });
1996
+ }
1997
+ const validationWaivers = [];
1998
+ if (waivers.length > 0) {
1999
+ const paths = renderedPaths(taskChangedPaths, resourceDiscoveryPolicy);
2000
+ const scope = await this.questionnaireScope(repository.repoRoot);
2001
+ for (const waiver of waivers) {
2002
+ const questionnaireId = validationWaiverId(input.taskId, waiver.validationId, paths, waiver.reason);
2003
+ const question = await this.dependencies.questionnaires.get(scope, questionnaireId);
2004
+ if (question?.answer?.choice !== 'waive') {
2005
+ throw refuse(`The user has not agreed to skip the ${waiver.validationId} check for ${samplePaths(paths) || 'this change'} with this reason. Call task.verify with the waiver so the tool asks them, or provide the evidence.`, 'task.verify');
2006
+ }
2007
+ validationWaivers.push({
2008
+ validationId: waiver.validationId,
2009
+ questionnaireId,
2010
+ reason: waiver.reason,
2011
+ paths,
2012
+ answerSource: question.answerSource?.hostTool ?? 'mcp_form',
2013
+ });
2014
+ }
2015
+ }
1708
2016
  const pathChangeManifest = taskMode === 'read_only'
1709
2017
  ? []
1710
2018
  : await buildPathChangeManifest(repository.repoRoot, taskChanges);
1711
2019
  const verifyBody = cleanJson({
1712
- source: await this.checkoutSource(repository, pointer.source),
2020
+ source: await this.checkoutSource(repository, pointer.source, pointer),
1713
2021
  taskId: input.taskId,
1714
2022
  sessionId: input.sessionId,
1715
2023
  expectedTaskVersion: numericTaskVersion(snapshotTask.lockVersion),
@@ -1729,6 +2037,7 @@ export class BridgeService {
1729
2037
  : {}),
1730
2038
  ...(taskMode === 'read_only' ? { worktreeHash: repository.git.worktreeHash } : {}),
1731
2039
  validations,
2040
+ ...(validationWaivers.length > 0 ? { validationWaivers } : {}),
1732
2041
  pendingOutboxCount,
1733
2042
  newResources: input.newResources,
1734
2043
  introducedStructures: input.introducedStructures,
@@ -1749,6 +2058,8 @@ export class BridgeService {
1749
2058
  if (!isBackendUnavailableError(error)) {
1750
2059
  await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, input.taskId);
1751
2060
  }
2061
+ if (backendRejectsField(error, ['validationWaivers']))
2062
+ throw refuse('This Engineering Memory server is older than this client and cannot record a validation waiver yet, so the task cannot verify on the waiver the user gave. Provide ui evidence that exercises the changed surface and call task.verify without waivers.', 'task.verify');
1752
2063
  throw error;
1753
2064
  }
1754
2065
  const responseData = objectValue(response.data);
@@ -1772,6 +2083,7 @@ export class BridgeService {
1772
2083
  else {
1773
2084
  await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, input.taskId);
1774
2085
  }
2086
+ timer.finish();
1775
2087
  return asJsonValue({
1776
2088
  ...objectOrEmpty(response.data),
1777
2089
  mode: taskMode,
@@ -1783,11 +2095,45 @@ export class BridgeService {
1783
2095
  });
1784
2096
  });
1785
2097
  }
2098
+ async validationWaiverSubject(input) {
2099
+ return await this.execute(async () => {
2100
+ const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
2101
+ const pointer = await this.requireActivePointer(repository.repoFingerprint, input.taskId, false, 'verify');
2102
+ if (!pointer.changeBaseline) {
2103
+ throw refuse('This task has no local change baseline, so what it changed cannot be measured. The baseline is restored from the task record.', 'session.resume');
2104
+ }
2105
+ const response = await this.dependencies.client.request(endpoints.sessionResume, {
2106
+ method: 'POST',
2107
+ body: {
2108
+ sessionId: input.sessionId,
2109
+ repoFingerprint: repository.repoFingerprint,
2110
+ source: (await this.checkoutSource(repository, pointer.source)) ?? null,
2111
+ afterSequence: 0,
2112
+ },
2113
+ });
2114
+ const snapshot = objectValue(response.data);
2115
+ const activeLease = objectValue(snapshot?.activeLease);
2116
+ const paths = renderedPaths(taskChangeSet(pointer.changeBaseline, activeLease, repository.git.changedPaths).taskChanges.map((entry) => entry.path), readResourceDiscoveryPolicy(snapshot, activeLease));
2117
+ if (paths.length === 0) {
2118
+ throw refuse(`No path this task changed renders UI, so there is no ${input.validationId} check to waive. Call task.verify without the waiver.`, 'task.verify');
2119
+ }
2120
+ const reason = normalizePersistentInput(input.reason, repository.repoRoot);
2121
+ return asJsonValue({
2122
+ taskId: input.taskId,
2123
+ externalTaskId: pointer.taskSlug,
2124
+ validationId: input.validationId,
2125
+ paths,
2126
+ reason,
2127
+ questionnaireId: validationWaiverId(input.taskId, input.validationId, paths, reason),
2128
+ });
2129
+ });
2130
+ }
1786
2131
  async taskClose(input) {
1787
2132
  return await this.execute(async () => {
1788
2133
  return await this.taskExclusive(input.taskId, async () => {
1789
- const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1790
- const pointer = await this.requireActivePointer(repository.repoFingerprint, input.taskId, false, 'close');
2134
+ const checkout = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
2135
+ const pointer = await this.requireActivePointer(checkout.repoFingerprint, input.taskId, false, 'close');
2136
+ const repository = await this.taskRepository(checkout, pointer);
1791
2137
  if (pointer.closeIntent) {
1792
2138
  return await this.retryCloseIntent(repository, pointer);
1793
2139
  }
@@ -1796,7 +2142,7 @@ export class BridgeService {
1796
2142
  body: {
1797
2143
  sessionId: pointer.sessionId,
1798
2144
  repoFingerprint: repository.repoFingerprint,
1799
- source: (await this.checkoutSource(repository, pointer.source)) ?? null,
2145
+ source: (await this.checkoutSource(repository, pointer.source, pointer)) ?? null,
1800
2146
  afterSequence: 0,
1801
2147
  },
1802
2148
  });
@@ -1810,7 +2156,7 @@ export class BridgeService {
1810
2156
  : [];
1811
2157
  await this.assertTaskWorktree(repository, task, pointer);
1812
2158
  const closeBody = {
1813
- source: (await this.checkoutSource(repository, pointer.source)) ?? null,
2159
+ source: (await this.checkoutSource(repository, pointer.source, pointer)) ?? null,
1814
2160
  taskId: input.taskId,
1815
2161
  sessionId: pointer.sessionId,
1816
2162
  expectedTaskVersion: numericTaskVersion(task.lockVersion),
@@ -1831,7 +2177,7 @@ export class BridgeService {
1831
2177
  }
1832
2178
  async worktreeTaskAllocation(input) {
1833
2179
  return this.execute(async () => {
1834
- const repository = await this.dependencies.repositories.resolve(input.repoRoot);
2180
+ const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot);
1835
2181
  const allocation = repository.projectId && this.dependencies.worktreePool
1836
2182
  ? await this.dependencies.worktreePool.find(repository.projectId, input.externalTaskId, repository.repoRoot)
1837
2183
  : null;
@@ -1840,18 +2186,68 @@ export class BridgeService {
1840
2186
  }
1841
2187
  async projectGitPreferences(input) {
1842
2188
  return this.execute(async () => {
1843
- const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd(), input.projectId);
2189
+ const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot ?? process.cwd(), input.projectId);
1844
2190
  if (!repository.projectId)
1845
2191
  throw refuse('Bind a project before reading Git preferences.', 'project.resolve');
1846
2192
  const response = await this.dependencies.client.request(endpoints.projectGitPreferences(repository.projectId));
1847
2193
  return asJsonValue({
1848
2194
  ...objectOrEmpty(objectValue(response.data)),
1849
2195
  repoRoot: repository.repoRoot,
1850
- currentBranch: await this.dependencies.repositories.git.currentBranch(repository.repoRoot),
2196
+ currentBranch: repository.branch,
2197
+ currentCommit: repository.head,
2198
+ });
2199
+ });
2200
+ }
2201
+ async taskBranchPreflight(input) {
2202
+ return this.execute(async () => {
2203
+ const repository = await this.dependencies.repositories.resolve(input.repoRoot);
2204
+ if (!repository.projectId)
2205
+ throw refuse('Resolve the project before choosing its task branch.', 'project.resolve');
2206
+ const existingAllocation = this.dependencies.worktreePool
2207
+ ? await this.dependencies.worktreePool.find(repository.projectId, input.externalTaskId, repository.repoRoot)
2208
+ : null;
2209
+ if (existingAllocation)
2210
+ return asJsonValue({ existingAllocation });
2211
+ if (input.name && !input.keepCurrent) {
2212
+ const unusable = await this.unusableBranchName(repository.repoRoot, input.name);
2213
+ if (unusable)
2214
+ throw refuse(unusable, 'task.branch');
2215
+ }
2216
+ const [preferences, currentBranch, folder, suggestedName] = await Promise.all([
2217
+ this.dependencies.client.request(endpoints.projectGitPreferences(repository.projectId)),
2218
+ this.dependencies.repositories.git.currentBranch(repository.repoRoot),
2219
+ this.folderState(repository, input.externalTaskId),
2220
+ this.suggestedBranchName(repository.repoRoot, input.externalTaskId),
2221
+ ]);
2222
+ return asJsonValue({
2223
+ existingAllocation: null,
2224
+ preferences: preferences.data,
2225
+ currentBranch,
1851
2226
  currentCommit: repository.git.head,
2227
+ folder,
2228
+ suggestedName,
1852
2229
  });
1853
2230
  });
1854
2231
  }
2232
+ async suggestedBranchName(repoRoot, externalTaskId) {
2233
+ const candidates = suggestedBranchNames(externalTaskId);
2234
+ for (const candidate of candidates)
2235
+ if (!(await this.unusableBranchName(repoRoot, candidate)))
2236
+ return candidate;
2237
+ return candidates[0];
2238
+ }
2239
+ async unusableBranchName(repoRoot, name) {
2240
+ const git = this.dependencies.repositories.git;
2241
+ try {
2242
+ await git.validateBranch(repoRoot, name);
2243
+ }
2244
+ catch {
2245
+ return `${name} is not a valid Git branch name, so no task branch was created.`;
2246
+ }
2247
+ return (await git.branchExists(repoRoot, name))
2248
+ ? `Branch ${name} already exists, so it cannot be created for a new task; nothing was created.`
2249
+ : null;
2250
+ }
1855
2251
  async setProjectGitPreferences(projectId, body) {
1856
2252
  return this.execute(async () => {
1857
2253
  assertSafeToPersist(body);
@@ -1887,18 +2283,47 @@ export class BridgeService {
1887
2283
  }
1888
2284
  async worktreeOperation(operation, input) {
1889
2285
  return this.execute(async () => {
1890
- const repository = await this.dependencies.repositories.resolve(input.repoRoot);
2286
+ const timer = startPhaseTimer(`worktree.${operation}`);
2287
+ const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot);
2288
+ timer.mark('resolve_identity');
1891
2289
  const projectId = repository.projectId;
1892
2290
  const pool = this.dependencies.worktreePool;
1893
2291
  if (!projectId || !pool)
1894
2292
  throw refuse('Resolve the project with a pool-capable client.', 'project.resolve');
1895
2293
  if (operation === 'list' || operation === 'reconcile') {
1896
2294
  const managedRoot = await pool.managedRoot();
2295
+ const policy = await this.poolPolicy();
2296
+ timer.mark('policy');
2297
+ const reconciled = await pool.reconcile(projectId, repository.repoRoot, policy);
2298
+ timer.mark('reconcile');
2299
+ const items = reconciled.map((item) => ({
2300
+ ...item,
2301
+ ...(item.activity === 'retired'
2302
+ ? {
2303
+ nextAction: 'worktree.reconcile with retireLegacy when the user wants the old folder removed; it is never reused.',
2304
+ }
2305
+ : unreadableWorktree(item)
2306
+ ? {
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.`,
2308
+ }
2309
+ : item.phase === 'delivery'
2310
+ ? {
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).',
2312
+ }
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
+ : {}),
2318
+ }));
2319
+ const retired = items.some((item) => item.activity === 'retired');
2320
+ const selected = await pool.forPath(projectId, repository.repoRoot);
2321
+ timer.finish();
1897
2322
  return asJsonValue({
1898
- items: await pool.reconcile(projectId, repository.repoRoot, await this.poolPolicy()),
1899
- selected: await pool.forPath(projectId, repository.repoRoot),
2323
+ items,
2324
+ selected,
1900
2325
  managedRoot,
1901
- ...(managedRoot.problem || managedRoot.synced
2326
+ ...(managedRoot.problem || managedRoot.synced || retired
1902
2327
  ? {
1903
2328
  notice: [
1904
2329
  ...(managedRoot.problem ? [managedRoot.problem] : []),
@@ -1907,6 +2332,11 @@ export class BridgeService {
1907
2332
  `Managed worktrees are created under ${managedRoot.root}, which a cloud sync client watches, so every Git operation there is re-scanned and uploaded. To create new worktrees elsewhere, write {"root": "<absolute directory>"} to ${managedRoot.override}; it takes effect for the next allocation, and existing worktrees keep working where they are.`,
1908
2333
  ]
1909
2334
  : []),
2335
+ ...(retired
2336
+ ? [
2337
+ `Slots outside ${managedRoot.root} are retired: they are never reused and do not count toward the limit.`,
2338
+ ]
2339
+ : []),
1910
2340
  ].join(' '),
1911
2341
  }
1912
2342
  : {}),
@@ -1934,29 +2364,132 @@ export class BridgeService {
1934
2364
  await pool.releaseUnowned(projectId, repository.repoRoot, input.generation, input.sourceCommit);
1935
2365
  return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
1936
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
+ }
2403
+ if (operation === 'retire') {
2404
+ const entry = input.worktreePath ? await pool.forPath(projectId, input.worktreePath) : null;
2405
+ if (!entry ||
2406
+ entry.generation !== input.generation ||
2407
+ input.decisionId !== 'worktree-retire-' + sha256(entry.repoRoot + entry.generation))
2408
+ throw refuse('The retired folder changed after its removal was asked about. List the worktrees again.', 'worktree.list');
2409
+ const decision = await this.questionnaireResume({
2410
+ repoRoot: repository.repoRoot,
2411
+ questionnaireId: input.decisionId,
2412
+ });
2413
+ if (decision.status !== 'answered' || decision.answer?.choice !== 'retire')
2414
+ throw refuse('A retired folder is removed only after the user approves it in the native worktree.reconcile form.', 'worktree.reconcile');
2415
+ await pool.retire(projectId, repository.repoRoot, entry.repoRoot, entry.generation);
2416
+ return asJsonValue({ operation, complete: true, removed: entry.repoRoot });
2417
+ }
1937
2418
  const entry = await pool.forPath(projectId, repository.repoRoot);
1938
2419
  if (!entry ||
1939
2420
  entry.externalTaskId !== input.externalTaskId ||
1940
2421
  entry.generation !== input.generation)
1941
2422
  throw refuse('Use the exact task, repoRoot and ownership generation returned on resume.', 'session.resume');
2423
+ if (operation === 'pause' && input.delivered !== undefined)
2424
+ throw refuse('task.pause keeps a task and its folder and records no delivery. After delivery, or when the user decided not to deliver, call worktree.release with deliveryOutcome delivered or cancelled.', 'worktree.release');
2425
+ const outcome = input.deliveryOutcome ?? (input.delivered === true ? 'delivered' : undefined);
2426
+ if (operation === 'release' && outcome === 'cancelled')
2427
+ throw refuse('A cancelled delivery is settled through the native question that worktree.release with deliveryOutcome cancelled asks.', 'worktree.release');
1942
2428
  const perform = async () => {
1943
- if (operation === 'heartbeat')
1944
- await pool.assertOwnership(projectId, repository.repoRoot, input.generation);
1945
- if (operation === 'pause') {
1946
- await pool.pause(projectId, repository.repoRoot, input.generation);
1947
- this.poolAllocations.delete(repository.repoRoot);
2429
+ try {
2430
+ if (operation === 'heartbeat') {
2431
+ await pool.assertOwnership(projectId, repository.repoRoot, input.generation);
2432
+ }
2433
+ if (operation === 'pause') {
2434
+ await pool.pause(projectId, repository.repoRoot, input.generation);
2435
+ this.poolAllocations.delete(repository.repoRoot);
2436
+ }
2437
+ if (operation === 'release') {
2438
+ await this.requireDeliveredTaskWork(entry);
2439
+ await pool.release(projectId, repository.repoRoot, input.generation, outcome === 'delivered' ? outcome : undefined);
2440
+ this.poolAllocations.delete(repository.repoRoot);
2441
+ }
2442
+ if (operation === 'cancel_delivery') {
2443
+ if (!entry.pendingDelivery)
2444
+ throw refuse('This worktree is not waiting for delivery, so there is no delivery to cancel. Release it with worktree.release.', 'worktree.release');
2445
+ const manifest = await this.dependencies.repositories.git.manifest(repository.repoRoot);
2446
+ const clean = manifest.changedPaths.length === 0;
2447
+ if (input.decisionId !== cancelledDeliveryQuestionId(entry, clean))
2448
+ throw refuse('The folder changed after the question was asked. Call worktree.release with deliveryOutcome cancelled again; it asks about the folder as it is now.', 'worktree.release');
2449
+ const decision = await this.questionnaireResume({
2450
+ repoRoot: repository.repoRoot,
2451
+ questionnaireId: input.decisionId,
2452
+ });
2453
+ const choice = decision.answer?.choice ?? '';
2454
+ const choices = clean || !entry.managed ? ['release', 'keep'] : ['save_and_release', 'keep'];
2455
+ if (decision.status !== 'answered' || !choices.includes(choice))
2456
+ throw refuse('Ask the user what happens to the folder in the native worktree.release form.', 'worktree.release');
2457
+ if (choice !== 'keep')
2458
+ await this.requireDeliveredTaskWork(entry);
2459
+ const cancelled = await pool.cancelDelivery(projectId, repository.repoRoot, input.generation, choice);
2460
+ this.poolAllocations.delete(repository.repoRoot);
2461
+ return asJsonValue({
2462
+ operation,
2463
+ complete: true,
2464
+ repoRoot: repository.repoRoot,
2465
+ deliveryOutcome: 'cancelled',
2466
+ ...cancelled,
2467
+ });
2468
+ }
2469
+ return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
1948
2470
  }
1949
- if (operation === 'release') {
1950
- if (entry.taskId)
1951
- await this.flushOutbox(entry.taskId);
1952
- await pool.release(projectId, repository.repoRoot, input.generation, input.delivered === true);
1953
- this.poolAllocations.delete(repository.repoRoot);
2471
+ finally {
2472
+ timer.finish();
1954
2473
  }
1955
- return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
1956
2474
  };
1957
2475
  return entry.taskId ? this.taskExclusive(entry.taskId, perform, false) : perform();
1958
2476
  });
1959
2477
  }
2478
+ async requireDeliveredTaskWork(entry) {
2479
+ if (!entry.taskId)
2480
+ return;
2481
+ await this.flushOutbox(entry.taskId);
2482
+ if (!entry.managed)
2483
+ return;
2484
+ const queued = await this.dependencies.outbox.listForTask(entry.taskId);
2485
+ if (queued.length)
2486
+ throw refuse(`Task ${entry.externalTaskId} still has ${queued.length} record(s) in the local outbox that the backend has not accepted: ${queued
2487
+ .map((item) => `${item.id} (${item.operation}${item.lastError ? ', ' + item.lastError : ''})`)
2488
+ .join(', ')}. The folder was left as it is. Inspect each with task.resolve_pending_delivery and retry or discard it, then call worktree.release again.`, 'task.resolve_pending_delivery');
2489
+ const pointer = await this.dependencies.activeContexts.loadForTask(entry.repoFingerprint, entry.taskId);
2490
+ if (pointer?.verificationIntent || pointer?.closeIntent)
2491
+ throw refuse(`Task ${entry.externalTaskId} has an interrupted ${pointer.closeIntent ? 'close' : 'verification'}. The folder was left as it is. Call session.resume in this folder to finish it, then call worktree.release again.`, 'session.resume');
2492
+ }
1960
2493
  async pooledTaskBranch(repository, input) {
1961
2494
  const pool = this.dependencies.worktreePool;
1962
2495
  const projectId = repository.projectId;
@@ -1967,24 +2500,72 @@ export class BridgeService {
1967
2500
  if (existing)
1968
2501
  source = { sourceCommit: existing.baseCommit };
1969
2502
  else {
1970
- if (!input.decisionId || !input.base)
2503
+ if (!input.decisionId || !input.startId || !input.base)
1971
2504
  throw refuse('Choose the task base through the native task.branch questionnaire.', 'task.branch');
1972
- const decision = await this.questionnaireResume({
2505
+ const start = taskStartChoice(await this.questionnaireResume({
1973
2506
  repoRoot: repository.repoRoot,
1974
- questionnaireId: input.decisionId,
1975
- });
1976
- const signature = sha256(stableStringify({
2507
+ questionnaireId: input.startId,
2508
+ }));
2509
+ const request = {
1977
2510
  externalTaskId: input.externalTaskId,
1978
2511
  base: input.base,
1979
2512
  name: input.name ?? null,
1980
2513
  keepCurrent: input.keepCurrent === true,
1981
2514
  ...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
1982
- }));
1983
- if (!['task-branch-confirm-' + signature, 'task-branch-fallback-' + signature].includes(decision.questionnaireId) ||
1984
- decision.answer?.choice !== 'approve')
1985
- throw refuse('The task branch decision does not match the approved source and branch.', 'task.branch');
2515
+ };
2516
+ const started = start?.status === 'start' &&
2517
+ start.externalTaskId === request.externalTaskId &&
2518
+ start.decisionAttempt === input.decisionAttempt &&
2519
+ start.name === request.name &&
2520
+ start.keepCurrent === request.keepCurrent &&
2521
+ start.inPlace === (input.inPlace === true)
2522
+ ? start
2523
+ : null;
2524
+ let approved = false;
2525
+ if (started && input.decisionId === input.startId)
2526
+ approved = stableStringify(started.base) === stableStringify(input.base);
2527
+ else if (started &&
2528
+ input.decisionId ===
2529
+ 'task-branch-fallback-' +
2530
+ sha256(stableStringify({ ...request, inPlace: input.inPlace === true }))) {
2531
+ const fallback = await this.questionnaireResume({
2532
+ repoRoot: repository.repoRoot,
2533
+ questionnaireId: input.decisionId,
2534
+ });
2535
+ approved = fallback.status === 'answered' && fallback.answer?.choice === 'approve';
2536
+ }
2537
+ if (!approved)
2538
+ throw refuse('The task branch decision does not match the approved source, branch and location.', 'task.branch');
2539
+ const holder = input.inPlace
2540
+ ? await this.folderHolder(repository, input.externalTaskId)
2541
+ : null;
1986
2542
  source = await resolveTaskBase(git, repository.repoRoot, input.base, preferences);
2543
+ if (holder && input.inPlace) {
2544
+ if (holder.managed || holder.running || !sameHolder(holder, started.holder))
2545
+ throw refuse(`This folder is held by task ${holder.externalTaskId}. Call task.branch again: its start form asks whether ${holder.externalTaskId} should move out of this folder before the new branch starts here, or opens the new task in a separate worktree.`, 'task.branch');
2546
+ if (repository.git.changedPaths.length > 0)
2547
+ throw refuse(`This folder has uncommitted changes, so the new branch cannot start here and ${holder.externalTaskId} stays in it. Call task.branch with a new decisionAttempt and open the task in a separate worktree.`, 'task.branch');
2548
+ if (await git.branchExists(repository.repoRoot, input.name))
2549
+ throw refuse(`Branch ${input.name} already exists, so it cannot start here and ${holder.externalTaskId} stays in this folder. Call task.branch with a new decisionAttempt and choose a new branch name.`, 'task.branch');
2550
+ if (holder.generation) {
2551
+ await pool.moveOut(projectId, repository.repoRoot, holder.generation, holder.oid ?? undefined);
2552
+ this.poolAllocations.delete(repository.repoRoot);
2553
+ }
2554
+ else
2555
+ await git.branchStore(repository.repoRoot).detach({
2556
+ projectId: holder.projectId,
2557
+ externalTaskId: holder.externalTaskId,
2558
+ branch: holder.branch,
2559
+ ...(holder.taskId ? { taskId: holder.taskId } : {}),
2560
+ }, holder.oid);
2561
+ const parked = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, holder.externalTaskId);
2562
+ if (parked?.worktreeId === sha256(repository.repoRoot))
2563
+ await this.parkPointer(parked);
2564
+ }
1987
2565
  }
2566
+ const allocationTimer = startPhaseTimer('task.branch.allocate');
2567
+ const allocationPolicy = await this.poolPolicy(true);
2568
+ allocationTimer.mark('policy');
1988
2569
  const allocation = await pool.allocate({
1989
2570
  projectId,
1990
2571
  projectName: preferences.projectName ?? projectId,
@@ -1994,8 +2575,11 @@ export class BridgeService {
1994
2575
  branch: existing?.branch ?? input.name ?? (await git.currentBranch(repository.repoRoot)),
1995
2576
  baseCommit: source.sourceCommit,
1996
2577
  keepCurrent: input.keepCurrent,
2578
+ inPlace: !existing && input.inPlace,
1997
2579
  resume: Boolean(existing),
1998
- }, await this.poolPolicy(true));
2580
+ }, allocationPolicy);
2581
+ allocationTimer.mark('pool_allocate');
2582
+ allocationTimer.finish();
1999
2583
  this.poolAllocations.set(allocation.repoRoot, allocation);
2000
2584
  if (existing && allocation.repoRoot === repository.repoRoot) {
2001
2585
  const current = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, input.externalTaskId);
@@ -2067,8 +2651,9 @@ export class BridgeService {
2067
2651
  (await this.dependencies.worktreePool.find(repository.projectId, input.externalTaskId, repository.repoRoot)))
2068
2652
  return this.pooledTaskBranch(repository, input);
2069
2653
  if (Boolean(input.name) === (input.keepCurrent === true) ||
2070
- (input.worktreePath && !input.name))
2071
- throw refuse('Choose exactly one branch name or keepCurrent. A separate worktree also needs a branch name.', 'task.branch');
2654
+ (input.worktreePath && !input.name) ||
2655
+ (input.inPlace && input.worktreePath))
2656
+ throw refuse('Choose exactly one branch name or keepCurrent. A separate worktree also needs a branch name, and a new branch in this folder has no worktreePath.', 'task.branch');
2072
2657
  assertSafeToPersist(cleanJson({ externalTaskId: input.externalTaskId, name: input.name }));
2073
2658
  if (this.dependencies.worktreePool)
2074
2659
  return this.pooledTaskBranch(repository, input);
@@ -2124,7 +2709,24 @@ export class BridgeService {
2124
2709
  async requireBranchDecision(repository, externalTaskId) {
2125
2710
  const git = this.dependencies.repositories.git;
2126
2711
  const store = git.branchStore(repository.repoRoot);
2712
+ const holder = await this.folderHolder(repository, externalTaskId);
2713
+ if (holder) {
2714
+ const held = `This folder is held by task ${holder.externalTaskId} (branch ${holder.branch ?? 'detached HEAD'}, last activity ${holder.lastCheckpointAt ?? 'unknown'})`;
2715
+ throw refuse(holder.managed
2716
+ ? `${held}, which the worktree pool allocated here. Call task.branch for ${externalTaskId} to open it in a separate worktree, then bootstrap in the returned repoRoot.`
2717
+ : holder.running
2718
+ ? `${held}, which is still running in another client. Pause it there with task.pause, or call task.branch for ${externalTaskId} to open it in a separate worktree; once ${holder.externalTaskId} is paused, its start form also offers this folder. Then bootstrap in the returned repoRoot.`
2719
+ : holder.pendingDelivery
2720
+ ? `${held}, which closed but never settled its delivery. Resume ${holder.externalTaskId} here and call worktree.release with deliveryOutcome delivered or cancelled to settle it, or call task.branch for ${externalTaskId} to open it in a separate worktree instead. Then bootstrap in the returned repoRoot.`
2721
+ : !holder.checkoutClean
2722
+ ? `${held}, which is not known to be finished. This folder has uncommitted changes, so call task.branch for ${externalTaskId} to open it in a separate worktree, then bootstrap in the returned repoRoot.`
2723
+ : `${held}, which is not known to be finished. Call task.branch for ${externalTaskId}: its start form offers a new branch in this folder with ${holder.externalTaskId} moved out, or a separate worktree. Then bootstrap in the returned repoRoot.`, 'task.branch');
2724
+ }
2127
2725
  let record = await store.read();
2726
+ if (record &&
2727
+ (record.decision.projectId !== repository.projectId ||
2728
+ record.decision.externalTaskId !== externalTaskId))
2729
+ record = null;
2128
2730
  if (!record) {
2129
2731
  const pointer = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, externalTaskId);
2130
2732
  if (pointer?.worktreeId === sha256(repository.repoRoot) &&
@@ -2140,15 +2742,102 @@ export class BridgeService {
2140
2742
  }
2141
2743
  }
2142
2744
  if (!record)
2143
- 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. The managed pool allocates the separate worktree; use its returned repoRoot.`, 'task.branch');
2144
- if (record.decision.projectId !== repository.projectId ||
2145
- record.decision.externalTaskId !== externalTaskId)
2146
- throw refuse(`This worktree belongs to task ${record.decision.externalTaskId}. Use task.branch to allocate a separate worktree for ${externalTaskId}, then bootstrap in the returned repoRoot.`, 'task.branch');
2745
+ throw refuse(`No branch was chosen for task ${externalTaskId}. Call task.branch with that externalTaskId: its one form asks where the task works, which branch it starts from and the branch name. Use its returned repoRoot.`, 'task.branch');
2147
2746
  const branch = await git.currentBranch(repository.repoRoot);
2148
2747
  if (branch !== record.decision.branch)
2149
2748
  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');
2150
2749
  return record.decision;
2151
2750
  }
2751
+ async folderHolder(repository, externalTaskId) {
2752
+ const pool = this.dependencies.worktreePool;
2753
+ const store = this.dependencies.repositories.git.branchStore(repository.repoRoot);
2754
+ const record = await store.read();
2755
+ const found = pool && repository.projectId
2756
+ ? await pool.forPath(repository.projectId, repository.repoRoot)
2757
+ : null;
2758
+ const entry = found && found.phase !== 'released' && found.externalTaskId !== externalTaskId ? found : null;
2759
+ const decision = record &&
2760
+ (record.decision.projectId !== repository.projectId ||
2761
+ record.decision.externalTaskId !== externalTaskId)
2762
+ ? record.decision
2763
+ : null;
2764
+ if (!entry && !decision)
2765
+ return null;
2766
+ const slug = entry?.externalTaskId ?? decision.externalTaskId;
2767
+ const taskId = entry ? entry.taskId : decision.taskId;
2768
+ const running = entry ? await pool.ownedElsewhere(entry) : false;
2769
+ const pointer = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, slug);
2770
+ let status = null;
2771
+ if (taskId && !entry?.managed) {
2772
+ try {
2773
+ status = objectValue((await this.dependencies.client.request(endpoints.taskStatus(taskId))).data);
2774
+ }
2775
+ catch {
2776
+ status = null;
2777
+ }
2778
+ }
2779
+ const pendingDelivery = entry?.pendingDelivery === true;
2780
+ if (taskId &&
2781
+ !entry?.managed &&
2782
+ !running &&
2783
+ !pendingDelivery &&
2784
+ (status?.status === 'closed' || status?.status === 'abandoned')) {
2785
+ if (entry) {
2786
+ await pool.moveOut(entry.projectId, repository.repoRoot, entry.generation);
2787
+ this.poolAllocations.delete(repository.repoRoot);
2788
+ }
2789
+ else
2790
+ await store.release(taskId);
2791
+ if (pointer?.worktreeId === sha256(repository.repoRoot)) {
2792
+ if (status.status === 'abandoned')
2793
+ await this.dependencies.activeContexts.forget(repository.repoFingerprint, pointer.taskId);
2794
+ else
2795
+ await this.parkPointer({
2796
+ ...pointer,
2797
+ closedAt: pointer.closedAt ?? new Date().toISOString(),
2798
+ });
2799
+ }
2800
+ return null;
2801
+ }
2802
+ return {
2803
+ projectId: entry?.projectId ?? decision.projectId,
2804
+ externalTaskId: slug,
2805
+ taskId: taskId ?? null,
2806
+ branch: entry ? entry.branch : decision.branch,
2807
+ lastCheckpointAt: typeof status?.updatedAt === 'string' ? status.updatedAt : (pointer?.updatedAt ?? null),
2808
+ checkoutClean: repository.git.changedPaths.length === 0,
2809
+ oid: record && decision?.externalTaskId === slug ? record.oid : null,
2810
+ generation: entry && !entry.managed ? entry.generation : null,
2811
+ managed: entry?.managed === true,
2812
+ running,
2813
+ pendingDelivery,
2814
+ };
2815
+ }
2816
+ async parkPointer(pointer) {
2817
+ const { worktreeId: _worktreeId, worktreeGeneration: _generation, ...parked } = pointer;
2818
+ await this.dependencies.activeContexts.save(parked);
2819
+ }
2820
+ async worktreeFolder(input) {
2821
+ return this.execute(async () => {
2822
+ const repository = await this.dependencies.repositories.resolve(input.repoRoot);
2823
+ if (!repository.projectId)
2824
+ throw refuse('Resolve the project before choosing its task branch.', 'project.resolve');
2825
+ return asJsonValue(await this.folderState(repository, input.externalTaskId));
2826
+ });
2827
+ }
2828
+ async folderState(repository, externalTaskId) {
2829
+ const holder = await this.folderHolder(repository, externalTaskId);
2830
+ const entry = this.dependencies.worktreePool
2831
+ ? await this.dependencies.worktreePool.forPath(repository.projectId, repository.repoRoot)
2832
+ : null;
2833
+ return {
2834
+ managed: entry?.managed === true,
2835
+ heldBy: holder && (holder.managed || holder.running) ? holder.externalTaskId : null,
2836
+ clean: repository.git.changedPaths.length === 0,
2837
+ deliveryPending: entry?.externalTaskId === externalTaskId && entry.pendingDelivery === true,
2838
+ holder: holder && !holder.managed && !holder.running ? holder : null,
2839
+ };
2840
+ }
2152
2841
  async assertTaskWorktree(repository, task, pointer) {
2153
2842
  if (normalizeTaskMode(task.mode) === 'read_only')
2154
2843
  return;
@@ -2177,7 +2866,10 @@ export class BridgeService {
2177
2866
  ? await this.ownedAllocation(repository.projectId, repository.repoRoot)
2178
2867
  : this.poolAllocations.get(repository.repoRoot);
2179
2868
  if (allocation && this.dependencies.worktreePool) {
2180
- await this.dependencies.worktreePool.pause(allocation.projectId, allocation.repoRoot, allocation.generation);
2869
+ if (allocation.managed)
2870
+ await this.dependencies.worktreePool.pause(allocation.projectId, allocation.repoRoot, allocation.generation);
2871
+ else
2872
+ await this.dependencies.worktreePool.release(allocation.projectId, allocation.repoRoot, allocation.generation);
2181
2873
  this.poolAllocations.delete(repository.repoRoot);
2182
2874
  }
2183
2875
  else
@@ -2202,8 +2894,8 @@ export class BridgeService {
2202
2894
  organizationId: input.organizationId,
2203
2895
  projectName: input.projectName,
2204
2896
  projectSlug: input.projectSlug ?? slugify(input.projectName),
2205
- repoFingerprint: repository.repoFingerprint,
2206
- legacyFingerprints: repository.legacyFingerprints,
2897
+ ...(await this.checkoutIdentity(repository)),
2898
+ remoteIdentity: await this.dependencies.repositories.git.remoteIdentity(repository.repoRoot),
2207
2899
  framework: input.framework,
2208
2900
  developmentArea: input.developmentArea,
2209
2901
  figmaConfig: input.figmaConfig,
@@ -2214,7 +2906,7 @@ export class BridgeService {
2214
2906
  }
2215
2907
  const persistentBody = normalizeRepositoryPaths(body, repository.repoRoot);
2216
2908
  assertSafeToPersist(persistentBody);
2217
- const response = await this.dependencies.client.request(endpoints.projectSetup, {
2909
+ const response = await this.requestWithNewerFields(endpoints.projectSetup, ['remoteIdentity'], {
2218
2910
  method: 'POST',
2219
2911
  body: persistentBody,
2220
2912
  });
@@ -2257,7 +2949,7 @@ export class BridgeService {
2257
2949
  else if (stableStringify(current.branches) !== stableStringify(choice.gitPreferences))
2258
2950
  throw refuse('The project already has different shared branch preferences. Review them before changing the project settings.', 'project.git_preferences');
2259
2951
  }
2260
- 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));
2261
2953
  return asJsonValue({ ...data, marker: null, markerPath: null, bindingPath });
2262
2954
  });
2263
2955
  }
@@ -2564,12 +3256,12 @@ export class BridgeService {
2564
3256
  repository: publicRepository(repository),
2565
3257
  });
2566
3258
  }
2567
- const response = await this.dependencies.client.request(endpoints.projectBind(input.projectId), {
3259
+ const response = await this.requestWithNewerFields(endpoints.projectBind(input.projectId), ['remoteIdentity'], {
2568
3260
  method: 'POST',
2569
- body: {
2570
- repoFingerprint: repository.repoFingerprint,
2571
- legacyFingerprints: repository.legacyFingerprints,
2572
- },
3261
+ body: cleanJson({
3262
+ ...(await this.checkoutIdentity(repository)),
3263
+ remoteIdentity: await this.dependencies.repositories.git.remoteIdentity(repository.repoRoot),
3264
+ }),
2573
3265
  });
2574
3266
  const project = objectValue(response.data);
2575
3267
  if (!project ||
@@ -2577,7 +3269,7 @@ export class BridgeService {
2577
3269
  typeof project.repoFingerprint !== 'string') {
2578
3270
  throw refuse('Project bind response does not match the selected project', 'project.resolve');
2579
3271
  }
2580
- 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));
2581
3273
  return asJsonValue({
2582
3274
  resolved: true,
2583
3275
  bound: true,
@@ -2590,31 +3282,110 @@ export class BridgeService {
2590
3282
  }
2591
3283
  const response = await this.dependencies.client.request(endpoints.projectResolve, {
2592
3284
  method: 'POST',
2593
- body: {
2594
- repoFingerprint: repository.repoFingerprint,
2595
- legacyFingerprints: repository.legacyFingerprints,
2596
- },
3285
+ body: await this.checkoutIdentity(repository),
2597
3286
  });
2598
3287
  const project = objectValue(response.data);
2599
3288
  const restoreRequired = Boolean(project?.archivedAt);
2600
3289
  const restoreGuidance = archivedProjectGuidance(project);
3290
+ const moved = project?.checkout === 'retired';
2601
3291
  return asJsonValue({
2602
- resolved: project !== null && !restoreRequired,
3292
+ resolved: project !== null && !restoreRequired && !moved,
2603
3293
  project,
2604
3294
  markerPath: null,
2605
3295
  repository: publicRepository(repository),
2606
3296
  selectionRequired: project === null,
2607
3297
  restoreRequired,
2608
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),
2609
3320
  });
2610
3321
  });
2611
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.',
3349
+ });
3350
+ });
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
+ }
2612
3380
  async sessionEntry(input = {}) {
2613
3381
  return await this.execute(async () => {
3382
+ const timer = startPhaseTimer('session.entry');
2614
3383
  const authentication = await this.dependencies.browserAuth.status();
3384
+ timer.mark('auth_status');
2615
3385
  let repository;
2616
3386
  try {
2617
3387
  repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
3388
+ timer.mark('resolve_repository');
2618
3389
  }
2619
3390
  catch (error) {
2620
3391
  const missingTarget = error.code === 'ENOENT';
@@ -2636,32 +3407,47 @@ export class BridgeService {
2636
3407
  });
2637
3408
  }
2638
3409
  const decision = await this.dependencies.repositoryDecisions.load(repository.repoFingerprint);
3410
+ timer.mark('load_decision');
2639
3411
  const authenticated = authentication.authenticated === true;
2640
3412
  const state = decision?.state === 'disabled' ? 'disabled' : repository.bindingPath ? 'bound' : 'none';
2641
- const client = await this.clientUpdate(authenticated);
3413
+ const projectId = repository.projectId;
3414
+ const livePointers = authenticated && state === 'bound'
3415
+ ? this.dependencies.activeContexts.list(repository.repoFingerprint)
3416
+ : Promise.resolve([]);
3417
+ const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires, moved,] = await Promise.all([
3418
+ this.clientUpdate(authenticated),
3419
+ livePointers,
3420
+ authenticated && state === 'bound' && projectId
3421
+ ? this.actionableWorkItems(projectId)
3422
+ : Promise.resolve([]),
3423
+ authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
3424
+ ? this.projectGitPreferences({ repoRoot: repository.repoRoot, projectId })
3425
+ : Promise.resolve(null),
3426
+ authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
3427
+ ? this.worktreeOperation('list', { repoRoot: repository.repoRoot })
3428
+ : Promise.resolve(null),
3429
+ state !== 'disabled'
3430
+ ? Promise.all([
3431
+ livePointers,
3432
+ this.checkoutTask(repository.repoFingerprint, repository.repoRoot),
3433
+ ]).then(([pointers, workedTask]) => this.questionnaireOverview(repository.repoRoot, workedTask, new Set(pointers.map((pointer) => pointer.taskSlug))))
3434
+ : Promise.resolve({ mine: [], other: [], deferred: [] }),
3435
+ authenticated && state === 'bound' && projectId
3436
+ ? this.movedAway(repository, projectId)
3437
+ : Promise.resolve({}),
3438
+ ]);
3439
+ timer.mark('independent_lookups');
3440
+ const liveTasks = liveTasksRaw.map(describePointer);
2642
3441
  const shipped = objectValue(objectValue(client)?.shippedKnowledge);
2643
3442
  const incomplete = shipped && (shipped.failure !== null || Number(shipped.live) < Number(shipped.expected));
2644
- const projectId = repository.projectId;
2645
- const liveTasks = authenticated && state === 'bound'
2646
- ? (await this.dependencies.activeContexts.list(repository.repoFingerprint)).map(describePointer)
2647
- : [];
2648
- const workItems = authenticated && state === 'bound' && projectId
2649
- ? await this.actionableWorkItems(projectId)
2650
- : [];
2651
- const gitPreferences = authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
2652
- ? await this.projectGitPreferences({ repoRoot: repository.repoRoot, projectId })
2653
- : null;
2654
3443
  const preferences = gitPreferences?.ok ? objectValue(gitPreferences.data) : null;
2655
3444
  const unansweredBranches = preferences?.canManage === true
2656
3445
  ? Object.entries(objectOrEmpty(objectValue(preferences.branches)))
2657
3446
  .filter(([, value]) => objectValue(value)?.answered === false)
2658
3447
  .map(([role]) => role)
2659
3448
  : [];
2660
- const worktrees = authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
2661
- ? await this.worktreeOperation('list', { repoRoot: repository.repoRoot })
2662
- : null;
2663
3449
  const taskSelectionRequired = liveTasks.length > 0 || workItems.length > 0;
2664
- const pendingQuestionnaires = state !== 'disabled' ? await this.pendingQuestionnaires(repository.repoRoot) : [];
3450
+ timer.finish();
2665
3451
  return asJsonValue({
2666
3452
  authenticated,
2667
3453
  repository: publicRepository(repository),
@@ -2676,25 +3462,44 @@ export class BridgeService {
2676
3462
  gitPreferences: gitPreferences.ok ? gitPreferences.data : gitPreferences.error,
2677
3463
  unansweredBranchPreferences: unansweredBranches,
2678
3464
  branchPreferenceAction: unansweredBranches.length
2679
- ? 'Ask only these unanswered shared branch preferences using native questionnaires and project.set_git_preferences. Other members choose only their task base.'
3465
+ ? 'Ask these unanswered shared branch preferences with project.set_git_preferences, which puts them all in one form. Other members choose only their own task base in task.branch.'
2680
3466
  : 'Existing answers remain valid, including explicit absence. Choose the task base through task.branch when starting development.',
2681
3467
  }
2682
3468
  : {}),
2683
- ...(state !== 'disabled' ? { pendingQuestionnaires } : {}),
3469
+ ...(state !== 'disabled' ? pendingQuestionnairesFields(pendingQuestionnaires) : {}),
3470
+ ...(pendingQuestionnaires.deferred.length > 0
3471
+ ? { deferredTaskStarts: pendingQuestionnaires.deferred }
3472
+ : {}),
2684
3473
  client,
3474
+ ...moved,
2685
3475
  ...(incomplete && state === 'bound'
2686
3476
  ? {
2687
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.',
2688
3478
  }
2689
3479
  : {}),
2690
- nextAction: pendingQuestionnaires.length > 0
2691
- ? '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.'
2692
- : taskSelectionRequired
2693
- ? '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.'
2694
- : 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),
2695
3487
  });
2696
3488
  });
2697
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
+ }
2698
3503
  async actionableWorkItems(projectId) {
2699
3504
  try {
2700
3505
  const response = await this.dependencies.client.request(`${endpoints.workItemList(projectId)}?limit=100`);
@@ -2749,7 +3554,7 @@ export class BridgeService {
2749
3554
  }
2750
3555
  async sessionSetDecision(input) {
2751
3556
  return await this.execute(async () => {
2752
- const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
3557
+ const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot ?? process.cwd());
2753
3558
  if (input.decision === 'none') {
2754
3559
  await this.dependencies.repositoryDecisions.clear(repository.repoFingerprint);
2755
3560
  return asJsonValue({
@@ -2892,6 +3697,10 @@ export class BridgeService {
2892
3697
  }
2893
3698
  const taskEntries = (await this.dependencies.outbox.list()).filter((entry) => taskIdFromDeliverySafe(entry) === input.taskId);
2894
3699
  const pointer = await this.resolveCheckpointPointer(input, repoRoot);
3700
+ if (pointer.closedAt && correction)
3701
+ throw refuse(`Task ${pointer.taskSlug} is closed, so it takes no more corrections. Record the user's correction as a proposal for the rule it concerns with memory.propose_revision, or in the task that next changes this code.`, 'memory.propose_revision');
3702
+ if (pointer.closedAt)
3703
+ throw refuse(`Task ${pointer.taskSlug} is closed, so it takes no more checkpoints. Further work belongs to a new task.`, 'session.bootstrap');
2895
3704
  const blockedEntry = taskEntries.find((entry) => entry.lastError && entry.lastError !== 'backend_unavailable');
2896
3705
  if (blockedEntry && blockedEntry.idempotencyKey !== idempotencyKey) {
2897
3706
  throw refuse('A blocked task delivery must be explicitly resolved before checkpointing', 'task.resolve_pending_delivery');
@@ -3013,7 +3822,7 @@ export class BridgeService {
3013
3822
  throw refuse('The receipt does not belong to this task project.', 'session.resume');
3014
3823
  if (pointer.resumeConflicts.length || pointer.verificationIntent || pointer.closeIntent)
3015
3824
  throw refuse('Resume a clean active task before recording this receipt.', 'session.resume');
3016
- await this.checkoutSource(repository, pointer.source);
3825
+ await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer);
3017
3826
  await this.recoverJournalOutbox(pointer.projectId, pointer.taskSlug);
3018
3827
  const entries = await this.dependencies.outbox.listForTask(taskId);
3019
3828
  if (entries.some((entry) => entry.lastError && entry.lastError !== 'backend_unavailable'))
@@ -3127,19 +3936,16 @@ export class BridgeService {
3127
3936
  });
3128
3937
  const backend = objectValue(response.data);
3129
3938
  const task = objectValue(backend?.task);
3130
- if (!task ||
3131
- task.id !== body.taskId ||
3132
- task.projectId !== entry.projectId ||
3133
- backend?.requiresContextRefresh === true)
3939
+ if (!task || task.id !== body.taskId || task.projectId !== entry.projectId)
3134
3940
  throw refuse('Refresh the matching task context before delivering this review.', 'session.resume');
3941
+ if (backend?.requiresContextRefresh === true)
3942
+ throw refuse('Approved memory changed what this task read, so its context must be refreshed before this review is delivered.', 'context.refresh');
3135
3943
  recovered = Boolean(task &&
3136
3944
  task.id === body.taskId &&
3137
3945
  task.lockVersion === Number(body.expectedTaskVersion) + 1 &&
3138
3946
  task.selfReviewDiffHash === body.diffHash &&
3139
- stableStringify(asJsonValue(task.selfReviewResourceIds)) ===
3140
- stableStringify(asJsonValue(body.reviewedResourceIds)) &&
3141
- stableStringify(asJsonValue(task.selfReviewFindings)) ===
3142
- stableStringify(asJsonValue(body.findings)));
3947
+ stableStringify(asJsonValue(task.selfReviewFiles)) ===
3948
+ stableStringify(asJsonValue(body.files)));
3143
3949
  if (recovered)
3144
3950
  await this.captureTaskSnapshot(response.data);
3145
3951
  }
@@ -3182,13 +3988,34 @@ export class BridgeService {
3182
3988
  }
3183
3989
  return pending.length;
3184
3990
  }
3185
- async checkoutSource(repository, expected) {
3991
+ async taskRepository(repository, pointer) {
3992
+ const base = pointer?.source?.sourceCommit;
3993
+ const git = this.dependencies.repositories.git;
3994
+ if (!pointer ||
3995
+ !base ||
3996
+ !repository.git.head ||
3997
+ repository.git.head === base ||
3998
+ normalizeTaskMode(pointer.mode) === 'read_only' ||
3999
+ !pointer.changeBaseline ||
4000
+ typeof pointer.branch !== 'string' ||
4001
+ (await git.currentBranch(repository.repoRoot)) !== pointer.branch ||
4002
+ !(await git.isAncestor(repository.repoRoot, base)) ||
4003
+ !pathsContainAll(pointer.changeBaseline.leasePaths, await git.committedPaths(repository.repoRoot, base)))
4004
+ return repository;
4005
+ return { ...repository, git: await git.manifestAgainst(repository.repoRoot, base) };
4006
+ }
4007
+ async checkoutSource(repository, expected, task) {
3186
4008
  const current = await this.dependencies.repositories.git.sourceIdentity(repository.repoRoot);
3187
4009
  if (expected &&
3188
4010
  (!current ||
3189
4011
  current.sourceCommit !== expected.sourceCommit ||
3190
4012
  current.treeHash !== expected.treeHash)) {
3191
- throw refuse('The checkout no longer matches this task source. Return to its commit or bootstrap a new task for the current committed source.', 'session.bootstrap');
4013
+ if (current &&
4014
+ repository.git.baseCommit === expected.sourceCommit &&
4015
+ repository.git.head === current.sourceCommit)
4016
+ return { ...expected };
4017
+ const branch = await this.dependencies.repositories.git.currentBranch(repository.repoRoot);
4018
+ throw refuse(`The checkout no longer matches this task source. ${task?.taskSlug ? 'Task ' + task.taskSlug : 'This task'} was opened${task?.branch ? ' on ' + task.branch : ''} at commit ${expected.sourceCommit.slice(0, 12)}; this checkout is on ${branch ?? 'detached HEAD'} at ${current?.sourceCommit.slice(0, 12) ?? 'no commit'}, which is not the task's own work committed on top of its source. Ask the user through the native questionnaire which way to go: switch this checkout back to ${task?.branch ?? 'that commit'} and continue the task, or end it with task.abandon.`, 'task.abandon');
3192
4019
  }
3193
4020
  if (current && repository.git.head !== current.sourceCommit) {
3194
4021
  throw refuse('The Git source changed during the request. Retry session.resume with a stable checkout.', 'session.resume');
@@ -3214,14 +4041,17 @@ export class BridgeService {
3214
4041
  body: {
3215
4042
  sessionId: pointer.sessionId,
3216
4043
  repoFingerprint: repository.repoFingerprint,
3217
- source: (await this.checkoutSource(repository, pointer.source)) ?? null,
4044
+ source: (await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer)) ?? null,
3218
4045
  afterSequence: 0,
3219
4046
  },
3220
4047
  });
3221
4048
  const backend = objectValue(response.data);
3222
4049
  const task = objectValue(backend?.task);
3223
- if (!task || task.id !== taskId || backend?.requiresContextRefresh === true) {
3224
- throw refuse('Backend task snapshot is stale or does not match the active task', 'session.resume');
4050
+ if (!task || task.id !== taskId) {
4051
+ throw refuse('Backend task snapshot does not match the active task', 'session.resume');
4052
+ }
4053
+ if (backend?.requiresContextRefresh === true) {
4054
+ throw refuse(contextRefreshAction(backend.staleReason, pointer.changeBaseline), 'context.refresh');
3225
4055
  }
3226
4056
  const refreshed = {
3227
4057
  ...pointer,
@@ -3243,8 +4073,11 @@ export class BridgeService {
3243
4073
  });
3244
4074
  const backend = objectValue(response.data);
3245
4075
  const task = objectValue(backend?.task);
3246
- if (!task || task.id !== taskId || backend?.requiresContextRefresh === true) {
3247
- throw refuse('Backend task snapshot is stale or does not match the pending delivery', 'session.resume');
4076
+ if (!task || task.id !== taskId) {
4077
+ throw refuse('Backend task snapshot does not match the pending delivery', 'session.resume');
4078
+ }
4079
+ if (backend?.requiresContextRefresh === true) {
4080
+ throw refuse(contextRefreshAction(backend.staleReason, pointer.changeBaseline), 'context.refresh');
3248
4081
  }
3249
4082
  const refreshed = {
3250
4083
  ...pointer,
@@ -3259,6 +4092,13 @@ export class BridgeService {
3259
4092
  const blockingConflicts = pointer?.resumeConflicts.filter((conflict) => !(allowOfflineWarnings && isOfflineDevelopmentWarning(conflict)));
3260
4093
  const lifecycleIntentBlocked = (pointer?.verificationIntent && allowedIntent !== 'verify') ||
3261
4094
  (pointer?.closeIntent && allowedIntent !== 'close');
4095
+ if (pointer &&
4096
+ pointer.taskId === taskId &&
4097
+ !lifecycleIntentBlocked &&
4098
+ blockingConflicts.length > 0 &&
4099
+ blockingConflicts.every((conflict) => contextRefreshConflicts.includes(conflict))) {
4100
+ throw refuse(contextRefreshAction(undefined, pointer.changeBaseline), 'context.refresh');
4101
+ }
3262
4102
  if (!pointer ||
3263
4103
  pointer.taskId !== taskId ||
3264
4104
  (blockingConflicts?.length ?? 0) > 0 ||
@@ -3275,7 +4115,7 @@ export class BridgeService {
3275
4115
  }
3276
4116
  }
3277
4117
  async retryVerificationRecovery(repository, pointer, backend) {
3278
- await this.checkoutSource(repository, pointer.source);
4118
+ await this.checkoutSource(repository, pointer.source, pointer);
3279
4119
  const intent = pointer.verificationIntent;
3280
4120
  const recovery = objectValue(backend?.verificationRecovery);
3281
4121
  if (!intent) {
@@ -3402,7 +4242,9 @@ export class BridgeService {
3402
4242
  return await this.deliverCloseIntent(repository, body, intent.taskChanges, true);
3403
4243
  }
3404
4244
  async deliverCloseIntent(repository, body, taskChanges, recoveredAfterResponseLoss) {
3405
- await this.checkoutSource(repository, sourceFromContext(body.source));
4245
+ await this.checkoutSource(repository, sourceFromContext(body.source), {
4246
+ branch: typeof body.branch === 'string' ? body.branch : null,
4247
+ });
3406
4248
  const touchedContract = taskChanges.some((change) => /\.controller\.[tj]s$/.test(change.path));
3407
4249
  const currentBranch = await this.dependencies.repositories.git.currentBranch(repository.repoRoot);
3408
4250
  if (typeof body.branch === 'string' && body.branch !== currentBranch)
@@ -3443,6 +4285,12 @@ export class BridgeService {
3443
4285
  taskChanges,
3444
4286
  });
3445
4287
  await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
4288
+ const closedPointer = await this.dependencies.activeContexts.loadForTask(repository.repoFingerprint, String(body.taskId));
4289
+ if (closedPointer)
4290
+ await this.dependencies.activeContexts.save({
4291
+ ...closedPointer,
4292
+ closedAt: new Date().toISOString(),
4293
+ });
3446
4294
  const allocation = repository.projectId
3447
4295
  ? await this.ownedAllocation(repository.projectId, repository.repoRoot)
3448
4296
  : this.poolAllocations.get(repository.repoRoot);
@@ -3456,7 +4304,7 @@ export class BridgeService {
3456
4304
  ...closedTask,
3457
4305
  repository: publicRepository(repository),
3458
4306
  recoveredAfterResponseLoss,
3459
- delivery: deliveryQuestion(touchedContract),
4307
+ delivery: deliveryQuestion(touchedContract, await this.dependencies.repositories.git.pushDestination(repository.repoRoot), objectValue(closedTask.sourcePublication)?.applicable === true),
3460
4308
  });
3461
4309
  }
3462
4310
  async seedResumeSnapshot(pointer, backendPatch) {
@@ -3506,7 +4354,14 @@ export class BridgeService {
3506
4354
  return await this.requireActivePointer(repository.repoFingerprint, input.taskId, true);
3507
4355
  })()
3508
4356
  : await this.dependencies.activeContexts.findByTaskId(input.taskId, input.projectId, false);
3509
- if (pointer.resumeConflicts.some((conflict) => !isOfflineDevelopmentWarning(conflict))) {
4357
+ const blockingConflicts = pointer.resumeConflicts.filter((conflict) => !isOfflineDevelopmentWarning(conflict));
4358
+ if (!pointer.verificationIntent &&
4359
+ !pointer.closeIntent &&
4360
+ blockingConflicts.length > 0 &&
4361
+ blockingConflicts.every((conflict) => contextRefreshConflicts.includes(conflict))) {
4362
+ throw refuse(contextRefreshAction(undefined, pointer.changeBaseline), 'context.refresh');
4363
+ }
4364
+ if (blockingConflicts.length > 0) {
3510
4365
  throw refuse('A clean active task snapshot is required for checkpointing', 'session.resume');
3511
4366
  }
3512
4367
  if (pointer.verificationIntent || pointer.closeIntent) {
@@ -3587,6 +4442,7 @@ export class BridgeService {
3587
4442
  code: error.code ?? error.httpStatus,
3588
4443
  retryable: error.retryable,
3589
4444
  ...(error.recovery ? { recovery: error.recovery } : {}),
4445
+ ...(error.envelope?.data ? { details: error.envelope.data } : {}),
3590
4446
  },
3591
4447
  };
3592
4448
  }
@@ -3597,6 +4453,9 @@ export class BridgeService {
3597
4453
  message: error instanceof Error ? error.message : 'Bridge operation failed',
3598
4454
  retryable: false,
3599
4455
  ...(error instanceof BridgeRecoveryError ? { recovery: error.recovery } : {}),
4456
+ ...(error instanceof BridgeRecoveryError && error.details !== undefined
4457
+ ? { details: error.details }
4458
+ : {}),
3600
4459
  },
3601
4460
  };
3602
4461
  }
@@ -3614,6 +4473,59 @@ function normalizeChangedPaths(paths) {
3614
4473
  }
3615
4474
  return [...new Set(normalized)].sort();
3616
4475
  }
4476
+ function reviewedFiles(input) {
4477
+ const repoRoot = input.repoRoot ?? process.cwd();
4478
+ const files = input.files.map(({ path, rules }) => ({
4479
+ path: reviewedFilePath(path),
4480
+ rules: rules.map((rule) => normalizePersistentInput(rule, repoRoot)),
4481
+ }));
4482
+ const repeated = files
4483
+ .map(({ path }) => path)
4484
+ .filter((path, index, paths) => paths.indexOf(path) !== index);
4485
+ if (repeated.length > 0)
4486
+ throw refuse(`The self review lists these files more than once: ${[...new Set(repeated)].join(', ')}. Record one entry per changed file with every rule that governs it.`, 'task.self_review');
4487
+ return files;
4488
+ }
4489
+ function reviewedFilePath(path) {
4490
+ const normalized = path.replace(/\\/g, '/').replace(/^\.\//, '');
4491
+ if (!normalized ||
4492
+ normalized.startsWith('/') ||
4493
+ /^[A-Za-z]:\//.test(normalized) ||
4494
+ normalized.split('/').includes('..'))
4495
+ throw refuse(`Name each reviewed file by its repository-relative path, as Git reports it: ${path}`, 'task.self_review');
4496
+ return normalized;
4497
+ }
4498
+ function isDeviation(rule) {
4499
+ return rule.outcome === 'user_accepted_deviation';
4500
+ }
4501
+ export function ruleDeviationQuestionnaireId(taskId, path, rule) {
4502
+ return ('rule-deviation-' +
4503
+ sha256(stableStringify({ taskId, path, resourceKey: rule.resourceKey, issue: rule.issue })));
4504
+ }
4505
+ function ruleDeviationQuestion(taskId, path, rule, language) {
4506
+ const copy = ruleDeviationCopy[language];
4507
+ return {
4508
+ questionnaireId: ruleDeviationQuestionnaireId(taskId, path, rule),
4509
+ message: copy.message(path, rule.resourceKey ?? rule.resourceId, rule.issue ?? ''),
4510
+ language,
4511
+ options: [
4512
+ { id: 'approve', label: copy.approve },
4513
+ { id: 'change', label: copy.change },
4514
+ ],
4515
+ };
4516
+ }
4517
+ const ruleDeviationCopy = {
4518
+ en: {
4519
+ message: (path, resourceKey, issue) => `${path} does not follow ${resourceKey}: ${issue}`,
4520
+ approve: 'Keep the code as written and record the deviation',
4521
+ change: 'Change the code to follow the rule',
4522
+ },
4523
+ tr: {
4524
+ message: (path, resourceKey, issue) => `${path}, ${resourceKey} kuralına uymuyor: ${issue}`,
4525
+ approve: 'Kodu olduğu gibi bırak ve bu sapmayı kaydet',
4526
+ change: 'Kodu kurala uyacak şekilde değiştir',
4527
+ },
4528
+ };
3617
4529
  function pathsContainAll(allowedPaths, actualPaths) {
3618
4530
  const allowed = new Set(normalizeChangedPaths(allowedPaths));
3619
4531
  return normalizeChangedPaths(actualPaths).every((path) => allowed.has(path));
@@ -3694,6 +4606,12 @@ async function buildPathChangeManifest(repoRoot, changes) {
3694
4606
  });
3695
4607
  }));
3696
4608
  }
4609
+ function pendingQuestionnairesFields(value) {
4610
+ return {
4611
+ pendingQuestionnaires: value.mine,
4612
+ ...(value.other.length > 0 ? { otherTasksPendingQuestionnaires: value.other } : {}),
4613
+ };
4614
+ }
3697
4615
  function describePointer(pointer) {
3698
4616
  return asJsonValue({
3699
4617
  taskSlug: pointer.taskSlug,
@@ -3740,26 +4658,42 @@ function entryNextAction(authenticated, decision) {
3740
4658
  return 'Ask the user to sign in before answering anything about this project, and start the browser flow when they agree.';
3741
4659
  }
3742
4660
  if (decision === 'bound') {
3743
- return 'Continue into the lifecycle with session.bootstrap or session.resume.';
4661
+ return 'Continue into the lifecycle. For a task already running here, call session.resume. For new write work, call task.branch first: one form asks where the branch starts, its name and which folder it uses. Then call session.bootstrap in the repoRoot it returns. Read-only work goes straight to session.bootstrap.';
3744
4662
  }
3745
4663
  return 'Ask which organization and then which project, whatever the user asked for, listing what they already have with the option to create a new one last. Switching Engineering Memory off in this repository is the other answer, and it is remembered.';
3746
4664
  }
3747
- function deliveryQuestion(touchedContract = false) {
4665
+ function deliveryQuestion(touchedContract, destination, publishApplicable) {
4666
+ const { branch, remote, pushUrl, remoteDefaultBranch, headCommit } = destination;
4667
+ const onto = branch ? `branch ${branch}` : 'a detached HEAD, which is on no branch';
4668
+ const target = branch && remote ? `${remote} (${pushUrl ?? 'no push URL'}) branch ${branch}` : null;
4669
+ const head = headCommit ? `, on top of ${headCommit.slice(0, 12)}` : '';
4670
+ const pushed = target ? `, and a push goes to ${target}` : '';
4671
+ const to = target ? ` to ${target}` : '';
4672
+ const blocked = target
4673
+ ? ''
4674
+ : !branch
4675
+ ? ' (not possible until the work is on a branch)'
4676
+ : ` (not possible: this clone has no remote that a push of ${branch} would go to)`;
4677
+ const base = 'base branch the pull request targets' +
4678
+ (remote && remoteDefaultBranch
4679
+ ? `; the default branch of ${remote} is ${remoteDefaultBranch}`
4680
+ : '');
3748
4681
  return asJsonValue({
3749
4682
  required: true,
3750
- question: 'The task is closed and nothing has been committed. Ask the user which of these to do, and do only what they choose.',
4683
+ question: `The task is closed and nothing has been committed. The commit goes on ${onto}${head}${pushed}. If the user already said in their own message which of these they want, that is the answer; do not ask it again. Otherwise ask, and do only what they choose.`,
4684
+ destination,
3751
4685
  options: [
3752
- { id: 'commit', label: 'Commit' },
3753
- { id: 'commit_push', label: 'Commit and push' },
4686
+ { id: 'commit', label: `Commit on ${onto}` },
4687
+ { id: 'commit_push', label: `Commit and push${to}${blocked}` },
3754
4688
  {
3755
4689
  id: 'commit_push_draft_pr',
3756
- label: 'Commit, push and open a draft pull request',
3757
- input: 'base branch the pull request targets',
4690
+ label: `Commit, push${to} and open a draft pull request${blocked}`,
4691
+ input: base,
3758
4692
  },
3759
4693
  {
3760
4694
  id: 'commit_push_pr',
3761
- label: 'Commit, push and open a pull request',
3762
- input: 'base branch the pull request targets',
4695
+ label: `Commit, push${to} and open a pull request${blocked}`,
4696
+ input: base,
3763
4697
  },
3764
4698
  ],
3765
4699
  ...(touchedContract
@@ -3768,6 +4702,9 @@ function deliveryQuestion(touchedContract = false) {
3768
4702
  }
3769
4703
  : {}),
3770
4704
  afterPullRequest: 'Do not end the turn once a pull request exists. Check whether it merges cleanly, report the conflicting files if it does not, and ask whether to resolve them before touching anything.',
4705
+ afterCommit: publishApplicable
4706
+ ? 'Call memory.publish_task with this closed task id and repoRoot after the authorized commit is created.'
4707
+ : 'This task has nothing to publish (see sourcePublication on the close response); do not call memory.publish_task.',
3771
4708
  });
3772
4709
  }
3773
4710
  function cleanJson(value) {
@@ -3790,6 +4727,73 @@ function objectValue(value) {
3790
4727
  function objectOrEmpty(value) {
3791
4728
  return objectValue(value ?? undefined) ?? {};
3792
4729
  }
4730
+ function journalSummary(journal) {
4731
+ return {
4732
+ directory: journal.directory,
4733
+ appliedEventCount: journal.projection?.appliedEventIds.length ?? 0,
4734
+ pendingDeliveryCount: journal.pendingDeliveryCount,
4735
+ };
4736
+ }
4737
+ function outboxSummary(outbox) {
4738
+ const value = objectOrEmpty(outbox);
4739
+ return {
4740
+ synchronizedCount: Array.isArray(value.synchronized) ? value.synchronized.length : 0,
4741
+ blocked: Array.isArray(value.blocked) ? value.blocked : [],
4742
+ pending: typeof value.pending === 'number' ? value.pending : 0,
4743
+ };
4744
+ }
4745
+ function documentViews(documents) {
4746
+ return (Array.isArray(documents) ? documents : []).flatMap((document) => {
4747
+ const value = objectValue(document);
4748
+ return value && typeof value.content === 'string' && value.content.trim()
4749
+ ? [{ documentType: value.documentType ?? null, content: value.content }]
4750
+ : [];
4751
+ });
4752
+ }
4753
+ function checkpointView(checkpoint) {
4754
+ if (!checkpoint)
4755
+ return null;
4756
+ const event = objectValue(checkpoint.event);
4757
+ return {
4758
+ event: event
4759
+ ? {
4760
+ id: event.id ?? null,
4761
+ sequence: event.sequence ?? null,
4762
+ checkpointType: event.checkpointType ?? null,
4763
+ createdAt: event.createdAt ?? null,
4764
+ }
4765
+ : null,
4766
+ documents: documentViews(checkpoint.documents),
4767
+ };
4768
+ }
4769
+ function resumeBackendView(backend, includeDocuments) {
4770
+ const rest = Object.fromEntries(Object.entries(backend).filter(([key]) => key !== 'requirements'));
4771
+ const task = objectValue(backend.task);
4772
+ const selfReviewFiles = Array.isArray(task?.selfReviewFiles) ? task.selfReviewFiles : [];
4773
+ return {
4774
+ ...rest,
4775
+ ...(task
4776
+ ? {
4777
+ task: {
4778
+ ...Object.fromEntries(Object.entries(task).filter(([key]) => key !== 'selfReviewFiles')),
4779
+ selfReviewFileCount: selfReviewFiles.length,
4780
+ },
4781
+ }
4782
+ : {}),
4783
+ events: (Array.isArray(backend.events) ? backend.events : []).map((event) => {
4784
+ const value = objectOrEmpty(event);
4785
+ const payload = objectOrEmpty(value.payload ?? null);
4786
+ return {
4787
+ sequence: value.sequence ?? null,
4788
+ eventType: value.eventType ?? null,
4789
+ checkpointType: value.checkpointType ?? null,
4790
+ createdAt: value.createdAt ?? null,
4791
+ ...(typeof payload.summary === 'string' ? { summary: payload.summary } : {}),
4792
+ };
4793
+ }),
4794
+ documents: includeDocuments ? documentViews(backend.documents) : [],
4795
+ };
4796
+ }
3793
4797
  function archivedProjectGuidance(project) {
3794
4798
  const projectId = typeof project?.id === 'string' ? project.id : null;
3795
4799
  const expectedVersion = typeof project?.lockVersion === 'number' ? project.lockVersion : null;
@@ -3810,6 +4814,26 @@ function archivedProjectGuidance(project) {
3810
4814
  nextAction: 'Call project.member_list with this project id, identify an owner, and ask that owner to restore the archived project.',
3811
4815
  };
3812
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
+ }
3813
4837
  function slugify(value) {
3814
4838
  return value
3815
4839
  .trim()
@@ -3837,6 +4861,11 @@ function classifyError(error) {
3837
4861
  }
3838
4862
  return 'bridge_delivery_error';
3839
4863
  }
4864
+ function backendRejectsField(error, fields) {
4865
+ return (error instanceof ApiResponseError &&
4866
+ error.httpStatus === 400 &&
4867
+ fields.some((field) => error.message.includes(`property ${field} should not exist`)));
4868
+ }
3840
4869
  function isDefinitiveAuthenticationFailure(error) {
3841
4870
  return (error instanceof ApiResponseError &&
3842
4871
  (error.httpStatus === 400 || error.httpStatus === 401 || error.httpStatus === 403));
@@ -3914,24 +4943,60 @@ function isOfflineLeaseUsable(lease, sessionId, changedPaths, baselineDiffHash)
3914
4943
  return false;
3915
4944
  }
3916
4945
  }
3917
- function refuse(message, recovery) {
3918
- return new BridgeRecoveryError(message, recovery);
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
+ }
4957
+ export function cancelledDeliveryQuestionId(entry, clean) {
4958
+ return ('worktree-delivery-cancel-' +
4959
+ sha256(entry.generation + ':' + (clean ? 'clean' : entry.managed ? 'modified' : 'own-folder')));
4960
+ }
4961
+ function refuse(message, recovery, details) {
4962
+ return new BridgeRecoveryError(message, recovery, details);
3919
4963
  }
3920
4964
  function short(value) {
3921
4965
  return typeof value === 'string' && value.length > 0 ? value.slice(0, 12) : 'none';
3922
4966
  }
3923
- function describePathDisagreement(reported, manifest) {
3924
- const inManifest = new Set(manifest);
3925
- const inReport = new Set(reported);
3926
- const missing = manifest.filter((path) => !inReport.has(path));
3927
- const extra = reported.filter((path) => !inManifest.has(path));
3928
- const sample = (paths) => paths.slice(0, 5).join(', ') + (paths.length > 5 ? `, and ${paths.length - 5} more` : '');
3929
- const parts = [`You reported ${reported.length}; the manifest has ${manifest.length}.`];
3930
- if (missing.length > 0)
3931
- parts.push(`Missing from your report: ${sample(missing)}.`);
3932
- if (extra.length > 0)
3933
- parts.push(`Reported but not in the manifest: ${sample(extra)}.`);
3934
- return parts.join(' ');
4967
+ function samplePaths(paths) {
4968
+ return paths.slice(0, 5).join(', ') + (paths.length > 5 ? `, and ${paths.length - 5} more` : '');
4969
+ }
4970
+ const contextRefreshConflicts = [
4971
+ 'context_refresh_required',
4972
+ 'approved_memory_revision_requires_refresh',
4973
+ ];
4974
+ function contextRefreshAction(staleReason, baseline) {
4975
+ const reason = objectValue(staleReason);
4976
+ const cause = reason && typeof reason.resourceKey === 'string'
4977
+ ? `Approved revision ${String(reason.revisionNumber)} of ${reason.resourceKey}${typeof reason.taskExternalId === 'string' ? ` (from ${reason.taskExternalId})` : ''} changed memory this task read.`
4978
+ : 'Approved memory changed what this task read.';
4979
+ return `${cause} Call context.refresh, then context.prepare_change with ${baseline?.leasePaths.length ? baseline.leasePaths.join(', ') : 'the paths this task changes'}.${baseline ? ` The task baseline stays ${short(baseline.diffHash)}.` : ''}`;
4980
+ }
4981
+ function taskChangeSet(baseline, activeLease, current) {
4982
+ const leaseChangedPaths = Array.isArray(activeLease?.changedPaths)
4983
+ ? activeLease.changedPaths.filter((path) => typeof path === 'string')
4984
+ : [];
4985
+ const wholeTreeDelta = manifestDelta(baseline.changedPaths, current);
4986
+ const leased = new Set(normalizeChangedPaths([...baseline.leasePaths, ...leaseChangedPaths]));
4987
+ const taskChanges = wholeTreeDelta.filter((entry) => leased.has(normalizeChangedPaths([entry.path])[0]));
4988
+ return { leaseChangedPaths, wholeTreeDelta, leased, taskChanges };
4989
+ }
4990
+ function renderedPaths(paths, policy) {
4991
+ if (!policy)
4992
+ return [];
4993
+ return paths
4994
+ .filter((path) => policy.units.some((unit) => (unit.kind === 'screen_logic' || unit.kind === 'component_mapping') &&
4995
+ unit.patterns.some((pattern) => globMatches(path, pattern))))
4996
+ .sort();
4997
+ }
4998
+ function validationWaiverId(taskId, validationId, paths, reason) {
4999
+ return `validation-waiver-${validationId}-${sha256(stableStringify({ taskId, validationId, paths, reason })).slice(0, 32)}`;
3935
5000
  }
3936
5001
  function repairChangeBaseline(local, recordedDiffHash, head) {
3937
5002
  if (!recordedDiffHash || local?.diffHash === recordedDiffHash)
@@ -4122,7 +5187,7 @@ function globMatches(path, pattern) {
4122
5187
  }
4123
5188
  return minimatch(path.replace(/\\/g, '/'), normalizedPattern, { dot: true });
4124
5189
  }
4125
- function assertNewResourceEvidence(candidates, evidence) {
5190
+ function newResourceEvidenceFindings(candidates, evidence) {
4126
5191
  const normalizedEvidence = evidence.map((entry) => ({
4127
5192
  ...entry,
4128
5193
  path: normalizeChangedPaths([entry.path])[0],
@@ -4133,47 +5198,56 @@ function assertNewResourceEvidence(candidates, evidence) {
4133
5198
  if (new Set(normalizedEvidence.map((entry) => entry.path)).size !== normalizedEvidence.length) {
4134
5199
  throw refuse('New resource evidence contains duplicate paths', 'task.verify');
4135
5200
  }
4136
- const expected = [...candidates].sort((left, right) => left.path.localeCompare(right.path));
4137
- const actual = normalizedEvidence
4138
- .map(({ path, kind }) => ({ path, kind }))
4139
- .sort((left, right) => left.path.localeCompare(right.path));
4140
- if (stableStringify(expected) !== stableStringify(actual)) {
4141
- const expectedPaths = new Set(expected.map((entry) => entry.path));
4142
- const actualPaths = new Set(actual.map((entry) => entry.path));
4143
- const missing = expected
4144
- .filter((entry) => !actualPaths.has(entry.path))
4145
- .map((entry) => `${entry.path} (${entry.kind})`);
4146
- const extra = actual
4147
- .filter((entry) => !expectedPaths.has(entry.path))
4148
- .map((entry) => `${entry.path} (${entry.kind})`);
4149
- const details = [
4150
- missing.length ? `missing evidence for ${missing.join(', ')}` : '',
4151
- extra.length ? `evidence for paths the policy does not classify: ${extra.join(', ')}` : '',
4152
- ].filter(Boolean);
4153
- throw refuse(`New screen or shared component paths require exact memory reconciliation. ${details.length ? details.join('; ') : 'The kinds do not match the project discovery policy.'}`, 'task.verify');
4154
- }
5201
+ const sent = new Map(normalizedEvidence.map((entry) => [entry.path, entry.kind]));
5202
+ const expected = new Map(candidates.map((entry) => [entry.path, entry.kind]));
5203
+ const missing = candidates
5204
+ .filter((entry) => !sent.has(entry.path))
5205
+ .map((entry) => `${entry.path} (${entry.kind})`)
5206
+ .sort();
5207
+ const extra = normalizedEvidence
5208
+ .filter((entry) => !expected.has(entry.path))
5209
+ .map((entry) => `${entry.path} (${entry.kind})`)
5210
+ .sort();
5211
+ const mismatched = normalizedEvidence
5212
+ .filter((entry) => expected.has(entry.path) && expected.get(entry.path) !== entry.kind)
5213
+ .map((entry) => `${entry.path} (sent ${entry.kind}, policy ${expected.get(entry.path)})`)
5214
+ .sort();
5215
+ const details = [
5216
+ missing.length ? `missing evidence for ${missing.join(', ')}` : '',
5217
+ extra.length ? `evidence for paths the policy does not classify: ${extra.join(', ')}` : '',
5218
+ mismatched.length
5219
+ ? `kinds that do not match the project discovery policy: ${mismatched.join(', ')}`
5220
+ : '',
5221
+ ].filter(Boolean);
5222
+ return details.length > 0
5223
+ ? [
5224
+ `New screen or shared component paths require exact memory reconciliation. ${details.join('; ')}.`,
5225
+ ]
5226
+ : [];
4155
5227
  }
4156
- function assertValidationEvidence(validations) {
5228
+ function validationEvidenceFindings(validations) {
4157
5229
  if (validations.length === 0) {
4158
- throw refuse('Validation evidence is required', 'task.verify');
5230
+ return ['Validation evidence is required'];
4159
5231
  }
4160
5232
  const allowed = new Set(validationIds);
4161
5233
  const seen = new Set();
5234
+ const findings = [];
4162
5235
  for (const validation of validations) {
4163
5236
  if (!allowed.has(validation.validationId)) {
4164
- throw refuse(`Unknown validation evidence: ${validation.validationId}`, 'task.verify');
5237
+ findings.push(`Unknown validation evidence: ${validation.validationId}`);
4165
5238
  }
4166
- if (seen.has(validation.validationId)) {
4167
- throw refuse(`Duplicate validation evidence: ${validation.validationId}`, 'task.verify');
5239
+ else if (seen.has(validation.validationId)) {
5240
+ findings.push(`Duplicate validation evidence: ${validation.validationId}`);
4168
5241
  }
4169
5242
  if (!validation.command.trim()) {
4170
- throw refuse(`Validation command is required: ${validation.validationId}`, 'task.verify');
5243
+ findings.push(`Validation command is required: ${validation.validationId}`);
4171
5244
  }
4172
5245
  if (!/^[0-9a-f]{64}$/.test(validation.outputHash)) {
4173
- throw refuse(`Validation output hash is invalid: ${validation.validationId}`, 'task.verify');
5246
+ findings.push(`Validation output hash is invalid: ${validation.validationId}`);
4174
5247
  }
4175
5248
  seen.add(validation.validationId);
4176
5249
  }
5250
+ return findings;
4177
5251
  }
4178
5252
  function sourceFromContext(value) {
4179
5253
  const context = objectValue(value);