engineering-memory 1.11.9 → 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 (34) hide show
  1. package/dispatcher/sections.mjs +6 -2
  2. package/install/codex-approval.mjs +457 -0
  3. package/install/files.mjs +5 -1
  4. package/install/installer.mjs +24 -1
  5. package/package.json +1 -1
  6. package/runtime/build.json +1 -1
  7. package/runtime/dist/src/config.js +1 -0
  8. package/runtime/dist/src/git/git-inspector.js +82 -3
  9. package/runtime/dist/src/git/verification-gate.js +30 -17
  10. package/runtime/dist/src/mcp/onboarding-tools.js +3 -16
  11. package/runtime/dist/src/mcp/questionnaire-tools.js +99 -15
  12. package/runtime/dist/src/mcp/tool-annotations.js +111 -0
  13. package/runtime/dist/src/mcp/tool-definitions.js +148 -28
  14. package/runtime/dist/src/mcp/worktree-tools.js +323 -236
  15. package/runtime/dist/src/project/repository.js +11 -5
  16. package/runtime/dist/src/runtime/active-context-store.js +3 -0
  17. package/runtime/dist/src/runtime/branch-preferences.js +0 -19
  18. package/runtime/dist/src/runtime/bridge-service.js +1131 -220
  19. package/runtime/dist/src/runtime/offline-outbox.js +33 -158
  20. package/runtime/dist/src/runtime/phase-timer.js +26 -0
  21. package/runtime/dist/src/runtime/privacy-detector.js +263 -0
  22. package/runtime/dist/src/runtime/questionnaire-store.js +210 -49
  23. package/runtime/dist/src/runtime/recovery-error.js +3 -1
  24. package/runtime/dist/src/runtime/runtime-entry.js +8 -0
  25. package/runtime/dist/src/runtime/runtime-host.js +1 -3
  26. package/runtime/dist/src/runtime/task-branch-store.js +17 -1
  27. package/runtime/dist/src/runtime/task-start.js +281 -0
  28. package/runtime/dist/src/runtime/worktree-pool.js +334 -24
  29. package/runtime/dist/src/utilities/process.js +1 -0
  30. package/skill/SKILL.md +3 -3
  31. package/skill/references/lifecycle.md +44 -19
  32. package/skill/references/memory-updates.md +9 -3
  33. package/skill/references/project-onboarding.md +19 -2
  34. 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
578
  worktree: (await this.ownedAllocation(projectId, repository.repoRoot)) ?? null,
463
- pendingQuestionnaires,
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
778
  worktree: (await this.ownedAllocation(projectId, repository.repoRoot)) ?? null,
646
- localJournal,
647
- pendingQuestionnaires,
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,
@@ -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 }
@@ -962,13 +1139,14 @@ export class BridgeService {
962
1139
  if (pointer && repository && pointer.mode !== 'read_only')
963
1140
  await this.assertPoolPointer(repository, pointer);
964
1141
  const source = repository
965
- ? await this.checkoutSource(repository, pointer?.source)
1142
+ ? await this.checkoutSource(await this.taskRepository(repository, pointer), pointer?.source, pointer ?? undefined)
966
1143
  : undefined;
967
1144
  const response = await this.dependencies.client.request(endpoints.contextRefresh, {
968
1145
  method: 'POST',
969
1146
  body: cleanJson({ sessionId: input.sessionId, source }),
970
1147
  });
971
- 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);
972
1150
  return asJsonValue({
973
1151
  ...objectOrEmpty(response.data),
974
1152
  requiresPrepareChange: true,
@@ -984,7 +1162,9 @@ export class BridgeService {
984
1162
  cacheKey: sha256(`memory.query\n${stableStringify(body)}`),
985
1163
  allowStaleOnUnavailable: true,
986
1164
  });
987
- return asJsonValue({ resources: response.data });
1165
+ return Array.isArray(response.data)
1166
+ ? asJsonValue({ resources: response.data })
1167
+ : response.data;
988
1168
  });
989
1169
  }
990
1170
  async memoryHistory(input) {
@@ -992,15 +1172,31 @@ export class BridgeService {
992
1172
  const body = cleanJson({
993
1173
  sessionId: input.sessionId,
994
1174
  resourceKeys: input.resourceKeys,
1175
+ resourceIds: input.resourceIds,
995
1176
  paths: input.paths,
1177
+ required: input.required,
996
1178
  depth: input.depth,
997
1179
  });
998
- const response = await this.dependencies.client.request(endpoints.memoryHistory, {
999
- method: 'POST',
1000
- body,
1001
- });
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);
1002
1193
  const relatedBranches = await this.branchTaskReferences(input.repoRoot ?? process.cwd(), input.paths ?? []);
1003
- 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
+ });
1004
1200
  });
1005
1201
  }
1006
1202
  async branchTaskReferences(repoRoot, paths) {
@@ -1341,7 +1537,7 @@ export class BridgeService {
1341
1537
  async memoryListProposals(input) {
1342
1538
  return await this.execute(async () => {
1343
1539
  const response = await this.dependencies.client.request(endpoints.memoryListProposals, { method: 'POST', body: cleanJson(input) });
1344
- return asJsonValue({ proposals: response.data });
1540
+ return asJsonValue(response.data);
1345
1541
  });
1346
1542
  }
1347
1543
  async memoryReviewProposal(input) {
@@ -1416,16 +1612,25 @@ export class BridgeService {
1416
1612
  }
1417
1613
  return [...pending];
1418
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
+ }
1419
1624
  async taskSelfReview(input) {
1420
1625
  return await this.execute(async () => {
1421
- const findings = normalizePersistentInput(input.findings, input.repoRoot ?? process.cwd());
1422
- assertSafeToPersist(cleanJson({ findings }));
1423
- const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
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));
1424
1630
  if (this.deferDeliveries) {
1425
1631
  return this.enqueueTaskReceipt('task.self_review', endpoints.taskSelfReview, input.taskId, repository.repoRoot, cleanJson({
1426
1632
  diffHash: repository.git.diffHash,
1427
- reviewedResourceIds: [...new Set(input.reviewedResourceIds)],
1428
- findings,
1633
+ files,
1429
1634
  }));
1430
1635
  }
1431
1636
  const snapshot = await this.activeTaskSnapshot(input.taskId, undefined, input.repoRoot);
@@ -1435,20 +1640,50 @@ export class BridgeService {
1435
1640
  taskId: input.taskId,
1436
1641
  expectedTaskVersion: snapshot.taskVersion,
1437
1642
  diffHash: repository.git.diffHash,
1438
- reviewedResourceIds: input.reviewedResourceIds,
1439
- findings,
1643
+ files,
1440
1644
  }),
1441
1645
  });
1442
1646
  await this.captureTaskSnapshot(asJsonValue({ task: response.data }));
1443
1647
  return asJsonValue({
1444
1648
  selfReview: {
1445
1649
  diffHash: repository.git.diffHash,
1446
- reviewedResourceIds: input.reviewedResourceIds,
1447
- findings: findings.length,
1650
+ files: files.map(({ path, rules }) => ({
1651
+ path,
1652
+ rules: rules.map(({ resourceId, outcome }) => ({ resourceId, outcome })),
1653
+ })),
1448
1654
  },
1449
1655
  });
1450
1656
  });
1451
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
+ }
1452
1687
  async taskReconcile(input) {
1453
1688
  return await this.execute(async () => {
1454
1689
  const repoRoot = input.repoRoot ?? process.cwd();
@@ -1567,7 +1802,7 @@ export class BridgeService {
1567
1802
  method: 'POST',
1568
1803
  body: cleanJson({
1569
1804
  sessionId: pointer.sessionId,
1570
- source: await this.checkoutSource(repository, pointer.source),
1805
+ source: await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer),
1571
1806
  }),
1572
1807
  });
1573
1808
  const refreshData = objectValue(refreshed.data);
@@ -1592,13 +1827,18 @@ export class BridgeService {
1592
1827
  }
1593
1828
  async taskVerify(input) {
1594
1829
  return await this.execute(async () => {
1830
+ const timer = startPhaseTimer('task.verify');
1595
1831
  return await this.taskExclusive(input.taskId, async () => {
1596
1832
  await this.recoverJournalOutbox();
1833
+ timer.mark('recover_journal_outbox');
1597
1834
  const outbox = await this.flushOutbox(input.taskId);
1835
+ timer.mark('flush_outbox');
1598
1836
  const pendingOutboxCount = (await this.dependencies.outbox.listForTask(input.taskId))
1599
1837
  .length;
1600
- 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');
1601
1840
  if (pendingOutboxCount > 0) {
1841
+ timer.finish();
1602
1842
  return asJsonValue({
1603
1843
  verified: false,
1604
1844
  reason: 'pending_outbox',
@@ -1608,6 +1848,7 @@ export class BridgeService {
1608
1848
  });
1609
1849
  }
1610
1850
  const pointer = await this.requireActivePointer(repository.repoFingerprint, input.taskId, false, 'verify');
1851
+ repository = await this.taskRepository(repository, pointer);
1611
1852
  const pendingCorrectionScopes = await this.pendingCorrectionScopes(pointer.projectId, pointer.taskSlug);
1612
1853
  if (pendingCorrectionScopes.length > 0) {
1613
1854
  return asJsonValue({
@@ -1640,7 +1881,7 @@ export class BridgeService {
1640
1881
  body: {
1641
1882
  sessionId: input.sessionId,
1642
1883
  repoFingerprint: repository.repoFingerprint,
1643
- source: (await this.checkoutSource(repository, pointer.source)) ?? null,
1884
+ source: (await this.checkoutSource(repository, pointer.source, pointer)) ?? null,
1644
1885
  afterSequence: 0,
1645
1886
  },
1646
1887
  });
@@ -1661,6 +1902,10 @@ export class BridgeService {
1661
1902
  await this.assertTaskWorktree(repository, snapshotTask, pointer);
1662
1903
  const resourceDiscoveryPolicy = readResourceDiscoveryPolicy(snapshot, activeLease);
1663
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
+ }
1664
1909
  if (taskMode === 'read_only') {
1665
1910
  if (input.leaseId || input.changedPaths?.length) {
1666
1911
  throw refuse('This task is read-only, so it has no lease and no changed paths to report.', 'task.verify');
@@ -1668,48 +1913,114 @@ export class BridgeService {
1668
1913
  }
1669
1914
  else {
1670
1915
  const baseline = pointer.changeBaseline;
1671
- const leaseChangedPaths = Array.isArray(activeLease?.changedPaths)
1672
- ? activeLease.changedPaths.filter((path) => typeof path === 'string')
1673
- : [];
1674
1916
  if (!baseline) {
1675
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');
1676
1918
  }
1677
- if (activeLease?.baselineDiffHash !== baseline.diffHash) {
1678
- throw refuse(`The active lease was taken against baseline ${short(activeLease?.baselineDiffHash)} but this task started from ${short(baseline.diffHash)}, so the lease must be taken again against the task's own baseline.`, 'context.prepare_change');
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');
1921
+ }
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');
1679
1924
  }
1680
- const wholeTreeDelta = manifestDelta(baseline.changedPaths, repository.git.changedPaths);
1681
- const leased = new Set(normalizeChangedPaths([...baseline.leasePaths, ...leaseChangedPaths]));
1682
- taskChanges = wholeTreeDelta.filter((entry) => leased.has(normalizeChangedPaths([entry.path])[0]));
1683
- foreignChangedPaths = wholeTreeDelta
1684
- .filter((entry) => !leased.has(normalizeChangedPaths([entry.path])[0]))
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]))
1685
1929
  .map((entry) => entry.path)
1686
1930
  .sort();
1687
1931
  taskChangedPaths = taskChanges.map((entry) => entry.path).sort();
1688
- if (input.changedPaths &&
1689
- stableStringify(normalizeChangedPaths(input.changedPaths)) !==
1690
- stableStringify(taskChangedPaths)) {
1691
- throw refuse(`Reported changed paths do not match the actual Git manifest. ${describePathDisagreement(normalizeChangedPaths(input.changedPaths), taskChangedPaths)} The manifest is the difference between the tree this task started from and the tree now; report exactly what it says. A manifest that looks short means the baseline has moved, and resuming repairs it.`, 'session.resume');
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
+ });
1692
1977
  }
1693
1978
  if (taskChangedPaths.length === 0) {
1694
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');
1695
1980
  }
1696
- if (!activeLease || !input.leaseId || activeLease.id !== input.leaseId) {
1697
- throw refuse(`Verification needs the lease the backend currently holds. It has ${short(activeLease?.id)} and you sent ${short(input.leaseId)}.`, 'context.prepare_change');
1698
- }
1699
- const unleased = taskChangedPaths.filter((path) => !pathsContainAll(baseline.leasePaths, [path]) ||
1700
- !pathsContainAll(leaseChangedPaths, [path]));
1701
- if (unleased.length > 0) {
1702
- throw refuse(`These changed paths are outside the current lease: ${unleased.slice(0, 5).join(', ')}${unleased.length > 5 ? `, and ${unleased.length - 5} more` : ''}. The lease must cover every path this task changed.`, 'context.prepare_change');
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');
1703
1983
  }
1704
- assertNewResourceEvidence(taskChanges.flatMap((entry) => newMemoryResourceCandidate(entry, resourceDiscoveryPolicy)), input.newResources ?? []);
1705
1984
  }
1706
1985
  assertSafeToPersist(cleanJson(validations));
1707
- 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
+ }
1708
2019
  const pathChangeManifest = taskMode === 'read_only'
1709
2020
  ? []
1710
2021
  : await buildPathChangeManifest(repository.repoRoot, taskChanges);
1711
2022
  const verifyBody = cleanJson({
1712
- source: await this.checkoutSource(repository, pointer.source),
2023
+ source: await this.checkoutSource(repository, pointer.source, pointer),
1713
2024
  taskId: input.taskId,
1714
2025
  sessionId: input.sessionId,
1715
2026
  expectedTaskVersion: numericTaskVersion(snapshotTask.lockVersion),
@@ -1729,6 +2040,7 @@ export class BridgeService {
1729
2040
  : {}),
1730
2041
  ...(taskMode === 'read_only' ? { worktreeHash: repository.git.worktreeHash } : {}),
1731
2042
  validations,
2043
+ ...(validationWaivers.length > 0 ? { validationWaivers } : {}),
1732
2044
  pendingOutboxCount,
1733
2045
  newResources: input.newResources,
1734
2046
  introducedStructures: input.introducedStructures,
@@ -1749,6 +2061,8 @@ export class BridgeService {
1749
2061
  if (!isBackendUnavailableError(error)) {
1750
2062
  await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, input.taskId);
1751
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');
1752
2066
  throw error;
1753
2067
  }
1754
2068
  const responseData = objectValue(response.data);
@@ -1772,6 +2086,7 @@ export class BridgeService {
1772
2086
  else {
1773
2087
  await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, input.taskId);
1774
2088
  }
2089
+ timer.finish();
1775
2090
  return asJsonValue({
1776
2091
  ...objectOrEmpty(response.data),
1777
2092
  mode: taskMode,
@@ -1783,11 +2098,45 @@ export class BridgeService {
1783
2098
  });
1784
2099
  });
1785
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
+ }
1786
2134
  async taskClose(input) {
1787
2135
  return await this.execute(async () => {
1788
2136
  return await this.taskExclusive(input.taskId, async () => {
1789
- const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1790
- const pointer = await this.requireActivePointer(repository.repoFingerprint, input.taskId, false, 'close');
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);
1791
2140
  if (pointer.closeIntent) {
1792
2141
  return await this.retryCloseIntent(repository, pointer);
1793
2142
  }
@@ -1796,7 +2145,7 @@ export class BridgeService {
1796
2145
  body: {
1797
2146
  sessionId: pointer.sessionId,
1798
2147
  repoFingerprint: repository.repoFingerprint,
1799
- source: (await this.checkoutSource(repository, pointer.source)) ?? null,
2148
+ source: (await this.checkoutSource(repository, pointer.source, pointer)) ?? null,
1800
2149
  afterSequence: 0,
1801
2150
  },
1802
2151
  });
@@ -1810,7 +2159,7 @@ export class BridgeService {
1810
2159
  : [];
1811
2160
  await this.assertTaskWorktree(repository, task, pointer);
1812
2161
  const closeBody = {
1813
- source: (await this.checkoutSource(repository, pointer.source)) ?? null,
2162
+ source: (await this.checkoutSource(repository, pointer.source, pointer)) ?? null,
1814
2163
  taskId: input.taskId,
1815
2164
  sessionId: pointer.sessionId,
1816
2165
  expectedTaskVersion: numericTaskVersion(task.lockVersion),
@@ -1831,7 +2180,7 @@ export class BridgeService {
1831
2180
  }
1832
2181
  async worktreeTaskAllocation(input) {
1833
2182
  return this.execute(async () => {
1834
- const repository = await this.dependencies.repositories.resolve(input.repoRoot);
2183
+ const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot);
1835
2184
  const allocation = repository.projectId && this.dependencies.worktreePool
1836
2185
  ? await this.dependencies.worktreePool.find(repository.projectId, input.externalTaskId, repository.repoRoot)
1837
2186
  : null;
@@ -1840,18 +2189,68 @@ export class BridgeService {
1840
2189
  }
1841
2190
  async projectGitPreferences(input) {
1842
2191
  return this.execute(async () => {
1843
- 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);
1844
2193
  if (!repository.projectId)
1845
2194
  throw refuse('Bind a project before reading Git preferences.', 'project.resolve');
1846
2195
  const response = await this.dependencies.client.request(endpoints.projectGitPreferences(repository.projectId));
1847
2196
  return asJsonValue({
1848
2197
  ...objectOrEmpty(objectValue(response.data)),
1849
2198
  repoRoot: repository.repoRoot,
1850
- 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,
1851
2229
  currentCommit: repository.git.head,
2230
+ folder,
2231
+ suggestedName,
1852
2232
  });
1853
2233
  });
1854
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
+ }
1855
2254
  async setProjectGitPreferences(projectId, body) {
1856
2255
  return this.execute(async () => {
1857
2256
  assertSafeToPersist(body);
@@ -1887,18 +2286,43 @@ export class BridgeService {
1887
2286
  }
1888
2287
  async worktreeOperation(operation, input) {
1889
2288
  return this.execute(async () => {
1890
- 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');
1891
2292
  const projectId = repository.projectId;
1892
2293
  const pool = this.dependencies.worktreePool;
1893
2294
  if (!projectId || !pool)
1894
2295
  throw refuse('Resolve the project with a pool-capable client.', 'project.resolve');
1895
2296
  if (operation === 'list' || operation === 'reconcile') {
1896
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();
1897
2321
  return asJsonValue({
1898
- items: await pool.reconcile(projectId, repository.repoRoot, await this.poolPolicy()),
1899
- selected: await pool.forPath(projectId, repository.repoRoot),
2322
+ items,
2323
+ selected,
1900
2324
  managedRoot,
1901
- ...(managedRoot.problem || managedRoot.synced
2325
+ ...(managedRoot.problem || managedRoot.synced || retired
1902
2326
  ? {
1903
2327
  notice: [
1904
2328
  ...(managedRoot.problem ? [managedRoot.problem] : []),
@@ -1907,6 +2331,11 @@ export class BridgeService {
1907
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.`,
1908
2332
  ]
1909
2333
  : []),
2334
+ ...(retired
2335
+ ? [
2336
+ `Slots outside ${managedRoot.root} are retired: they are never reused and do not count toward the limit.`,
2337
+ ]
2338
+ : []),
1910
2339
  ].join(' '),
1911
2340
  }
1912
2341
  : {}),
@@ -1934,29 +2363,96 @@ export class BridgeService {
1934
2363
  await pool.releaseUnowned(projectId, repository.repoRoot, input.generation, input.sourceCommit);
1935
2364
  return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
1936
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
+ }
1937
2381
  const entry = await pool.forPath(projectId, repository.repoRoot);
1938
2382
  if (!entry ||
1939
2383
  entry.externalTaskId !== input.externalTaskId ||
1940
2384
  entry.generation !== input.generation)
1941
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');
1942
2391
  const perform = async () => {
1943
- if (operation === 'heartbeat')
1944
- await pool.assertOwnership(projectId, repository.repoRoot, input.generation);
1945
- if (operation === 'pause') {
1946
- await pool.pause(projectId, repository.repoRoot, input.generation);
1947
- this.poolAllocations.delete(repository.repoRoot);
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 });
1948
2433
  }
1949
- if (operation === 'release') {
1950
- if (entry.taskId)
1951
- await this.flushOutbox(entry.taskId);
1952
- await pool.release(projectId, repository.repoRoot, input.generation, input.delivered === true);
1953
- this.poolAllocations.delete(repository.repoRoot);
2434
+ finally {
2435
+ timer.finish();
1954
2436
  }
1955
- return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
1956
2437
  };
1957
2438
  return entry.taskId ? this.taskExclusive(entry.taskId, perform, false) : perform();
1958
2439
  });
1959
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
+ }
1960
2456
  async pooledTaskBranch(repository, input) {
1961
2457
  const pool = this.dependencies.worktreePool;
1962
2458
  const projectId = repository.projectId;
@@ -1967,24 +2463,72 @@ export class BridgeService {
1967
2463
  if (existing)
1968
2464
  source = { sourceCommit: existing.baseCommit };
1969
2465
  else {
1970
- if (!input.decisionId || !input.base)
2466
+ if (!input.decisionId || !input.startId || !input.base)
1971
2467
  throw refuse('Choose the task base through the native task.branch questionnaire.', 'task.branch');
1972
- const decision = await this.questionnaireResume({
2468
+ const start = taskStartChoice(await this.questionnaireResume({
1973
2469
  repoRoot: repository.repoRoot,
1974
- questionnaireId: input.decisionId,
1975
- });
1976
- const signature = sha256(stableStringify({
2470
+ questionnaireId: input.startId,
2471
+ }));
2472
+ const request = {
1977
2473
  externalTaskId: input.externalTaskId,
1978
2474
  base: input.base,
1979
2475
  name: input.name ?? null,
1980
2476
  keepCurrent: input.keepCurrent === true,
1981
2477
  ...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
1982
- }));
1983
- if (!['task-branch-confirm-' + signature, 'task-branch-fallback-' + signature].includes(decision.questionnaireId) ||
1984
- decision.answer?.choice !== 'approve')
1985
- throw refuse('The task branch decision does not match the approved source and branch.', 'task.branch');
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;
1986
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
+ }
1987
2528
  }
2529
+ const allocationTimer = startPhaseTimer('task.branch.allocate');
2530
+ const allocationPolicy = await this.poolPolicy(true);
2531
+ allocationTimer.mark('policy');
1988
2532
  const allocation = await pool.allocate({
1989
2533
  projectId,
1990
2534
  projectName: preferences.projectName ?? projectId,
@@ -1994,8 +2538,11 @@ export class BridgeService {
1994
2538
  branch: existing?.branch ?? input.name ?? (await git.currentBranch(repository.repoRoot)),
1995
2539
  baseCommit: source.sourceCommit,
1996
2540
  keepCurrent: input.keepCurrent,
2541
+ inPlace: !existing && input.inPlace,
1997
2542
  resume: Boolean(existing),
1998
- }, await this.poolPolicy(true));
2543
+ }, allocationPolicy);
2544
+ allocationTimer.mark('pool_allocate');
2545
+ allocationTimer.finish();
1999
2546
  this.poolAllocations.set(allocation.repoRoot, allocation);
2000
2547
  if (existing && allocation.repoRoot === repository.repoRoot) {
2001
2548
  const current = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, input.externalTaskId);
@@ -2067,8 +2614,9 @@ export class BridgeService {
2067
2614
  (await this.dependencies.worktreePool.find(repository.projectId, input.externalTaskId, repository.repoRoot)))
2068
2615
  return this.pooledTaskBranch(repository, input);
2069
2616
  if (Boolean(input.name) === (input.keepCurrent === true) ||
2070
- (input.worktreePath && !input.name))
2071
- throw refuse('Choose exactly one branch name or keepCurrent. A separate worktree also needs a branch name.', 'task.branch');
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');
2072
2620
  assertSafeToPersist(cleanJson({ externalTaskId: input.externalTaskId, name: input.name }));
2073
2621
  if (this.dependencies.worktreePool)
2074
2622
  return this.pooledTaskBranch(repository, input);
@@ -2124,7 +2672,24 @@ export class BridgeService {
2124
2672
  async requireBranchDecision(repository, externalTaskId) {
2125
2673
  const git = this.dependencies.repositories.git;
2126
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
+ }
2127
2688
  let record = await store.read();
2689
+ if (record &&
2690
+ (record.decision.projectId !== repository.projectId ||
2691
+ record.decision.externalTaskId !== externalTaskId))
2692
+ record = null;
2128
2693
  if (!record) {
2129
2694
  const pointer = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, externalTaskId);
2130
2695
  if (pointer?.worktreeId === sha256(repository.repoRoot) &&
@@ -2140,15 +2705,102 @@ export class BridgeService {
2140
2705
  }
2141
2706
  }
2142
2707
  if (!record)
2143
- throw refuse(`Ask the branch question through the native questionnaire for task ${externalTaskId}, then call task.branch with that externalTaskId and a name or keepCurrent. The managed pool allocates the separate worktree; use its returned repoRoot.`, 'task.branch');
2144
- if (record.decision.projectId !== repository.projectId ||
2145
- record.decision.externalTaskId !== externalTaskId)
2146
- throw refuse(`This worktree belongs to task ${record.decision.externalTaskId}. Use task.branch to allocate a separate worktree for ${externalTaskId}, then bootstrap in the returned repoRoot.`, 'task.branch');
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');
2147
2709
  const branch = await git.currentBranch(repository.repoRoot);
2148
2710
  if (branch !== record.decision.branch)
2149
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');
2150
2712
  return record.decision;
2151
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
+ }
2152
2804
  async assertTaskWorktree(repository, task, pointer) {
2153
2805
  if (normalizeTaskMode(task.mode) === 'read_only')
2154
2806
  return;
@@ -2177,7 +2829,10 @@ export class BridgeService {
2177
2829
  ? await this.ownedAllocation(repository.projectId, repository.repoRoot)
2178
2830
  : this.poolAllocations.get(repository.repoRoot);
2179
2831
  if (allocation && this.dependencies.worktreePool) {
2180
- 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);
2181
2836
  this.poolAllocations.delete(repository.repoRoot);
2182
2837
  }
2183
2838
  else
@@ -2611,10 +3266,13 @@ export class BridgeService {
2611
3266
  }
2612
3267
  async sessionEntry(input = {}) {
2613
3268
  return await this.execute(async () => {
3269
+ const timer = startPhaseTimer('session.entry');
2614
3270
  const authentication = await this.dependencies.browserAuth.status();
3271
+ timer.mark('auth_status');
2615
3272
  let repository;
2616
3273
  try {
2617
3274
  repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
3275
+ timer.mark('resolve_repository');
2618
3276
  }
2619
3277
  catch (error) {
2620
3278
  const missingTarget = error.code === 'ENOENT';
@@ -2636,32 +3294,44 @@ export class BridgeService {
2636
3294
  });
2637
3295
  }
2638
3296
  const decision = await this.dependencies.repositoryDecisions.load(repository.repoFingerprint);
3297
+ timer.mark('load_decision');
2639
3298
  const authenticated = authentication.authenticated === true;
2640
3299
  const state = decision?.state === 'disabled' ? 'disabled' : repository.bindingPath ? 'bound' : 'none';
2641
- 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);
2642
3325
  const shipped = objectValue(objectValue(client)?.shippedKnowledge);
2643
3326
  const incomplete = shipped && (shipped.failure !== null || Number(shipped.live) < Number(shipped.expected));
2644
- const projectId = repository.projectId;
2645
- const liveTasks = authenticated && state === 'bound'
2646
- ? (await this.dependencies.activeContexts.list(repository.repoFingerprint)).map(describePointer)
2647
- : [];
2648
- const workItems = authenticated && state === 'bound' && projectId
2649
- ? await this.actionableWorkItems(projectId)
2650
- : [];
2651
- const gitPreferences = authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
2652
- ? await this.projectGitPreferences({ repoRoot: repository.repoRoot, projectId })
2653
- : null;
2654
3327
  const preferences = gitPreferences?.ok ? objectValue(gitPreferences.data) : null;
2655
3328
  const unansweredBranches = preferences?.canManage === true
2656
3329
  ? Object.entries(objectOrEmpty(objectValue(preferences.branches)))
2657
3330
  .filter(([, value]) => objectValue(value)?.answered === false)
2658
3331
  .map(([role]) => role)
2659
3332
  : [];
2660
- const worktrees = authenticated && state === 'bound' && projectId && this.dependencies.worktreePool
2661
- ? await this.worktreeOperation('list', { repoRoot: repository.repoRoot })
2662
- : null;
2663
3333
  const taskSelectionRequired = liveTasks.length > 0 || workItems.length > 0;
2664
- const pendingQuestionnaires = state !== 'disabled' ? await this.pendingQuestionnaires(repository.repoRoot) : [];
3334
+ timer.finish();
2665
3335
  return asJsonValue({
2666
3336
  authenticated,
2667
3337
  repository: publicRepository(repository),
@@ -2676,21 +3346,24 @@ export class BridgeService {
2676
3346
  gitPreferences: gitPreferences.ok ? gitPreferences.data : gitPreferences.error,
2677
3347
  unansweredBranchPreferences: unansweredBranches,
2678
3348
  branchPreferenceAction: unansweredBranches.length
2679
- ? 'Ask only these unanswered shared branch preferences using native questionnaires and project.set_git_preferences. Other members choose only their task base.'
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.'
2680
3350
  : 'Existing answers remain valid, including explicit absence. Choose the task base through task.branch when starting development.',
2681
3351
  }
2682
3352
  : {}),
2683
- ...(state !== 'disabled' ? { pendingQuestionnaires } : {}),
3353
+ ...(state !== 'disabled' ? pendingQuestionnairesFields(pendingQuestionnaires) : {}),
3354
+ ...(pendingQuestionnaires.deferred.length > 0
3355
+ ? { deferredTaskStarts: pendingQuestionnaires.deferred }
3356
+ : {}),
2684
3357
  client,
2685
3358
  ...(incomplete && state === 'bound'
2686
3359
  ? {
2687
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.',
2688
3361
  }
2689
3362
  : {}),
2690
- nextAction: pendingQuestionnaires.length > 0
3363
+ nextAction: pendingQuestionnaires.mine.length > 0
2691
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.'
2692
3365
  : taskSelectionRequired
2693
- ? 'Present the live runs and actionable project work items, then ask which one this chat should handle. Resume only the run they choose, or bootstrap a separate run with the chosen workItemId. Another live task never blocks opening this one.'
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.'
2694
3367
  : entryNextAction(authenticated, state),
2695
3368
  });
2696
3369
  });
@@ -2749,7 +3422,7 @@ export class BridgeService {
2749
3422
  }
2750
3423
  async sessionSetDecision(input) {
2751
3424
  return await this.execute(async () => {
2752
- const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
3425
+ const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot ?? process.cwd());
2753
3426
  if (input.decision === 'none') {
2754
3427
  await this.dependencies.repositoryDecisions.clear(repository.repoFingerprint);
2755
3428
  return asJsonValue({
@@ -2892,6 +3565,10 @@ export class BridgeService {
2892
3565
  }
2893
3566
  const taskEntries = (await this.dependencies.outbox.list()).filter((entry) => taskIdFromDeliverySafe(entry) === input.taskId);
2894
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');
2895
3572
  const blockedEntry = taskEntries.find((entry) => entry.lastError && entry.lastError !== 'backend_unavailable');
2896
3573
  if (blockedEntry && blockedEntry.idempotencyKey !== idempotencyKey) {
2897
3574
  throw refuse('A blocked task delivery must be explicitly resolved before checkpointing', 'task.resolve_pending_delivery');
@@ -3013,7 +3690,7 @@ export class BridgeService {
3013
3690
  throw refuse('The receipt does not belong to this task project.', 'session.resume');
3014
3691
  if (pointer.resumeConflicts.length || pointer.verificationIntent || pointer.closeIntent)
3015
3692
  throw refuse('Resume a clean active task before recording this receipt.', 'session.resume');
3016
- await this.checkoutSource(repository, pointer.source);
3693
+ await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer);
3017
3694
  await this.recoverJournalOutbox(pointer.projectId, pointer.taskSlug);
3018
3695
  const entries = await this.dependencies.outbox.listForTask(taskId);
3019
3696
  if (entries.some((entry) => entry.lastError && entry.lastError !== 'backend_unavailable'))
@@ -3127,19 +3804,16 @@ export class BridgeService {
3127
3804
  });
3128
3805
  const backend = objectValue(response.data);
3129
3806
  const task = objectValue(backend?.task);
3130
- if (!task ||
3131
- task.id !== body.taskId ||
3132
- task.projectId !== entry.projectId ||
3133
- backend?.requiresContextRefresh === true)
3807
+ if (!task || task.id !== body.taskId || task.projectId !== entry.projectId)
3134
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');
3135
3811
  recovered = Boolean(task &&
3136
3812
  task.id === body.taskId &&
3137
3813
  task.lockVersion === Number(body.expectedTaskVersion) + 1 &&
3138
3814
  task.selfReviewDiffHash === body.diffHash &&
3139
- stableStringify(asJsonValue(task.selfReviewResourceIds)) ===
3140
- stableStringify(asJsonValue(body.reviewedResourceIds)) &&
3141
- stableStringify(asJsonValue(task.selfReviewFindings)) ===
3142
- stableStringify(asJsonValue(body.findings)));
3815
+ stableStringify(asJsonValue(task.selfReviewFiles)) ===
3816
+ stableStringify(asJsonValue(body.files)));
3143
3817
  if (recovered)
3144
3818
  await this.captureTaskSnapshot(response.data);
3145
3819
  }
@@ -3182,13 +3856,34 @@ export class BridgeService {
3182
3856
  }
3183
3857
  return pending.length;
3184
3858
  }
3185
- 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) {
3186
3876
  const current = await this.dependencies.repositories.git.sourceIdentity(repository.repoRoot);
3187
3877
  if (expected &&
3188
3878
  (!current ||
3189
3879
  current.sourceCommit !== expected.sourceCommit ||
3190
3880
  current.treeHash !== expected.treeHash)) {
3191
- throw refuse('The checkout no longer matches this task source. Return to its commit or bootstrap a new task for the current committed source.', 'session.bootstrap');
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');
3192
3887
  }
3193
3888
  if (current && repository.git.head !== current.sourceCommit) {
3194
3889
  throw refuse('The Git source changed during the request. Retry session.resume with a stable checkout.', 'session.resume');
@@ -3214,14 +3909,17 @@ export class BridgeService {
3214
3909
  body: {
3215
3910
  sessionId: pointer.sessionId,
3216
3911
  repoFingerprint: repository.repoFingerprint,
3217
- source: (await this.checkoutSource(repository, pointer.source)) ?? null,
3912
+ source: (await this.checkoutSource(await this.taskRepository(repository, pointer), pointer.source, pointer)) ?? null,
3218
3913
  afterSequence: 0,
3219
3914
  },
3220
3915
  });
3221
3916
  const backend = objectValue(response.data);
3222
3917
  const task = objectValue(backend?.task);
3223
- if (!task || task.id !== taskId || backend?.requiresContextRefresh === true) {
3224
- throw refuse('Backend task snapshot is stale or does not match the active task', 'session.resume');
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');
3225
3923
  }
3226
3924
  const refreshed = {
3227
3925
  ...pointer,
@@ -3243,8 +3941,11 @@ export class BridgeService {
3243
3941
  });
3244
3942
  const backend = objectValue(response.data);
3245
3943
  const task = objectValue(backend?.task);
3246
- if (!task || task.id !== taskId || backend?.requiresContextRefresh === true) {
3247
- throw refuse('Backend task snapshot is stale or does not match the pending delivery', 'session.resume');
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');
3248
3949
  }
3249
3950
  const refreshed = {
3250
3951
  ...pointer,
@@ -3259,6 +3960,13 @@ export class BridgeService {
3259
3960
  const blockingConflicts = pointer?.resumeConflicts.filter((conflict) => !(allowOfflineWarnings && isOfflineDevelopmentWarning(conflict)));
3260
3961
  const lifecycleIntentBlocked = (pointer?.verificationIntent && allowedIntent !== 'verify') ||
3261
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
+ }
3262
3970
  if (!pointer ||
3263
3971
  pointer.taskId !== taskId ||
3264
3972
  (blockingConflicts?.length ?? 0) > 0 ||
@@ -3275,7 +3983,7 @@ export class BridgeService {
3275
3983
  }
3276
3984
  }
3277
3985
  async retryVerificationRecovery(repository, pointer, backend) {
3278
- await this.checkoutSource(repository, pointer.source);
3986
+ await this.checkoutSource(repository, pointer.source, pointer);
3279
3987
  const intent = pointer.verificationIntent;
3280
3988
  const recovery = objectValue(backend?.verificationRecovery);
3281
3989
  if (!intent) {
@@ -3402,7 +4110,9 @@ export class BridgeService {
3402
4110
  return await this.deliverCloseIntent(repository, body, intent.taskChanges, true);
3403
4111
  }
3404
4112
  async deliverCloseIntent(repository, body, taskChanges, recoveredAfterResponseLoss) {
3405
- 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
+ });
3406
4116
  const touchedContract = taskChanges.some((change) => /\.controller\.[tj]s$/.test(change.path));
3407
4117
  const currentBranch = await this.dependencies.repositories.git.currentBranch(repository.repoRoot);
3408
4118
  if (typeof body.branch === 'string' && body.branch !== currentBranch)
@@ -3443,6 +4153,12 @@ export class BridgeService {
3443
4153
  taskChanges,
3444
4154
  });
3445
4155
  await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
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
+ });
3446
4162
  const allocation = repository.projectId
3447
4163
  ? await this.ownedAllocation(repository.projectId, repository.repoRoot)
3448
4164
  : this.poolAllocations.get(repository.repoRoot);
@@ -3456,7 +4172,7 @@ export class BridgeService {
3456
4172
  ...closedTask,
3457
4173
  repository: publicRepository(repository),
3458
4174
  recoveredAfterResponseLoss,
3459
- delivery: deliveryQuestion(touchedContract),
4175
+ delivery: deliveryQuestion(touchedContract, await this.dependencies.repositories.git.pushDestination(repository.repoRoot), objectValue(closedTask.sourcePublication)?.applicable === true),
3460
4176
  });
3461
4177
  }
3462
4178
  async seedResumeSnapshot(pointer, backendPatch) {
@@ -3506,7 +4222,14 @@ export class BridgeService {
3506
4222
  return await this.requireActivePointer(repository.repoFingerprint, input.taskId, true);
3507
4223
  })()
3508
4224
  : await this.dependencies.activeContexts.findByTaskId(input.taskId, input.projectId, false);
3509
- 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) {
3510
4233
  throw refuse('A clean active task snapshot is required for checkpointing', 'session.resume');
3511
4234
  }
3512
4235
  if (pointer.verificationIntent || pointer.closeIntent) {
@@ -3587,6 +4310,7 @@ export class BridgeService {
3587
4310
  code: error.code ?? error.httpStatus,
3588
4311
  retryable: error.retryable,
3589
4312
  ...(error.recovery ? { recovery: error.recovery } : {}),
4313
+ ...(error.envelope?.data ? { details: error.envelope.data } : {}),
3590
4314
  },
3591
4315
  };
3592
4316
  }
@@ -3597,6 +4321,9 @@ export class BridgeService {
3597
4321
  message: error instanceof Error ? error.message : 'Bridge operation failed',
3598
4322
  retryable: false,
3599
4323
  ...(error instanceof BridgeRecoveryError ? { recovery: error.recovery } : {}),
4324
+ ...(error instanceof BridgeRecoveryError && error.details !== undefined
4325
+ ? { details: error.details }
4326
+ : {}),
3600
4327
  },
3601
4328
  };
3602
4329
  }
@@ -3614,6 +4341,59 @@ function normalizeChangedPaths(paths) {
3614
4341
  }
3615
4342
  return [...new Set(normalized)].sort();
3616
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
+ };
3617
4397
  function pathsContainAll(allowedPaths, actualPaths) {
3618
4398
  const allowed = new Set(normalizeChangedPaths(allowedPaths));
3619
4399
  return normalizeChangedPaths(actualPaths).every((path) => allowed.has(path));
@@ -3694,6 +4474,12 @@ async function buildPathChangeManifest(repoRoot, changes) {
3694
4474
  });
3695
4475
  }));
3696
4476
  }
4477
+ function pendingQuestionnairesFields(value) {
4478
+ return {
4479
+ pendingQuestionnaires: value.mine,
4480
+ ...(value.other.length > 0 ? { otherTasksPendingQuestionnaires: value.other } : {}),
4481
+ };
4482
+ }
3697
4483
  function describePointer(pointer) {
3698
4484
  return asJsonValue({
3699
4485
  taskSlug: pointer.taskSlug,
@@ -3740,26 +4526,42 @@ function entryNextAction(authenticated, decision) {
3740
4526
  return 'Ask the user to sign in before answering anything about this project, and start the browser flow when they agree.';
3741
4527
  }
3742
4528
  if (decision === 'bound') {
3743
- 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.';
3744
4530
  }
3745
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.';
3746
4532
  }
3747
- 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
+ : '');
3748
4549
  return asJsonValue({
3749
4550
  required: true,
3750
- question: 'The task is closed and nothing has been committed. Ask the user which of these to do, and do only what they choose.',
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,
3751
4553
  options: [
3752
- { id: 'commit', label: 'Commit' },
3753
- { id: 'commit_push', label: 'Commit and push' },
4554
+ { id: 'commit', label: `Commit on ${onto}` },
4555
+ { id: 'commit_push', label: `Commit and push${to}${blocked}` },
3754
4556
  {
3755
4557
  id: 'commit_push_draft_pr',
3756
- label: 'Commit, push and open a draft pull request',
3757
- input: 'base branch the pull request targets',
4558
+ label: `Commit, push${to} and open a draft pull request${blocked}`,
4559
+ input: base,
3758
4560
  },
3759
4561
  {
3760
4562
  id: 'commit_push_pr',
3761
- label: 'Commit, push and open a pull request',
3762
- input: 'base branch the pull request targets',
4563
+ label: `Commit, push${to} and open a pull request${blocked}`,
4564
+ input: base,
3763
4565
  },
3764
4566
  ],
3765
4567
  ...(touchedContract
@@ -3768,6 +4570,9 @@ function deliveryQuestion(touchedContract = false) {
3768
4570
  }
3769
4571
  : {}),
3770
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.',
3771
4576
  });
3772
4577
  }
3773
4578
  function cleanJson(value) {
@@ -3790,6 +4595,73 @@ function objectValue(value) {
3790
4595
  function objectOrEmpty(value) {
3791
4596
  return objectValue(value ?? undefined) ?? {};
3792
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
+ }
3793
4665
  function archivedProjectGuidance(project) {
3794
4666
  const projectId = typeof project?.id === 'string' ? project.id : null;
3795
4667
  const expectedVersion = typeof project?.lockVersion === 'number' ? project.lockVersion : null;
@@ -3837,6 +4709,11 @@ function classifyError(error) {
3837
4709
  }
3838
4710
  return 'bridge_delivery_error';
3839
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
+ }
3840
4717
  function isDefinitiveAuthenticationFailure(error) {
3841
4718
  return (error instanceof ApiResponseError &&
3842
4719
  (error.httpStatus === 400 || error.httpStatus === 401 || error.httpStatus === 403));
@@ -3914,24 +4791,49 @@ function isOfflineLeaseUsable(lease, sessionId, changedPaths, baselineDiffHash)
3914
4791
  return false;
3915
4792
  }
3916
4793
  }
3917
- function refuse(message, recovery) {
3918
- 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);
3919
4800
  }
3920
4801
  function short(value) {
3921
4802
  return typeof value === 'string' && value.length > 0 ? value.slice(0, 12) : 'none';
3922
4803
  }
3923
- function describePathDisagreement(reported, manifest) {
3924
- const inManifest = new Set(manifest);
3925
- const inReport = new Set(reported);
3926
- const missing = manifest.filter((path) => !inReport.has(path));
3927
- const extra = reported.filter((path) => !inManifest.has(path));
3928
- const sample = (paths) => paths.slice(0, 5).join(', ') + (paths.length > 5 ? `, and ${paths.length - 5} more` : '');
3929
- const parts = [`You reported ${reported.length}; the manifest has ${manifest.length}.`];
3930
- if (missing.length > 0)
3931
- parts.push(`Missing from your report: ${sample(missing)}.`);
3932
- if (extra.length > 0)
3933
- parts.push(`Reported but not in the manifest: ${sample(extra)}.`);
3934
- return parts.join(' ');
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)}`;
3935
4837
  }
3936
4838
  function repairChangeBaseline(local, recordedDiffHash, head) {
3937
4839
  if (!recordedDiffHash || local?.diffHash === recordedDiffHash)
@@ -4122,7 +5024,7 @@ function globMatches(path, pattern) {
4122
5024
  }
4123
5025
  return minimatch(path.replace(/\\/g, '/'), normalizedPattern, { dot: true });
4124
5026
  }
4125
- function assertNewResourceEvidence(candidates, evidence) {
5027
+ function newResourceEvidenceFindings(candidates, evidence) {
4126
5028
  const normalizedEvidence = evidence.map((entry) => ({
4127
5029
  ...entry,
4128
5030
  path: normalizeChangedPaths([entry.path])[0],
@@ -4133,47 +5035,56 @@ function assertNewResourceEvidence(candidates, evidence) {
4133
5035
  if (new Set(normalizedEvidence.map((entry) => entry.path)).size !== normalizedEvidence.length) {
4134
5036
  throw refuse('New resource evidence contains duplicate paths', 'task.verify');
4135
5037
  }
4136
- const expected = [...candidates].sort((left, right) => left.path.localeCompare(right.path));
4137
- const actual = normalizedEvidence
4138
- .map(({ path, kind }) => ({ path, kind }))
4139
- .sort((left, right) => left.path.localeCompare(right.path));
4140
- if (stableStringify(expected) !== stableStringify(actual)) {
4141
- const expectedPaths = new Set(expected.map((entry) => entry.path));
4142
- const actualPaths = new Set(actual.map((entry) => entry.path));
4143
- const missing = expected
4144
- .filter((entry) => !actualPaths.has(entry.path))
4145
- .map((entry) => `${entry.path} (${entry.kind})`);
4146
- const extra = actual
4147
- .filter((entry) => !expectedPaths.has(entry.path))
4148
- .map((entry) => `${entry.path} (${entry.kind})`);
4149
- const details = [
4150
- missing.length ? `missing evidence for ${missing.join(', ')}` : '',
4151
- extra.length ? `evidence for paths the policy does not classify: ${extra.join(', ')}` : '',
4152
- ].filter(Boolean);
4153
- throw refuse(`New screen or shared component paths require exact memory reconciliation. ${details.length ? details.join('; ') : 'The kinds do not match the project discovery policy.'}`, 'task.verify');
4154
- }
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
+ : [];
4155
5064
  }
4156
- function assertValidationEvidence(validations) {
5065
+ function validationEvidenceFindings(validations) {
4157
5066
  if (validations.length === 0) {
4158
- throw refuse('Validation evidence is required', 'task.verify');
5067
+ return ['Validation evidence is required'];
4159
5068
  }
4160
5069
  const allowed = new Set(validationIds);
4161
5070
  const seen = new Set();
5071
+ const findings = [];
4162
5072
  for (const validation of validations) {
4163
5073
  if (!allowed.has(validation.validationId)) {
4164
- throw refuse(`Unknown validation evidence: ${validation.validationId}`, 'task.verify');
5074
+ findings.push(`Unknown validation evidence: ${validation.validationId}`);
4165
5075
  }
4166
- if (seen.has(validation.validationId)) {
4167
- throw refuse(`Duplicate validation evidence: ${validation.validationId}`, 'task.verify');
5076
+ else if (seen.has(validation.validationId)) {
5077
+ findings.push(`Duplicate validation evidence: ${validation.validationId}`);
4168
5078
  }
4169
5079
  if (!validation.command.trim()) {
4170
- throw refuse(`Validation command is required: ${validation.validationId}`, 'task.verify');
5080
+ findings.push(`Validation command is required: ${validation.validationId}`);
4171
5081
  }
4172
5082
  if (!/^[0-9a-f]{64}$/.test(validation.outputHash)) {
4173
- throw refuse(`Validation output hash is invalid: ${validation.validationId}`, 'task.verify');
5083
+ findings.push(`Validation output hash is invalid: ${validation.validationId}`);
4174
5084
  }
4175
5085
  seen.add(validation.validationId);
4176
5086
  }
5087
+ return findings;
4177
5088
  }
4178
5089
  function sourceFromContext(value) {
4179
5090
  const context = objectValue(value);