engineering-memory 1.11.9 → 1.11.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dispatcher/sections.mjs +7 -2
  2. package/install/codex-approval.mjs +457 -0
  3. package/install/files.mjs +5 -1
  4. package/install/installer.mjs +24 -1
  5. package/package.json +1 -1
  6. package/runtime/build.json +1 -1
  7. package/runtime/dist/src/config.js +2 -0
  8. package/runtime/dist/src/git/git-inspector.js +114 -4
  9. package/runtime/dist/src/git/verification-gate.js +30 -17
  10. package/runtime/dist/src/mcp/onboarding-tools.js +110 -16
  11. package/runtime/dist/src/mcp/questionnaire-tools.js +99 -15
  12. package/runtime/dist/src/mcp/tool-annotations.js +112 -0
  13. package/runtime/dist/src/mcp/tool-definitions.js +150 -29
  14. package/runtime/dist/src/mcp/worktree-tools.js +395 -236
  15. package/runtime/dist/src/project/repository.js +26 -8
  16. package/runtime/dist/src/runtime/active-context-store.js +3 -0
  17. package/runtime/dist/src/runtime/api-client.js +1 -0
  18. package/runtime/dist/src/runtime/branch-preferences.js +0 -19
  19. package/runtime/dist/src/runtime/bridge-service.js +1320 -246
  20. package/runtime/dist/src/runtime/offline-outbox.js +33 -158
  21. package/runtime/dist/src/runtime/phase-timer.js +26 -0
  22. package/runtime/dist/src/runtime/privacy-detector.js +263 -0
  23. package/runtime/dist/src/runtime/questionnaire-store.js +210 -49
  24. package/runtime/dist/src/runtime/recovery-error.js +3 -1
  25. package/runtime/dist/src/runtime/runtime-entry.js +8 -0
  26. package/runtime/dist/src/runtime/runtime-host.js +1 -3
  27. package/runtime/dist/src/runtime/task-branch-store.js +17 -1
  28. package/runtime/dist/src/runtime/task-start.js +281 -0
  29. package/runtime/dist/src/runtime/worktree-pool.js +374 -26
  30. package/runtime/dist/src/utilities/process.js +1 -0
  31. package/skill/SKILL.md +3 -3
  32. package/skill/references/lifecycle.md +59 -19
  33. package/skill/references/memory-updates.md +9 -3
  34. package/skill/references/project-onboarding.md +19 -2
  35. package/skill/references/questionnaires.md +86 -15
@@ -6,7 +6,71 @@ import { assertManagedPath, ensureManagedDirectory, isNodeError, readJson, remov
6
6
  import { sha256, stableStringify } from '../utilities/hash.js';
7
7
  import { assertSafeToPersist } from './offline-outbox.js';
8
8
  const identifier = z.string().regex(/^[A-Za-z0-9_-]{1,100}$/);
9
- export const questionnaireDefinitionSchema = z.strictObject({
9
+ const questionId = z.string().regex(/^[a-z][a-z0-9_]{0,39}$/);
10
+ const optionSchema = z.strictObject({
11
+ id: z
12
+ .string()
13
+ .regex(/^[A-Za-z0-9_-]{1,80}$/)
14
+ .refine((value) => value !== '__other__'),
15
+ label: z.string().trim().min(1).max(240),
16
+ });
17
+ const textFieldSchema = z.strictObject({
18
+ title: z.string().trim().min(1).max(120),
19
+ maxLength: z.number().int().min(1).max(2000),
20
+ requiredForChoice: z
21
+ .string()
22
+ .regex(/^[A-Za-z0-9_-]{1,80}$/)
23
+ .optional(),
24
+ valueType: z.enum(['text', 'uuid_list']).optional(),
25
+ });
26
+ const subQuestionSchema = z
27
+ .strictObject({
28
+ id: questionId,
29
+ message: z.string().trim().min(1).max(600),
30
+ options: z
31
+ .array(optionSchema)
32
+ .min(2)
33
+ .max(4)
34
+ .refine((values) => new Set(values.map((value) => value.id)).size === values.length),
35
+ otherAsText: z.boolean().default(false),
36
+ textField: textFieldSchema.optional(),
37
+ })
38
+ .refine((value) => !value.otherAsText ||
39
+ (value.options.some((option) => option.id === 'other') &&
40
+ value.textField?.requiredForChoice === 'other'), 'otherAsText requires an "other" option with a textField required for that choice.');
41
+ const ownerSchema = z.strictObject({
42
+ tool: z.string().min(1).max(80),
43
+ externalTaskId: z.string().min(1).max(160).optional(),
44
+ decisionAttempt: z.number().int().min(1).max(10000).optional(),
45
+ retryArguments: z.record(z.string(), z.unknown()).optional(),
46
+ });
47
+ function definitionIssues(value) {
48
+ const issues = [];
49
+ const hasOptions = value.options !== undefined;
50
+ const hasQuestions = value.questions !== undefined;
51
+ if (hasOptions === hasQuestions)
52
+ issues.push('Exactly one of options or questions must be set.');
53
+ if (hasQuestions && (value.allowFreeText || value.textField))
54
+ issues.push('allowFreeText and textField apply only to a single-question definition.');
55
+ if (value.terminalChoices) {
56
+ if (!hasQuestions)
57
+ issues.push('terminalChoices requires questions.');
58
+ else {
59
+ const ids = new Set((value.questions ?? []).map((question) => question.id));
60
+ for (const terminal of value.terminalChoices)
61
+ if (!ids.has(terminal.questionId))
62
+ issues.push(`terminalChoices references unknown question "${terminal.questionId}".`);
63
+ }
64
+ }
65
+ return issues;
66
+ }
67
+ function attachDefinitionChecks(schema) {
68
+ return schema.superRefine((value, ctx) => {
69
+ for (const message of definitionIssues(value))
70
+ ctx.addIssue({ code: 'custom', message });
71
+ });
72
+ }
73
+ const questionnaireDefinitionShape = z.strictObject({
10
74
  questionnaireId: identifier,
11
75
  message: z.string().trim().min(1).max(2000),
12
76
  language: z
@@ -14,51 +78,60 @@ export const questionnaireDefinitionSchema = z.strictObject({
14
78
  .optional()
15
79
  .describe('Language of the current conversation: tr for Turkish, en for English. Translate the message and option labels into that language too.'),
16
80
  options: z
17
- .array(z.strictObject({
18
- id: z
19
- .string()
20
- .regex(/^[A-Za-z0-9_-]{1,80}$/)
21
- .refine((value) => value !== '__other__'),
22
- label: z.string().trim().min(1).max(240),
23
- }))
81
+ .array(optionSchema)
24
82
  .min(2)
25
83
  .max(12)
26
- .refine((values) => new Set(values.map((value) => value.id)).size === values.length),
84
+ .refine((values) => new Set(values.map((value) => value.id)).size === values.length)
85
+ .optional(),
27
86
  allowFreeText: z.boolean().default(false),
28
- textField: z
29
- .strictObject({
30
- title: z.string().trim().min(1).max(120),
31
- maxLength: z.number().int().min(1).max(2000),
32
- requiredForChoice: z
33
- .string()
34
- .regex(/^[A-Za-z0-9_-]{1,80}$/)
35
- .optional(),
36
- valueType: z.enum(['text', 'uuid_list']).optional(),
37
- })
87
+ textField: textFieldSchema.optional(),
88
+ questions: z
89
+ .array(subQuestionSchema)
90
+ .min(1)
91
+ .max(8)
92
+ .refine((values) => new Set(values.map((value) => value.id)).size === values.length)
93
+ .optional(),
94
+ terminalChoices: z
95
+ .array(z.strictObject({ questionId, choice: z.string().regex(/^[A-Za-z0-9_-]{1,80}$/) }))
96
+ .max(8)
38
97
  .optional(),
39
98
  });
99
+ export const questionnaireDefinitionSchema = attachDefinitionChecks(questionnaireDefinitionShape);
100
+ const boundDefinitionShape = questionnaireDefinitionShape.extend({
101
+ binding: z.record(z.string(), z.json()).optional(),
102
+ });
103
+ const boundDefinitionSchema = attachDefinitionChecks(boundDefinitionShape);
40
104
  const scopeSchema = z.strictObject({
41
105
  principalHash: z.string().regex(/^[0-9a-f]{64}$/),
42
106
  repoFingerprint: z.string().regex(/^[0-9a-f]{64}$/),
43
107
  projectId: z.string().uuid().nullable(),
44
108
  });
45
- const storedQuestionSchema = questionnaireDefinitionSchema.extend({
109
+ const storedQuestionSchema = attachDefinitionChecks(boundDefinitionShape.extend({
46
110
  schemaVersion: z.literal(1),
47
111
  scope: scopeSchema,
48
112
  contentHash: z.string().regex(/^[0-9a-f]{64}$/),
49
113
  requestKey: z.string().regex(/^questionnaire_[0-9a-f]{64}$/),
50
114
  createdAt: z.string().datetime(),
51
- });
52
- export const hostAnswerSchema = z.strictObject({
115
+ owner: ownerSchema.optional(),
116
+ }));
117
+ const singleAnswerShape = z.strictObject({ choice: z.string(), text: z.string().optional() });
118
+ export const hostAnswerSchema = z
119
+ .strictObject({
53
120
  questionnaireId: identifier,
54
121
  requestKey: z.string().regex(/^questionnaire_[0-9a-f]{64}$/),
55
122
  contentHash: z.string().regex(/^[0-9a-f]{64}$/),
56
123
  hostTool: z.enum(['AskUserQuestion', 'request_user_input']),
57
- answer: z.strictObject({
124
+ answer: z
125
+ .strictObject({ choice: z.string().min(1).max(80), text: z.string().max(2000).optional() })
126
+ .optional(),
127
+ answers: z
128
+ .record(questionId, z.strictObject({
58
129
  choice: z.string().min(1).max(80),
59
130
  text: z.string().max(2000).optional(),
60
- }),
61
- });
131
+ }))
132
+ .optional(),
133
+ })
134
+ .refine((value) => Boolean(value.answer) !== Boolean(value.answers), 'Exactly one of answer or answers must be set.');
62
135
  const answerSourceSchema = z.strictObject({
63
136
  kind: z.literal('host_native_relay'),
64
137
  hostTool: z.enum(['AskUserQuestion', 'request_user_input']),
@@ -69,9 +142,10 @@ const storedAnswerSchema = z.strictObject({
69
142
  answerAvailable: z.boolean(),
70
143
  withdrawn: z.literal(true).optional(),
71
144
  answerSource: answerSourceSchema.optional(),
72
- answer: z.strictObject({ choice: z.string(), text: z.string().optional() }).optional(),
145
+ answer: singleAnswerShape.optional(),
146
+ answers: z.record(questionId, singleAnswerShape).optional(),
73
147
  });
74
- export function questionnaireAnswerSchema(record) {
148
+ function singleQuestionAnswerSchema(record) {
75
149
  const textSchema = record.textField
76
150
  ? record.textField.valueType === 'uuid_list'
77
151
  ? z
@@ -111,20 +185,55 @@ export function questionnaireAnswerSchema(record) {
111
185
  return answer.text === undefined;
112
186
  });
113
187
  }
188
+ function multiQuestionAnswerSchema(record) {
189
+ const terminal = record.terminalChoices ?? [];
190
+ const shape = {};
191
+ for (const sub of record.questions) {
192
+ const optionIds = sub.options.map((option) => option.id);
193
+ const textSchema = sub.textField
194
+ ? z.string().trim().min(1).max(sub.textField.maxLength).optional()
195
+ : z.never().optional();
196
+ shape[sub.id] = z
197
+ .strictObject({
198
+ choice: z.enum(optionIds),
199
+ text: z.preprocess((value) => (typeof value === 'string' && value.trim() === '' ? undefined : value), textSchema),
200
+ })
201
+ .refine((answer) => {
202
+ if (sub.textField?.requiredForChoice === answer.choice)
203
+ return typeof answer.text === 'string';
204
+ return answer.text === undefined;
205
+ })
206
+ .optional();
207
+ }
208
+ return z.strictObject(shape).refine((value) => {
209
+ const answers = value;
210
+ const terminalHit = terminal.some((choice) => answers[choice.questionId]?.choice === choice.choice);
211
+ if (terminalHit)
212
+ return true;
213
+ return record.questions.every((sub) => answers[sub.id] !== undefined);
214
+ }, 'All questions must be answered unless a terminal choice was picked.');
215
+ }
216
+ export function questionnaireAnswerSchema(record) {
217
+ return record.questions
218
+ ? multiQuestionAnswerSchema(record)
219
+ : singleQuestionAnswerSchema(record);
220
+ }
114
221
  export class QuestionnaireStore {
115
222
  root;
116
223
  constructor(stateRoot) {
117
224
  this.root = join(stateRoot, 'questionnaires');
118
225
  }
119
- async ask(scope, input, previousDefinitions = []) {
120
- const definition = questionnaireDefinitionSchema.parse(input);
226
+ async ask(scope, input, previousDefinitions = [], owner) {
227
+ const definition = boundDefinitionSchema.parse(input);
121
228
  const previousHashes = previousDefinitions.map((previous) => {
122
- const parsed = questionnaireDefinitionSchema.parse(previous);
229
+ const parsed = boundDefinitionSchema.parse(previous);
123
230
  if (parsed.questionnaireId !== definition.questionnaireId)
124
231
  throw new Error('Previous questionnaire content must belong to the same decision.');
125
232
  return sha256(stableStringify(parsed));
126
233
  });
127
234
  scopeSchema.parse(scope);
235
+ if (owner)
236
+ ownerSchema.parse(owner);
128
237
  assertSafeToPersist(definition);
129
238
  const contentHash = sha256(stableStringify(definition));
130
239
  const question = {
@@ -134,6 +243,7 @@ export class QuestionnaireStore {
134
243
  contentHash,
135
244
  requestKey: `questionnaire_${sha256(`${stableStringify(scope)}\n${contentHash}\n${randomUUID()}`)}`,
136
245
  createdAt: new Date().toISOString(),
246
+ ...(owner ? { owner } : {}),
137
247
  };
138
248
  await this.publish(this.pathFor(scope, definition.questionnaireId, 'pending.json'), question);
139
249
  const record = await this.get(scope, definition.questionnaireId);
@@ -148,13 +258,16 @@ export class QuestionnaireStore {
148
258
  if (!raw)
149
259
  return null;
150
260
  const question = storedQuestionSchema.parse(raw);
151
- const definition = questionnaireDefinitionSchema.parse({
261
+ const definition = boundDefinitionSchema.parse({
152
262
  questionnaireId: question.questionnaireId,
153
263
  message: question.message,
154
264
  language: question.language,
155
265
  options: question.options,
156
266
  allowFreeText: question.allowFreeText,
157
267
  textField: question.textField,
268
+ questions: question.questions,
269
+ terminalChoices: question.terminalChoices,
270
+ binding: question.binding,
158
271
  });
159
272
  if (stableStringify(question.scope) !== stableStringify(scope) ||
160
273
  question.questionnaireId !== questionnaireId ||
@@ -167,11 +280,36 @@ export class QuestionnaireStore {
167
280
  return { ...question, status: 'pending' };
168
281
  const answer = storedAnswerSchema.parse(rawAnswer);
169
282
  if (answer.withdrawn) {
170
- if (answer.requestKey !== question.requestKey || answer.answerAvailable || answer.answer)
283
+ if (answer.requestKey !== question.requestKey ||
284
+ answer.answerAvailable ||
285
+ answer.answer ||
286
+ answer.answers)
171
287
  throw new Error('Invalid questionnaire withdrawal receipt.');
172
288
  return { ...question, ...answer, status: 'withdrawn' };
173
289
  }
290
+ if (question.questions) {
291
+ if (answer.requestKey !== question.requestKey ||
292
+ !answer.answerAvailable ||
293
+ !answer.answers ||
294
+ answer.answer !== undefined)
295
+ throw new Error('Questionnaire answer does not match its durable question.');
296
+ for (const [id, value] of Object.entries(answer.answers)) {
297
+ const sub = question.questions.find((item) => item.id === id);
298
+ if (!sub || !sub.options.some((option) => option.id === value.choice))
299
+ throw new Error('Questionnaire answer does not match its durable question.');
300
+ if (value.text !== undefined && !sub.textField)
301
+ throw new Error('Questionnaire text answer does not match its durable question.');
302
+ if (sub.textField?.requiredForChoice === value.choice && typeof value.text !== 'string')
303
+ throw new Error('Questionnaire text answer is required for this choice.');
304
+ }
305
+ const terminalHit = (question.terminalChoices ?? []).some((choice) => answer.answers[choice.questionId]?.choice === choice.choice);
306
+ if (!terminalHit && !question.questions.every((sub) => answer.answers[sub.id] !== undefined))
307
+ throw new Error('Questionnaire answer is missing a required question.');
308
+ assertSafeToPersist(answer);
309
+ return { ...question, ...answer, status: 'answered' };
310
+ }
174
311
  if (answer.requestKey !== question.requestKey ||
312
+ answer.answers !== undefined ||
175
313
  (answer.answerAvailable
176
314
  ? !answer.answer || !question.options.some((option) => option.id === answer.answer.choice)
177
315
  : !question.allowFreeText || answer.answer !== undefined)) {
@@ -187,6 +325,12 @@ export class QuestionnaireStore {
187
325
  return { ...question, ...answer, status: 'answered' };
188
326
  }
189
327
  async pending(scope) {
328
+ return (await this.scan(scope)).filter((record) => record.status === 'pending');
329
+ }
330
+ async list(scope) {
331
+ return this.scan(scope);
332
+ }
333
+ async scan(scope) {
190
334
  const directory = this.scopeRoot(scope);
191
335
  await ensureManagedDirectory(this.root, directory);
192
336
  const entries = await readdir(directory, { withFileTypes: true });
@@ -204,7 +348,7 @@ export class QuestionnaireStore {
204
348
  if (sha256(question.questionnaireId) !== entry.name)
205
349
  throw new Error('Questionnaire directory does not match its id.');
206
350
  const record = await this.get(scope, question.questionnaireId);
207
- if (record?.status === 'pending')
351
+ if (record)
208
352
  records.push(record);
209
353
  }
210
354
  return records.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
@@ -217,31 +361,48 @@ export class QuestionnaireStore {
217
361
  throw new Error('Questionnaire response is stale or belongs to a different scope.');
218
362
  if (record.status === 'withdrawn')
219
363
  throw new Error('Questionnaire was explicitly withdrawn. Start a new decision if the user resumes the work.');
220
- const answer = questionnaireAnswerSchema(record).parse(input);
221
- assertSafeToPersist(answer);
364
+ const parsed = questionnaireAnswerSchema(record).parse(input);
365
+ assertSafeToPersist(parsed);
222
366
  if (record.status === 'answered') {
223
- if (record.answer && stableStringify(record.answer) !== stableStringify(answer))
367
+ const stored = record.questions ? record.answers : record.answer;
368
+ if (stored !== undefined && stableStringify(stored) !== stableStringify(parsed))
224
369
  throw new Error('Questionnaire was already answered differently.');
225
- return { record, answer: record.answer, replayed: true };
370
+ return { record, answer: stored, replayed: true };
226
371
  }
227
- const receipt = {
228
- requestKey,
229
- ...(answerSource ? { answerSource } : {}),
230
- answeredAt: new Date().toISOString(),
231
- answerAvailable: answer.choice !== '__other__',
232
- ...(answer.choice !== '__other__'
233
- ? { answer: { choice: answer.choice, ...(answer.text ? { text: answer.text } : {}) } }
234
- : {}),
235
- };
372
+ const receipt = record.questions
373
+ ? {
374
+ requestKey,
375
+ ...(answerSource ? { answerSource } : {}),
376
+ answeredAt: new Date().toISOString(),
377
+ answerAvailable: true,
378
+ answers: parsed,
379
+ }
380
+ : (() => {
381
+ const answer = parsed;
382
+ return {
383
+ requestKey,
384
+ ...(answerSource ? { answerSource } : {}),
385
+ answeredAt: new Date().toISOString(),
386
+ answerAvailable: answer.choice !== '__other__',
387
+ ...(answer.choice !== '__other__'
388
+ ? { answer: { choice: answer.choice, ...(answer.text ? { text: answer.text } : {}) } }
389
+ : {}),
390
+ };
391
+ })();
236
392
  const published = await this.publish(this.pathFor(scope, questionnaireId, 'answer.json'), receipt);
237
393
  const resolved = (await this.get(scope, questionnaireId));
238
394
  if (resolved.status === 'withdrawn')
239
395
  throw new Error('Questionnaire was explicitly withdrawn before this response.');
396
+ const resolvedStored = record.questions ? resolved.answers : resolved.answer;
240
397
  if (!published &&
241
- resolved.answer &&
242
- stableStringify(resolved.answer) !== stableStringify(answer))
398
+ resolvedStored !== undefined &&
399
+ stableStringify(resolvedStored) !== stableStringify(parsed))
243
400
  throw new Error('Questionnaire was already answered differently.');
244
- return { record: resolved, answer: published ? answer : resolved.answer, replayed: !published };
401
+ return {
402
+ record: resolved,
403
+ answer: published ? parsed : resolvedStored,
404
+ replayed: !published,
405
+ };
245
406
  }
246
407
  async withdraw(scope, questionnaireId) {
247
408
  const record = await this.get(scope, questionnaireId);
@@ -1,8 +1,10 @@
1
1
  export class BridgeRecoveryError extends Error {
2
2
  recovery;
3
- constructor(message, recovery) {
3
+ details;
4
+ constructor(message, recovery, details) {
4
5
  super(`${message} Recovery: call ${recovery}.`);
5
6
  this.recovery = recovery;
7
+ this.details = details;
6
8
  this.name = 'BridgeRecoveryError';
7
9
  }
8
10
  }
@@ -1,9 +1,17 @@
1
1
  import { registerAllTools } from '../mcp/server.js';
2
+ import { toolAnnotations } from '../mcp/tool-annotations.js';
2
3
  import { recordingHost } from '../mcp/tool-host.js';
3
4
  import { createBridgeService } from './create-bridge-service.js';
4
5
  export function collectTools(service, server) {
5
6
  const recording = recordingHost(server);
6
7
  registerAllTools(recording.host, service);
8
+ const described = toolAnnotations;
9
+ for (const spec of recording.tools) {
10
+ const annotations = described[spec.name];
11
+ if (!annotations)
12
+ throw new Error(`Tool ${spec.name} has no entry in tool-annotations.ts; describe it there.`);
13
+ spec.config = { ...spec.config, annotations: { ...annotations, ...spec.config.annotations } };
14
+ }
7
15
  return recording.tools;
8
16
  }
9
17
  export function createRuntime(options) {
@@ -171,9 +171,7 @@ export class RuntimeHost {
171
171
  ...(spec.config.outputSchema !== undefined
172
172
  ? { outputSchema: spec.config.outputSchema }
173
173
  : {}),
174
- ...(spec.config.annotations !== undefined
175
- ? { annotations: spec.config.annotations }
176
- : {}),
174
+ annotations: spec.config.annotations ?? {},
177
175
  });
178
176
  existing.signature = signature;
179
177
  }
@@ -89,10 +89,26 @@ export class TaskBranchStore {
89
89
  throw new BridgeRecoveryError('The task has finished but its worktree reservation could not be released. Resume the task to retry cleanup.', 'session.resume');
90
90
  }
91
91
  }
92
+ async detach(expected, oid) {
93
+ const current = await this.read();
94
+ const changed = () => new BridgeRecoveryError(`The reservation of this folder changed after the question about ${expected.externalTaskId} was asked. Call task.branch again; its question shows which task holds the folder now.`, 'task.branch');
95
+ if (!current ||
96
+ current.oid !== oid ||
97
+ current.decision.projectId !== expected.projectId ||
98
+ current.decision.externalTaskId !== expected.externalTaskId ||
99
+ current.decision.taskId !== expected.taskId)
100
+ throw changed();
101
+ const result = await this.runner.run('git', ['update-ref', '--no-deref', '-d', decisionRef, oid], { cwd: this.repoRoot });
102
+ if (result.exitCode !== 0) {
103
+ if ((await this.read())?.oid !== oid)
104
+ throw changed();
105
+ throw new BridgeRecoveryError('Git could not move the task out of this folder. Retry task.branch; nothing was changed.', 'task.branch');
106
+ }
107
+ }
92
108
  assertSame(current, requested) {
93
109
  if (current.projectId !== requested.projectId ||
94
110
  current.externalTaskId !== requested.externalTaskId) {
95
- throw new BridgeRecoveryError(`This worktree belongs to task ${current.externalTaskId}. Resume that task, or call task.branch with a new task branch name to allocate a separate worktree.`, 'task.branch');
111
+ throw new BridgeRecoveryError(`This folder is held by task ${current.externalTaskId}. Continue that task, or call task.branch for the new task: its question offers a separate worktree, or a new branch in this folder after asking whether ${current.externalTaskId} should move out.`, 'task.branch');
96
112
  }
97
113
  if (current.branch !== requested.branch) {
98
114
  throw new BridgeRecoveryError(`This task selected ${current.branch ?? 'detached HEAD'}. Return to that branch before continuing.`, 'task.branch');