engineering-memory 1.11.8 → 1.11.10

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 (39) hide show
  1. package/dispatcher/sections.mjs +7 -3
  2. package/install/codex-approval.mjs +457 -0
  3. package/install/files.mjs +119 -9
  4. package/install/installer.mjs +44 -5
  5. package/install/launcher.mjs +95 -0
  6. package/package.json +1 -1
  7. package/runtime/build.json +1 -1
  8. package/runtime/dist/src/config.js +4 -0
  9. package/runtime/dist/src/git/git-inspector.js +82 -3
  10. package/runtime/dist/src/git/verification-gate.js +30 -17
  11. package/runtime/dist/src/index.js +25 -81
  12. package/runtime/dist/src/mcp/onboarding-tools.js +3 -16
  13. package/runtime/dist/src/mcp/questionnaire-tools.js +99 -15
  14. package/runtime/dist/src/mcp/server.js +14 -7
  15. package/runtime/dist/src/mcp/tool-annotations.js +111 -0
  16. package/runtime/dist/src/mcp/tool-definitions.js +148 -28
  17. package/runtime/dist/src/mcp/tool-host.js +12 -0
  18. package/runtime/dist/src/mcp/worktree-tools.js +323 -236
  19. package/runtime/dist/src/project/repository.js +11 -5
  20. package/runtime/dist/src/runtime/active-context-store.js +3 -0
  21. package/runtime/dist/src/runtime/branch-preferences.js +0 -19
  22. package/runtime/dist/src/runtime/bridge-service.js +1163 -231
  23. package/runtime/dist/src/runtime/create-bridge-service.js +82 -0
  24. package/runtime/dist/src/runtime/offline-outbox.js +33 -158
  25. package/runtime/dist/src/runtime/phase-timer.js +26 -0
  26. package/runtime/dist/src/runtime/privacy-detector.js +263 -0
  27. package/runtime/dist/src/runtime/questionnaire-store.js +210 -49
  28. package/runtime/dist/src/runtime/recovery-error.js +3 -1
  29. package/runtime/dist/src/runtime/runtime-entry.js +24 -0
  30. package/runtime/dist/src/runtime/runtime-host.js +263 -0
  31. package/runtime/dist/src/runtime/task-branch-store.js +17 -1
  32. package/runtime/dist/src/runtime/task-start.js +281 -0
  33. package/runtime/dist/src/runtime/worktree-pool.js +340 -24
  34. package/runtime/dist/src/utilities/process.js +1 -0
  35. package/skill/SKILL.md +3 -3
  36. package/skill/references/lifecycle.md +44 -19
  37. package/skill/references/memory-updates.md +9 -3
  38. package/skill/references/project-onboarding.md +19 -2
  39. package/skill/references/questionnaires.md +77 -15
@@ -8,10 +8,12 @@ import { ProjectIntake } from '../project/project-intake.js';
8
8
  import { canonicalPath } from '../utilities/files.js';
9
9
  import { minimatch } from 'minimatch';
10
10
  import { endpoints } from '../config.js';
11
+ import { startPhaseTimer } from './phase-timer.js';
11
12
  import { sha256, stableStringify } from '../utilities/hash.js';
12
13
  import { ApiResponseError, BackendUnavailableError, } from './api-client.js';
13
14
  import { assertSafeToPersist, normalizeRepositoryPaths } from './offline-outbox.js';
14
15
  import { principalFingerprint } from './principal-state.js';
16
+ import { sameHolder, suggestedBranchNames, taskStartChoice } from './task-start.js';
15
17
  import { BridgeRecoveryError } from './recovery-error.js';
16
18
  export const validationIds = [
17
19
  'format',
@@ -38,6 +40,7 @@ export class BridgeService {
38
40
  deliveryQueues = new Map();
39
41
  deliveryWaiters = [];
40
42
  activeDeliveries = 0;
43
+ workedTasks = new Map();
41
44
  constructor(dependencies) {
42
45
  this.dependencies = dependencies;
43
46
  this.deferDeliveries = dependencies.deferDeliveries ?? true;
@@ -45,10 +48,22 @@ export class BridgeService {
45
48
  managesWorktrees() {
46
49
  return Boolean(this.dependencies.worktreePool);
47
50
  }
48
- async questionnaireAsk(input, previousDefinitions = []) {
51
+ async questionnaireAsk(input, previousDefinitions = [], owner) {
49
52
  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);
53
+ const { repoRoot: _repoRoot, preparation: _preparation, presentation: _presentation, ...definition } = input;
54
+ const askingTask = owner || input.preparation
55
+ ? null
56
+ : await this.checkoutTask(scope.repoFingerprint, await this.dependencies.repositories.git.findRoot(input.repoRoot ?? process.cwd()));
57
+ return await this.dependencies.questionnaires.ask(scope, definition, previousDefinitions, owner ?? (askingTask ? { tool: 'questionnaire.ask', externalTaskId: askingTask } : undefined));
58
+ }
59
+ async checkoutTask(repoFingerprint, repoRoot) {
60
+ const worktreeId = sha256(await canonicalPath(repoRoot));
61
+ const task = this.workedTasks.get(worktreeId);
62
+ if (!task)
63
+ return null;
64
+ return (await this.dependencies.activeContexts.list(repoFingerprint)).some((pointer) => pointer.taskSlug === task && pointer.worktreeId === worktreeId)
65
+ ? task
66
+ : null;
52
67
  }
53
68
  async questionnaireResume(input) {
54
69
  const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
@@ -66,18 +81,38 @@ export class BridgeService {
66
81
  if (stableStringify(scope) !== stableStringify(input.scope)) {
67
82
  throw refuse('The account or repository binding changed while the questionnaire was open.', 'session.entry');
68
83
  }
69
- return await this.dependencies.questionnaires.accept(scope, input.questionnaireId, input.requestKey, input.answer, input.answerSource);
84
+ return await this.acceptWithScope(scope, input.questionnaireId, input.requestKey, input.answer, input.answerSource);
85
+ }
86
+ async acceptWithScope(scope, questionnaireId, requestKey, answer, answerSource) {
87
+ return this.dependencies.questionnaires.accept(scope, questionnaireId, requestKey, answer, answerSource);
88
+ }
89
+ retryHint(record, repoRoot, attemptOffset = 0) {
90
+ if (!record.owner || record.owner.tool === 'questionnaire.ask')
91
+ return undefined;
92
+ const attempt = (record.owner.decisionAttempt ?? 0) + attemptOffset;
93
+ return {
94
+ tool: record.owner.tool,
95
+ arguments: {
96
+ ...record.owner.retryArguments,
97
+ repoRoot,
98
+ ...(record.owner.externalTaskId ? { externalTaskId: record.owner.externalTaskId } : {}),
99
+ ...(attempt > 0 ? { decisionAttempt: attempt } : {}),
100
+ },
101
+ };
70
102
  }
71
103
  async questionnaireAnswerFromHost(input) {
72
- const record = await this.questionnaireResume(input);
104
+ const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
105
+ const record = await this.dependencies.questionnaires.get(scope, input.questionnaireId);
106
+ if (!record)
107
+ throw refuse('No questionnaire exists for this account and repository binding.', 'session.entry');
73
108
  if (record.requestKey !== input.requestKey || record.contentHash !== input.contentHash) {
74
109
  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
110
  }
76
- return this.questionnaireAccept({
77
- ...input,
78
- scope: record.scope,
79
- answerSource: { kind: 'host_native_relay', hostTool: input.hostTool },
80
- });
111
+ const resolved = await this.acceptWithScope(scope, input.questionnaireId, input.requestKey, (input.answer ?? input.answers), { kind: 'host_native_relay', hostTool: input.hostTool });
112
+ return {
113
+ ...resolved,
114
+ retry: this.retryHint(resolved.record, input.repoRoot ?? process.cwd()),
115
+ };
81
116
  }
82
117
  async questionnaireScope(repoRoot, preparation = false) {
83
118
  await this.dependencies.principalState?.ensure();
@@ -99,7 +134,7 @@ export class BridgeService {
99
134
  }
100
135
  let repository;
101
136
  try {
102
- repository = await this.dependencies.repositories.resolve(lookup);
137
+ repository = await this.dependencies.repositories.resolveIdentity(lookup);
103
138
  }
104
139
  catch (error) {
105
140
  if (!preparation ||
@@ -128,18 +163,64 @@ export class BridgeService {
128
163
  projectId: preparation ? null : repository.projectId,
129
164
  };
130
165
  }
131
- async pendingQuestionnaires(repoRoot) {
166
+ async questionnaireOverview(repoRoot, currentExternalTaskId, liveTaskSlugs = new Set()) {
132
167
  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.',
168
+ const records = await this.dependencies.questionnaires.list(scope);
169
+ const mine = [];
170
+ const other = [];
171
+ for (const record of records.filter((item) => item.status === 'pending')) {
172
+ const ownerTask = record.owner?.externalTaskId;
173
+ if (!ownerTask || ownerTask === currentExternalTaskId)
174
+ mine.push(asJsonValue({
175
+ questionnaireId: record.questionnaireId,
176
+ message: record.message,
177
+ ...(record.questions
178
+ ? {
179
+ questions: record.questions,
180
+ ...(record.terminalChoices ? { terminalChoices: record.terminalChoices } : {}),
181
+ }
182
+ : {
183
+ options: record.options,
184
+ allowFreeText: record.allowFreeText,
185
+ ...(record.textField ? { textField: record.textField } : {}),
186
+ }),
187
+ createdAt: record.createdAt,
188
+ status: record.status,
189
+ 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.',
190
+ }));
191
+ else
192
+ other.push(asJsonValue({
193
+ questionnaireId: record.questionnaireId,
194
+ ownerTask,
195
+ createdAt: record.createdAt,
196
+ summary: record.message.slice(0, 120),
197
+ 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.`,
198
+ }));
199
+ }
200
+ const latestStart = new Map();
201
+ for (const record of records) {
202
+ const task = record.owner?.tool === 'task.branch' ? record.owner.externalTaskId : undefined;
203
+ const current = task ? latestStart.get(task) : undefined;
204
+ if (task &&
205
+ record.status !== 'withdrawn' &&
206
+ (!current || (record.owner?.decisionAttempt ?? 0) >= (current.owner?.decisionAttempt ?? 0)))
207
+ latestStart.set(task, record);
208
+ }
209
+ const deferred = [...latestStart.entries()]
210
+ .filter(([task, record]) => !liveTaskSlugs.has(task) &&
211
+ (taskStartChoice(record)?.status === 'deferred' ||
212
+ (record.questionnaireId.startsWith('task-branch-fallback-') &&
213
+ record.status === 'answered' &&
214
+ record.answer?.choice === 'defer')))
215
+ .sort(([, left], [, right]) => (left.answeredAt ?? left.createdAt).localeCompare(right.answeredAt ?? right.createdAt))
216
+ .slice(-5)
217
+ .map(([externalTaskId, record]) => asJsonValue({
218
+ externalTaskId,
219
+ deferredAt: record.answeredAt ?? record.createdAt,
220
+ reconsider: this.retryHint(record, repoRoot, 1),
221
+ 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
222
  }));
223
+ return { mine, other, deferred };
143
224
  }
144
225
  deliverInBackground(taskId) {
145
226
  if (!this.deferDeliveries)
@@ -274,6 +355,10 @@ export class BridgeService {
274
355
  poolAllocation.taskId = task.id;
275
356
  this.poolAllocations.set(repository.repoRoot, poolAllocation);
276
357
  }
358
+ const replayedPointer = existingTask?.taskId === task.id ? existingTask : null;
359
+ const recordedBaselineDiffHash = typeof data.changeBaselineDiffHash === 'string' ? data.changeBaselineDiffHash : undefined;
360
+ const repairedBaseline = repairChangeBaseline(replayedPointer?.changeBaseline, recordedBaselineDiffHash, repository.git.head);
361
+ const replayedBaseline = repairedBaseline ?? replayedPointer?.changeBaseline;
277
362
  const pointer = {
278
363
  repoFingerprint: repository.repoFingerprint,
279
364
  projectId,
@@ -289,9 +374,21 @@ export class BridgeService {
289
374
  sessionId: session.id,
290
375
  lastSequence: numericSequence(task.lastSequence),
291
376
  taskVersion: numericTaskVersion(task.lockVersion),
292
- resumeConflicts: [],
377
+ resumeConflicts: [
378
+ ...new Set([
379
+ ...(replayedPointer?.resumeConflicts.filter((conflict) => contextRefreshConflicts.includes(conflict)) ?? []),
380
+ ...(data.requiresContextRefresh === true ? ['context_refresh_required'] : []),
381
+ ...(recordedBaselineDiffHash &&
382
+ replayedBaseline &&
383
+ replayedBaseline.diffHash !== recordedBaselineDiffHash
384
+ ? ['task_change_baseline_recaptured']
385
+ : []),
386
+ ]),
387
+ ],
388
+ ...(repairedBaseline ? { changeBaseline: repairedBaseline } : {}),
293
389
  };
294
390
  await this.dependencies.activeContexts.save(pointer);
391
+ this.workedTasks.set(sha256(repository.repoRoot), input.externalTaskId);
295
392
  await this.dependencies.journal.apply({
296
393
  eventId: checkpointId,
297
394
  taskId: task.id,
@@ -312,9 +409,10 @@ export class BridgeService {
312
409
  await this.seedResumeSnapshot(pointer, data);
313
410
  return asJsonValue({
314
411
  ...data,
412
+ checkpoint: checkpointView(checkpoint),
315
413
  repository: publicRepository(repository),
316
- localJournal: await this.dependencies.journal.load(projectId, input.externalTaskId),
317
- outbox: { recoveredDeliveries, ...objectOrEmpty(outbox) },
414
+ localJournal: journalSummary(await this.dependencies.journal.load(projectId, input.externalTaskId)),
415
+ outbox: { recoveredDeliveries, ...outboxSummary(outbox) },
318
416
  });
319
417
  });
320
418
  }
@@ -328,12 +426,12 @@ export class BridgeService {
328
426
  nextAction: entryNextAction(false, 'disabled'),
329
427
  });
330
428
  }
331
- const pendingQuestionnaires = await this.pendingQuestionnaires(repository.repoRoot);
429
+ const earlyPendingQuestionnaires = await this.questionnaireOverview(repository.repoRoot, input.taskSlug ?? null);
332
430
  const signedIn = (await this.dependencies.browserAuth.status()).authenticated;
333
- if (!signedIn && pendingQuestionnaires.length > 0) {
431
+ if (!signedIn && earlyPendingQuestionnaires.mine.length > 0) {
334
432
  return asJsonValue({
335
433
  authenticated: false,
336
- pendingQuestionnaires,
434
+ ...pendingQuestionnairesFields(earlyPendingQuestionnaires),
337
435
  repository: publicRepository(repository),
338
436
  nextAction: 'Resume only the pending sign-in questionnaire for this work. Signing in is still required before accessing project memory.',
339
437
  });
@@ -357,7 +455,7 @@ export class BridgeService {
357
455
  return asJsonValue({
358
456
  taskChoiceRequired: true,
359
457
  requestedTaskSlug: input.taskSlug,
360
- pendingQuestionnaires,
458
+ ...pendingQuestionnairesFields(earlyPendingQuestionnaires),
361
459
  liveTasks: live.map(describePointer),
362
460
  repository: publicRepository(repository),
363
461
  });
@@ -366,7 +464,7 @@ export class BridgeService {
366
464
  return asJsonValue({
367
465
  taskChoiceRequired: true,
368
466
  requestedSessionId: input.sessionId,
369
- pendingQuestionnaires,
467
+ ...pendingQuestionnairesFields(earlyPendingQuestionnaires),
370
468
  liveTasks: live.map(describePointer),
371
469
  repository: publicRepository(repository),
372
470
  });
@@ -374,7 +472,7 @@ export class BridgeService {
374
472
  if (!pointer && live.length > 1) {
375
473
  return asJsonValue({
376
474
  taskChoiceRequired: true,
377
- pendingQuestionnaires,
475
+ ...pendingQuestionnairesFields(earlyPendingQuestionnaires),
378
476
  liveTasks: live.map(describePointer),
379
477
  repository: publicRepository(repository),
380
478
  });
@@ -382,10 +480,13 @@ export class BridgeService {
382
480
  const projectId = input.projectId ?? repository.projectId ?? pointer?.projectId;
383
481
  const taskSlug = input.taskSlug ?? pointer?.taskSlug;
384
482
  const sessionId = input.sessionId ?? pointer?.sessionId;
483
+ const pendingQuestionnaires = taskSlug && taskSlug !== (input.taskSlug ?? null)
484
+ ? await this.questionnaireOverview(repository.repoRoot, taskSlug)
485
+ : earlyPendingQuestionnaires;
385
486
  if (!projectId || !taskSlug || !sessionId) {
386
- if (pendingQuestionnaires.length > 0) {
487
+ if (pendingQuestionnaires.mine.length > 0) {
387
488
  return asJsonValue({
388
- pendingQuestionnaires,
489
+ ...pendingQuestionnairesFields(pendingQuestionnaires),
389
490
  repository: publicRepository(repository),
390
491
  nextAction: 'Resume only the pending questionnaire for this work. Unrelated pending questions never block a separate task; continue independently authorized work.',
391
492
  });
@@ -405,7 +506,17 @@ export class BridgeService {
405
506
  this.dependencies.worktreePool
406
507
  ? await this.dependencies.worktreePool.find(projectId, taskSlug, repository.repoRoot)
407
508
  : null;
408
- if (pointer && this.dependencies.worktreePool && (pointer.worktreeGeneration || adoptable)) {
509
+ const parked = pointer &&
510
+ !pointer.worktreeId &&
511
+ !pointer.worktreeGeneration &&
512
+ !pointer.closedAt &&
513
+ normalizeTaskMode(pointer.mode) !== 'read_only' &&
514
+ typeof pointer.branch === 'string' &&
515
+ (await this.dependencies.repositories.git.currentBranch(repository.repoRoot)) !==
516
+ pointer.branch;
517
+ if (pointer &&
518
+ this.dependencies.worktreePool &&
519
+ (pointer.worktreeGeneration || adoptable || parked)) {
409
520
  const pool = this.dependencies.worktreePool;
410
521
  const previous = adoptable ?? (await pool.find(projectId, taskSlug, repository.repoRoot));
411
522
  if (previous && previous.repoRoot !== repository.repoRoot)
@@ -416,10 +527,14 @@ export class BridgeService {
416
527
  sessionId,
417
528
  nextAction: 'Call session.resume from this returned repoRoot and use it for every subsequent command.',
418
529
  });
419
- const sourceCommit = previous?.baseCommit ?? pointer.source?.sourceCommit;
420
530
  const branch = pointer.worktreeGeneration
421
531
  ? pointer.branch
422
532
  : (previous?.branch ?? pointer.branch);
533
+ const sourceCommit = previous?.baseCommit ??
534
+ pointer.source?.sourceCommit ??
535
+ (parked && branch
536
+ ? (await this.dependencies.repositories.git.sourceIdentity(repository.repoRoot, 'refs/heads/' + branch))?.sourceCommit
537
+ : undefined);
423
538
  if ((!sourceCommit && !(previous && previous.baseCommit === null && !previous.managed)) ||
424
539
  branch === undefined)
425
540
  throw refuse('The task branch and base must be restored before resuming its worktree.', 'worktree.reconcile');
@@ -455,12 +570,13 @@ export class BridgeService {
455
570
  });
456
571
  await this.dependencies.gate.rebindWorktreeOwnership(repository.repoRoot, pointer.taskId, oldGeneration, allocation.generation);
457
572
  }
573
+ const checkout = await this.taskRepository(repository, pointer);
458
574
  if (pointer?.closeIntent) {
459
- const recoveredClose = await this.retryCloseIntent(repository, pointer);
575
+ const recoveredClose = await this.retryCloseIntent(checkout, pointer);
460
576
  return asJsonValue({
461
577
  backend: { close: recoveredClose },
462
- worktree: this.poolAllocations.get(repository.repoRoot) ?? null,
463
- pendingQuestionnaires,
578
+ worktree: (await this.ownedAllocation(projectId, repository.repoRoot)) ?? null,
579
+ ...pendingQuestionnairesFields(pendingQuestionnaires),
464
580
  repository: publicRepository(repository),
465
581
  localJournal: await this.dependencies.journal.load(projectId, taskSlug),
466
582
  synchronization: {
@@ -474,7 +590,7 @@ export class BridgeService {
474
590
  const recoveredDeliveries = await this.recoverJournalOutbox(projectId, taskSlug);
475
591
  const outbox = await this.flushOutbox(pointer?.taskId);
476
592
  const afterSequence = input.afterSequence ?? 0;
477
- const source = await this.checkoutSource(repository, pointer?.source);
593
+ const source = await this.checkoutSource(checkout, pointer?.source, pointer ?? undefined);
478
594
  const response = await this.dependencies.client.request(endpoints.sessionResume, {
479
595
  method: 'POST',
480
596
  cacheKey: resumeCacheKey(sessionId, repository.repoFingerprint, afterSequence),
@@ -512,7 +628,7 @@ export class BridgeService {
512
628
  let pendingOutbox = await this.dependencies.outbox.listForTask(backendTask.id);
513
629
  const responseSource = this.dependencies.client.getResponseSource(response);
514
630
  const recoveredVerification = responseSource !== 'stale_cache' && pointer
515
- ? await this.retryVerificationRecovery(repository, pointer, backend)
631
+ ? await this.retryVerificationRecovery(checkout, pointer, backend)
516
632
  : null;
517
633
  if (recoveredVerification) {
518
634
  pointer = await this.dependencies.activeContexts.loadForTask(repository.repoFingerprint, pointer.taskId);
@@ -576,6 +692,11 @@ export class BridgeService {
576
692
  ? await this.dependencies.journal.readDocuments(projectId, taskSlug)
577
693
  : {};
578
694
  const conflicts = resumeConflicts(backend, backendTask, pointer, localJournal, localDocuments, pendingOutbox, responseSource);
695
+ if (backend.requiresContextRefresh === true)
696
+ conflicts.push('context_refresh_required');
697
+ if (pointer?.resumeConflicts.includes('approved_memory_revision_requires_refresh'))
698
+ conflicts.push('approved_memory_revision_requires_refresh');
699
+ const contextRefreshRequired = conflicts.some((conflict) => contextRefreshConflicts.includes(conflict));
579
700
  const resumedLease = objectValue(backend.activeLease);
580
701
  const resumeIdentityMatches = pointer !== null &&
581
702
  pointer.repoFingerprint === repository.repoFingerprint &&
@@ -607,11 +728,12 @@ export class BridgeService {
607
728
  const resumedTaskVersion = responseSource === 'stale_cache' && pointer
608
729
  ? Math.max(pointer.taskVersion, numericTaskVersion(backendTask.lockVersion))
609
730
  : numericTaskVersion(backendTask.lockVersion);
610
- const hasBlockingConflict = conflicts.some((conflict) => !isOfflineDevelopmentWarning(conflict));
731
+ const hasBlockingConflict = conflicts.some((conflict) => !isOfflineDevelopmentWarning(conflict) && !contextRefreshConflicts.includes(conflict));
611
732
  if (pointer && (!resumeIdentityMatches || hasBlockingConflict)) {
612
733
  await this.dependencies.activeContexts.save({ ...pointer, resumeConflicts: conflicts });
613
734
  }
614
735
  else {
736
+ this.workedTasks.set(sha256(repository.repoRoot), taskSlug);
615
737
  await this.dependencies.activeContexts.save({
616
738
  repoFingerprint: repository.repoFingerprint,
617
739
  projectId,
@@ -637,22 +759,33 @@ export class BridgeService {
637
759
  ? { verificationIntent: pointer.verificationIntent }
638
760
  : {}),
639
761
  ...(pointer?.closeIntent ? { closeIntent: pointer.closeIntent } : {}),
762
+ ...(backendTask.status === 'closed'
763
+ ? { closedAt: pointer?.closedAt ?? new Date().toISOString() }
764
+ : {}),
640
765
  });
641
766
  }
767
+ const backendFresh = responseSource !== 'stale_cache';
642
768
  return asJsonValue({
643
- backend,
769
+ backend: resumeBackendView(backend, backendFresh || !localJournal.projection),
770
+ requirements: backend.requirements ?? null,
771
+ ...(contextRefreshRequired
772
+ ? {
773
+ staleReason: backend.staleReason ?? null,
774
+ nextAction: contextRefreshAction(backend.staleReason, resumedBaseline),
775
+ }
776
+ : {}),
644
777
  repository: publicRepository(repository),
645
- worktree: this.poolAllocations.get(repository.repoRoot) ?? null,
646
- localJournal,
647
- pendingQuestionnaires,
778
+ worktree: (await this.ownedAllocation(projectId, repository.repoRoot)) ?? null,
779
+ localJournal: backendFresh ? journalSummary(localJournal) : localJournal,
780
+ ...pendingQuestionnairesFields(pendingQuestionnaires),
648
781
  synchronization: {
649
782
  backendSource: responseSource,
650
- backendFresh: responseSource !== 'stale_cache',
783
+ backendFresh,
651
784
  hydratedFromBackend,
652
785
  mergedFromBackend,
653
786
  blockedJournalEventIds,
654
787
  recoveredDeliveries,
655
- outbox,
788
+ outbox: outboxSummary(outbox),
656
789
  pendingOutboxCount: pendingOutbox.length,
657
790
  pendingOutbox: pendingOutbox.map((entry) => ({
658
791
  id: entry.id,
@@ -670,6 +803,15 @@ export class BridgeService {
670
803
  (conflicts.length === 0 || offlineDevelopmentAllowed),
671
804
  offlineDevelopmentOnly: offlineDevelopmentAllowed,
672
805
  recoveredVerification,
806
+ ...(checkout.git.baseCommit
807
+ ? {
808
+ taskWorkCommitted: {
809
+ baseCommit: checkout.git.baseCommit,
810
+ headCommit: checkout.git.head,
811
+ branch: pointer?.branch ?? null,
812
+ },
813
+ }
814
+ : {}),
673
815
  },
674
816
  });
675
817
  });
@@ -720,9 +862,44 @@ export class BridgeService {
720
862
  conflicts: [...new Set(blockingConflicts)],
721
863
  outbox,
722
864
  repository: publicRepository(repository),
865
+ ...(blockingConflicts.some((conflict) => contextRefreshConflicts.includes(conflict))
866
+ ? { nextAction: contextRefreshAction(undefined, pointer.changeBaseline) }
867
+ : {}),
723
868
  });
724
869
  }
725
- const activeBaseline = pointer?.sessionId === input.sessionId ? pointer.changeBaseline : undefined;
870
+ let activeBaseline = pointer?.sessionId === input.sessionId ? pointer.changeBaseline : undefined;
871
+ if (!activeBaseline) {
872
+ const snapshot = objectValue((await this.dependencies.client.request(endpoints.sessionResume, {
873
+ method: 'POST',
874
+ cacheKey: resumeCacheKey(input.sessionId, repository.repoFingerprint, 0),
875
+ allowStaleOnUnavailable: true,
876
+ body: {
877
+ sessionId: input.sessionId,
878
+ repoFingerprint: repository.repoFingerprint,
879
+ source: (await this.checkoutSource(repository, pointer.source)) ?? null,
880
+ afterSequence: 0,
881
+ },
882
+ })).data);
883
+ const recordedBaselineDiffHash = typeof snapshot?.changeBaselineDiffHash === 'string'
884
+ ? snapshot.changeBaselineDiffHash
885
+ : undefined;
886
+ if (recordedBaselineDiffHash) {
887
+ activeBaseline = repairChangeBaseline(undefined, recordedBaselineDiffHash, repository.git.head);
888
+ if (!activeBaseline) {
889
+ return asJsonValue({
890
+ editLeaseGranted: false,
891
+ conflicts: ['task_change_baseline_recaptured'],
892
+ outbox,
893
+ repository: publicRepository(repository),
894
+ 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.`,
895
+ });
896
+ }
897
+ await this.dependencies.activeContexts.save({
898
+ ...pointer,
899
+ changeBaseline: activeBaseline,
900
+ });
901
+ }
902
+ }
726
903
  const changedPaths = normalizeChangedPaths([
727
904
  ...(activeBaseline?.leasePaths ?? []),
728
905
  ...input.changedPaths,
@@ -741,7 +918,7 @@ export class BridgeService {
741
918
  transitionTaskVersion = refreshed.taskVersion;
742
919
  }
743
920
  if (decision && this.dependencies.worktreePool) {
744
- const allocation = this.poolAllocations.get(repository.repoRoot);
921
+ const allocation = await this.ownedAllocation(pointer.projectId, repository.repoRoot);
745
922
  if (!allocation || allocation.externalTaskId !== pointer.taskSlug)
746
923
  throw refuse('Choose this task branch and use its allocated repoRoot before transitioning to write.', 'task.branch');
747
924
  await this.dependencies.worktreePool.assertOwnership(pointer.projectId, repository.repoRoot, allocation.generation);
@@ -750,7 +927,7 @@ export class BridgeService {
750
927
  sessionId: input.sessionId,
751
928
  changedPaths,
752
929
  baselineDiffHash,
753
- source: await this.checkoutSource(repository, pointer?.source),
930
+ source: await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer),
754
931
  ...(decision ? { branch: decision.branch } : {}),
755
932
  ...(input.transitionToWrite
756
933
  ? { transitionToWrite: true, expectedTaskVersion: transitionTaskVersion }
@@ -802,16 +979,14 @@ export class BridgeService {
802
979
  await this.dependencies.repositories.git
803
980
  .branchStore(repository.repoRoot)
804
981
  .bind(preparedPointer.projectId, preparedPointer.taskSlug, preparedPointer.taskId);
982
+ const allocation = await this.ownedAllocation(preparedPointer.projectId, repository.repoRoot);
805
983
  await this.dependencies.activeContexts.save({
806
984
  ...preparedPointer,
807
985
  worktreeId: sha256(repository.repoRoot),
808
986
  branch: typeof preparedTask?.branch === 'string' ? preparedTask.branch : decision.branch,
809
987
  mode: 'write',
810
- ...(this.poolAllocations.get(repository.repoRoot)
811
- ? { worktreeGeneration: this.poolAllocations.get(repository.repoRoot).generation }
812
- : {}),
988
+ ...(allocation ? { worktreeGeneration: allocation.generation } : {}),
813
989
  });
814
- const allocation = this.poolAllocations.get(repository.repoRoot);
815
990
  if (allocation && this.dependencies.worktreePool) {
816
991
  await this.dependencies.worktreePool.bind(preparedPointer.projectId, repository.repoRoot, allocation.generation, preparedPointer.taskId);
817
992
  allocation.taskId = preparedPointer.taskId;
@@ -964,13 +1139,14 @@ export class BridgeService {
964
1139
  if (pointer && repository && pointer.mode !== 'read_only')
965
1140
  await this.assertPoolPointer(repository, pointer);
966
1141
  const source = repository
967
- ? await this.checkoutSource(repository, pointer?.source)
1142
+ ? await this.checkoutSource(await this.taskRepository(repository, pointer), pointer?.source, pointer ?? undefined)
968
1143
  : undefined;
969
1144
  const response = await this.dependencies.client.request(endpoints.contextRefresh, {
970
1145
  method: 'POST',
971
1146
  body: cleanJson({ sessionId: input.sessionId, source }),
972
1147
  });
973
- await this.dependencies.activeContexts.clearSessionConflict(input.sessionId, 'approved_memory_revision_requires_refresh');
1148
+ for (const conflict of contextRefreshConflicts)
1149
+ await this.dependencies.activeContexts.clearSessionConflict(input.sessionId, conflict);
974
1150
  return asJsonValue({
975
1151
  ...objectOrEmpty(response.data),
976
1152
  requiresPrepareChange: true,
@@ -986,7 +1162,9 @@ export class BridgeService {
986
1162
  cacheKey: sha256(`memory.query\n${stableStringify(body)}`),
987
1163
  allowStaleOnUnavailable: true,
988
1164
  });
989
- return asJsonValue({ resources: response.data });
1165
+ return Array.isArray(response.data)
1166
+ ? asJsonValue({ resources: response.data })
1167
+ : response.data;
990
1168
  });
991
1169
  }
992
1170
  async memoryHistory(input) {
@@ -994,15 +1172,31 @@ export class BridgeService {
994
1172
  const body = cleanJson({
995
1173
  sessionId: input.sessionId,
996
1174
  resourceKeys: input.resourceKeys,
1175
+ resourceIds: input.resourceIds,
997
1176
  paths: input.paths,
1177
+ required: input.required,
998
1178
  depth: input.depth,
999
1179
  });
1000
- const response = await this.dependencies.client.request(endpoints.memoryHistory, {
1001
- method: 'POST',
1002
- body,
1003
- });
1180
+ let response;
1181
+ try {
1182
+ response = await this.dependencies.client.request(endpoints.memoryHistory, {
1183
+ method: 'POST',
1184
+ body,
1185
+ });
1186
+ }
1187
+ catch (error) {
1188
+ if (backendRejectsField(error, ['required', 'resourceIds']))
1189
+ 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');
1190
+ throw error;
1191
+ }
1192
+ const data = objectValue(response.data);
1004
1193
  const relatedBranches = await this.branchTaskReferences(input.repoRoot ?? process.cwd(), input.paths ?? []);
1005
- return asJsonValue({ history: response.data, relatedBranches });
1194
+ return asJsonValue({
1195
+ history: Array.isArray(response.data) ? response.data : (data?.histories ?? []),
1196
+ uncoveredPaths: data?.uncoveredPaths ?? [],
1197
+ historyReadsRequired: data?.historyReadsRequired ?? [],
1198
+ relatedBranches,
1199
+ });
1006
1200
  });
1007
1201
  }
1008
1202
  async branchTaskReferences(repoRoot, paths) {
@@ -1343,7 +1537,7 @@ export class BridgeService {
1343
1537
  async memoryListProposals(input) {
1344
1538
  return await this.execute(async () => {
1345
1539
  const response = await this.dependencies.client.request(endpoints.memoryListProposals, { method: 'POST', body: cleanJson(input) });
1346
- return asJsonValue({ proposals: response.data });
1540
+ return asJsonValue(response.data);
1347
1541
  });
1348
1542
  }
1349
1543
  async memoryReviewProposal(input) {
@@ -1418,16 +1612,25 @@ export class BridgeService {
1418
1612
  }
1419
1613
  return [...pending];
1420
1614
  }
1615
+ ruleDeviationQuestions(input) {
1616
+ const language = input.language ?? 'en';
1617
+ return reviewedFiles(input).flatMap(({ path, rules }) => rules.filter(isDeviation).map((rule) => ({
1618
+ definition: ruleDeviationQuestion(input.taskId, path, rule, language),
1619
+ previousDefinitions: ['en', 'tr']
1620
+ .filter((other) => other !== language)
1621
+ .map((other) => ruleDeviationQuestion(input.taskId, path, rule, other)),
1622
+ })));
1623
+ }
1421
1624
  async taskSelfReview(input) {
1422
1625
  return await this.execute(async () => {
1423
- const findings = normalizePersistentInput(input.findings, input.repoRoot ?? process.cwd());
1424
- assertSafeToPersist(cleanJson({ findings }));
1425
- const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1626
+ const files = await this.approvedDeviations(input);
1627
+ assertSafeToPersist(cleanJson({ files }));
1628
+ const checkout = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1629
+ const repository = await this.taskRepository(checkout, await this.dependencies.activeContexts.loadForTask(checkout.repoFingerprint, input.taskId));
1426
1630
  if (this.deferDeliveries) {
1427
1631
  return this.enqueueTaskReceipt('task.self_review', endpoints.taskSelfReview, input.taskId, repository.repoRoot, cleanJson({
1428
1632
  diffHash: repository.git.diffHash,
1429
- reviewedResourceIds: [...new Set(input.reviewedResourceIds)],
1430
- findings,
1633
+ files,
1431
1634
  }));
1432
1635
  }
1433
1636
  const snapshot = await this.activeTaskSnapshot(input.taskId, undefined, input.repoRoot);
@@ -1437,20 +1640,50 @@ export class BridgeService {
1437
1640
  taskId: input.taskId,
1438
1641
  expectedTaskVersion: snapshot.taskVersion,
1439
1642
  diffHash: repository.git.diffHash,
1440
- reviewedResourceIds: input.reviewedResourceIds,
1441
- findings,
1643
+ files,
1442
1644
  }),
1443
1645
  });
1444
1646
  await this.captureTaskSnapshot(asJsonValue({ task: response.data }));
1445
1647
  return asJsonValue({
1446
1648
  selfReview: {
1447
1649
  diffHash: repository.git.diffHash,
1448
- reviewedResourceIds: input.reviewedResourceIds,
1449
- findings: findings.length,
1650
+ files: files.map(({ path, rules }) => ({
1651
+ path,
1652
+ rules: rules.map(({ resourceId, outcome }) => ({ resourceId, outcome })),
1653
+ })),
1450
1654
  },
1451
1655
  });
1452
1656
  });
1453
1657
  }
1658
+ async approvedDeviations(input) {
1659
+ const files = reviewedFiles(input);
1660
+ if (!files.some(({ rules }) => rules.some(isDeviation)))
1661
+ return files;
1662
+ const scope = await this.questionnaireScope(input.repoRoot);
1663
+ return await Promise.all(files.map(async ({ path, rules }) => ({
1664
+ path,
1665
+ rules: await Promise.all(rules.map(async (rule) => {
1666
+ if (!isDeviation(rule))
1667
+ return rule;
1668
+ const questionnaireId = ruleDeviationQuestionnaireId(input.taskId, path, rule);
1669
+ const question = await this.dependencies.questionnaires.get(scope, questionnaireId);
1670
+ const choice = question?.status === 'answered' ? question.answer?.choice : undefined;
1671
+ if (choice === 'change')
1672
+ 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');
1673
+ if (choice !== 'approve' || !question?.answeredAt)
1674
+ 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');
1675
+ return {
1676
+ ...rule,
1677
+ questionnaireId,
1678
+ deviationApproval: {
1679
+ questionnaireId,
1680
+ answeredAt: question.answeredAt,
1681
+ hostTool: question.answerSource?.hostTool ?? null,
1682
+ },
1683
+ };
1684
+ })),
1685
+ })));
1686
+ }
1454
1687
  async taskReconcile(input) {
1455
1688
  return await this.execute(async () => {
1456
1689
  const repoRoot = input.repoRoot ?? process.cwd();
@@ -1569,7 +1802,7 @@ export class BridgeService {
1569
1802
  method: 'POST',
1570
1803
  body: cleanJson({
1571
1804
  sessionId: pointer.sessionId,
1572
- source: await this.checkoutSource(repository, pointer.source),
1805
+ source: await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer),
1573
1806
  }),
1574
1807
  });
1575
1808
  const refreshData = objectValue(refreshed.data);
@@ -1594,13 +1827,18 @@ export class BridgeService {
1594
1827
  }
1595
1828
  async taskVerify(input) {
1596
1829
  return await this.execute(async () => {
1830
+ const timer = startPhaseTimer('task.verify');
1597
1831
  return await this.taskExclusive(input.taskId, async () => {
1598
1832
  await this.recoverJournalOutbox();
1833
+ timer.mark('recover_journal_outbox');
1599
1834
  const outbox = await this.flushOutbox(input.taskId);
1835
+ timer.mark('flush_outbox');
1600
1836
  const pendingOutboxCount = (await this.dependencies.outbox.listForTask(input.taskId))
1601
1837
  .length;
1602
- const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1838
+ let repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1839
+ timer.mark('resolve_repository');
1603
1840
  if (pendingOutboxCount > 0) {
1841
+ timer.finish();
1604
1842
  return asJsonValue({
1605
1843
  verified: false,
1606
1844
  reason: 'pending_outbox',
@@ -1610,6 +1848,7 @@ export class BridgeService {
1610
1848
  });
1611
1849
  }
1612
1850
  const pointer = await this.requireActivePointer(repository.repoFingerprint, input.taskId, false, 'verify');
1851
+ repository = await this.taskRepository(repository, pointer);
1613
1852
  const pendingCorrectionScopes = await this.pendingCorrectionScopes(pointer.projectId, pointer.taskSlug);
1614
1853
  if (pendingCorrectionScopes.length > 0) {
1615
1854
  return asJsonValue({
@@ -1642,7 +1881,7 @@ export class BridgeService {
1642
1881
  body: {
1643
1882
  sessionId: input.sessionId,
1644
1883
  repoFingerprint: repository.repoFingerprint,
1645
- source: (await this.checkoutSource(repository, pointer.source)) ?? null,
1884
+ source: (await this.checkoutSource(repository, pointer.source, pointer)) ?? null,
1646
1885
  afterSequence: 0,
1647
1886
  },
1648
1887
  });
@@ -1663,6 +1902,10 @@ export class BridgeService {
1663
1902
  await this.assertTaskWorktree(repository, snapshotTask, pointer);
1664
1903
  const resourceDiscoveryPolicy = readResourceDiscoveryPolicy(snapshot, activeLease);
1665
1904
  const validations = normalizePersistentInput(input.validations, repository.repoRoot);
1905
+ const waivers = normalizePersistentInput(input.waivers ?? [], repository.repoRoot);
1906
+ if (snapshot?.requiresContextRefresh === true) {
1907
+ throw refuse(contextRefreshAction(snapshot.staleReason, pointer.changeBaseline), 'context.refresh');
1908
+ }
1666
1909
  if (taskMode === 'read_only') {
1667
1910
  if (input.leaseId || input.changedPaths?.length) {
1668
1911
  throw refuse('This task is read-only, so it has no lease and no changed paths to report.', 'task.verify');
@@ -1670,48 +1913,114 @@ export class BridgeService {
1670
1913
  }
1671
1914
  else {
1672
1915
  const baseline = pointer.changeBaseline;
1673
- const leaseChangedPaths = Array.isArray(activeLease?.changedPaths)
1674
- ? activeLease.changedPaths.filter((path) => typeof path === 'string')
1675
- : [];
1676
1916
  if (!baseline) {
1677
1917
  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');
1678
1918
  }
1679
- if (activeLease?.baselineDiffHash !== baseline.diffHash) {
1680
- 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');
1919
+ if (!activeLease) {
1920
+ 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');
1681
1921
  }
1682
- const wholeTreeDelta = manifestDelta(baseline.changedPaths, repository.git.changedPaths);
1683
- const leased = new Set(normalizeChangedPaths([...baseline.leasePaths, ...leaseChangedPaths]));
1684
- taskChanges = wholeTreeDelta.filter((entry) => leased.has(normalizeChangedPaths([entry.path])[0]));
1685
- foreignChangedPaths = wholeTreeDelta
1686
- .filter((entry) => !leased.has(normalizeChangedPaths([entry.path])[0]))
1922
+ if (activeLease.baselineDiffHash !== baseline.diffHash) {
1923
+ 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');
1924
+ }
1925
+ const changeSet = taskChangeSet(baseline, activeLease, repository.git.changedPaths);
1926
+ taskChanges = changeSet.taskChanges;
1927
+ foreignChangedPaths = changeSet.wholeTreeDelta
1928
+ .filter((entry) => !changeSet.leased.has(normalizeChangedPaths([entry.path])[0]))
1687
1929
  .map((entry) => entry.path)
1688
1930
  .sort();
1689
1931
  taskChangedPaths = taskChanges.map((entry) => entry.path).sort();
1690
- if (input.changedPaths &&
1691
- stableStringify(normalizeChangedPaths(input.changedPaths)) !==
1692
- stableStringify(taskChangedPaths)) {
1693
- 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');
1932
+ const reported = input.changedPaths
1933
+ ? normalizeChangedPaths(input.changedPaths)
1934
+ : taskChangedPaths;
1935
+ const treePaths = new Set(normalizeChangedPaths(changeSet.wholeTreeDelta.map((entry) => entry.path)));
1936
+ const outsideLease = [
1937
+ ...new Set([
1938
+ ...reported.filter((path) => treePaths.has(path) && !changeSet.leased.has(path)),
1939
+ ...taskChangedPaths.filter((path) => !pathsContainAll(baseline.leasePaths, [path]) ||
1940
+ !pathsContainAll(changeSet.leaseChangedPaths, [path])),
1941
+ ]),
1942
+ ].sort();
1943
+ const notChanged = reported.filter((path) => !treePaths.has(path));
1944
+ const unreported = taskChangedPaths.filter((path) => !reported.includes(path));
1945
+ const baselineMoved = typeof snapshot?.changeBaselineDiffHash === 'string' &&
1946
+ snapshot.changeBaselineDiffHash !== baseline.diffHash;
1947
+ const pathFindings = [
1948
+ ...(outsideLease.length > 0
1949
+ ? [
1950
+ `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)}.`,
1951
+ ]
1952
+ : []),
1953
+ ...(notChanged.length > 0
1954
+ ? [
1955
+ `These reported paths do not differ from the tree this task started from (baseline ${short(baseline.diffHash)}): ${samplePaths(notChanged)}. ${baselineMoved
1956
+ ? 'The baseline has moved from the one the task recorded; resuming repairs it.'
1957
+ : 'Report only the paths the manifest lists.'}`,
1958
+ ]
1959
+ : []),
1960
+ ...(unreported.length > 0
1961
+ ? [
1962
+ `This task changed these paths but did not report them: ${samplePaths(unreported)}. Report every path the manifest lists.`,
1963
+ ]
1964
+ : []),
1965
+ ];
1966
+ if (pathFindings.length > 0) {
1967
+ throw refuse(pathFindings.join(' '), outsideLease.length > 0
1968
+ ? 'context.prepare_change'
1969
+ : notChanged.length > 0 && baselineMoved
1970
+ ? 'session.resume'
1971
+ : 'task.verify', {
1972
+ outsideLease,
1973
+ notChanged,
1974
+ unreported,
1975
+ changedPathsOutsideThisTask: foreignChangedPaths,
1976
+ });
1694
1977
  }
1695
1978
  if (taskChangedPaths.length === 0) {
1696
1979
  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');
1697
1980
  }
1698
- if (!activeLease || !input.leaseId || activeLease.id !== input.leaseId) {
1699
- throw refuse(`Verification needs the lease the backend currently holds. It has ${short(activeLease?.id)} and you sent ${short(input.leaseId)}.`, 'context.prepare_change');
1981
+ if (!input.leaseId || activeLease.id !== input.leaseId) {
1982
+ throw refuse(`Verification needs the lease the backend currently holds. It has ${short(activeLease.id)} and you sent ${short(input.leaseId)}.`, 'context.prepare_change');
1700
1983
  }
1701
- const unleased = taskChangedPaths.filter((path) => !pathsContainAll(baseline.leasePaths, [path]) ||
1702
- !pathsContainAll(leaseChangedPaths, [path]));
1703
- if (unleased.length > 0) {
1704
- 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');
1705
- }
1706
- assertNewResourceEvidence(taskChanges.flatMap((entry) => newMemoryResourceCandidate(entry, resourceDiscoveryPolicy)), input.newResources ?? []);
1707
1984
  }
1708
1985
  assertSafeToPersist(cleanJson(validations));
1709
- assertValidationEvidence(validations);
1986
+ assertSafeToPersist(cleanJson(waivers));
1987
+ const evidenceFindings = [
1988
+ ...(taskMode === 'read_only'
1989
+ ? []
1990
+ : newResourceEvidenceFindings(taskChanges.flatMap((entry) => newMemoryResourceCandidate(entry, resourceDiscoveryPolicy)), input.newResources ?? [])),
1991
+ ...validationEvidenceFindings(validations),
1992
+ ];
1993
+ if (evidenceFindings.length > 0) {
1994
+ throw refuse(evidenceFindings.length === 1
1995
+ ? evidenceFindings[0]
1996
+ : `Verification found ${evidenceFindings.length} unmet requirements: ${evidenceFindings
1997
+ .map((finding, index) => `(${index + 1}) ${finding}`)
1998
+ .join(' ')}`, 'task.verify', { findings: evidenceFindings });
1999
+ }
2000
+ const validationWaivers = [];
2001
+ if (waivers.length > 0) {
2002
+ const paths = renderedPaths(taskChangedPaths, resourceDiscoveryPolicy);
2003
+ const scope = await this.questionnaireScope(repository.repoRoot);
2004
+ for (const waiver of waivers) {
2005
+ const questionnaireId = validationWaiverId(input.taskId, waiver.validationId, paths, waiver.reason);
2006
+ const question = await this.dependencies.questionnaires.get(scope, questionnaireId);
2007
+ if (question?.answer?.choice !== 'waive') {
2008
+ 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');
2009
+ }
2010
+ validationWaivers.push({
2011
+ validationId: waiver.validationId,
2012
+ questionnaireId,
2013
+ reason: waiver.reason,
2014
+ paths,
2015
+ answerSource: question.answerSource?.hostTool ?? 'mcp_form',
2016
+ });
2017
+ }
2018
+ }
1710
2019
  const pathChangeManifest = taskMode === 'read_only'
1711
2020
  ? []
1712
2021
  : await buildPathChangeManifest(repository.repoRoot, taskChanges);
1713
2022
  const verifyBody = cleanJson({
1714
- source: await this.checkoutSource(repository, pointer.source),
2023
+ source: await this.checkoutSource(repository, pointer.source, pointer),
1715
2024
  taskId: input.taskId,
1716
2025
  sessionId: input.sessionId,
1717
2026
  expectedTaskVersion: numericTaskVersion(snapshotTask.lockVersion),
@@ -1731,6 +2040,7 @@ export class BridgeService {
1731
2040
  : {}),
1732
2041
  ...(taskMode === 'read_only' ? { worktreeHash: repository.git.worktreeHash } : {}),
1733
2042
  validations,
2043
+ ...(validationWaivers.length > 0 ? { validationWaivers } : {}),
1734
2044
  pendingOutboxCount,
1735
2045
  newResources: input.newResources,
1736
2046
  introducedStructures: input.introducedStructures,
@@ -1751,6 +2061,8 @@ export class BridgeService {
1751
2061
  if (!isBackendUnavailableError(error)) {
1752
2062
  await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, input.taskId);
1753
2063
  }
2064
+ if (backendRejectsField(error, ['validationWaivers']))
2065
+ 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');
1754
2066
  throw error;
1755
2067
  }
1756
2068
  const responseData = objectValue(response.data);
@@ -1774,6 +2086,7 @@ export class BridgeService {
1774
2086
  else {
1775
2087
  await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, input.taskId);
1776
2088
  }
2089
+ timer.finish();
1777
2090
  return asJsonValue({
1778
2091
  ...objectOrEmpty(response.data),
1779
2092
  mode: taskMode,
@@ -1785,11 +2098,45 @@ export class BridgeService {
1785
2098
  });
1786
2099
  });
1787
2100
  }
2101
+ async validationWaiverSubject(input) {
2102
+ return await this.execute(async () => {
2103
+ const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
2104
+ const pointer = await this.requireActivePointer(repository.repoFingerprint, input.taskId, false, 'verify');
2105
+ if (!pointer.changeBaseline) {
2106
+ 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');
2107
+ }
2108
+ const response = await this.dependencies.client.request(endpoints.sessionResume, {
2109
+ method: 'POST',
2110
+ body: {
2111
+ sessionId: input.sessionId,
2112
+ repoFingerprint: repository.repoFingerprint,
2113
+ source: (await this.checkoutSource(repository, pointer.source)) ?? null,
2114
+ afterSequence: 0,
2115
+ },
2116
+ });
2117
+ const snapshot = objectValue(response.data);
2118
+ const activeLease = objectValue(snapshot?.activeLease);
2119
+ const paths = renderedPaths(taskChangeSet(pointer.changeBaseline, activeLease, repository.git.changedPaths).taskChanges.map((entry) => entry.path), readResourceDiscoveryPolicy(snapshot, activeLease));
2120
+ if (paths.length === 0) {
2121
+ 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');
2122
+ }
2123
+ const reason = normalizePersistentInput(input.reason, repository.repoRoot);
2124
+ return asJsonValue({
2125
+ taskId: input.taskId,
2126
+ externalTaskId: pointer.taskSlug,
2127
+ validationId: input.validationId,
2128
+ paths,
2129
+ reason,
2130
+ questionnaireId: validationWaiverId(input.taskId, input.validationId, paths, reason),
2131
+ });
2132
+ });
2133
+ }
1788
2134
  async taskClose(input) {
1789
2135
  return await this.execute(async () => {
1790
2136
  return await this.taskExclusive(input.taskId, async () => {
1791
- const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1792
- const pointer = await this.requireActivePointer(repository.repoFingerprint, input.taskId, false, 'close');
2137
+ const checkout = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
2138
+ const pointer = await this.requireActivePointer(checkout.repoFingerprint, input.taskId, false, 'close');
2139
+ const repository = await this.taskRepository(checkout, pointer);
1793
2140
  if (pointer.closeIntent) {
1794
2141
  return await this.retryCloseIntent(repository, pointer);
1795
2142
  }
@@ -1798,7 +2145,7 @@ export class BridgeService {
1798
2145
  body: {
1799
2146
  sessionId: pointer.sessionId,
1800
2147
  repoFingerprint: repository.repoFingerprint,
1801
- source: (await this.checkoutSource(repository, pointer.source)) ?? null,
2148
+ source: (await this.checkoutSource(repository, pointer.source, pointer)) ?? null,
1802
2149
  afterSequence: 0,
1803
2150
  },
1804
2151
  });
@@ -1812,7 +2159,7 @@ export class BridgeService {
1812
2159
  : [];
1813
2160
  await this.assertTaskWorktree(repository, task, pointer);
1814
2161
  const closeBody = {
1815
- source: (await this.checkoutSource(repository, pointer.source)) ?? null,
2162
+ source: (await this.checkoutSource(repository, pointer.source, pointer)) ?? null,
1816
2163
  taskId: input.taskId,
1817
2164
  sessionId: pointer.sessionId,
1818
2165
  expectedTaskVersion: numericTaskVersion(task.lockVersion),
@@ -1833,7 +2180,7 @@ export class BridgeService {
1833
2180
  }
1834
2181
  async worktreeTaskAllocation(input) {
1835
2182
  return this.execute(async () => {
1836
- const repository = await this.dependencies.repositories.resolve(input.repoRoot);
2183
+ const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot);
1837
2184
  const allocation = repository.projectId && this.dependencies.worktreePool
1838
2185
  ? await this.dependencies.worktreePool.find(repository.projectId, input.externalTaskId, repository.repoRoot)
1839
2186
  : null;
@@ -1842,18 +2189,68 @@ export class BridgeService {
1842
2189
  }
1843
2190
  async projectGitPreferences(input) {
1844
2191
  return this.execute(async () => {
1845
- const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd(), input.projectId);
2192
+ const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot ?? process.cwd(), input.projectId);
1846
2193
  if (!repository.projectId)
1847
2194
  throw refuse('Bind a project before reading Git preferences.', 'project.resolve');
1848
2195
  const response = await this.dependencies.client.request(endpoints.projectGitPreferences(repository.projectId));
1849
2196
  return asJsonValue({
1850
2197
  ...objectOrEmpty(objectValue(response.data)),
1851
2198
  repoRoot: repository.repoRoot,
1852
- currentBranch: await this.dependencies.repositories.git.currentBranch(repository.repoRoot),
2199
+ currentBranch: repository.branch,
2200
+ currentCommit: repository.head,
2201
+ });
2202
+ });
2203
+ }
2204
+ async taskBranchPreflight(input) {
2205
+ return this.execute(async () => {
2206
+ const repository = await this.dependencies.repositories.resolve(input.repoRoot);
2207
+ if (!repository.projectId)
2208
+ throw refuse('Resolve the project before choosing its task branch.', 'project.resolve');
2209
+ const existingAllocation = this.dependencies.worktreePool
2210
+ ? await this.dependencies.worktreePool.find(repository.projectId, input.externalTaskId, repository.repoRoot)
2211
+ : null;
2212
+ if (existingAllocation)
2213
+ return asJsonValue({ existingAllocation });
2214
+ if (input.name && !input.keepCurrent) {
2215
+ const unusable = await this.unusableBranchName(repository.repoRoot, input.name);
2216
+ if (unusable)
2217
+ throw refuse(unusable, 'task.branch');
2218
+ }
2219
+ const [preferences, currentBranch, folder, suggestedName] = await Promise.all([
2220
+ this.dependencies.client.request(endpoints.projectGitPreferences(repository.projectId)),
2221
+ this.dependencies.repositories.git.currentBranch(repository.repoRoot),
2222
+ this.folderState(repository, input.externalTaskId),
2223
+ this.suggestedBranchName(repository.repoRoot, input.externalTaskId),
2224
+ ]);
2225
+ return asJsonValue({
2226
+ existingAllocation: null,
2227
+ preferences: preferences.data,
2228
+ currentBranch,
1853
2229
  currentCommit: repository.git.head,
2230
+ folder,
2231
+ suggestedName,
1854
2232
  });
1855
2233
  });
1856
2234
  }
2235
+ async suggestedBranchName(repoRoot, externalTaskId) {
2236
+ const candidates = suggestedBranchNames(externalTaskId);
2237
+ for (const candidate of candidates)
2238
+ if (!(await this.unusableBranchName(repoRoot, candidate)))
2239
+ return candidate;
2240
+ return candidates[0];
2241
+ }
2242
+ async unusableBranchName(repoRoot, name) {
2243
+ const git = this.dependencies.repositories.git;
2244
+ try {
2245
+ await git.validateBranch(repoRoot, name);
2246
+ }
2247
+ catch {
2248
+ return `${name} is not a valid Git branch name, so no task branch was created.`;
2249
+ }
2250
+ return (await git.branchExists(repoRoot, name))
2251
+ ? `Branch ${name} already exists, so it cannot be created for a new task; nothing was created.`
2252
+ : null;
2253
+ }
1857
2254
  async setProjectGitPreferences(projectId, body) {
1858
2255
  return this.execute(async () => {
1859
2256
  assertSafeToPersist(body);
@@ -1889,18 +2286,43 @@ export class BridgeService {
1889
2286
  }
1890
2287
  async worktreeOperation(operation, input) {
1891
2288
  return this.execute(async () => {
1892
- const repository = await this.dependencies.repositories.resolve(input.repoRoot);
2289
+ const timer = startPhaseTimer(`worktree.${operation}`);
2290
+ const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot);
2291
+ timer.mark('resolve_identity');
1893
2292
  const projectId = repository.projectId;
1894
2293
  const pool = this.dependencies.worktreePool;
1895
2294
  if (!projectId || !pool)
1896
2295
  throw refuse('Resolve the project with a pool-capable client.', 'project.resolve');
1897
2296
  if (operation === 'list' || operation === 'reconcile') {
1898
2297
  const managedRoot = await pool.managedRoot();
2298
+ const policy = await this.poolPolicy();
2299
+ timer.mark('policy');
2300
+ const reconciled = await pool.reconcile(projectId, repository.repoRoot, policy);
2301
+ timer.mark('reconcile');
2302
+ const items = reconciled.map((item) => ({
2303
+ ...item,
2304
+ ...(item.activity === 'retired'
2305
+ ? {
2306
+ nextAction: 'worktree.reconcile with retireLegacy when the user wants the old folder removed; it is never reused.',
2307
+ }
2308
+ : item.phase === 'delivery'
2309
+ ? {
2310
+ nextAction: 'Ask the user whether it was delivered; call worktree.release with deliveryOutcome delivered or cancelled (the product asks the user what happens to the folder).',
2311
+ }
2312
+ : !item.managed && item.phase !== 'released'
2313
+ ? {
2314
+ nextAction: 'The user’s own folder, held by this task. Resume the task here to continue it. For another task here, task.branch asks whether this one should move out; a closed or abandoned task is let go when the next task starts here.',
2315
+ }
2316
+ : {}),
2317
+ }));
2318
+ const retired = items.some((item) => item.activity === 'retired');
2319
+ const selected = await pool.forPath(projectId, repository.repoRoot);
2320
+ timer.finish();
1899
2321
  return asJsonValue({
1900
- items: await pool.reconcile(projectId, repository.repoRoot, await this.poolPolicy()),
1901
- selected: await pool.forPath(projectId, repository.repoRoot),
2322
+ items,
2323
+ selected,
1902
2324
  managedRoot,
1903
- ...(managedRoot.problem || managedRoot.synced
2325
+ ...(managedRoot.problem || managedRoot.synced || retired
1904
2326
  ? {
1905
2327
  notice: [
1906
2328
  ...(managedRoot.problem ? [managedRoot.problem] : []),
@@ -1909,6 +2331,11 @@ export class BridgeService {
1909
2331
  `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.`,
1910
2332
  ]
1911
2333
  : []),
2334
+ ...(retired
2335
+ ? [
2336
+ `Slots outside ${managedRoot.root} are retired: they are never reused and do not count toward the limit.`,
2337
+ ]
2338
+ : []),
1912
2339
  ].join(' '),
1913
2340
  }
1914
2341
  : {}),
@@ -1936,29 +2363,96 @@ export class BridgeService {
1936
2363
  await pool.releaseUnowned(projectId, repository.repoRoot, input.generation, input.sourceCommit);
1937
2364
  return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
1938
2365
  }
2366
+ if (operation === 'retire') {
2367
+ const entry = input.worktreePath ? await pool.forPath(projectId, input.worktreePath) : null;
2368
+ if (!entry ||
2369
+ entry.generation !== input.generation ||
2370
+ input.decisionId !== 'worktree-retire-' + sha256(entry.repoRoot + entry.generation))
2371
+ throw refuse('The retired folder changed after its removal was asked about. List the worktrees again.', 'worktree.list');
2372
+ const decision = await this.questionnaireResume({
2373
+ repoRoot: repository.repoRoot,
2374
+ questionnaireId: input.decisionId,
2375
+ });
2376
+ if (decision.status !== 'answered' || decision.answer?.choice !== 'retire')
2377
+ throw refuse('A retired folder is removed only after the user approves it in the native worktree.reconcile form.', 'worktree.reconcile');
2378
+ await pool.retire(projectId, repository.repoRoot, entry.repoRoot, entry.generation);
2379
+ return asJsonValue({ operation, complete: true, removed: entry.repoRoot });
2380
+ }
1939
2381
  const entry = await pool.forPath(projectId, repository.repoRoot);
1940
2382
  if (!entry ||
1941
2383
  entry.externalTaskId !== input.externalTaskId ||
1942
2384
  entry.generation !== input.generation)
1943
2385
  throw refuse('Use the exact task, repoRoot and ownership generation returned on resume.', 'session.resume');
2386
+ if (operation === 'pause' && input.delivered !== undefined)
2387
+ 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');
2388
+ const outcome = input.deliveryOutcome ?? (input.delivered === true ? 'delivered' : undefined);
2389
+ if (operation === 'release' && outcome === 'cancelled')
2390
+ throw refuse('A cancelled delivery is settled through the native question that worktree.release with deliveryOutcome cancelled asks.', 'worktree.release');
1944
2391
  const perform = async () => {
1945
- if (operation === 'heartbeat')
1946
- await pool.assertOwnership(projectId, repository.repoRoot, input.generation);
1947
- if (operation === 'pause') {
1948
- await pool.pause(projectId, repository.repoRoot, input.generation);
1949
- this.poolAllocations.delete(repository.repoRoot);
2392
+ try {
2393
+ if (operation === 'heartbeat') {
2394
+ await pool.assertOwnership(projectId, repository.repoRoot, input.generation);
2395
+ }
2396
+ if (operation === 'pause') {
2397
+ await pool.pause(projectId, repository.repoRoot, input.generation);
2398
+ this.poolAllocations.delete(repository.repoRoot);
2399
+ }
2400
+ if (operation === 'release') {
2401
+ await this.requireDeliveredTaskWork(entry);
2402
+ await pool.release(projectId, repository.repoRoot, input.generation, outcome === 'delivered' ? outcome : undefined);
2403
+ this.poolAllocations.delete(repository.repoRoot);
2404
+ }
2405
+ if (operation === 'cancel_delivery') {
2406
+ if (!entry.pendingDelivery)
2407
+ throw refuse('This worktree is not waiting for delivery, so there is no delivery to cancel. Release it with worktree.release.', 'worktree.release');
2408
+ const manifest = await this.dependencies.repositories.git.manifest(repository.repoRoot);
2409
+ const clean = manifest.changedPaths.length === 0;
2410
+ if (input.decisionId !== cancelledDeliveryQuestionId(entry, clean))
2411
+ 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');
2412
+ const decision = await this.questionnaireResume({
2413
+ repoRoot: repository.repoRoot,
2414
+ questionnaireId: input.decisionId,
2415
+ });
2416
+ const choice = decision.answer?.choice ?? '';
2417
+ const choices = clean || !entry.managed ? ['release', 'keep'] : ['save_and_release', 'keep'];
2418
+ if (decision.status !== 'answered' || !choices.includes(choice))
2419
+ throw refuse('Ask the user what happens to the folder in the native worktree.release form.', 'worktree.release');
2420
+ if (choice !== 'keep')
2421
+ await this.requireDeliveredTaskWork(entry);
2422
+ const cancelled = await pool.cancelDelivery(projectId, repository.repoRoot, input.generation, choice);
2423
+ this.poolAllocations.delete(repository.repoRoot);
2424
+ return asJsonValue({
2425
+ operation,
2426
+ complete: true,
2427
+ repoRoot: repository.repoRoot,
2428
+ deliveryOutcome: 'cancelled',
2429
+ ...cancelled,
2430
+ });
2431
+ }
2432
+ return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
1950
2433
  }
1951
- if (operation === 'release') {
1952
- if (entry.taskId)
1953
- await this.flushOutbox(entry.taskId);
1954
- await pool.release(projectId, repository.repoRoot, input.generation, input.delivered === true);
1955
- this.poolAllocations.delete(repository.repoRoot);
2434
+ finally {
2435
+ timer.finish();
1956
2436
  }
1957
- return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
1958
2437
  };
1959
2438
  return entry.taskId ? this.taskExclusive(entry.taskId, perform, false) : perform();
1960
2439
  });
1961
2440
  }
2441
+ async requireDeliveredTaskWork(entry) {
2442
+ if (!entry.taskId)
2443
+ return;
2444
+ await this.flushOutbox(entry.taskId);
2445
+ if (!entry.managed)
2446
+ return;
2447
+ const queued = await this.dependencies.outbox.listForTask(entry.taskId);
2448
+ if (queued.length)
2449
+ throw refuse(`Task ${entry.externalTaskId} still has ${queued.length} record(s) in the local outbox that the backend has not accepted: ${queued
2450
+ .map((item) => `${item.id} (${item.operation}${item.lastError ? ', ' + item.lastError : ''})`)
2451
+ .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');
2452
+ const pointer = await this.dependencies.activeContexts.loadForTask(entry.repoFingerprint, entry.taskId);
2453
+ if (pointer?.verificationIntent || pointer?.closeIntent)
2454
+ 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');
2455
+ }
1962
2456
  async pooledTaskBranch(repository, input) {
1963
2457
  const pool = this.dependencies.worktreePool;
1964
2458
  const projectId = repository.projectId;
@@ -1969,24 +2463,72 @@ export class BridgeService {
1969
2463
  if (existing)
1970
2464
  source = { sourceCommit: existing.baseCommit };
1971
2465
  else {
1972
- if (!input.decisionId || !input.base)
2466
+ if (!input.decisionId || !input.startId || !input.base)
1973
2467
  throw refuse('Choose the task base through the native task.branch questionnaire.', 'task.branch');
1974
- const decision = await this.questionnaireResume({
2468
+ const start = taskStartChoice(await this.questionnaireResume({
1975
2469
  repoRoot: repository.repoRoot,
1976
- questionnaireId: input.decisionId,
1977
- });
1978
- const signature = sha256(stableStringify({
2470
+ questionnaireId: input.startId,
2471
+ }));
2472
+ const request = {
1979
2473
  externalTaskId: input.externalTaskId,
1980
2474
  base: input.base,
1981
2475
  name: input.name ?? null,
1982
2476
  keepCurrent: input.keepCurrent === true,
1983
2477
  ...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
1984
- }));
1985
- if (!['task-branch-confirm-' + signature, 'task-branch-fallback-' + signature].includes(decision.questionnaireId) ||
1986
- decision.answer?.choice !== 'approve')
1987
- throw refuse('The task branch decision does not match the approved source and branch.', 'task.branch');
2478
+ };
2479
+ const started = start?.status === 'start' &&
2480
+ start.externalTaskId === request.externalTaskId &&
2481
+ start.decisionAttempt === input.decisionAttempt &&
2482
+ start.name === request.name &&
2483
+ start.keepCurrent === request.keepCurrent &&
2484
+ start.inPlace === (input.inPlace === true)
2485
+ ? start
2486
+ : null;
2487
+ let approved = false;
2488
+ if (started && input.decisionId === input.startId)
2489
+ approved = stableStringify(started.base) === stableStringify(input.base);
2490
+ else if (started &&
2491
+ input.decisionId ===
2492
+ 'task-branch-fallback-' +
2493
+ sha256(stableStringify({ ...request, inPlace: input.inPlace === true }))) {
2494
+ const fallback = await this.questionnaireResume({
2495
+ repoRoot: repository.repoRoot,
2496
+ questionnaireId: input.decisionId,
2497
+ });
2498
+ approved = fallback.status === 'answered' && fallback.answer?.choice === 'approve';
2499
+ }
2500
+ if (!approved)
2501
+ throw refuse('The task branch decision does not match the approved source, branch and location.', 'task.branch');
2502
+ const holder = input.inPlace
2503
+ ? await this.folderHolder(repository, input.externalTaskId)
2504
+ : null;
1988
2505
  source = await resolveTaskBase(git, repository.repoRoot, input.base, preferences);
2506
+ if (holder && input.inPlace) {
2507
+ if (holder.managed || holder.running || !sameHolder(holder, started.holder))
2508
+ 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');
2509
+ if (repository.git.changedPaths.length > 0)
2510
+ 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');
2511
+ if (await git.branchExists(repository.repoRoot, input.name))
2512
+ 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');
2513
+ if (holder.generation) {
2514
+ await pool.moveOut(projectId, repository.repoRoot, holder.generation, holder.oid ?? undefined);
2515
+ this.poolAllocations.delete(repository.repoRoot);
2516
+ }
2517
+ else
2518
+ await git.branchStore(repository.repoRoot).detach({
2519
+ projectId: holder.projectId,
2520
+ externalTaskId: holder.externalTaskId,
2521
+ branch: holder.branch,
2522
+ ...(holder.taskId ? { taskId: holder.taskId } : {}),
2523
+ }, holder.oid);
2524
+ const parked = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, holder.externalTaskId);
2525
+ if (parked?.worktreeId === sha256(repository.repoRoot))
2526
+ await this.parkPointer(parked);
2527
+ }
1989
2528
  }
2529
+ const allocationTimer = startPhaseTimer('task.branch.allocate');
2530
+ const allocationPolicy = await this.poolPolicy(true);
2531
+ allocationTimer.mark('policy');
1990
2532
  const allocation = await pool.allocate({
1991
2533
  projectId,
1992
2534
  projectName: preferences.projectName ?? projectId,
@@ -1996,8 +2538,11 @@ export class BridgeService {
1996
2538
  branch: existing?.branch ?? input.name ?? (await git.currentBranch(repository.repoRoot)),
1997
2539
  baseCommit: source.sourceCommit,
1998
2540
  keepCurrent: input.keepCurrent,
2541
+ inPlace: !existing && input.inPlace,
1999
2542
  resume: Boolean(existing),
2000
- }, await this.poolPolicy(true));
2543
+ }, allocationPolicy);
2544
+ allocationTimer.mark('pool_allocate');
2545
+ allocationTimer.finish();
2001
2546
  this.poolAllocations.set(allocation.repoRoot, allocation);
2002
2547
  if (existing && allocation.repoRoot === repository.repoRoot) {
2003
2548
  const current = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, input.externalTaskId);
@@ -2020,6 +2565,25 @@ export class BridgeService {
2020
2565
  nextAction: 'Run every file, terminal, lifecycle and delivery operation from this repoRoot. Use task.heartbeat during long work. Pause before handing the task to another client.',
2021
2566
  });
2022
2567
  }
2568
+ adoptOwned(repoRoot, entry) {
2569
+ const cached = this.poolAllocations.get(repoRoot);
2570
+ if (cached)
2571
+ return cached;
2572
+ const pool = this.dependencies.worktreePool;
2573
+ if (!entry || !pool || !pool.owns(entry))
2574
+ return undefined;
2575
+ this.poolAllocations.set(repoRoot, entry);
2576
+ return entry;
2577
+ }
2578
+ async ownedAllocation(projectId, repoRoot) {
2579
+ const cached = this.poolAllocations.get(repoRoot);
2580
+ if (cached)
2581
+ return cached;
2582
+ const pool = this.dependencies.worktreePool;
2583
+ if (!pool)
2584
+ return undefined;
2585
+ return this.adoptOwned(repoRoot, await pool.forPath(projectId, repoRoot));
2586
+ }
2023
2587
  async assertPoolPointer(repository, pointer) {
2024
2588
  const pool = this.dependencies.worktreePool;
2025
2589
  if (!pool)
@@ -2029,7 +2593,7 @@ export class BridgeService {
2029
2593
  return; // existing unmanaged tasks retain their legacy reservation
2030
2594
  if (entry && entry.externalTaskId !== pointer.taskSlug)
2031
2595
  throw refuse(`This directory is allocated to task ${entry.externalTaskId}. Resume this task from its own worktree, or reconcile the pool to release the directory.`, 'worktree.reconcile');
2032
- const owned = this.poolAllocations.get(repository.repoRoot);
2596
+ const owned = this.adoptOwned(repository.repoRoot, entry);
2033
2597
  if (!entry || !owned || owned.generation !== pointer.worktreeGeneration)
2034
2598
  throw refuse('Resume this task to acquire its worktree before writing or delivering.', 'session.resume');
2035
2599
  await pool.assertOwnership(pointer.projectId, repository.repoRoot, owned.generation);
@@ -2050,8 +2614,9 @@ export class BridgeService {
2050
2614
  (await this.dependencies.worktreePool.find(repository.projectId, input.externalTaskId, repository.repoRoot)))
2051
2615
  return this.pooledTaskBranch(repository, input);
2052
2616
  if (Boolean(input.name) === (input.keepCurrent === true) ||
2053
- (input.worktreePath && !input.name))
2054
- throw refuse('Choose exactly one branch name or keepCurrent. A separate worktree also needs a branch name.', 'task.branch');
2617
+ (input.worktreePath && !input.name) ||
2618
+ (input.inPlace && input.worktreePath))
2619
+ 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');
2055
2620
  assertSafeToPersist(cleanJson({ externalTaskId: input.externalTaskId, name: input.name }));
2056
2621
  if (this.dependencies.worktreePool)
2057
2622
  return this.pooledTaskBranch(repository, input);
@@ -2107,7 +2672,24 @@ export class BridgeService {
2107
2672
  async requireBranchDecision(repository, externalTaskId) {
2108
2673
  const git = this.dependencies.repositories.git;
2109
2674
  const store = git.branchStore(repository.repoRoot);
2675
+ const holder = await this.folderHolder(repository, externalTaskId);
2676
+ if (holder) {
2677
+ const held = `This folder is held by task ${holder.externalTaskId} (branch ${holder.branch ?? 'detached HEAD'}, last activity ${holder.lastCheckpointAt ?? 'unknown'})`;
2678
+ throw refuse(holder.managed
2679
+ ? `${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.`
2680
+ : holder.running
2681
+ ? `${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.`
2682
+ : holder.pendingDelivery
2683
+ ? `${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.`
2684
+ : !holder.checkoutClean
2685
+ ? `${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.`
2686
+ : `${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');
2687
+ }
2110
2688
  let record = await store.read();
2689
+ if (record &&
2690
+ (record.decision.projectId !== repository.projectId ||
2691
+ record.decision.externalTaskId !== externalTaskId))
2692
+ record = null;
2111
2693
  if (!record) {
2112
2694
  const pointer = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, externalTaskId);
2113
2695
  if (pointer?.worktreeId === sha256(repository.repoRoot) &&
@@ -2123,15 +2705,102 @@ export class BridgeService {
2123
2705
  }
2124
2706
  }
2125
2707
  if (!record)
2126
- 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');
2127
- if (record.decision.projectId !== repository.projectId ||
2128
- record.decision.externalTaskId !== externalTaskId)
2129
- 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');
2708
+ 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');
2130
2709
  const branch = await git.currentBranch(repository.repoRoot);
2131
2710
  if (branch !== record.decision.branch)
2132
2711
  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');
2133
2712
  return record.decision;
2134
2713
  }
2714
+ async folderHolder(repository, externalTaskId) {
2715
+ const pool = this.dependencies.worktreePool;
2716
+ const store = this.dependencies.repositories.git.branchStore(repository.repoRoot);
2717
+ const record = await store.read();
2718
+ const found = pool && repository.projectId
2719
+ ? await pool.forPath(repository.projectId, repository.repoRoot)
2720
+ : null;
2721
+ const entry = found && found.phase !== 'released' && found.externalTaskId !== externalTaskId ? found : null;
2722
+ const decision = record &&
2723
+ (record.decision.projectId !== repository.projectId ||
2724
+ record.decision.externalTaskId !== externalTaskId)
2725
+ ? record.decision
2726
+ : null;
2727
+ if (!entry && !decision)
2728
+ return null;
2729
+ const slug = entry?.externalTaskId ?? decision.externalTaskId;
2730
+ const taskId = entry ? entry.taskId : decision.taskId;
2731
+ const running = entry ? await pool.ownedElsewhere(entry) : false;
2732
+ const pointer = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, slug);
2733
+ let status = null;
2734
+ if (taskId && !entry?.managed) {
2735
+ try {
2736
+ status = objectValue((await this.dependencies.client.request(endpoints.taskStatus(taskId))).data);
2737
+ }
2738
+ catch {
2739
+ status = null;
2740
+ }
2741
+ }
2742
+ const pendingDelivery = entry?.pendingDelivery === true;
2743
+ if (taskId &&
2744
+ !entry?.managed &&
2745
+ !running &&
2746
+ !pendingDelivery &&
2747
+ (status?.status === 'closed' || status?.status === 'abandoned')) {
2748
+ if (entry) {
2749
+ await pool.moveOut(entry.projectId, repository.repoRoot, entry.generation);
2750
+ this.poolAllocations.delete(repository.repoRoot);
2751
+ }
2752
+ else
2753
+ await store.release(taskId);
2754
+ if (pointer?.worktreeId === sha256(repository.repoRoot)) {
2755
+ if (status.status === 'abandoned')
2756
+ await this.dependencies.activeContexts.forget(repository.repoFingerprint, pointer.taskId);
2757
+ else
2758
+ await this.parkPointer({
2759
+ ...pointer,
2760
+ closedAt: pointer.closedAt ?? new Date().toISOString(),
2761
+ });
2762
+ }
2763
+ return null;
2764
+ }
2765
+ return {
2766
+ projectId: entry?.projectId ?? decision.projectId,
2767
+ externalTaskId: slug,
2768
+ taskId: taskId ?? null,
2769
+ branch: entry ? entry.branch : decision.branch,
2770
+ lastCheckpointAt: typeof status?.updatedAt === 'string' ? status.updatedAt : (pointer?.updatedAt ?? null),
2771
+ checkoutClean: repository.git.changedPaths.length === 0,
2772
+ oid: record && decision?.externalTaskId === slug ? record.oid : null,
2773
+ generation: entry && !entry.managed ? entry.generation : null,
2774
+ managed: entry?.managed === true,
2775
+ running,
2776
+ pendingDelivery,
2777
+ };
2778
+ }
2779
+ async parkPointer(pointer) {
2780
+ const { worktreeId: _worktreeId, worktreeGeneration: _generation, ...parked } = pointer;
2781
+ await this.dependencies.activeContexts.save(parked);
2782
+ }
2783
+ async worktreeFolder(input) {
2784
+ return this.execute(async () => {
2785
+ const repository = await this.dependencies.repositories.resolve(input.repoRoot);
2786
+ if (!repository.projectId)
2787
+ throw refuse('Resolve the project before choosing its task branch.', 'project.resolve');
2788
+ return asJsonValue(await this.folderState(repository, input.externalTaskId));
2789
+ });
2790
+ }
2791
+ async folderState(repository, externalTaskId) {
2792
+ const holder = await this.folderHolder(repository, externalTaskId);
2793
+ const entry = this.dependencies.worktreePool
2794
+ ? await this.dependencies.worktreePool.forPath(repository.projectId, repository.repoRoot)
2795
+ : null;
2796
+ return {
2797
+ managed: entry?.managed === true,
2798
+ heldBy: holder && (holder.managed || holder.running) ? holder.externalTaskId : null,
2799
+ clean: repository.git.changedPaths.length === 0,
2800
+ deliveryPending: entry?.externalTaskId === externalTaskId && entry.pendingDelivery === true,
2801
+ holder: holder && !holder.managed && !holder.running ? holder : null,
2802
+ };
2803
+ }
2135
2804
  async assertTaskWorktree(repository, task, pointer) {
2136
2805
  if (normalizeTaskMode(task.mode) === 'read_only')
2137
2806
  return;
@@ -2156,9 +2825,14 @@ export class BridgeService {
2156
2825
  if (!abandoned || abandoned.abandoned !== true) {
2157
2826
  throw refuse('The backend did not record the abandonment, so the task is still open.', 'session.resume');
2158
2827
  }
2159
- const allocation = this.poolAllocations.get(repository.repoRoot);
2828
+ const allocation = repository.projectId
2829
+ ? await this.ownedAllocation(repository.projectId, repository.repoRoot)
2830
+ : this.poolAllocations.get(repository.repoRoot);
2160
2831
  if (allocation && this.dependencies.worktreePool) {
2161
- await this.dependencies.worktreePool.pause(allocation.projectId, allocation.repoRoot, allocation.generation);
2832
+ if (allocation.managed)
2833
+ await this.dependencies.worktreePool.pause(allocation.projectId, allocation.repoRoot, allocation.generation);
2834
+ else
2835
+ await this.dependencies.worktreePool.release(allocation.projectId, allocation.repoRoot, allocation.generation);
2162
2836
  this.poolAllocations.delete(repository.repoRoot);
2163
2837
  }
2164
2838
  else
@@ -2592,10 +3266,13 @@ export class BridgeService {
2592
3266
  }
2593
3267
  async sessionEntry(input = {}) {
2594
3268
  return await this.execute(async () => {
3269
+ const timer = startPhaseTimer('session.entry');
2595
3270
  const authentication = await this.dependencies.browserAuth.status();
3271
+ timer.mark('auth_status');
2596
3272
  let repository;
2597
3273
  try {
2598
3274
  repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
3275
+ timer.mark('resolve_repository');
2599
3276
  }
2600
3277
  catch (error) {
2601
3278
  const missingTarget = error.code === 'ENOENT';
@@ -2617,32 +3294,44 @@ export class BridgeService {
2617
3294
  });
2618
3295
  }
2619
3296
  const decision = await this.dependencies.repositoryDecisions.load(repository.repoFingerprint);
3297
+ timer.mark('load_decision');
2620
3298
  const authenticated = authentication.authenticated === true;
2621
3299
  const state = decision?.state === 'disabled' ? 'disabled' : repository.bindingPath ? 'bound' : 'none';
2622
- const client = await this.clientUpdate(authenticated);
3300
+ const projectId = repository.projectId;
3301
+ const livePointers = authenticated && state === 'bound'
3302
+ ? this.dependencies.activeContexts.list(repository.repoFingerprint)
3303
+ : Promise.resolve([]);
3304
+ const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires] = await Promise.all([
3305
+ this.clientUpdate(authenticated),
3306
+ livePointers,
3307
+ authenticated && state === 'bound' && projectId
3308
+ ? this.actionableWorkItems(projectId)
3309
+ : Promise.resolve([]),
3310
+ authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
3311
+ ? this.projectGitPreferences({ repoRoot: repository.repoRoot, projectId })
3312
+ : Promise.resolve(null),
3313
+ authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
3314
+ ? this.worktreeOperation('list', { repoRoot: repository.repoRoot })
3315
+ : Promise.resolve(null),
3316
+ state !== 'disabled'
3317
+ ? Promise.all([
3318
+ livePointers,
3319
+ this.checkoutTask(repository.repoFingerprint, repository.repoRoot),
3320
+ ]).then(([pointers, workedTask]) => this.questionnaireOverview(repository.repoRoot, workedTask, new Set(pointers.map((pointer) => pointer.taskSlug))))
3321
+ : Promise.resolve({ mine: [], other: [], deferred: [] }),
3322
+ ]);
3323
+ timer.mark('independent_lookups');
3324
+ const liveTasks = liveTasksRaw.map(describePointer);
2623
3325
  const shipped = objectValue(objectValue(client)?.shippedKnowledge);
2624
3326
  const incomplete = shipped && (shipped.failure !== null || Number(shipped.live) < Number(shipped.expected));
2625
- const projectId = repository.projectId;
2626
- const liveTasks = authenticated && state === 'bound'
2627
- ? (await this.dependencies.activeContexts.list(repository.repoFingerprint)).map(describePointer)
2628
- : [];
2629
- const workItems = authenticated && state === 'bound' && projectId
2630
- ? await this.actionableWorkItems(projectId)
2631
- : [];
2632
- const gitPreferences = authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
2633
- ? await this.projectGitPreferences({ repoRoot: repository.repoRoot, projectId })
2634
- : null;
2635
3327
  const preferences = gitPreferences?.ok ? objectValue(gitPreferences.data) : null;
2636
3328
  const unansweredBranches = preferences?.canManage === true
2637
3329
  ? Object.entries(objectOrEmpty(objectValue(preferences.branches)))
2638
3330
  .filter(([, value]) => objectValue(value)?.answered === false)
2639
3331
  .map(([role]) => role)
2640
3332
  : [];
2641
- const worktrees = authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
2642
- ? await this.worktreeOperation('list', { repoRoot: repository.repoRoot })
2643
- : null;
2644
3333
  const taskSelectionRequired = liveTasks.length > 0 || workItems.length > 0;
2645
- const pendingQuestionnaires = state !== 'disabled' ? await this.pendingQuestionnaires(repository.repoRoot) : [];
3334
+ timer.finish();
2646
3335
  return asJsonValue({
2647
3336
  authenticated,
2648
3337
  repository: publicRepository(repository),
@@ -2657,21 +3346,24 @@ export class BridgeService {
2657
3346
  gitPreferences: gitPreferences.ok ? gitPreferences.data : gitPreferences.error,
2658
3347
  unansweredBranchPreferences: unansweredBranches,
2659
3348
  branchPreferenceAction: unansweredBranches.length
2660
- ? 'Ask only these unanswered shared branch preferences using native questionnaires and project.set_git_preferences. Other members choose only their task base.'
3349
+ ? '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.'
2661
3350
  : 'Existing answers remain valid, including explicit absence. Choose the task base through task.branch when starting development.',
2662
3351
  }
2663
3352
  : {}),
2664
- ...(state !== 'disabled' ? { pendingQuestionnaires } : {}),
3353
+ ...(state !== 'disabled' ? pendingQuestionnairesFields(pendingQuestionnaires) : {}),
3354
+ ...(pendingQuestionnaires.deferred.length > 0
3355
+ ? { deferredTaskStarts: pendingQuestionnaires.deferred }
3356
+ : {}),
2665
3357
  client,
2666
3358
  ...(incomplete && state === 'bound'
2667
3359
  ? {
2668
3360
  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.',
2669
3361
  }
2670
3362
  : {}),
2671
- nextAction: pendingQuestionnaires.length > 0
3363
+ nextAction: pendingQuestionnaires.mine.length > 0
2672
3364
  ? 'Resume only the pending questionnaire for this work with questionnaire.resume before taking action that depends on its answer. Unrelated pending questions never block opening a separate task. Never infer consent from a timeout, dismissal, interrupted turn or missing answer.'
2673
3365
  : taskSelectionRequired
2674
- ? '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.'
3366
+ ? 'Present the live runs and actionable project work items, then ask which one this chat should handle. Resume only the run they choose, or start a separate run for the chosen work item: task.branch first for write work, then session.bootstrap with its workItemId in the returned repoRoot. Another live task never blocks opening this one.'
2675
3367
  : entryNextAction(authenticated, state),
2676
3368
  });
2677
3369
  });
@@ -2730,7 +3422,7 @@ export class BridgeService {
2730
3422
  }
2731
3423
  async sessionSetDecision(input) {
2732
3424
  return await this.execute(async () => {
2733
- const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
3425
+ const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot ?? process.cwd());
2734
3426
  if (input.decision === 'none') {
2735
3427
  await this.dependencies.repositoryDecisions.clear(repository.repoFingerprint);
2736
3428
  return asJsonValue({
@@ -2873,6 +3565,10 @@ export class BridgeService {
2873
3565
  }
2874
3566
  const taskEntries = (await this.dependencies.outbox.list()).filter((entry) => taskIdFromDeliverySafe(entry) === input.taskId);
2875
3567
  const pointer = await this.resolveCheckpointPointer(input, repoRoot);
3568
+ if (pointer.closedAt && correction)
3569
+ 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');
3570
+ if (pointer.closedAt)
3571
+ throw refuse(`Task ${pointer.taskSlug} is closed, so it takes no more checkpoints. Further work belongs to a new task.`, 'session.bootstrap');
2876
3572
  const blockedEntry = taskEntries.find((entry) => entry.lastError && entry.lastError !== 'backend_unavailable');
2877
3573
  if (blockedEntry && blockedEntry.idempotencyKey !== idempotencyKey) {
2878
3574
  throw refuse('A blocked task delivery must be explicitly resolved before checkpointing', 'task.resolve_pending_delivery');
@@ -2994,7 +3690,7 @@ export class BridgeService {
2994
3690
  throw refuse('The receipt does not belong to this task project.', 'session.resume');
2995
3691
  if (pointer.resumeConflicts.length || pointer.verificationIntent || pointer.closeIntent)
2996
3692
  throw refuse('Resume a clean active task before recording this receipt.', 'session.resume');
2997
- await this.checkoutSource(repository, pointer.source);
3693
+ await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer);
2998
3694
  await this.recoverJournalOutbox(pointer.projectId, pointer.taskSlug);
2999
3695
  const entries = await this.dependencies.outbox.listForTask(taskId);
3000
3696
  if (entries.some((entry) => entry.lastError && entry.lastError !== 'backend_unavailable'))
@@ -3108,19 +3804,16 @@ export class BridgeService {
3108
3804
  });
3109
3805
  const backend = objectValue(response.data);
3110
3806
  const task = objectValue(backend?.task);
3111
- if (!task ||
3112
- task.id !== body.taskId ||
3113
- task.projectId !== entry.projectId ||
3114
- backend?.requiresContextRefresh === true)
3807
+ if (!task || task.id !== body.taskId || task.projectId !== entry.projectId)
3115
3808
  throw refuse('Refresh the matching task context before delivering this review.', 'session.resume');
3809
+ if (backend?.requiresContextRefresh === true)
3810
+ throw refuse('Approved memory changed what this task read, so its context must be refreshed before this review is delivered.', 'context.refresh');
3116
3811
  recovered = Boolean(task &&
3117
3812
  task.id === body.taskId &&
3118
3813
  task.lockVersion === Number(body.expectedTaskVersion) + 1 &&
3119
3814
  task.selfReviewDiffHash === body.diffHash &&
3120
- stableStringify(asJsonValue(task.selfReviewResourceIds)) ===
3121
- stableStringify(asJsonValue(body.reviewedResourceIds)) &&
3122
- stableStringify(asJsonValue(task.selfReviewFindings)) ===
3123
- stableStringify(asJsonValue(body.findings)));
3815
+ stableStringify(asJsonValue(task.selfReviewFiles)) ===
3816
+ stableStringify(asJsonValue(body.files)));
3124
3817
  if (recovered)
3125
3818
  await this.captureTaskSnapshot(response.data);
3126
3819
  }
@@ -3163,13 +3856,34 @@ export class BridgeService {
3163
3856
  }
3164
3857
  return pending.length;
3165
3858
  }
3166
- async checkoutSource(repository, expected) {
3859
+ async taskRepository(repository, pointer) {
3860
+ const base = pointer?.source?.sourceCommit;
3861
+ const git = this.dependencies.repositories.git;
3862
+ if (!pointer ||
3863
+ !base ||
3864
+ !repository.git.head ||
3865
+ repository.git.head === base ||
3866
+ normalizeTaskMode(pointer.mode) === 'read_only' ||
3867
+ !pointer.changeBaseline ||
3868
+ typeof pointer.branch !== 'string' ||
3869
+ (await git.currentBranch(repository.repoRoot)) !== pointer.branch ||
3870
+ !(await git.isAncestor(repository.repoRoot, base)) ||
3871
+ !pathsContainAll(pointer.changeBaseline.leasePaths, await git.committedPaths(repository.repoRoot, base)))
3872
+ return repository;
3873
+ return { ...repository, git: await git.manifestAgainst(repository.repoRoot, base) };
3874
+ }
3875
+ async checkoutSource(repository, expected, task) {
3167
3876
  const current = await this.dependencies.repositories.git.sourceIdentity(repository.repoRoot);
3168
3877
  if (expected &&
3169
3878
  (!current ||
3170
3879
  current.sourceCommit !== expected.sourceCommit ||
3171
3880
  current.treeHash !== expected.treeHash)) {
3172
- 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');
3881
+ if (current &&
3882
+ repository.git.baseCommit === expected.sourceCommit &&
3883
+ repository.git.head === current.sourceCommit)
3884
+ return { ...expected };
3885
+ const branch = await this.dependencies.repositories.git.currentBranch(repository.repoRoot);
3886
+ 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');
3173
3887
  }
3174
3888
  if (current && repository.git.head !== current.sourceCommit) {
3175
3889
  throw refuse('The Git source changed during the request. Retry session.resume with a stable checkout.', 'session.resume');
@@ -3195,14 +3909,17 @@ export class BridgeService {
3195
3909
  body: {
3196
3910
  sessionId: pointer.sessionId,
3197
3911
  repoFingerprint: repository.repoFingerprint,
3198
- source: (await this.checkoutSource(repository, pointer.source)) ?? null,
3912
+ source: (await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer)) ?? null,
3199
3913
  afterSequence: 0,
3200
3914
  },
3201
3915
  });
3202
3916
  const backend = objectValue(response.data);
3203
3917
  const task = objectValue(backend?.task);
3204
- if (!task || task.id !== taskId || backend?.requiresContextRefresh === true) {
3205
- throw refuse('Backend task snapshot is stale or does not match the active task', 'session.resume');
3918
+ if (!task || task.id !== taskId) {
3919
+ throw refuse('Backend task snapshot does not match the active task', 'session.resume');
3920
+ }
3921
+ if (backend?.requiresContextRefresh === true) {
3922
+ throw refuse(contextRefreshAction(backend.staleReason, pointer.changeBaseline), 'context.refresh');
3206
3923
  }
3207
3924
  const refreshed = {
3208
3925
  ...pointer,
@@ -3224,8 +3941,11 @@ export class BridgeService {
3224
3941
  });
3225
3942
  const backend = objectValue(response.data);
3226
3943
  const task = objectValue(backend?.task);
3227
- if (!task || task.id !== taskId || backend?.requiresContextRefresh === true) {
3228
- throw refuse('Backend task snapshot is stale or does not match the pending delivery', 'session.resume');
3944
+ if (!task || task.id !== taskId) {
3945
+ throw refuse('Backend task snapshot does not match the pending delivery', 'session.resume');
3946
+ }
3947
+ if (backend?.requiresContextRefresh === true) {
3948
+ throw refuse(contextRefreshAction(backend.staleReason, pointer.changeBaseline), 'context.refresh');
3229
3949
  }
3230
3950
  const refreshed = {
3231
3951
  ...pointer,
@@ -3240,6 +3960,13 @@ export class BridgeService {
3240
3960
  const blockingConflicts = pointer?.resumeConflicts.filter((conflict) => !(allowOfflineWarnings && isOfflineDevelopmentWarning(conflict)));
3241
3961
  const lifecycleIntentBlocked = (pointer?.verificationIntent && allowedIntent !== 'verify') ||
3242
3962
  (pointer?.closeIntent && allowedIntent !== 'close');
3963
+ if (pointer &&
3964
+ pointer.taskId === taskId &&
3965
+ !lifecycleIntentBlocked &&
3966
+ blockingConflicts.length > 0 &&
3967
+ blockingConflicts.every((conflict) => contextRefreshConflicts.includes(conflict))) {
3968
+ throw refuse(contextRefreshAction(undefined, pointer.changeBaseline), 'context.refresh');
3969
+ }
3243
3970
  if (!pointer ||
3244
3971
  pointer.taskId !== taskId ||
3245
3972
  (blockingConflicts?.length ?? 0) > 0 ||
@@ -3256,7 +3983,7 @@ export class BridgeService {
3256
3983
  }
3257
3984
  }
3258
3985
  async retryVerificationRecovery(repository, pointer, backend) {
3259
- await this.checkoutSource(repository, pointer.source);
3986
+ await this.checkoutSource(repository, pointer.source, pointer);
3260
3987
  const intent = pointer.verificationIntent;
3261
3988
  const recovery = objectValue(backend?.verificationRecovery);
3262
3989
  if (!intent) {
@@ -3383,7 +4110,9 @@ export class BridgeService {
3383
4110
  return await this.deliverCloseIntent(repository, body, intent.taskChanges, true);
3384
4111
  }
3385
4112
  async deliverCloseIntent(repository, body, taskChanges, recoveredAfterResponseLoss) {
3386
- await this.checkoutSource(repository, sourceFromContext(body.source));
4113
+ await this.checkoutSource(repository, sourceFromContext(body.source), {
4114
+ branch: typeof body.branch === 'string' ? body.branch : null,
4115
+ });
3387
4116
  const touchedContract = taskChanges.some((change) => /\.controller\.[tj]s$/.test(change.path));
3388
4117
  const currentBranch = await this.dependencies.repositories.git.currentBranch(repository.repoRoot);
3389
4118
  if (typeof body.branch === 'string' && body.branch !== currentBranch)
@@ -3424,7 +4153,15 @@ export class BridgeService {
3424
4153
  taskChanges,
3425
4154
  });
3426
4155
  await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
3427
- const allocation = this.poolAllocations.get(repository.repoRoot);
4156
+ const closedPointer = await this.dependencies.activeContexts.loadForTask(repository.repoFingerprint, String(body.taskId));
4157
+ if (closedPointer)
4158
+ await this.dependencies.activeContexts.save({
4159
+ ...closedPointer,
4160
+ closedAt: new Date().toISOString(),
4161
+ });
4162
+ const allocation = repository.projectId
4163
+ ? await this.ownedAllocation(repository.projectId, repository.repoRoot)
4164
+ : this.poolAllocations.get(repository.repoRoot);
3428
4165
  if (allocation && this.dependencies.worktreePool)
3429
4166
  await this.dependencies.worktreePool.delivery(allocation.projectId, repository.repoRoot, allocation.generation);
3430
4167
  else
@@ -3435,7 +4172,7 @@ export class BridgeService {
3435
4172
  ...closedTask,
3436
4173
  repository: publicRepository(repository),
3437
4174
  recoveredAfterResponseLoss,
3438
- delivery: deliveryQuestion(touchedContract),
4175
+ delivery: deliveryQuestion(touchedContract, await this.dependencies.repositories.git.pushDestination(repository.repoRoot), objectValue(closedTask.sourcePublication)?.applicable === true),
3439
4176
  });
3440
4177
  }
3441
4178
  async seedResumeSnapshot(pointer, backendPatch) {
@@ -3485,7 +4222,14 @@ export class BridgeService {
3485
4222
  return await this.requireActivePointer(repository.repoFingerprint, input.taskId, true);
3486
4223
  })()
3487
4224
  : await this.dependencies.activeContexts.findByTaskId(input.taskId, input.projectId, false);
3488
- if (pointer.resumeConflicts.some((conflict) => !isOfflineDevelopmentWarning(conflict))) {
4225
+ const blockingConflicts = pointer.resumeConflicts.filter((conflict) => !isOfflineDevelopmentWarning(conflict));
4226
+ if (!pointer.verificationIntent &&
4227
+ !pointer.closeIntent &&
4228
+ blockingConflicts.length > 0 &&
4229
+ blockingConflicts.every((conflict) => contextRefreshConflicts.includes(conflict))) {
4230
+ throw refuse(contextRefreshAction(undefined, pointer.changeBaseline), 'context.refresh');
4231
+ }
4232
+ if (blockingConflicts.length > 0) {
3489
4233
  throw refuse('A clean active task snapshot is required for checkpointing', 'session.resume');
3490
4234
  }
3491
4235
  if (pointer.verificationIntent || pointer.closeIntent) {
@@ -3510,7 +4254,7 @@ export class BridgeService {
3510
4254
  try {
3511
4255
  const allocation = await this.dependencies.worktreePool?.forTask(taskId);
3512
4256
  if (allocation) {
3513
- const owned = this.poolAllocations.get(allocation.repoRoot);
4257
+ const owned = this.adoptOwned(allocation.repoRoot, allocation);
3514
4258
  if (!owned || owned.generation !== allocation.generation)
3515
4259
  throw refuse('Resume the task in its owned worktree before this operation.', 'session.resume');
3516
4260
  await this.dependencies.worktreePool.assertOwnership(allocation.projectId, allocation.repoRoot, owned.generation);
@@ -3566,6 +4310,7 @@ export class BridgeService {
3566
4310
  code: error.code ?? error.httpStatus,
3567
4311
  retryable: error.retryable,
3568
4312
  ...(error.recovery ? { recovery: error.recovery } : {}),
4313
+ ...(error.envelope?.data ? { details: error.envelope.data } : {}),
3569
4314
  },
3570
4315
  };
3571
4316
  }
@@ -3576,6 +4321,9 @@ export class BridgeService {
3576
4321
  message: error instanceof Error ? error.message : 'Bridge operation failed',
3577
4322
  retryable: false,
3578
4323
  ...(error instanceof BridgeRecoveryError ? { recovery: error.recovery } : {}),
4324
+ ...(error instanceof BridgeRecoveryError && error.details !== undefined
4325
+ ? { details: error.details }
4326
+ : {}),
3579
4327
  },
3580
4328
  };
3581
4329
  }
@@ -3593,6 +4341,59 @@ function normalizeChangedPaths(paths) {
3593
4341
  }
3594
4342
  return [...new Set(normalized)].sort();
3595
4343
  }
4344
+ function reviewedFiles(input) {
4345
+ const repoRoot = input.repoRoot ?? process.cwd();
4346
+ const files = input.files.map(({ path, rules }) => ({
4347
+ path: reviewedFilePath(path),
4348
+ rules: rules.map((rule) => normalizePersistentInput(rule, repoRoot)),
4349
+ }));
4350
+ const repeated = files
4351
+ .map(({ path }) => path)
4352
+ .filter((path, index, paths) => paths.indexOf(path) !== index);
4353
+ if (repeated.length > 0)
4354
+ 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');
4355
+ return files;
4356
+ }
4357
+ function reviewedFilePath(path) {
4358
+ const normalized = path.replace(/\\/g, '/').replace(/^\.\//, '');
4359
+ if (!normalized ||
4360
+ normalized.startsWith('/') ||
4361
+ /^[A-Za-z]:\//.test(normalized) ||
4362
+ normalized.split('/').includes('..'))
4363
+ throw refuse(`Name each reviewed file by its repository-relative path, as Git reports it: ${path}`, 'task.self_review');
4364
+ return normalized;
4365
+ }
4366
+ function isDeviation(rule) {
4367
+ return rule.outcome === 'user_accepted_deviation';
4368
+ }
4369
+ export function ruleDeviationQuestionnaireId(taskId, path, rule) {
4370
+ return ('rule-deviation-' +
4371
+ sha256(stableStringify({ taskId, path, resourceKey: rule.resourceKey, issue: rule.issue })));
4372
+ }
4373
+ function ruleDeviationQuestion(taskId, path, rule, language) {
4374
+ const copy = ruleDeviationCopy[language];
4375
+ return {
4376
+ questionnaireId: ruleDeviationQuestionnaireId(taskId, path, rule),
4377
+ message: copy.message(path, rule.resourceKey ?? rule.resourceId, rule.issue ?? ''),
4378
+ language,
4379
+ options: [
4380
+ { id: 'approve', label: copy.approve },
4381
+ { id: 'change', label: copy.change },
4382
+ ],
4383
+ };
4384
+ }
4385
+ const ruleDeviationCopy = {
4386
+ en: {
4387
+ message: (path, resourceKey, issue) => `${path} does not follow ${resourceKey}: ${issue}`,
4388
+ approve: 'Keep the code as written and record the deviation',
4389
+ change: 'Change the code to follow the rule',
4390
+ },
4391
+ tr: {
4392
+ message: (path, resourceKey, issue) => `${path}, ${resourceKey} kuralına uymuyor: ${issue}`,
4393
+ approve: 'Kodu olduğu gibi bırak ve bu sapmayı kaydet',
4394
+ change: 'Kodu kurala uyacak şekilde değiştir',
4395
+ },
4396
+ };
3596
4397
  function pathsContainAll(allowedPaths, actualPaths) {
3597
4398
  const allowed = new Set(normalizeChangedPaths(allowedPaths));
3598
4399
  return normalizeChangedPaths(actualPaths).every((path) => allowed.has(path));
@@ -3673,6 +4474,12 @@ async function buildPathChangeManifest(repoRoot, changes) {
3673
4474
  });
3674
4475
  }));
3675
4476
  }
4477
+ function pendingQuestionnairesFields(value) {
4478
+ return {
4479
+ pendingQuestionnaires: value.mine,
4480
+ ...(value.other.length > 0 ? { otherTasksPendingQuestionnaires: value.other } : {}),
4481
+ };
4482
+ }
3676
4483
  function describePointer(pointer) {
3677
4484
  return asJsonValue({
3678
4485
  taskSlug: pointer.taskSlug,
@@ -3719,26 +4526,42 @@ function entryNextAction(authenticated, decision) {
3719
4526
  return 'Ask the user to sign in before answering anything about this project, and start the browser flow when they agree.';
3720
4527
  }
3721
4528
  if (decision === 'bound') {
3722
- return 'Continue into the lifecycle with session.bootstrap or session.resume.';
4529
+ 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.';
3723
4530
  }
3724
4531
  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.';
3725
4532
  }
3726
- function deliveryQuestion(touchedContract = false) {
4533
+ function deliveryQuestion(touchedContract, destination, publishApplicable) {
4534
+ const { branch, remote, pushUrl, remoteDefaultBranch, headCommit } = destination;
4535
+ const onto = branch ? `branch ${branch}` : 'a detached HEAD, which is on no branch';
4536
+ const target = branch && remote ? `${remote} (${pushUrl ?? 'no push URL'}) branch ${branch}` : null;
4537
+ const head = headCommit ? `, on top of ${headCommit.slice(0, 12)}` : '';
4538
+ const pushed = target ? `, and a push goes to ${target}` : '';
4539
+ const to = target ? ` to ${target}` : '';
4540
+ const blocked = target
4541
+ ? ''
4542
+ : !branch
4543
+ ? ' (not possible until the work is on a branch)'
4544
+ : ` (not possible: this clone has no remote that a push of ${branch} would go to)`;
4545
+ const base = 'base branch the pull request targets' +
4546
+ (remote && remoteDefaultBranch
4547
+ ? `; the default branch of ${remote} is ${remoteDefaultBranch}`
4548
+ : '');
3727
4549
  return asJsonValue({
3728
4550
  required: true,
3729
- question: 'The task is closed and nothing has been committed. Ask the user which of these to do, and do only what they choose.',
4551
+ 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.`,
4552
+ destination,
3730
4553
  options: [
3731
- { id: 'commit', label: 'Commit' },
3732
- { id: 'commit_push', label: 'Commit and push' },
4554
+ { id: 'commit', label: `Commit on ${onto}` },
4555
+ { id: 'commit_push', label: `Commit and push${to}${blocked}` },
3733
4556
  {
3734
4557
  id: 'commit_push_draft_pr',
3735
- label: 'Commit, push and open a draft pull request',
3736
- input: 'base branch the pull request targets',
4558
+ label: `Commit, push${to} and open a draft pull request${blocked}`,
4559
+ input: base,
3737
4560
  },
3738
4561
  {
3739
4562
  id: 'commit_push_pr',
3740
- label: 'Commit, push and open a pull request',
3741
- input: 'base branch the pull request targets',
4563
+ label: `Commit, push${to} and open a pull request${blocked}`,
4564
+ input: base,
3742
4565
  },
3743
4566
  ],
3744
4567
  ...(touchedContract
@@ -3747,6 +4570,9 @@ function deliveryQuestion(touchedContract = false) {
3747
4570
  }
3748
4571
  : {}),
3749
4572
  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.',
4573
+ afterCommit: publishApplicable
4574
+ ? 'Call memory.publish_task with this closed task id and repoRoot after the authorized commit is created.'
4575
+ : 'This task has nothing to publish (see sourcePublication on the close response); do not call memory.publish_task.',
3750
4576
  });
3751
4577
  }
3752
4578
  function cleanJson(value) {
@@ -3769,6 +4595,73 @@ function objectValue(value) {
3769
4595
  function objectOrEmpty(value) {
3770
4596
  return objectValue(value ?? undefined) ?? {};
3771
4597
  }
4598
+ function journalSummary(journal) {
4599
+ return {
4600
+ directory: journal.directory,
4601
+ appliedEventCount: journal.projection?.appliedEventIds.length ?? 0,
4602
+ pendingDeliveryCount: journal.pendingDeliveryCount,
4603
+ };
4604
+ }
4605
+ function outboxSummary(outbox) {
4606
+ const value = objectOrEmpty(outbox);
4607
+ return {
4608
+ synchronizedCount: Array.isArray(value.synchronized) ? value.synchronized.length : 0,
4609
+ blocked: Array.isArray(value.blocked) ? value.blocked : [],
4610
+ pending: typeof value.pending === 'number' ? value.pending : 0,
4611
+ };
4612
+ }
4613
+ function documentViews(documents) {
4614
+ return (Array.isArray(documents) ? documents : []).flatMap((document) => {
4615
+ const value = objectValue(document);
4616
+ return value && typeof value.content === 'string' && value.content.trim()
4617
+ ? [{ documentType: value.documentType ?? null, content: value.content }]
4618
+ : [];
4619
+ });
4620
+ }
4621
+ function checkpointView(checkpoint) {
4622
+ if (!checkpoint)
4623
+ return null;
4624
+ const event = objectValue(checkpoint.event);
4625
+ return {
4626
+ event: event
4627
+ ? {
4628
+ id: event.id ?? null,
4629
+ sequence: event.sequence ?? null,
4630
+ checkpointType: event.checkpointType ?? null,
4631
+ createdAt: event.createdAt ?? null,
4632
+ }
4633
+ : null,
4634
+ documents: documentViews(checkpoint.documents),
4635
+ };
4636
+ }
4637
+ function resumeBackendView(backend, includeDocuments) {
4638
+ const rest = Object.fromEntries(Object.entries(backend).filter(([key]) => key !== 'requirements'));
4639
+ const task = objectValue(backend.task);
4640
+ const selfReviewFiles = Array.isArray(task?.selfReviewFiles) ? task.selfReviewFiles : [];
4641
+ return {
4642
+ ...rest,
4643
+ ...(task
4644
+ ? {
4645
+ task: {
4646
+ ...Object.fromEntries(Object.entries(task).filter(([key]) => key !== 'selfReviewFiles')),
4647
+ selfReviewFileCount: selfReviewFiles.length,
4648
+ },
4649
+ }
4650
+ : {}),
4651
+ events: (Array.isArray(backend.events) ? backend.events : []).map((event) => {
4652
+ const value = objectOrEmpty(event);
4653
+ const payload = objectOrEmpty(value.payload ?? null);
4654
+ return {
4655
+ sequence: value.sequence ?? null,
4656
+ eventType: value.eventType ?? null,
4657
+ checkpointType: value.checkpointType ?? null,
4658
+ createdAt: value.createdAt ?? null,
4659
+ ...(typeof payload.summary === 'string' ? { summary: payload.summary } : {}),
4660
+ };
4661
+ }),
4662
+ documents: includeDocuments ? documentViews(backend.documents) : [],
4663
+ };
4664
+ }
3772
4665
  function archivedProjectGuidance(project) {
3773
4666
  const projectId = typeof project?.id === 'string' ? project.id : null;
3774
4667
  const expectedVersion = typeof project?.lockVersion === 'number' ? project.lockVersion : null;
@@ -3816,6 +4709,11 @@ function classifyError(error) {
3816
4709
  }
3817
4710
  return 'bridge_delivery_error';
3818
4711
  }
4712
+ function backendRejectsField(error, fields) {
4713
+ return (error instanceof ApiResponseError &&
4714
+ error.httpStatus === 400 &&
4715
+ fields.some((field) => error.message.includes(`property ${field} should not exist`)));
4716
+ }
3819
4717
  function isDefinitiveAuthenticationFailure(error) {
3820
4718
  return (error instanceof ApiResponseError &&
3821
4719
  (error.httpStatus === 400 || error.httpStatus === 401 || error.httpStatus === 403));
@@ -3893,24 +4791,49 @@ function isOfflineLeaseUsable(lease, sessionId, changedPaths, baselineDiffHash)
3893
4791
  return false;
3894
4792
  }
3895
4793
  }
3896
- function refuse(message, recovery) {
3897
- return new BridgeRecoveryError(message, recovery);
4794
+ export function cancelledDeliveryQuestionId(entry, clean) {
4795
+ return ('worktree-delivery-cancel-' +
4796
+ sha256(entry.generation + ':' + (clean ? 'clean' : entry.managed ? 'modified' : 'own-folder')));
4797
+ }
4798
+ function refuse(message, recovery, details) {
4799
+ return new BridgeRecoveryError(message, recovery, details);
3898
4800
  }
3899
4801
  function short(value) {
3900
4802
  return typeof value === 'string' && value.length > 0 ? value.slice(0, 12) : 'none';
3901
4803
  }
3902
- function describePathDisagreement(reported, manifest) {
3903
- const inManifest = new Set(manifest);
3904
- const inReport = new Set(reported);
3905
- const missing = manifest.filter((path) => !inReport.has(path));
3906
- const extra = reported.filter((path) => !inManifest.has(path));
3907
- const sample = (paths) => paths.slice(0, 5).join(', ') + (paths.length > 5 ? `, and ${paths.length - 5} more` : '');
3908
- const parts = [`You reported ${reported.length}; the manifest has ${manifest.length}.`];
3909
- if (missing.length > 0)
3910
- parts.push(`Missing from your report: ${sample(missing)}.`);
3911
- if (extra.length > 0)
3912
- parts.push(`Reported but not in the manifest: ${sample(extra)}.`);
3913
- return parts.join(' ');
4804
+ function samplePaths(paths) {
4805
+ return paths.slice(0, 5).join(', ') + (paths.length > 5 ? `, and ${paths.length - 5} more` : '');
4806
+ }
4807
+ const contextRefreshConflicts = [
4808
+ 'context_refresh_required',
4809
+ 'approved_memory_revision_requires_refresh',
4810
+ ];
4811
+ function contextRefreshAction(staleReason, baseline) {
4812
+ const reason = objectValue(staleReason);
4813
+ const cause = reason && typeof reason.resourceKey === 'string'
4814
+ ? `Approved revision ${String(reason.revisionNumber)} of ${reason.resourceKey}${typeof reason.taskExternalId === 'string' ? ` (from ${reason.taskExternalId})` : ''} changed memory this task read.`
4815
+ : 'Approved memory changed what this task read.';
4816
+ 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)}.` : ''}`;
4817
+ }
4818
+ function taskChangeSet(baseline, activeLease, current) {
4819
+ const leaseChangedPaths = Array.isArray(activeLease?.changedPaths)
4820
+ ? activeLease.changedPaths.filter((path) => typeof path === 'string')
4821
+ : [];
4822
+ const wholeTreeDelta = manifestDelta(baseline.changedPaths, current);
4823
+ const leased = new Set(normalizeChangedPaths([...baseline.leasePaths, ...leaseChangedPaths]));
4824
+ const taskChanges = wholeTreeDelta.filter((entry) => leased.has(normalizeChangedPaths([entry.path])[0]));
4825
+ return { leaseChangedPaths, wholeTreeDelta, leased, taskChanges };
4826
+ }
4827
+ function renderedPaths(paths, policy) {
4828
+ if (!policy)
4829
+ return [];
4830
+ return paths
4831
+ .filter((path) => policy.units.some((unit) => (unit.kind === 'screen_logic' || unit.kind === 'component_mapping') &&
4832
+ unit.patterns.some((pattern) => globMatches(path, pattern))))
4833
+ .sort();
4834
+ }
4835
+ function validationWaiverId(taskId, validationId, paths, reason) {
4836
+ return `validation-waiver-${validationId}-${sha256(stableStringify({ taskId, validationId, paths, reason })).slice(0, 32)}`;
3914
4837
  }
3915
4838
  function repairChangeBaseline(local, recordedDiffHash, head) {
3916
4839
  if (!recordedDiffHash || local?.diffHash === recordedDiffHash)
@@ -4101,7 +5024,7 @@ function globMatches(path, pattern) {
4101
5024
  }
4102
5025
  return minimatch(path.replace(/\\/g, '/'), normalizedPattern, { dot: true });
4103
5026
  }
4104
- function assertNewResourceEvidence(candidates, evidence) {
5027
+ function newResourceEvidenceFindings(candidates, evidence) {
4105
5028
  const normalizedEvidence = evidence.map((entry) => ({
4106
5029
  ...entry,
4107
5030
  path: normalizeChangedPaths([entry.path])[0],
@@ -4112,47 +5035,56 @@ function assertNewResourceEvidence(candidates, evidence) {
4112
5035
  if (new Set(normalizedEvidence.map((entry) => entry.path)).size !== normalizedEvidence.length) {
4113
5036
  throw refuse('New resource evidence contains duplicate paths', 'task.verify');
4114
5037
  }
4115
- const expected = [...candidates].sort((left, right) => left.path.localeCompare(right.path));
4116
- const actual = normalizedEvidence
4117
- .map(({ path, kind }) => ({ path, kind }))
4118
- .sort((left, right) => left.path.localeCompare(right.path));
4119
- if (stableStringify(expected) !== stableStringify(actual)) {
4120
- const expectedPaths = new Set(expected.map((entry) => entry.path));
4121
- const actualPaths = new Set(actual.map((entry) => entry.path));
4122
- const missing = expected
4123
- .filter((entry) => !actualPaths.has(entry.path))
4124
- .map((entry) => `${entry.path} (${entry.kind})`);
4125
- const extra = actual
4126
- .filter((entry) => !expectedPaths.has(entry.path))
4127
- .map((entry) => `${entry.path} (${entry.kind})`);
4128
- const details = [
4129
- missing.length ? `missing evidence for ${missing.join(', ')}` : '',
4130
- extra.length ? `evidence for paths the policy does not classify: ${extra.join(', ')}` : '',
4131
- ].filter(Boolean);
4132
- 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');
4133
- }
5038
+ const sent = new Map(normalizedEvidence.map((entry) => [entry.path, entry.kind]));
5039
+ const expected = new Map(candidates.map((entry) => [entry.path, entry.kind]));
5040
+ const missing = candidates
5041
+ .filter((entry) => !sent.has(entry.path))
5042
+ .map((entry) => `${entry.path} (${entry.kind})`)
5043
+ .sort();
5044
+ const extra = normalizedEvidence
5045
+ .filter((entry) => !expected.has(entry.path))
5046
+ .map((entry) => `${entry.path} (${entry.kind})`)
5047
+ .sort();
5048
+ const mismatched = normalizedEvidence
5049
+ .filter((entry) => expected.has(entry.path) && expected.get(entry.path) !== entry.kind)
5050
+ .map((entry) => `${entry.path} (sent ${entry.kind}, policy ${expected.get(entry.path)})`)
5051
+ .sort();
5052
+ const details = [
5053
+ missing.length ? `missing evidence for ${missing.join(', ')}` : '',
5054
+ extra.length ? `evidence for paths the policy does not classify: ${extra.join(', ')}` : '',
5055
+ mismatched.length
5056
+ ? `kinds that do not match the project discovery policy: ${mismatched.join(', ')}`
5057
+ : '',
5058
+ ].filter(Boolean);
5059
+ return details.length > 0
5060
+ ? [
5061
+ `New screen or shared component paths require exact memory reconciliation. ${details.join('; ')}.`,
5062
+ ]
5063
+ : [];
4134
5064
  }
4135
- function assertValidationEvidence(validations) {
5065
+ function validationEvidenceFindings(validations) {
4136
5066
  if (validations.length === 0) {
4137
- throw refuse('Validation evidence is required', 'task.verify');
5067
+ return ['Validation evidence is required'];
4138
5068
  }
4139
5069
  const allowed = new Set(validationIds);
4140
5070
  const seen = new Set();
5071
+ const findings = [];
4141
5072
  for (const validation of validations) {
4142
5073
  if (!allowed.has(validation.validationId)) {
4143
- throw refuse(`Unknown validation evidence: ${validation.validationId}`, 'task.verify');
5074
+ findings.push(`Unknown validation evidence: ${validation.validationId}`);
4144
5075
  }
4145
- if (seen.has(validation.validationId)) {
4146
- throw refuse(`Duplicate validation evidence: ${validation.validationId}`, 'task.verify');
5076
+ else if (seen.has(validation.validationId)) {
5077
+ findings.push(`Duplicate validation evidence: ${validation.validationId}`);
4147
5078
  }
4148
5079
  if (!validation.command.trim()) {
4149
- throw refuse(`Validation command is required: ${validation.validationId}`, 'task.verify');
5080
+ findings.push(`Validation command is required: ${validation.validationId}`);
4150
5081
  }
4151
5082
  if (!/^[0-9a-f]{64}$/.test(validation.outputHash)) {
4152
- throw refuse(`Validation output hash is invalid: ${validation.validationId}`, 'task.verify');
5083
+ findings.push(`Validation output hash is invalid: ${validation.validationId}`);
4153
5084
  }
4154
5085
  seen.add(validation.validationId);
4155
5086
  }
5087
+ return findings;
4156
5088
  }
4157
5089
  function sourceFromContext(value) {
4158
5090
  const context = objectValue(value);