engineering-memory 1.11.13 → 1.11.15

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.
@@ -20,7 +20,7 @@ Never answer a question about a bound repository from the working tree alone. Wh
20
20
 
21
21
  After compaction, a new chat, interruption, or handoff, call \`session.resume\` before continuing.
22
22
 
23
- Before a new write/scaffold task, call \`task.branch\` directly: do not ask the user where, from which branch or under which name first, and do not call \`worktree.list\` for it. Its one native form asks where the task works, which branch it starts from and the branch name, and nothing is created until the user answers. The pool reuses a safely available worktree slot before creating another; never pass or invent a worktree path. Reconcile protected legacy entries through the native recovery flow; clean files alone do not establish that the old agent and delivery have finished. Run all commands from its returned \`repoRoot\`. Renew \`task.heartbeat\` during actual work, use \`task.pause\` on handoff, and resume before writing again. Inactivity never permits force checkout or cleanup. Keep closed worktrees until authorized delivery completes. When \`session.entry\` says the user can manage them, unanswered shared branch preferences are asked together in one native form via \`project.set_git_preferences\`; other members choose only their own task base in \`task.branch\`. Answering "Not now" returns \`deferred\` with nothing allocated and a \`reconsider\` call; say so in one line, keep working on anything that does not depend on it, and make that \`reconsider\` call only when the user actually asks to start that task.
23
+ Before a new write/scaffold task, call \`task.branch\` directly: do not ask the user where, from which branch or under which name first, and do not call \`worktree.list\` for it. Its short native form asks only the starting branch and where to work. It generates the new branch name automatically; do not ask the user to name it unless they request a specific name. Nothing is created until the user answers. The pool reuses a safely available worktree slot before creating another; never pass or invent a worktree path. Reconcile protected legacy entries through the native recovery flow; clean files alone do not establish that the old agent and delivery have finished. Run all commands from its returned \`repoRoot\`. Renew \`task.heartbeat\` during actual work, use \`task.pause\` on handoff, and resume before writing again. Inactivity never permits force checkout or cleanup. Keep closed worktrees until authorized delivery completes. When \`session.entry\` says the user can manage them, unanswered shared branch preferences are asked together in one native form via \`project.set_git_preferences\`; other members choose only their own task base in \`task.branch\`. Answering "Not now" returns \`deferred\` with nothing allocated and a \`reconsider\` call; say so in one line, keep working on anything that does not depend on it, and make that \`reconsider\` call only when the user actually asks to start that task.
24
24
 
25
25
  Do not edit until the skill lifecycle has completed discovery, its checkpoint, and \`context.prepare_change\`. Do not claim completion until \`task.verify\` succeeds.
26
26
 
@@ -28,7 +28,7 @@ Do not block independent task work on \`memory.propose_revision\` drafting, subm
28
28
 
29
29
  A check whose answer decides the next action — delivery preconditions such as the commit hook, the remote and the branch; whether verification or close can pass; what a waiver allows — is run by the agent that takes that action, in its own turn. Hand work to another agent only when you have other real work to do while it runs, and never take the action a delegated check gates before its answer is back. Work another agent checked is neither the user's approval nor task verification. Size delegation to the task — a one- or two-file change needs no multi-agent workflow — and once work is delegated, wait for its real completion signal, never a sleep loop polling its output.
30
30
 
31
- Use the host's native questionnaire for every question to the user, including implementation choices, names, clarification, branch/worktree decisions and delivery. For a required decision, use \`questionnaire.ask\` to open a durable native MCP form and \`questionnaire.resume\` to return to the same unanswered question. Read the skill's questionnaires reference first. Write every question in the user's language, say in \`context\` why it is asked, give in \`example\` one concrete thing the decision changes, and give every option a \`description\` of what happens next. Show a record by its title, never by an id or a hash. When the user says they did not understand, that is not an answer: explain in chat and show the same question again. Never use request_user_input_async for a required decision: it does not wait for an answer. When the durable form is unavailable, use a blocking native control only where the host permits it: request_user_input in Codex or AskUserQuestion in Claude. Never replace the questionnaire with a chat instruction such as 'type this', 'reply yes', or 'write X if you want Y'. Do not open a survey web page. If the required native control is unavailable or prohibited for that kind of question, follow the host's tool restrictions, explain the limitation, and continue only work already authorized; do not fabricate a survey or silently choose an answer. A timeout, dismissed form, empty response or ended turn is not an answer. Keep the decision pending and resume it; do not start dependent work or report it as resolved. Existing answers remain valid through retries and handoffs.
31
+ Choose question detail to suit the decision: task start is short, while complex decisions retain context and examples. Put a deliberate recommended choice first and preserve the host's free-input path without requiring an option or adding a duplicate Other choice; feedback is not consent. Use the host's native questionnaire for every question to the user, including implementation choices, names, clarification, branch/worktree decisions and delivery. For a required decision, use \`questionnaire.ask\` to open a durable native MCP form and \`questionnaire.resume\` to return to the same unanswered question. Read the skill's questionnaires reference first. Write every question in the user's language, say in \`context\` why it is asked, give in \`example\` one concrete thing the decision changes, and give every option a \`description\` of what happens next. Show a record by its title, never by an id or a hash. When the user says they did not understand, that is not an answer: explain in chat and show the same question again. Never use request_user_input_async for a required decision: it does not wait for an answer. When the durable form is unavailable, use a blocking native control only where the host permits it: request_user_input in Codex or AskUserQuestion in Claude. Never replace the questionnaire with a chat instruction such as 'type this', 'reply yes', or 'write X if you want Y'. Do not open a survey web page. If the required native control is unavailable or prohibited for that kind of question, follow the host's tool restrictions, explain the limitation, and continue only work already authorized; do not fabricate a survey or silently choose an answer. A timeout, dismissed form, empty response or ended turn is not an answer. Keep the decision pending and resume it; do not start dependent work or report it as resolved. Existing answers remain valid through retries and handoffs.
32
32
 
33
33
  When a durable MCP form cannot be displayed, read its \`hostFallback\` or call \`questionnaire.resume\` with \`presentation: host_native\` to get the original question without reopening the MCP form. Display the same question, all choices and notices through a blocking native control only if the host permits that control for this decision. After an actual native answer, call \`questionnaire.answer_from_host\` with the unchanged questionnaireId, requestKey and contentHash, the hostTool name and the returned choice/text. This is an agent-reported relay, not MCP transport attestation. Then retry the owning operation; its authority, content and version checks still apply. Never relay prose consent, a default, an asynchronous response or a cancelled/declined/missing answer. Decline alone is not proof that the host cannot display forms. Keep the decision pending if no permitted native control can represent it.
34
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engineering-memory",
3
- "version": "1.11.13",
3
+ "version": "1.11.15",
4
4
  "description": "Installs the Engineering Memory skill and its local MCP bridge. Sign in after installing; your organization and project are resolved from your account.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -11,7 +11,7 @@
11
11
  "engineering-memory": "bin/engineering-memory.mjs"
12
12
  },
13
13
  "files": [
14
- "bin",
14
+ "bin/engineering-memory.mjs",
15
15
  "dispatcher",
16
16
  "lib",
17
17
  "install",
@@ -1,3 +1,3 @@
1
1
  {
2
- "gitHead": "b6171df2dae6cc2a967101902fbedc155d8d301d"
2
+ "gitHead": "52f278262fad1afde8d11aa6e2343b3de5e8fe78"
3
3
  }
@@ -2,7 +2,7 @@ import * as z from 'zod/v4';
2
2
  import { sha256, stableStringify } from '../utilities/hash.js';
3
3
  import { restrictedValueKind } from '../runtime/privacy-detector.js';
4
4
  import { languageTag } from '../runtime/questionnaire-store.js';
5
- import { answerChoice, answerData, askQuestionnaire, resumeQuestionnaire, } from './questionnaire-tools.js';
5
+ import { answerChoice, answerData, askFailed, askQuestionnaire, resumeQuestionnaire, } from './questionnaire-tools.js';
6
6
  const uuid = z.string().uuid();
7
7
  const hash = z.string().regex(/^[a-f0-9]{64}$/);
8
8
  const project = { projectId: uuid };
@@ -411,18 +411,30 @@ export function registerOnboardingTools(server, service) {
411
411
  ],
412
412
  };
413
413
  });
414
- const form = await askQuestionnaire(server, service, {
415
- ...definitions.find((entry) => entry.language === language),
414
+ const binding = { decision: questionnaireId };
415
+ const bound = definitions.map((entry) => ({ ...entry, binding }));
416
+ const asked = {
416
417
  repoRoot: input.repoRoot,
418
+ questionnaireId,
417
419
  presentation: input.presentation,
418
- }, context, [...definitions, ...previousWording], {
419
- tool: 'project.move_repository',
420
- ...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
421
- retryArguments: {
422
- projectId: input.projectId,
423
- ...(input.language ? { language: input.language } : {}),
424
- },
425
- });
420
+ };
421
+ const stored = await service.questionnaireResume(asked).catch(() => undefined);
422
+ const form = stored?.binding && stableStringify(stored.binding) === stableStringify(binding)
423
+ ? await resumeQuestionnaire(server, service, asked, context)
424
+ : await askQuestionnaire(server, service, {
425
+ ...bound.find((entry) => entry.language === language),
426
+ repoRoot: input.repoRoot,
427
+ presentation: input.presentation,
428
+ }, context, [...bound, ...definitions, ...previousWording], {
429
+ tool: 'project.move_repository',
430
+ ...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
431
+ retryArguments: {
432
+ projectId: input.projectId,
433
+ ...(input.language ? { language: input.language } : {}),
434
+ },
435
+ });
436
+ if (askFailed(form))
437
+ return form;
426
438
  const record = await service.questionnaireResume({
427
439
  repoRoot: input.repoRoot,
428
440
  questionnaireId,
@@ -8,18 +8,29 @@ export async function askQuestionnaire(server, service, input, context, previous
8
8
  return failure(error);
9
9
  }
10
10
  }
11
+ export function askFailed(result) {
12
+ return payload(result)?.ok === false;
13
+ }
11
14
  export function answerChoice(result) {
12
15
  return answerData(result)?.choice;
13
16
  }
14
17
  export function answerData(result) {
18
+ const value = payload(result);
19
+ return value?.ok && value.data?.status === 'answered' && value.data.answerAvailable
20
+ ? value.data.answer
21
+ : undefined;
22
+ }
23
+ function payload(result) {
15
24
  const response = result;
16
25
  const text = response.content?.find((item) => item.type === 'text')?.text;
17
26
  if (!text)
18
27
  return undefined;
19
- const value = JSON.parse(text);
20
- return value.ok && value.data?.status === 'answered' && value.data.answerAvailable
21
- ? value.data.answer
22
- : undefined;
28
+ try {
29
+ return JSON.parse(text);
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
23
34
  }
24
35
  export async function resumeQuestionnaire(server, service, input, context) {
25
36
  try {
@@ -31,12 +42,21 @@ export async function resumeQuestionnaire(server, service, input, context) {
31
42
  }
32
43
  export async function answerQuestionnaireFromHost(service, input) {
33
44
  try {
34
- if (input.answer?.choice === explain) {
45
+ if (input.answer?.choice === explain ||
46
+ input.answer?.choice === '__other__' ||
47
+ Object.values(input.answers ?? {}).some((answer) => answer.choice === '__other__')) {
35
48
  const record = await service.questionnaireResume(input);
36
49
  if (record.status === 'pending' &&
37
50
  record.requestKey === input.requestKey &&
38
- record.contentHash === input.contentHash)
39
- return pending(record, 'needs_explanation');
51
+ record.contentHash === input.contentHash) {
52
+ if (input.answer?.choice === explain)
53
+ return pending(record, 'needs_explanation');
54
+ if (record.questions || !record.allowFreeText) {
55
+ const feedback = input.answer ??
56
+ Object.values(input.answers ?? {}).find((answer) => answer.choice === '__other__');
57
+ return feedbackResult(record, feedback?.text);
58
+ }
59
+ }
40
60
  }
41
61
  const resolved = await service.questionnaireAnswerFromHost(input);
42
62
  return answered(resolved.record, resolved.answer, resolved.replayed, resolved.retry);
@@ -66,11 +86,33 @@ async function present(server, service, record, repoRoot, context, preparation,
66
86
  const response = inputResponse(responses, record.requestKey);
67
87
  if (response.kind === 'elicit' && response.action === 'accept') {
68
88
  const content = acceptedContent(responses, record.requestKey);
69
- if (content?.choice === explain || content?.[explain] === true) {
89
+ if (content?.choice === explain ||
90
+ content?.[explain] === true ||
91
+ record.questions?.some((question) => content?.[question.id] === explain)) {
70
92
  consumedResponses(context).add(record.requestKey);
71
93
  return pending(record, 'needs_explanation');
72
94
  }
73
- const parsed = questionnaireAnswerSchema(record).safeParse(record.questions && content ? formAnswers(record, content) : content);
95
+ if (content?.__feedback__ !== undefined &&
96
+ !(typeof content.__feedback__ === 'string' && !content.__feedback__.trim())) {
97
+ consumedResponses(context).add(record.requestKey);
98
+ return feedbackResult(record, content.__feedback__);
99
+ }
100
+ if (!record.questions &&
101
+ !record.allowFreeText &&
102
+ !content?.choice &&
103
+ typeof content?.text === 'string' &&
104
+ content.text.trim()) {
105
+ consumedResponses(context).add(record.requestKey);
106
+ return feedbackResult(record, content.text);
107
+ }
108
+ const { __feedback__: _feedback, ...answerContent } = content ?? {};
109
+ if (!record.questions &&
110
+ record.allowFreeText &&
111
+ !answerContent.choice &&
112
+ typeof answerContent.text === 'string' &&
113
+ answerContent.text.trim())
114
+ answerContent.choice = '__other__';
115
+ const parsed = questionnaireAnswerSchema(record).safeParse(record.questions ? formAnswers(record, answerContent) : answerContent);
74
116
  const answer = parsed.success ? parsed.data : undefined;
75
117
  if (!answer)
76
118
  return pending(record, 'invalid_response');
@@ -98,15 +140,22 @@ async function present(server, service, record, repoRoot, context, preparation,
98
140
  return pending(record, 'native_form_unavailable');
99
141
  const copy = copyFor(record);
100
142
  if (record.questions) {
143
+ const compact = record.questionnaireId.startsWith('task-start-') && !!record.binding;
101
144
  const properties = {};
102
145
  for (const sub of record.questions) {
146
+ const options = compact && sub.otherAsText
147
+ ? sub.options.filter((option) => option.id !== 'other')
148
+ : sub.options;
103
149
  properties[sub.id] = {
104
150
  type: 'string',
105
151
  title: sub.message,
106
- enum: sub.options.map((option) => option.id),
107
- enumNames: sub.options.map((option) => option.label),
152
+ description: [sub.context, sub.example && `${copy.example}: ${sub.example}`]
153
+ .filter(Boolean)
154
+ .join('\n'),
155
+ enum: options.map((option) => option.id),
156
+ enumNames: optionLabels(options, copy),
108
157
  };
109
- if (sub.textField)
158
+ if (sub.textField && !(compact && sub.otherAsText))
110
159
  properties[`${sub.id}_text`] = {
111
160
  type: 'string',
112
161
  title: sub.textField.title,
@@ -114,12 +163,12 @@ async function present(server, service, record, repoRoot, context, preparation,
114
163
  maxLength: sub.textField.maxLength,
115
164
  };
116
165
  }
117
- properties[explain] = { type: 'boolean', title: copy.explain };
166
+ properties.__feedback__ = { type: 'string', title: copy.feedback, maxLength: 2000 };
118
167
  return inputRequired({
119
168
  inputRequests: {
120
169
  [record.requestKey]: inputRequired.elicit({
121
170
  mode: 'form',
122
- message: presentationMessage(record),
171
+ message: presentationMessage(record, compact),
123
172
  requestedSchema: { type: 'object', properties },
124
173
  }),
125
174
  },
@@ -136,23 +185,14 @@ async function present(server, service, record, repoRoot, context, preparation,
136
185
  choice: {
137
186
  type: 'string',
138
187
  title: copy.answer,
139
- enum: [
140
- ...record.options.map((option) => option.id),
141
- ...(record.allowFreeText ? ['__other__'] : []),
142
- explain,
143
- ],
144
- enumNames: [
145
- ...record.options.map((option) => option.label),
146
- ...(record.allowFreeText ? [copy.other] : []),
147
- copy.explain,
148
- ],
188
+ enum: record.options.map((option) => option.id),
189
+ enumNames: optionLabels(record.options, copy),
149
190
  },
150
191
  ...(record.allowFreeText
151
192
  ? {
152
193
  text: {
153
194
  type: 'string',
154
- title: copy.otherAnswer,
155
- minLength: 1,
195
+ title: copy.freeAnswer,
156
196
  maxLength: 2000,
157
197
  },
158
198
  }
@@ -167,8 +207,10 @@ async function present(server, service, record, repoRoot, context, preparation,
167
207
  },
168
208
  }
169
209
  : {}),
210
+ ...(!record.allowFreeText
211
+ ? { __feedback__: { type: 'string', title: copy.feedback, maxLength: 2000 } }
212
+ : {}),
170
213
  },
171
- required: ['choice'],
172
214
  },
173
215
  }),
174
216
  },
@@ -184,25 +226,25 @@ function consumedResponses(context) {
184
226
  const questionnaireCopy = {
185
227
  en: {
186
228
  pending: 'Choose an answer to resolve this decision. Closing or cancelling the form leaves it pending.',
187
- freeText: ' For another answer choose Other and fill in text. Free text is not stored and cannot be replayed.',
229
+ freeText: ' You can type a different answer without choosing an option. Free text is not stored and cannot be replayed.',
188
230
  storedText: ' The bounded text field is stored as part of this exact decision and is replayed after restart.',
189
- everyQuestion: ' Answer every question before submitting, unless you pick an option that ends the form.',
231
+ everyQuestion: ' To record a decision, answer each question or choose an option that ends the form. You can send a comment without selecting any option.',
190
232
  example: 'Example',
191
- explain: 'I did not understand; explain in more detail first',
192
233
  answer: 'Answer',
193
- other: 'Other',
194
- otherAnswer: 'Other answer',
234
+ freeAnswer: 'Write another answer (optional)',
235
+ recommended: 'Recommended',
236
+ feedback: 'Write an answer or comment (optional)',
195
237
  },
196
238
  tr: {
197
239
  pending: 'Kararını kaydetmek için bir seçenek seç. Formu kapatmak veya iptal etmek onay sayılmaz; soru beklemede kalır.',
198
- freeText: ' Farklı bir yanıt için Diğer seçeneğini seçip metin alanını doldur. Serbest yanıt saklanmaz ve daha sonra geri getirilemez.',
240
+ freeText: ' Bir seçenek seçmeden farklı yanıtını yazabilirsin. Serbest yanıt saklanmaz ve daha sonra geri getirilemez.',
199
241
  storedText: ' Bu metin alanındaki yanıt kararınla birlikte saklanır; yeniden başlattığında korunur.',
200
- everyQuestion: ' Göndermeden önce her soruyu cevapla; formu bitiren bir seçenek seçtiysen gerekmez.',
242
+ everyQuestion: ' Karar vermek için soruları cevapla veya formu bitiren bir seçenek seç. Hiçbir seçenek seçmeden yorum da gönderebilirsin.',
201
243
  example: 'Örnek',
202
- explain: 'Anlamadım; önce daha ayrıntılı anlat',
203
244
  answer: 'Yanıt',
204
- other: 'Diğer',
205
- otherAnswer: 'Diğer yanıt',
245
+ freeAnswer: 'Farklı yanıtını yaz (isteğe bağlı)',
246
+ recommended: 'Önerilen',
247
+ feedback: 'Yanıtını veya yorumunu yaz (isteğe bağlı)',
206
248
  },
207
249
  };
208
250
  function formAnswers(record, content) {
@@ -220,23 +262,26 @@ function formAnswers(record, content) {
220
262
  }
221
263
  return answers;
222
264
  }
223
- function presentationMessage(record) {
265
+ function presentationMessage(record, compact = false) {
224
266
  const copy = copyFor(record);
267
+ if (compact)
268
+ return [
269
+ record.message,
270
+ record.context,
271
+ record.example,
272
+ record.language?.startsWith('tr')
273
+ ? 'İptal edersen başlamaz.'
274
+ : 'Cancelling does not start the task.',
275
+ ]
276
+ .filter(Boolean)
277
+ .join('\n');
225
278
  const explained = (item) => [
226
279
  item.context,
227
280
  item.example && `${copy.example}: ${item.example}`,
228
- (item.options ?? [])
229
- .filter((option) => option.description)
230
- .map((option) => `${option.label}: ${option.description}`)
231
- .join('\n'),
232
281
  ];
233
282
  return [
234
283
  record.message,
235
284
  ...explained(record),
236
- ...(record.questions ?? []).flatMap((sub) => {
237
- const details = explained(sub).filter(Boolean);
238
- return details.length ? [sub.message, ...details] : [];
239
- }),
240
285
  `${copy.pending}${record.questions ? copy.everyQuestion : ''}${record.allowFreeText ? copy.freeText : ''}${record.textField ? copy.storedText : ''}`,
241
286
  ]
242
287
  .filter(Boolean)
@@ -245,19 +290,38 @@ function presentationMessage(record) {
245
290
  function copyFor(record) {
246
291
  return questionnaireCopy[record.language?.startsWith('tr') ? 'tr' : 'en'];
247
292
  }
248
- function pending(record, reason) {
293
+ function optionLabels(options, copy) {
294
+ const recommended = options.find((option) => option.label.includes(`(${copy.recommended})`))?.id ?? options[0].id;
295
+ return options.map((option) => {
296
+ const label = option.id === recommended && !option.label.includes(`(${copy.recommended})`)
297
+ ? `${option.label} (${copy.recommended})`
298
+ : option.label;
299
+ return option.description ? `${label} — ${option.description}` : label;
300
+ });
301
+ }
302
+ function feedbackResult(record, text) {
303
+ if (typeof text !== 'string' || !text.trim() || text.length > 2000)
304
+ return pending(record, 'invalid_response');
305
+ return pending(record, 'feedback', { text: text.trim() });
306
+ }
307
+ function pending(record, reason, feedback) {
249
308
  return result({
250
309
  questionnaireId: record.questionnaireId,
251
310
  status: 'pending',
252
311
  reason,
312
+ ...(feedback ? { feedback } : {}),
253
313
  answerAvailable: false,
254
314
  hostFallback: {
255
315
  operation: 'questionnaire.answer_from_host',
256
316
  questionnaireId: record.questionnaireId,
257
317
  requestKey: record.requestKey,
258
318
  contentHash: record.contentHash,
259
- message: presentationMessage(record),
319
+ message: presentationMessage(record, record.questionnaireId.startsWith('task-start-') && !!record.binding),
260
320
  language: record.language ?? 'en',
321
+ freeTextFeedback: {
322
+ relayChoice: '__other__',
323
+ recordsDecision: !record.questions && record.allowFreeText,
324
+ },
261
325
  ...(record.questions
262
326
  ? {
263
327
  questions: record.questions,
@@ -270,15 +334,17 @@ function pending(record, reason) {
270
334
  }),
271
335
  instructions: `${record.questions
272
336
  ? 'Only if the host permits a blocking native control for this decision, display every question, its options and notices in AskUserQuestion or request_user_input, one call at a time if needed. Relay every answer together in one questionnaire.answer_from_host call as `answers`, keyed by question id. Closing, declining, timeout, prose consent and missing answers are not native answers for any question. Do not reopen a dismissed question in a loop or label decline as proof of host incompatibility. The relay records agent-reported provenance, not MCP transport attestation. Retry the owning operation after acceptance; its authority and version checks still apply.'
273
- : 'Only if the host permits a blocking native control for this decision, display this exact question, all options and notices in AskUserQuestion or request_user_input. Relay only the actual returned answer with these unchanged bindings. Closing, declining, timeout, prose consent and missing answers are not native answers. Do not reopen a dismissed question in a loop or label decline as proof of host incompatibility. The relay records agent-reported provenance, not MCP transport attestation. Retry the owning operation after acceptance; its authority and version checks still apply.'} Show each option's description under its label. If the person says they did not understand, including through the control's free-text answer, that is not an answer: call questionnaire.answer_from_host with answer {choice: "${explain}"} and the same bindings, and the decision stays pending.${(record.options?.length ?? 0) > 4
337
+ : 'Only if the host permits a blocking native control for this decision, display this exact question, all options and notices in AskUserQuestion or request_user_input. Relay only the actual returned answer with these unchanged bindings. Closing, declining, timeout, prose consent and missing answers are not native answers. Do not reopen a dismissed question in a loop or label decline as proof of host incompatibility. The relay records agent-reported provenance, not MCP transport attestation. Retry the owning operation after acceptance; its authority and version checks still apply.'} Keep the question proportional to the decision: short for task start, detailed when needed; do not omit context, examples or consequences. Mark the explicitly recommended option, otherwise the first option, as recommended without treating it as an answer. Use the host control's existing free-text input so the person can write without picking an option. Do not add a duplicate Other choice or a second feedback question. Relay that actual native text through the internal __other__ choice with text and preserve all bindings. This internal relay identifier is not a visible option. Show each option's description under its label. If the person says they did not understand, including through the control's free-text answer, that is not an answer: call questionnaire.answer_from_host with answer {choice: "${explain}"} and the same bindings, and the decision stays pending.${(record.options?.length ?? 0) > 4
274
338
  ? ' The host control shows fewer options than this question has: show them three at a time with one more option that reveals the rest, and relay only the final pick.'
275
339
  : ''}`,
276
340
  },
277
- nextAction: reason === 'needs_explanation'
278
- ? 'The person asked for more detail and the decision is still pending. Explain in chat, in their language, why this is asked, what each option does next and one concrete example. Then show the same question once more with questionnaire.resume. Do not infer an answer.'
279
- : reason === 'native_form_unavailable'
280
- ? 'This host does not advertise native MCP forms. The decision remains pending. Explain the limitation and continue only independently authorized work. Use hostFallback only with a permitted blocking native control; otherwise keep pending. Never substitute an asynchronous question.'
281
- : 'The decision remains pending without expiry. Call questionnaire.resume with this questionnaireId to show the same question again. Do not infer an answer or continue dependent work.',
341
+ nextAction: reason === 'feedback'
342
+ ? 'The person provided feedback, not consent. Address it in their language. The original decision is still pending and no choice has been saved. Resume it after explaining, or compose a new bound question if the requested choices change. Do not persist arbitrary feedback text.'
343
+ : reason === 'needs_explanation'
344
+ ? 'The person asked for more detail and the decision is still pending. Explain in chat, in their language, why this is asked, what each option does next and one concrete example. Then show the same question once more with questionnaire.resume. Do not infer an answer.'
345
+ : reason === 'native_form_unavailable'
346
+ ? 'This host does not advertise native MCP forms. The decision remains pending. Explain the limitation and continue only independently authorized work. Use hostFallback only with a permitted blocking native control; otherwise keep pending. Never substitute an asynchronous question.'
347
+ : 'The decision remains pending without expiry. Call questionnaire.resume with this questionnaireId to show the same question again. Do not infer an answer or continue dependent work.',
282
348
  });
283
349
  }
284
350
  function answered(record, answer, replayed, retry) {
@@ -2,7 +2,7 @@ import * as z from 'zod/v4';
2
2
  import { validationIds } from '../runtime/bridge-service.js';
3
3
  import { stableStringify } from '../utilities/hash.js';
4
4
  import { hostAnswerSchema, languageTag, questionnaireDefinitionSchema, } from '../runtime/questionnaire-store.js';
5
- import { answerChoice, answerQuestionnaireFromHost, askQuestionnaire, resumeQuestionnaire, } from './questionnaire-tools.js';
5
+ import { answerChoice, answerQuestionnaireFromHost, askFailed, askQuestionnaire, resumeQuestionnaire, } from './questionnaire-tools.js';
6
6
  const optionalRepoRoot = z.string().min(1).optional();
7
7
  const stringList = z.array(z.string().min(1));
8
8
  const jsonValue = z.lazy(() => z.union([
@@ -726,6 +726,8 @@ export function registerEngineeringMemoryTools(server, service) {
726
726
  ...definitions.find((definition) => definition.language === language),
727
727
  repoRoot: request.repoRoot,
728
728
  }, context, [...definitions, ...earlier]);
729
+ if (askFailed(form))
730
+ return form;
729
731
  const record = await service.questionnaireResume({
730
732
  repoRoot: request.repoRoot,
731
733
  questionnaireId,
@@ -1,10 +1,10 @@
1
1
  import { basename } from 'node:path';
2
2
  import * as z from 'zod/v4';
3
- import { cancelledDeliveryQuestionId, forgetUnreadableQuestionId, } from '../runtime/bridge-service.js';
3
+ import { cancelledDeliveryQuestionId, forgetUnreadableQuestionId, retireQuestionId, } from '../runtime/bridge-service.js';
4
4
  import { configuredBase, sourceLabel, taskStartChoice, taskStartDefinition, } from '../runtime/task-start.js';
5
5
  import { sha256, stableStringify } from '../utilities/hash.js';
6
6
  import { unreadableWorktree } from '../runtime/worktree-pool.js';
7
- import { askQuestionnaire } from './questionnaire-tools.js';
7
+ import { askFailed, askQuestionnaire } from './questionnaire-tools.js';
8
8
  import { languageTag } from '../runtime/questionnaire-store.js';
9
9
  const repo = z.string().min(1);
10
10
  const branch = z.string().min(1).max(255);
@@ -333,6 +333,8 @@ export function registerWorktreeTools(server, service) {
333
333
  repoRoot: input.repoRoot,
334
334
  presentation: input.presentation,
335
335
  }, context, [definition(language !== 'tr'), previous(true), previous(false)], owner);
336
+ if (askFailed(form))
337
+ return form;
336
338
  const record = await service.questionnaireResume({
337
339
  repoRoot: input.repoRoot,
338
340
  questionnaireId,
@@ -356,7 +358,7 @@ export function registerWorktreeTools(server, service) {
356
358
  inputSchema: z.strictObject(policy),
357
359
  }, async (input) => output(await service.worktreePolicy(json(input))));
358
360
  server.registerTool('task.branch', {
359
- description: 'Start a write task with ONE durable native form: where the branch starts (unless base is given), the new branch name with a suggested default (unless name is given), and the folder: a separate managed worktree, this folder as a new branch when it is clean (moving out an unfinished task that holds it, named in the option), this folder on its current branch, or not now. Nothing is created until the user answers; the next call with the same arguments allocates. A remote base is fetched and its exact commit pinned. Retry/resume preserves the allocation. Not now returns status deferred with the exact reconsider call; retries keep the same decisionAttempt. Use the returned repoRoot for ALL commands. A read-only task allocates only when transitioning to write.',
361
+ description: 'Start a write task with ONE durable native form: two short questions for the starting branch (unless base is given) and the folder. A unique branch name is generated automatically unless name is supplied. Folder options: a separate managed worktree, this folder as a new branch when it is clean (moving out an unfinished task that holds it, named in the option), this folder on its current branch, or not now. Nothing is created until the user answers; the next call with the same arguments allocates. A remote base is fetched and its exact commit pinned. Retry/resume preserves the allocation. Not now returns status deferred with the exact reconsider call; retries keep the same decisionAttempt. Use the returned repoRoot for ALL commands. A read-only task allocates only when transitioning to write.',
360
362
  inputSchema: z.strictObject({
361
363
  repoRoot: repo,
362
364
  externalTaskId: z.string().min(2).max(160),
@@ -399,12 +401,17 @@ export function registerWorktreeTools(server, service) {
399
401
  keepCurrent: input.keepCurrent,
400
402
  }),
401
403
  };
402
- const form = await askQuestionnaire(server, service, { ...definition, repoRoot: input.repoRoot, presentation: input.presentation }, context, [previous], owner);
403
- const record = await service.questionnaireResume({
404
+ const form = await askQuestionnaire(server, service, { ...definition, repoRoot: input.repoRoot, presentation: input.presentation }, context, previous, owner);
405
+ const record = await service
406
+ .questionnaireResume({
404
407
  repoRoot: input.repoRoot,
405
408
  questionnaireId: definition.questionnaireId,
406
- });
407
- const choice = taskStartChoice(record);
409
+ })
410
+ .catch(() => undefined);
411
+ const choice = record?.binding &&
412
+ [definition, ...previous].some((offered) => stableStringify(record.binding) === stableStringify(offered.binding))
413
+ ? taskStartChoice(record)
414
+ : null;
408
415
  if (!choice)
409
416
  return form;
410
417
  if (choice.status === 'deferred')
@@ -490,6 +497,8 @@ export function registerWorktreeTools(server, service) {
490
497
  repoRoot: input.repoRoot,
491
498
  presentation: input.presentation,
492
499
  }, context, [definition(language !== 'tr'), previousFallback(true), previousFallback(false)], owner);
500
+ if (askFailed(fallbackForm))
501
+ return fallbackForm;
493
502
  const answer = await service.questionnaireResume({
494
503
  repoRoot: input.repoRoot,
495
504
  questionnaireId: fallbackId,
@@ -531,7 +540,7 @@ export function registerWorktreeTools(server, service) {
531
540
  inputSchema: locationSchema,
532
541
  }, async (input) => output(await service.worktreeOperation('list', input)));
533
542
  server.registerTool('worktree.reconcile', {
534
- description: 'Reconcile Git with the local registry, including unregistered legacy checkouts. Use releaseUnowned only on a selected unreserved recovered checkout after prior work has finished; a native source-bound confirmation and fresh safety checks precede reuse. Use retireLegacy only when the user wants old-location slots gone: each retired folder is removed with git worktree remove after its own native approval, and the branch stays. Use forgetUnreadable with the externalTaskId of a checkout that is gone or can no longer be read as a Git worktree, called from a working checkout of the same project: after its own native approval Engineering Memory stops tracking that entry, so it no longer counts toward the limit, and no file in the folder is read, moved or deleted. Otherwise never delete files or reset branches.',
543
+ description: 'Reconcile Git with the local registry, including unregistered legacy checkouts. Use releaseUnowned only on a selected unreserved recovered checkout after prior work has finished; a native source-bound confirmation and fresh safety checks precede reuse. Use retireLegacy only when the user wants old-location slots gone: each retired folder is removed with git worktree remove after its own native approval, once every retired folder has an answer, and the branch stays. Reconsider a retired folder with a new decisionAttempt; retries keep the same attempt. Use forgetUnreadable with the externalTaskId of a checkout that is gone or can no longer be read as a Git worktree, called from a working checkout of the same project: after its own native approval Engineering Memory stops tracking that entry, so it no longer counts toward the limit, and no file in the folder is read, moved or deleted. Otherwise never delete files or reset branches.',
535
544
  inputSchema: z.strictObject({
536
545
  repoRoot: repo,
537
546
  releaseUnowned: z.boolean().optional(),
@@ -613,6 +622,8 @@ export function registerWorktreeTools(server, service) {
613
622
  language: input.language,
614
623
  }),
615
624
  });
625
+ if (askFailed(form))
626
+ return form;
616
627
  const decision = await service.questionnaireResume({
617
628
  repoRoot: input.repoRoot,
618
629
  questionnaireId,
@@ -634,9 +645,10 @@ export function registerWorktreeTools(server, service) {
634
645
  const retired = [];
635
646
  const kept = [];
636
647
  const refused = [];
648
+ const approved = [];
637
649
  for (const item of items) {
638
- const questionnaireId = 'worktree-retire-' + sha256(item.repoRoot + item.generation);
639
650
  const missing = item.reasons.includes('worktree directory missing');
651
+ const questionnaireId = retireQuestionId(item, !missing, input.decisionAttempt);
640
652
  const folder = basename(item.repoRoot);
641
653
  const definition = (turkish) => {
642
654
  const copy = retireCopy[turkish ? 'tr' : 'en'];
@@ -677,20 +689,30 @@ export function registerWorktreeTools(server, service) {
677
689
  ],
678
690
  });
679
691
  const form = await askQuestionnaire(server, service, { ...definition(language === 'tr'), repoRoot: input.repoRoot }, context, [definition(language !== 'tr'), previous(true), previous(false)]);
692
+ if (askFailed(form)) {
693
+ refused.push({
694
+ repoRoot: item.repoRoot,
695
+ message: 'This folder was not asked about and nothing was removed: the decision stored under its question could not be opened.',
696
+ });
697
+ continue;
698
+ }
680
699
  const decision = await service.questionnaireResume({
681
700
  repoRoot: input.repoRoot,
682
701
  questionnaireId,
683
702
  });
684
703
  if (decision.status !== 'answered')
685
704
  return form;
686
- if (decision.answer?.choice !== 'retire') {
705
+ if (decision.answer?.choice === 'retire')
706
+ approved.push({ item, questionnaireId });
707
+ else
687
708
  kept.push(item.repoRoot);
688
- continue;
689
- }
709
+ }
710
+ for (const { item, questionnaireId } of approved) {
690
711
  const result = await service.worktreeOperation('retire', {
691
712
  repoRoot: input.repoRoot,
692
713
  worktreePath: item.repoRoot,
693
714
  generation: item.generation,
715
+ decisionAttempt: input.decisionAttempt,
694
716
  decisionId: questionnaireId,
695
717
  });
696
718
  if (result.ok)
@@ -753,6 +775,8 @@ export function registerWorktreeTools(server, service) {
753
775
  ],
754
776
  });
755
777
  const form = await askQuestionnaire(server, service, { ...definition(language === 'tr'), repoRoot: input.repoRoot }, context, [definition(language !== 'tr'), previous(true), previous(false)]);
778
+ if (askFailed(form))
779
+ return form;
756
780
  const decision = await service.questionnaireResume({
757
781
  repoRoot: input.repoRoot,
758
782
  questionnaireId,
@@ -857,6 +881,8 @@ export function registerWorktreeTools(server, service) {
857
881
  ],
858
882
  });
859
883
  const form = await askQuestionnaire(server, service, { ...definition(language === 'tr'), repoRoot: input.repoRoot }, context, [definition(language !== 'tr'), previous(true), previous(false)]);
884
+ if (askFailed(form))
885
+ return form;
860
886
  const decision = await service.questionnaireResume({
861
887
  repoRoot: input.repoRoot,
862
888
  questionnaireId,
@@ -6,7 +6,7 @@ import { lstat, readFile } from 'node:fs/promises';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { SourceSnapshot } from '../git/source-snapshot.js';
8
8
  import { ProjectIntake } from '../project/project-intake.js';
9
- import { canonicalPath } from '../utilities/files.js';
9
+ import { canonicalPath, pathExists } from '../utilities/files.js';
10
10
  import { minimatch } from 'minimatch';
11
11
  import { endpoints } from '../config.js';
12
12
  import { startPhaseTimer } from './phase-timer.js';
@@ -2276,7 +2276,13 @@ export class BridgeService {
2276
2276
  for (const candidate of candidates)
2277
2277
  if (!(await this.unusableBranchName(repoRoot, candidate)))
2278
2278
  return candidate;
2279
- return candidates[0];
2279
+ const git = this.dependencies.repositories.git;
2280
+ let suffix = candidates.length + 1;
2281
+ while (await git.branchExists(repoRoot, candidates[0] + '-' + suffix))
2282
+ suffix++;
2283
+ const name = candidates[0] + '-' + suffix;
2284
+ await git.validateBranch(repoRoot, name);
2285
+ return name;
2280
2286
  }
2281
2287
  async unusableBranchName(repoRoot, name) {
2282
2288
  const git = this.dependencies.repositories.git;
@@ -2444,9 +2450,10 @@ export class BridgeService {
2444
2450
  }
2445
2451
  if (operation === 'retire') {
2446
2452
  const entry = input.worktreePath ? await pool.forPath(projectId, input.worktreePath) : null;
2453
+ const folderPresent = entry ? await pathExists(entry.repoRoot) : false;
2447
2454
  if (!entry ||
2448
2455
  entry.generation !== input.generation ||
2449
- input.decisionId !== 'worktree-retire-' + sha256(entry.repoRoot + entry.generation))
2456
+ input.decisionId !== retireQuestionId(entry, folderPresent, input.decisionAttempt ?? 0))
2450
2457
  throw refuse('The retired folder changed after its removal was asked about. List the worktrees again.', 'worktree.list');
2451
2458
  const decision = await this.questionnaireResume({
2452
2459
  repoRoot: repository.repoRoot,
@@ -2454,7 +2461,7 @@ export class BridgeService {
2454
2461
  });
2455
2462
  if (decision.status !== 'answered' || decision.answer?.choice !== 'retire')
2456
2463
  throw refuse('A retired folder is removed only after the user approves it in the native worktree.reconcile form.', 'worktree.reconcile');
2457
- await pool.retire(projectId, repository.repoRoot, entry.repoRoot, entry.generation);
2464
+ await pool.retire(projectId, repository.repoRoot, entry.repoRoot, entry.generation, folderPresent);
2458
2465
  return asJsonValue({ operation, complete: true, removed: entry.repoRoot });
2459
2466
  }
2460
2467
  const entry = await pool.forPath(projectId, repository.repoRoot);
@@ -5066,6 +5073,13 @@ export function cancelledDeliveryQuestionId(entry, clean) {
5066
5073
  return ('worktree-delivery-cancel-' +
5067
5074
  sha256(entry.generation + ':' + (clean ? 'clean' : entry.managed ? 'modified' : 'own-folder')));
5068
5075
  }
5076
+ export function retireQuestionId(entry, folderPresent, decisionAttempt) {
5077
+ return ('worktree-retire-' +
5078
+ sha256(entry.repoRoot +
5079
+ entry.generation +
5080
+ (folderPresent ? '' : ':record') +
5081
+ (decisionAttempt ? ':' + decisionAttempt : '')));
5082
+ }
5069
5083
  function refuse(message, recovery, details) {
5070
5084
  return new BridgeRecoveryError(message, recovery, details);
5071
5085
  }
@@ -73,6 +73,130 @@ export function configuredBase(preferences, role) {
73
73
  : null;
74
74
  }
75
75
  export function taskStartDefinition(facts) {
76
+ const { previous, ...legacy } = legacyTaskStartDefinition(facts);
77
+ const tr = facts.language === 'tr';
78
+ const binding = { ...legacy.binding };
79
+ const keepOnly = facts.keepCurrent === true || !facts.currentCommit;
80
+ if (!keepOnly) {
81
+ binding.name = facts.name ?? facts.suggestedName;
82
+ delete binding.suggestedName;
83
+ }
84
+ const seen = new Set();
85
+ const questions = legacy
86
+ .questions.filter((question) => question.id !== 'name')
87
+ .map((question) => {
88
+ if (question.id === 'base')
89
+ return {
90
+ ...question,
91
+ message: tr ? 'Hangi daldan başlayalım?' : 'Which starting branch?',
92
+ options: question.options
93
+ .filter((option) => {
94
+ const base = binding.bases?.[option.id];
95
+ const identity = base ? stableStringify(base) : option.id;
96
+ if (seen.has(identity))
97
+ return false;
98
+ seen.add(identity);
99
+ return true;
100
+ })
101
+ .map((option) => {
102
+ const base = binding.bases?.[option.id];
103
+ return {
104
+ ...option,
105
+ label: base?.kind === 'remote'
106
+ ? sourceLabel(base, facts.currentBranch)
107
+ : option.id === 'current'
108
+ ? (tr ? 'Yerel: ' : 'Local: ') + (facts.currentBranch ?? 'HEAD')
109
+ : tr
110
+ ? 'origin üzerinde başka dal'
111
+ : 'Another branch on origin',
112
+ description: option.id === 'current'
113
+ ? tr
114
+ ? 'Çekmeden, mevcut commit’ten.'
115
+ : 'Current commit; no fetch.'
116
+ : option.id === 'other'
117
+ ? tr
118
+ ? 'Alttaki alana yaz.'
119
+ : 'Enter it below.'
120
+ : tr
121
+ ? 'Güncel hali çekilir.'
122
+ : 'Fetch the latest commit.',
123
+ };
124
+ }),
125
+ textField: {
126
+ ...question.textField,
127
+ title: tr
128
+ ? 'origin dalı (yalnızca başka dal için)'
129
+ : 'Branch on origin (only for another branch)',
130
+ },
131
+ };
132
+ const holder = binding.holder?.externalTaskId;
133
+ return {
134
+ ...question,
135
+ message: tr ? 'Nerede çalışalım?' : 'Where should we work?',
136
+ ...(holder
137
+ ? {
138
+ context: tr
139
+ ? `Bu klasördeki ${holder} görevi burada başlarsan ayrı worktree’ye geçer; işi korunur.`
140
+ : `Starting here moves ${holder} to a separate worktree; its work is kept.`,
141
+ }
142
+ : {}),
143
+ options: question.options.map((option) => ({
144
+ ...option,
145
+ label: option.id === 'worktree'
146
+ ? tr
147
+ ? 'Ayrı worktree'
148
+ : 'Separate worktree'
149
+ : option.id === 'here'
150
+ ? tr
151
+ ? 'Bu klasörde yeni dal'
152
+ : 'New branch in this folder'
153
+ : option.id === 'keep_current'
154
+ ? tr
155
+ ? 'Mevcut dalda kal'
156
+ : 'Stay on the current branch'
157
+ : tr
158
+ ? 'Şimdilik başlatma'
159
+ : 'Not now',
160
+ description: option.id === 'worktree'
161
+ ? tr
162
+ ? 'Bu klasör değişmez.'
163
+ : 'Leave this folder unchanged.'
164
+ : option.id === 'here'
165
+ ? tr
166
+ ? 'Klasör yeni dala geçer.'
167
+ : 'Switch this folder to the new branch.'
168
+ : option.id === 'keep_current'
169
+ ? tr
170
+ ? `${facts.currentBranch ?? 'HEAD'} üzerinde devam et.`
171
+ : `Continue on ${facts.currentBranch ?? 'HEAD'}.`
172
+ : tr
173
+ ? 'Hiçbir şey oluşturulmaz.'
174
+ : 'Create nothing.',
175
+ })),
176
+ };
177
+ });
178
+ questions.sort((left, right) => Number(right.id === 'base') - Number(left.id === 'base'));
179
+ return {
180
+ ...legacy,
181
+ message: tr ? 'Göreve başla' : 'Start task',
182
+ context: keepOnly
183
+ ? tr
184
+ ? 'Mevcut dalda devam edilecek.'
185
+ : 'Continue on the current branch.'
186
+ : (tr ? 'Yeni dal: ' : 'New branch: ') +
187
+ binding.name +
188
+ (binding.base ? ' · ' + sourceLabel(binding.base, facts.currentBranch) : ''),
189
+ example: keepOnly
190
+ ? legacy.example
191
+ : tr
192
+ ? 'Ayrı worktree, işi ayrı klasörde tutar.'
193
+ : 'A worktree keeps this task in its own folder.',
194
+ questions,
195
+ binding: JSON.parse(JSON.stringify(binding)),
196
+ previous: [previous, legacy],
197
+ };
198
+ }
199
+ function legacyTaskStartDefinition(facts) {
76
200
  const tr = facts.language === 'tr';
77
201
  const copy = startCopy[tr ? 'tr' : 'en'];
78
202
  const folder = facts.folder;
@@ -429,7 +429,7 @@ export class WorktreePool {
429
429
  await this.save(registry);
430
430
  });
431
431
  }
432
- async retire(projectId, repoRoot, path, generation) {
432
+ async retire(projectId, repoRoot, path, generation, folderPresent) {
433
433
  const commonDir = await this.commonDirectory(repoRoot);
434
434
  const activeRoot = key(await this.documentsDirectory());
435
435
  const legacyRoot = await this.legacyDocumentsDirectory();
@@ -443,7 +443,10 @@ export class WorktreePool {
443
443
  entry.commonDir !== commonDir ||
444
444
  !this.outsideActiveRoot(entry, activeRoot, legacyRoot))
445
445
  throw refuse('This folder is no longer the same retired slot. List the worktrees again before removing it.');
446
- if (await pathExists(entry.repoRoot)) {
446
+ const present = await pathExists(entry.repoRoot);
447
+ if (present !== folderPresent)
448
+ throw refuse('The retired folder changed after its removal was asked about. List the worktrees again.');
449
+ if (present) {
447
450
  const reasons = await this.fileProtection(entry);
448
451
  if (reasons.length)
449
452
  throw refuse('The retired folder is protected: ' +
@@ -20,7 +20,7 @@ For a bound repository, call `session.bootstrap` before producing a plan or chan
20
20
 
21
21
  Before a new write or scaffold task, read `session.entry` and the project Git preferences. If `canManage` is true, and any of development/production/test is still unanswered, ask them through one native form and save them with `project.set_git_preferences` — the tool asks every supplied role as its own question in that single form, then saves the approved roles together in one PUT. Development needs a branch; production/test may explicitly be absent. Their answered flags prevent repeated questions. Other members choose only a task-specific base and continue without changing shared preferences. A later request to change a base updates future tasks only. Branch preferences never label memory sources or change commit/tree applicability.
22
22
 
23
- Start new write or scaffold work with `task.branch`, before `session.bootstrap`, passing the stable `externalTaskId` and `repoRoot`. It opens one native form: where the branch starts (the configured project branches, this folder's branch and commit, or another remote branch), the new branch's name with a suggested default, and the folder: a separate managed worktree, this folder as a new branch when it is clean (moving out an unfinished task that holds it, which that option names), this folder on its current branch, or not now. Pass `base` or `name` only when the user has already said them; those questions then drop out of the form. Show the whole form in as few native control calls as the host allows, relay every answer together, then call `task.branch` again with the same arguments, which is the `retry` the relay returns. That call allocates exactly what was answered, fetches a remote base and pins its commit, and returns the `repoRoot` to bootstrap in. When the fetch fails it asks once whether to continue from the local commit; never select a cached branch silently. "Not now" returns `status: 'deferred'` with nothing created, reserved or saved: say so in one line, continue only read-only work, and call the returned `reconsider` exactly as given once the user asks to start it. A refusal that names a new `decisionAttempt`, such as a branch name that already exists, asks the form again under that attempt. Existing task decisions survive retries and restarts; resume a recorded branch rather than recreating or resetting it.
23
+ Start new write or scaffold work with `task.branch`, before `session.bootstrap`, passing the stable `externalTaskId` and `repoRoot`. It opens one short native form with only two questions: the starting branch and the folder. The branch name is generated automatically; a user-requested name may be supplied through `name`. Identical configured remote branches appear once. Folder choices: a separate managed worktree, this folder as a new branch when it is clean (moving out an unfinished task that holds it, which that option names), this folder on its current branch, or not now. Pass `base` or `name` when the user has already specified them; a supplied base removes that question. Do not ask a separate naming question. Keep the form concise and do not repeat its choices in a long chat introduction. Show the whole form in as few native control calls as the host allows, relay every answer together, then call `task.branch` again with the same arguments, which is the `retry` the relay returns. That call allocates exactly what was answered, fetches a remote base and pins its commit, and returns the `repoRoot` to bootstrap in. When the fetch fails it asks once whether to continue from the local commit; never select a cached branch silently. "Not now" returns `status: 'deferred'` with nothing created, reserved or saved: say so in one line, continue only read-only work, and call the returned `reconsider` exactly as given once the user asks to start it. A refusal that names a new `decisionAttempt`, such as a branch name that already exists, asks the form again under that attempt. Existing task decisions survive retries and restarts; resume a recorded branch rather than recreating or resetting it.
24
24
 
25
25
  Use the returned `repoRoot` for **every** file read/write, terminal, context, validation and Git/delivery operation. The user-local pool is shared by Codex and Claude. Managed directories live under `engineering_memory/worktrees/<stable-project-folder>/<folder>_worktreeN`, at the root of the system drive on Windows (`C:\engineering_memory\worktrees\...`) and in the home directory elsewhere, or under the absolute directory named by `worktree-root.json` in the user-level API state directory when that file exists; `worktree.list` reports the effective root, and worktrees created by earlier clients under the Documents folder keep working where they are. Do not supply arbitrary `worktreePath` values or create ad-hoc siblings. Independent clones cannot reuse each other's worktrees. `worktree.list` explains which slots are active, inactive but protected, or safely reusable. The versioned backend policy defaults to 50 directories per project/computer, 30-second heartbeats and 10-minute inactivity. Protected inactive directories still count toward the limit. Only a global admin changes `worktree.set_policy`; it is not an environment setting.
26
26
 
@@ -8,9 +8,11 @@ Use `questionnaire.ask` for a required decision. It records the question before
8
8
 
9
9
  Pass `repoRoot`, `questionnaireId`, `message` and two to twelve `options`, each with an `id` and `label`. Identifiers use letters, digits, underscores or hyphens.
10
10
 
11
+ Order choices with the recommendation first, or label the recommended choice explicitly in the user’s language. The renderer marks the recommendation without preselecting an answer. Keep task-start questions short; for a complex decision preserve the context, a concrete example and each option’s consequences. Do not repeat option explanations in the question body.
12
+
11
13
  A question has to be answerable by someone who has not followed the work. Say in `context` why it is being asked, give in `example` one concrete thing the decision changes, and give every option a `description` of what happens next if it is chosen. Show a record, a branch or a project by its title or name; an identifier or a hash in the visible text is refused, and one that really has to be shown goes inside backticks. Write all of it in the language the user is writing in and pass that language as `language`, a BCP-47 tag such as `tr`, `en` or `pt-BR`. The tag is remembered for the account and the computer, so it is asked for once: `session.entry` and every tool that opens a form take the same argument, and `questionnaire.ask` is refused while no language is known.
12
14
 
13
- Every form carries one more choice, "I did not understand; explain in more detail first". It is never recorded as an answer. When a call returns `needs_explanation`, explain in chat why the question is asked, what each option leads to and one example, then show the same question again with `questionnaire.resume`. A free-text reply that says the question was not understood is handled the same way and is not passed on as the decision. Set `allowFreeText` only when the user needs to give an answer outside those options. `questionnaire.resume` takes the same `repoRoot` and `questionnaireId`. These tools collect decisions; they do not commit, publish, bind a project or perform the selected action. Apply the accepted answer through the relevant lifecycle tool, checking the current target and version first.
15
+ Every form lets the person write an answer or comment without selecting an option. Use the host control's own free-input area; do not add a duplicate Other choice or a second question for feedback. Standard MCP forms expose one optional free-input field. On fixed decisions, text returns `status: pending` and `reason: feedback`, including when a selection is present: a comment may qualify that selection. It is not persisted as a decision. Address the feedback, then resume the original question or use a new bound question if its choices change. A host-native free response is relayed with the internal `__other__` choice and `text`; that identifier is not a visible choice. Explicit `allowFreeText` forms retain their free-answer contract and accept a typed answer without a selection. Earlier explanation choices and checkbox replies still return `needs_explanation` without recording consent. When the person asks for more detail, explain why the question is asked, what the options lead to and one example, then resume the original question. Set `allowFreeText` only when the user needs to give an answer outside those options. `questionnaire.resume` takes the same `repoRoot` and `questionnaireId`. These tools collect decisions; they do not commit, publish, bind a project or perform the selected action. Apply the accepted answer through the relevant lifecycle tool, checking the current target and version first.
14
16
 
15
17
  Fixed-choice answers can be replayed from the local receipt. Free text is returned only in the accepting call and is never stored. If a later receipt says the answer is unavailable, recover it from the user's actual answer in the conversation; never reconstruct it from the options. If it cannot be recovered, explain that it must be requested again using a new question identifier.
16
18
 
@@ -26,7 +28,7 @@ never enough to answer here. Resume it only in the chat working on that task.
26
28
 
27
29
  Only an explicit, valid accepted response answers a question. A timeout, dismissed form, empty response, invalid response, connection loss or ended turn is not an answer. These events leave the decision pending. Never substitute the recommended choice or treat silence as consent. The form's cancel or decline button dismisses the form; if abandoning the work is a meaningful decision, offer it as an explicit answer in the question. A question left pending this way stays pending; show it again once, on the user's next message, and then wait rather than re-asking on every following turn.
28
30
 
29
- State the decision and one concrete example directly in the question's `message`, in the language the user is writing in. Never put a resource key, hash, task ID or other internal identifier in a question's text — it explains nothing to the person answering and only makes the question harder to read.
31
+ State the decision in `message` and one useful example in `example`, in the language the user is writing in. Never put a resource key, hash, task ID or other internal identifier in a question's text — it explains nothing to the person answering and only makes the question harder to read.
30
32
 
31
33
  Never use request_user_input_async for a required decision: it does not wait for an answer. Do not end a turn while a required native form is still awaiting the user. Work that does not depend on the answer may continue. If the host removes the form or interrupts the tool call, retain the pending decision and resume that same question when the user returns. Do not automatically reopen dismissed forms in a loop, or use repeated sleeps to claim that a host timer has been disabled.
32
34
 
@@ -65,12 +67,12 @@ host: offer the MCP form again in a fresh session, since decline alone is not pr
65
67
  cannot display one.
66
68
 
67
69
  Some forms carry several related questions in one durable record. The `task.branch` start form
68
- asks where the task works, which branch it starts from and the branch name; the
70
+ asks only the starting branch and where to work; it names the new branch automatically. The
69
71
  `project.set_git_preferences` form asks one question per role. Their `hostFallback` carries a
70
72
  `questions` array instead of a single `options` array. AskUserQuestion takes at most 4
71
73
  questions per call, so show them in as few calls as needed and relay every answer together in
72
74
  one `questionnaire.answer_from_host` call, as `answers` keyed by each question's id. A choice
73
- that needs text, such as a custom branch name, carries it as that answer's `text`. A record can
75
+ that needs text, such as another starting branch, carries it as that answer's `text`. A record can
74
76
  also declare `terminalChoices`: picking one of those answers (for example "Not now") makes
75
77
  every other question in that form optional.
76
78
 
@@ -180,7 +182,7 @@ Ask for the registered email and intended role. Show owner, maintainer, member,
180
182
 
181
183
  ## Branch
182
184
 
183
- Call `task.branch` once for every new write or scaffold task, on any branch, and before a read-only task transitions to writing. Do not ask these questions yourself first: its one form asks where the task works (a separate managed worktree, this folder when it is clean, this folder after the task holding it moves out, or the branch already checked out), which branch it starts from, and the branch name, with a suggestion that follows the returned naming convention. Pass the exact `externalTaskId`. Pass `name`, `base` or `keepCurrent` only when the user already stated them, so the form skips that question; never pass a `worktreePath`. A task whose branch already exists resumes its recorded task instead of starting a new one. Continue in the returned `repoRoot`. Retries and handoffs reuse the stored answer. Read-only analysis does not create a branch or consume another task's answer.
185
+ Call `task.branch` once for every new write or scaffold task, on any branch, and before a read-only task transitions to writing. Do not ask these questions yourself first: its one form asks where the task works (a separate managed worktree, this folder when it is clean, this folder after the task holding it moves out, or the branch already checked out), and which branch it starts from. The new branch name is generated automatically; never add a naming question unless the user requests it. Keep the two questions short; do not repeat all choices in the introduction. Pass the exact `externalTaskId`. Pass `name`, `base` or `keepCurrent` only when the user already stated them, so the form skips that question; never pass a `worktreePath`. A task whose branch already exists resumes its recorded task instead of starting a new one. Continue in the returned `repoRoot`. Retries and handoffs reuse the stored answer. Read-only analysis does not create a branch or consume another task's answer.
184
186
 
185
187
  ## Flow Entry and Exit
186
188
 
@@ -375,3 +377,5 @@ freed with `worktree.release` instead.
375
377
 
376
378
  If the user later reconsiders a preserved legacy checkout, repeat `worktree.reconcile` with a new
377
379
  `decisionAttempt`; never reinterpret the previous preserve answer as approval.
380
+
381
+ Codex exposes native user-input tools and MCP elicitation as separate host capabilities. Use only tools permitted in the current mode. Standard MCP fields support labels, descriptions and optional text. Codex's native question UI already supports free responses; do not mistake a restriction in our form schema for a host limitation. Do not guess an undocumented `openai/form` schema. Keep fallback questions structured rather than flattening options into one long prompt.
@@ -1,29 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { readFile } from 'node:fs/promises';
3
- import test from 'node:test';
4
-
5
- const entryPoint = new URL('./engineering-memory.mjs', import.meta.url);
6
- const manifest = new URL('../package.json', import.meta.url);
7
-
8
- test('the published entry point tells the installer which version it is', async () => {
9
- const source = await readFile(entryPoint, 'utf8');
10
-
11
- assert.match(
12
- source,
13
- /'--client-version',\s*await publishedVersion\(\)/,
14
- 'The installer is never told the version, so every install reports itself as unknown and no update is ever offered',
15
- );
16
- });
17
-
18
- test('the published entry point tells the installer which backend it was built for', async () => {
19
- const source = await readFile(entryPoint, 'utf8');
20
-
21
- assert.match(source, /'--api-url',\s*await publishedApiUrl\(\)/);
22
- });
23
-
24
- test('the manifest carries a version the entry point can stamp', async () => {
25
- const declared = JSON.parse(await readFile(manifest, 'utf8'));
26
-
27
- assert.match(String(declared.version), /^\d+\.\d+\.\d+$/);
28
- assert.equal(declared.name, 'engineering-memory');
29
- });