engineering-memory 1.11.5 → 1.11.7

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.
@@ -19,7 +19,7 @@ Never answer a question about a bound repository from the working tree alone. Wh
19
19
 
20
20
  After compaction, a new chat, interruption, or handoff, call \`session.resume\` before continuing.
21
21
 
22
- Before a new write/scaffold task, use \`task.branch\` for native base selection and automatic worktree allocation. 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. Ask only unanswered shared branch preferences when \`session.entry\` says the user can manage them; otherwise choose a task-specific base.
22
+ Before a new write/scaffold task, inspect \`worktree.list\` first, then use \`task.branch\` for native base selection and pool allocation. A separate worktree does not necessarily mean a new directory: reuse a safely available slot before creating another. Never invent the next numbered 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. Ask only unanswered shared branch preferences when \`session.entry\` says the user can manage them; otherwise choose a task-specific base.
23
23
 
24
24
  Do not edit until the skill lifecycle has completed discovery, its checkpoint, and \`context.prepare_change\`. Do not claim completion until \`task.verify\` succeeds.
25
25
 
@@ -27,6 +27,8 @@ Do not block independent task work on \`memory.propose_revision\` drafting, subm
27
27
 
28
28
  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. 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.
29
29
 
30
+ 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.
31
+
30
32
  When the repository is unbound, use the native questionnaire required by the skill. Do not silently create or bind a project. Do not open a survey web page.
31
33
 
32
34
  For project creation or adoption, read the skill's project-onboarding reference. Use read-only maturity evidence, then native selection of greenfield, exhaustive adoption or phased rework. A missing folder or Git repository uses preparation:true questionnaires before project.initialize. Recognize later memory-refresh intent semantically and confirm its scope natively; never trigger it from a fixed word list. Adoption and refresh cover architecture, state, services, network, data, screens and cross-module flows, not just page logic.`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engineering-memory",
3
- "version": "1.11.5",
3
+ "version": "1.11.7",
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",
@@ -50,9 +50,9 @@ export class VerificationGate {
50
50
  }, this.root);
51
51
  }
52
52
  async rebindWorktreeOwnership(repoRoot, taskId, previous, generation) {
53
- const fingerprint = await this.git.fingerprint(repoRoot);
54
- const identity = worktreeIdentity(await this.git.findRoot(repoRoot));
55
- const path = this.pathFor(fingerprint, identity, taskId);
53
+ const repository = await this.repositories.resolve(repoRoot);
54
+ const identity = worktreeIdentity(repository.repoRoot);
55
+ const path = this.pathFor(repository.repoFingerprint, identity, taskId);
56
56
  const receipt = await readJson(path, this.root);
57
57
  if (!receipt || receipt.taskId !== taskId || receipt.worktreeGeneration !== previous)
58
58
  return;
@@ -10,13 +10,22 @@ export async function askQuestionnaire(server, service, input, context, previous
10
10
  }
11
11
  export async function resumeQuestionnaire(server, service, input, context) {
12
12
  try {
13
- return await present(server, service, await service.questionnaireResume(input), input.repoRoot, context, input.preparation);
13
+ return await present(server, service, await service.questionnaireResume(input), input.repoRoot, context, input.preparation, input.presentation);
14
14
  }
15
15
  catch (error) {
16
16
  return failure(error);
17
17
  }
18
18
  }
19
- async function present(server, service, record, repoRoot, context, preparation) {
19
+ export async function answerQuestionnaireFromHost(service, input) {
20
+ try {
21
+ const resolved = await service.questionnaireAnswerFromHost(input);
22
+ return answered(resolved.record, resolved.answer, resolved.replayed);
23
+ }
24
+ catch (error) {
25
+ return failure(error);
26
+ }
27
+ }
28
+ async function present(server, service, record, repoRoot, context, preparation, presentation) {
20
29
  if (record.status === 'withdrawn')
21
30
  return result({
22
31
  questionnaireId: record.questionnaireId,
@@ -28,6 +37,8 @@ async function present(server, service, record, repoRoot, context, preparation)
28
37
  return answered(record, record.answer, true);
29
38
  if (context.mcpReq.signal.aborted)
30
39
  return pending(record, 'interrupted');
40
+ if (presentation === 'host_native')
41
+ return pending(record, 'host_native_requested');
31
42
  const responses = context.mcpReq.inputResponses;
32
43
  const response = inputResponse(responses, record.requestKey);
33
44
  if (response.kind === 'elicit' && response.action === 'accept') {
@@ -53,15 +64,12 @@ async function present(server, service, record, repoRoot, context, preparation)
53
64
  const elicitation = capabilities?.elicitation;
54
65
  if (!elicitation || (!elicitation.form && elicitation.url !== undefined))
55
66
  return pending(record, 'native_form_unavailable');
56
- const presentationNotice = /^memory-batch-[a-f0-9]{64}-[0-9]+$/.test(record.questionnaireId)
57
- ? 'For an unversioned project proposal, approval updates legacy memory only. Source-aware tasks cannot read it until an inspection explicitly retains it. Check each sourceApplicabilityNotice in memory.list_proposals before approving.'
58
- : undefined;
59
67
  const copy = questionnaireCopy[record.language ?? 'en'];
60
68
  return inputRequired({
61
69
  inputRequests: {
62
70
  [record.requestKey]: inputRequired.elicit({
63
71
  mode: 'form',
64
- message: `${record.message}${presentationNotice ? `\n\n${presentationNotice}` : ''}\n\n${copy.pending}${record.allowFreeText ? copy.freeText : ''}${record.textField ? copy.storedText : ''}`,
72
+ message: presentationMessage(record),
65
73
  requestedSchema: {
66
74
  type: 'object',
67
75
  properties: {
@@ -122,14 +130,33 @@ const questionnaireCopy = {
122
130
  otherAnswer: 'Diğer yanıt',
123
131
  },
124
132
  };
133
+ function presentationMessage(record) {
134
+ const notice = /^memory-batch-[a-f0-9]{64}-[0-9]+$/.test(record.questionnaireId)
135
+ ? 'For an unversioned project proposal, approval updates legacy memory only. Source-aware tasks cannot read it until an inspection explicitly retains it. Check each sourceApplicabilityNotice in memory.list_proposals before approving.'
136
+ : undefined;
137
+ const copy = questionnaireCopy[record.language ?? 'en'];
138
+ return `${record.message}${notice ? `\n\n${notice}` : ''}\n\n${copy.pending}${record.allowFreeText ? copy.freeText : ''}${record.textField ? copy.storedText : ''}`;
139
+ }
125
140
  function pending(record, reason) {
126
141
  return result({
127
142
  questionnaireId: record.questionnaireId,
128
143
  status: 'pending',
129
144
  reason,
130
145
  answerAvailable: false,
146
+ hostFallback: {
147
+ operation: 'questionnaire.answer_from_host',
148
+ questionnaireId: record.questionnaireId,
149
+ requestKey: record.requestKey,
150
+ contentHash: record.contentHash,
151
+ message: presentationMessage(record),
152
+ options: record.options,
153
+ language: record.language ?? 'en',
154
+ allowFreeText: record.allowFreeText,
155
+ ...(record.textField ? { textField: record.textField } : {}),
156
+ instructions: '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.',
157
+ },
131
158
  nextAction: reason === 'native_form_unavailable'
132
- ? 'This host does not advertise native MCP forms. The decision remains pending. Explain the limitation and continue only independently authorized work. Use a supported native host and questionnaire.resume to answer; do not silently substitute an asynchronous question.'
159
+ ? '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.'
133
160
  : '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.',
134
161
  });
135
162
  }
@@ -140,6 +167,7 @@ function answered(record, answer, replayed) {
140
167
  answerAvailable: answer !== undefined,
141
168
  ...(answer ? { answer } : {}),
142
169
  replayed,
170
+ ...(record.answerSource ? { answerSource: record.answerSource } : {}),
143
171
  ...(!answer
144
172
  ? {
145
173
  nextAction: 'The free-text answer was accepted earlier but was not stored for privacy. It cannot be recovered or inferred. Use the original answer if still present in this conversation; otherwise collect it again through a new questionnaire or supported native input before dependent work.',
@@ -1,7 +1,7 @@
1
1
  import * as z from 'zod/v4';
2
2
  import { validationIds } from '../runtime/bridge-service.js';
3
- import { questionnaireDefinitionSchema } from '../runtime/questionnaire-store.js';
4
- import { askQuestionnaire, resumeQuestionnaire } from './questionnaire-tools.js';
3
+ import { hostAnswerSchema, questionnaireDefinitionSchema } from '../runtime/questionnaire-store.js';
4
+ import { answerQuestionnaireFromHost, askQuestionnaire, resumeQuestionnaire, } from './questionnaire-tools.js';
5
5
  const optionalRepoRoot = z.string().min(1).optional();
6
6
  const stringList = z.array(z.string().min(1));
7
7
  const jsonValue = z.lazy(() => z.union([
@@ -58,6 +58,7 @@ export const engineeringMemoryToolNames = [
58
58
  'audit.list',
59
59
  'questionnaire.ask',
60
60
  'questionnaire.resume',
61
+ 'questionnaire.answer_from_host',
61
62
  'questionnaire.withdraw',
62
63
  'session.entry',
63
64
  'session.set_decision',
@@ -152,6 +153,13 @@ const reconciliationEntry = z.object({
152
153
  reason: z.string().optional(),
153
154
  });
154
155
  export function registerQuestionnaireTools(server, service) {
156
+ server.registerTool('questionnaire.answer_from_host', {
157
+ description: 'Relay an actual answer from a host-permitted blocking native AskUserQuestion or request_user_input control to the exact durable question. First read hostFallback from the pending operation or questionnaire.resume with presentation host_native, display its unchanged question/options/notices and await the real native result. Never infer answers, use chat consent, asynchronous controls, defaults or a declined/dismissed form. The hostTool field is agent-reported provenance, not transport-verified attestation. Bind requestKey and contentHash exactly; changed scope, withdrawn questions, invalid or conflicting answers are refused. This only saves the answer; retry the owning operation for current authority and version checks.',
158
+ inputSchema: hostAnswerSchema.extend({
159
+ repoRoot: optionalRepoRoot,
160
+ preparation: z.boolean().optional(),
161
+ }),
162
+ }, async (input) => answerQuestionnaireFromHost(service, input));
155
163
  server.registerTool('questionnaire.withdraw', {
156
164
  description: 'Withdraw a pending decision only when the user explicitly cancels that decision or its owning workflow. This is not approval. An answered decision is immutable. Interruption, timeout and dismissal alone never authorize withdrawal.',
157
165
  inputSchema: z.strictObject({
@@ -183,18 +191,19 @@ export function registerQuestionnaireTools(server, service) {
183
191
  }
184
192
  });
185
193
  server.registerTool('questionnaire.ask', {
186
- description: 'Persist a required decision before displaying a native questionnaire. Write the message and labels in the conversation language and set language (tr or en) for the native form help. Use a questionnaireId unique to this decision occurrence or task, and reuse it only for identical retries. A later decision or changed wording/options requires a new id. Only a schema-validated native acceptance answers it. Dismissal, timeout and missing replies remain pending without expiry. Never supply answers in tool arguments. Do not put personal information or secrets in the question or options. Free text is returned once and never stored; use explicit options for replayable decisions.',
194
+ description: 'Persist a required decision before displaying a native questionnaire. Write the message and labels in the conversation language and set language (tr or en) for the native form help. Use a questionnaireId unique to this decision occurrence or task, and reuse it only for identical retries. A later decision or changed wording/options requires a new id. Only a schema-validated native acceptance answers it. Dismissal, timeout and missing replies remain pending without expiry. Never supply answers in questionnaire.ask arguments; use questionnaire.answer_from_host only after an actual permitted blocking native answer. Do not put personal information or secrets in the question or options. Free text is returned once and never stored; use explicit options for replayable decisions.',
187
195
  inputSchema: questionnaireDefinitionSchema.extend({
188
196
  repoRoot: optionalRepoRoot,
189
197
  preparation: z.boolean().optional(),
190
198
  }),
191
199
  }, async (input, context) => await askQuestionnaire(server, service, input, context));
192
200
  server.registerTool('questionnaire.resume', {
193
- description: 'Display the original durable questionnaire after interruption or return its saved accepted choice. No timeout or dismissal resolves a pending question. A saved free-text receipt cannot replay the text and must never be treated as a recovered answer.',
201
+ description: 'Display the original durable questionnaire after interruption or return its saved accepted choice. No timeout or dismissal resolves a pending question. A saved free-text receipt cannot replay the text and must never be treated as a recovered answer. presentation host_native returns the original question and bindings for a permitted blocking host control without reopening the MCP form.',
194
202
  inputSchema: z.strictObject({
195
203
  repoRoot: optionalRepoRoot,
196
204
  questionnaireId: z.string().regex(/^[A-Za-z0-9_-]{1,100}$/),
197
205
  preparation: z.boolean().optional(),
206
+ presentation: z.literal('host_native').optional(),
198
207
  }),
199
208
  }, async (input, context) => await resumeQuestionnaire(server, service, input, context));
200
209
  }
@@ -338,13 +338,76 @@ export function registerWorktreeTools(server, service) {
338
338
  delivered: z.boolean().optional(),
339
339
  });
340
340
  server.registerTool('worktree.list', {
341
- description: 'List task checkouts, activity and reasons each directory is protected.',
341
+ description: 'Discover existing managed Git checkouts and list activity and protection reasons before allocating another folder. Unregistered legacy checkouts remain protected until explicitly reconciled.',
342
342
  inputSchema: locationSchema,
343
343
  }, async (input) => output(await service.worktreeOperation('list', input)));
344
344
  server.registerTool('worktree.reconcile', {
345
- description: 'Reconcile Git with the local registry and recover interrupted allocations or a validated backup, without deleting files or resetting branches.',
346
- inputSchema: locationSchema,
347
- }, async (input) => output(await service.worktreeOperation('reconcile', input)));
345
+ 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. Never delete files or reset branches.',
346
+ inputSchema: z.strictObject({
347
+ repoRoot: repo,
348
+ releaseUnowned: z.boolean().optional(),
349
+ decisionAttempt: z.number().int().min(0).default(0),
350
+ language: z.enum(['tr', 'en']).default('en'),
351
+ }),
352
+ }, async (input, context) => {
353
+ const inspection = await service.worktreeOperation('reconcile', input);
354
+ if (!inspection.ok || !input.releaseUnowned)
355
+ return output(inspection);
356
+ const data = inspection.data;
357
+ const entry = data.selected;
358
+ if (!entry?.recoveredWithoutReservation || !entry.baseCommit)
359
+ return output({
360
+ ok: false,
361
+ error: {
362
+ kind: 'bridge_error',
363
+ retryable: false,
364
+ recovery: 'worktree.reconcile',
365
+ message: 'Select an unregistered, unreserved checkout from worktree.list. Existing task ownership cannot be released by this recovery.',
366
+ },
367
+ });
368
+ const digest = sha256(stableStringify({
369
+ projectId: entry.projectId,
370
+ repoRoot: entry.repoRoot,
371
+ generation: entry.generation,
372
+ sourceCommit: entry.baseCommit,
373
+ decisionAttempt: input.decisionAttempt,
374
+ }));
375
+ const questionnaireId = 'worktree-release-unowned-' + digest;
376
+ const form = await askQuestionnaire(server, service, {
377
+ repoRoot: input.repoRoot,
378
+ questionnaireId,
379
+ language: input.language,
380
+ allowFreeText: false,
381
+ message: input.language === 'tr'
382
+ ? 'Bu eski çalışma klasörünün önceki sahiplik kaydı yok. Önceki ajan burada artık çalışmıyor ve teslim tamamlandıysa, temiz klasörü yeniden kullanılabilir yapalım mı? Dosyalar ve branch silinmeyecek.'
383
+ : 'This legacy checkout has no prior ownership record. If the previous agent has stopped and delivery is complete, make the clean checkout reusable? No files or branches will be deleted.',
384
+ options: [
385
+ {
386
+ id: 'release_unowned',
387
+ label: input.language === 'tr'
388
+ ? 'Önceki iş bitti; güvenli kontrollerden sonra kullanılabilir yap'
389
+ : 'Prior work finished; release after safety checks',
390
+ },
391
+ {
392
+ id: 'preserve',
393
+ label: input.language === 'tr' ? 'Korumaya devam et' : 'Keep protected',
394
+ },
395
+ ],
396
+ }, context);
397
+ const decision = await service.questionnaireResume({
398
+ repoRoot: input.repoRoot,
399
+ questionnaireId,
400
+ });
401
+ if (decision.status !== 'answered' || decision.answer?.choice !== 'release_unowned')
402
+ return form;
403
+ return output(await service.worktreeOperation('release_unowned', {
404
+ repoRoot: input.repoRoot,
405
+ generation: entry.generation,
406
+ sourceCommit: entry.baseCommit,
407
+ decisionAttempt: input.decisionAttempt,
408
+ decisionId: questionnaireId,
409
+ }));
410
+ });
348
411
  server.registerTool('task.heartbeat', {
349
412
  description: 'Renew this exact task ownership during real activity. Never run an idle timer to keep an abandoned task active.',
350
413
  inputSchema: ownershipSchema,
@@ -66,7 +66,18 @@ export class BridgeService {
66
66
  if (stableStringify(scope) !== stableStringify(input.scope)) {
67
67
  throw refuse('The account or repository binding changed while the questionnaire was open.', 'session.entry');
68
68
  }
69
- return await this.dependencies.questionnaires.accept(scope, input.questionnaireId, input.requestKey, input.answer);
69
+ return await this.dependencies.questionnaires.accept(scope, input.questionnaireId, input.requestKey, input.answer, input.answerSource);
70
+ }
71
+ async questionnaireAnswerFromHost(input) {
72
+ const record = await this.questionnaireResume(input);
73
+ if (record.requestKey !== input.requestKey || record.contentHash !== input.contentHash) {
74
+ 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
+ }
76
+ return this.questionnaireAccept({
77
+ ...input,
78
+ scope: record.scope,
79
+ answerSource: { kind: 'host_native_relay', hostTool: input.hostTool },
80
+ });
70
81
  }
71
82
  async questionnaireScope(repoRoot, preparation = false) {
72
83
  await this.dependencies.principalState?.ensure();
@@ -643,7 +654,10 @@ export class BridgeService {
643
654
  localPendingDeliveryCount: localJournal.pendingDeliveryCount,
644
655
  conflicts,
645
656
  requiresAttention: conflicts.length > 0,
646
- editLeaseAllowed: conflicts.length === 0 || offlineDevelopmentAllowed,
657
+ editLeaseAllowed: backendTask.status !== 'closed' &&
658
+ backendTask.status !== 'abandoned' &&
659
+ backendSession.status !== 'closed' &&
660
+ (conflicts.length === 0 || offlineDevelopmentAllowed),
647
661
  offlineDevelopmentOnly: offlineDevelopmentAllowed,
648
662
  recoveredVerification,
649
663
  },
@@ -1870,12 +1884,33 @@ export class BridgeService {
1870
1884
  const pool = this.dependencies.worktreePool;
1871
1885
  if (!projectId || !pool)
1872
1886
  throw refuse('Resolve the project with a pool-capable client.', 'project.resolve');
1873
- if (operation === 'list')
1874
- return asJsonValue({ items: await pool.list(projectId, await this.poolPolicy()) });
1875
- if (operation === 'reconcile')
1887
+ if (operation === 'list' || operation === 'reconcile')
1876
1888
  return asJsonValue({
1877
1889
  items: await pool.reconcile(projectId, repository.repoRoot, await this.poolPolicy()),
1890
+ selected: await pool.forPath(projectId, repository.repoRoot),
1891
+ });
1892
+ if (operation === 'release_unowned') {
1893
+ const entry = await pool.forPath(projectId, repository.repoRoot);
1894
+ if (!entry || !input.sourceCommit || !input.generation || !input.decisionId)
1895
+ throw refuse('Inspect the recovered checkout with worktree.reconcile before confirming its release.', 'worktree.reconcile');
1896
+ const digest = sha256(stableStringify({
1897
+ projectId,
1898
+ repoRoot: entry.repoRoot,
1899
+ generation: input.generation,
1900
+ sourceCommit: input.sourceCommit,
1901
+ decisionAttempt: input.decisionAttempt ?? 0,
1902
+ }));
1903
+ if (input.decisionId !== 'worktree-release-unowned-' + digest)
1904
+ throw refuse('The availability decision does not match this checkout and source. Repeat worktree.reconcile.', 'worktree.reconcile');
1905
+ const decision = await this.questionnaireResume({
1906
+ repoRoot: repository.repoRoot,
1907
+ questionnaireId: input.decisionId,
1878
1908
  });
1909
+ if (decision.status !== 'answered' || decision.answer?.choice !== 'release_unowned')
1910
+ throw refuse('Confirm that prior work and delivery have finished in the native worktree.reconcile form.', 'worktree.reconcile');
1911
+ await pool.releaseUnowned(projectId, repository.repoRoot, input.generation, input.sourceCommit);
1912
+ return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
1913
+ }
1879
1914
  const entry = await pool.forPath(projectId, repository.repoRoot);
1880
1915
  if (!entry ||
1881
1916
  entry.externalTaskId !== input.externalTaskId ||
@@ -49,11 +49,26 @@ const storedQuestionSchema = questionnaireDefinitionSchema.extend({
49
49
  requestKey: z.string().regex(/^questionnaire_[0-9a-f]{64}$/),
50
50
  createdAt: z.string().datetime(),
51
51
  });
52
+ export const hostAnswerSchema = z.strictObject({
53
+ questionnaireId: identifier,
54
+ requestKey: z.string().regex(/^questionnaire_[0-9a-f]{64}$/),
55
+ contentHash: z.string().regex(/^[0-9a-f]{64}$/),
56
+ hostTool: z.enum(['AskUserQuestion', 'request_user_input']),
57
+ answer: z.strictObject({
58
+ choice: z.string().min(1).max(80),
59
+ text: z.string().max(2000).optional(),
60
+ }),
61
+ });
62
+ const answerSourceSchema = z.strictObject({
63
+ kind: z.literal('host_native_relay'),
64
+ hostTool: z.enum(['AskUserQuestion', 'request_user_input']),
65
+ });
52
66
  const storedAnswerSchema = z.strictObject({
53
67
  requestKey: z.string().regex(/^questionnaire_[0-9a-f]{64}$/),
54
68
  answeredAt: z.string().datetime(),
55
69
  answerAvailable: z.boolean(),
56
70
  withdrawn: z.literal(true).optional(),
71
+ answerSource: answerSourceSchema.optional(),
57
72
  answer: z.strictObject({ choice: z.string(), text: z.string().optional() }).optional(),
58
73
  });
59
74
  export function questionnaireAnswerSchema(record) {
@@ -194,7 +209,9 @@ export class QuestionnaireStore {
194
209
  }
195
210
  return records.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
196
211
  }
197
- async accept(scope, questionnaireId, requestKey, input) {
212
+ async accept(scope, questionnaireId, requestKey, input, answerSource) {
213
+ if (answerSource)
214
+ answerSourceSchema.parse(answerSource);
198
215
  const record = await this.get(scope, questionnaireId);
199
216
  if (!record || record.requestKey !== requestKey)
200
217
  throw new Error('Questionnaire response is stale or belongs to a different scope.');
@@ -209,6 +226,7 @@ export class QuestionnaireStore {
209
226
  }
210
227
  const receipt = {
211
228
  requestKey,
229
+ ...(answerSource ? { answerSource } : {}),
212
230
  answeredAt: new Date().toISOString(),
213
231
  answerAvailable: answer.choice !== '__other__',
214
232
  ...(answer.choice !== '__other__'
@@ -35,6 +35,7 @@ const allocationSchema = z.object({
35
35
  lastActivityAt: z.number().finite(),
36
36
  phase: z.enum(['creating', 'working', 'paused', 'delivery', 'released', 'quarantined']),
37
37
  pendingDelivery: z.boolean(),
38
+ recoveredWithoutReservation: z.boolean().optional(),
38
39
  reason: z.string().optional(),
39
40
  });
40
41
  const registrySchema = z.object({
@@ -74,6 +75,7 @@ export class WorktreePool {
74
75
  throw new BridgeRecoveryError('Choose a valid literal Git branch name of at most 255 characters. Retry task.branch with the corrected name; no allocation was recorded.', 'task.branch');
75
76
  }
76
77
  }
78
+ await this.reconcile(input.projectId, input.repoRoot, policy, true);
77
79
  const commonDir = await this.commonDirectory(input.repoRoot);
78
80
  return this.transaction(async (registry) => {
79
81
  if (!registry.projects[input.projectId]) {
@@ -292,33 +294,83 @@ export class WorktreePool {
292
294
  await this.save(registry);
293
295
  });
294
296
  }
295
- async reconcile(projectId, repoRoot, policy) {
297
+ async releaseUnowned(projectId, repoRoot, generation, sourceCommit) {
298
+ await this.transaction(async (registry) => {
299
+ const entry = registry.projects[projectId]?.entries.find((e) => key(e.repoRoot) === key(repoRoot));
300
+ if (!entry ||
301
+ entry.generation !== generation ||
302
+ !entry.recoveredWithoutReservation ||
303
+ entry.phase !== 'quarantined' ||
304
+ entry.owner ||
305
+ entry.taskId ||
306
+ (await this.git.branchStore(repoRoot).read()))
307
+ throw refuse('This is not the same unowned recovered checkout. List and reconcile its current ownership before release.');
308
+ if ((await this.git.sourceIdentity(repoRoot))?.sourceCommit !== sourceCommit ||
309
+ entry.baseCommit !== sourceCommit)
310
+ throw refuse('The recovered checkout source changed. Inspect it again before confirming availability.');
311
+ const reasons = await this.fileProtection(entry);
312
+ if (reasons.length)
313
+ throw refuse('The recovered checkout is protected: ' + reasons.join(', '));
314
+ entry.phase = 'released';
315
+ entry.pendingDelivery = false;
316
+ entry.recoveredWithoutReservation = false;
317
+ entry.generation = randomUUID();
318
+ delete entry.reason;
319
+ await this.save(registry);
320
+ });
321
+ }
322
+ async reconcile(projectId, repoRoot, policy, discoverOnly = false) {
296
323
  await this.restoreRegistry();
297
324
  const commonDir = await this.commonDirectory(repoRoot);
298
325
  await this.transaction(async (registry) => {
299
326
  let project = registry.projects[projectId];
300
- if (!project) {
301
- const locations = await this.gitPaths(repoRoot);
302
- for (const location of locations) {
303
- if (!(await pathExists(location.path)))
304
- continue;
305
- const reservation = await this.git.branchStore(location.path).read();
306
- if (reservation?.decision.projectId !== projectId)
307
- continue;
308
- const folder = dirname(dirname(location.path)).split(/[\\/]/).pop();
309
- const expected = join(await this.documentsDirectory(), 'engineeringmemory', folder, 'worktrees');
310
- if (key(dirname(location.path)) !== key(expected))
327
+ const locations = await this.gitPaths(repoRoot);
328
+ const candidates = [];
329
+ const managedRoot = join(await this.documentsDirectory(), 'engineeringmemory');
330
+ for (const location of locations) {
331
+ if (project?.entries.some((entry) => key(entry.repoRoot) === key(location.path)))
332
+ continue;
333
+ if (!(await pathExists(location.path)))
334
+ continue;
335
+ const parent = dirname(location.path);
336
+ const folder = dirname(parent).split(/[\\/]/).pop();
337
+ const name = location.path.split(/[\\/]/).pop();
338
+ if (key(parent) !== key(join(managedRoot, folder, 'worktrees')))
339
+ continue;
340
+ const prefix = folder + '-worktree';
341
+ const slot = Number(name.slice(prefix.length));
342
+ if (!name.startsWith(prefix) ||
343
+ !Number.isSafeInteger(slot) ||
344
+ slot < 1 ||
345
+ name !== prefix + slot)
346
+ continue;
347
+ try {
348
+ await assertManagedPath(managedRoot, location.path, false);
349
+ if ((await this.commonDirectory(location.path)) !== commonDir)
311
350
  continue;
312
- registry.projects[projectId] = { folder, entries: [] };
313
- break;
314
351
  }
352
+ catch {
353
+ continue;
354
+ }
355
+ const reservation = await this.git.branchStore(location.path).read();
356
+ if (reservation && reservation.decision.projectId !== projectId)
357
+ continue;
358
+ if (Object.entries(registry.projects).some(([id, value]) => id !== projectId && key(value.folder) === key(folder)))
359
+ continue;
360
+ candidates.push({ path: location.path, folder, slot });
361
+ }
362
+ if (!project) {
363
+ const folders = [...new Set(candidates.map((candidate) => candidate.folder))];
364
+ if (folders.length > 1)
365
+ throw refuse('Several managed folders belong to this clone. Restore the project index to identify its original folder before allocating a directory.');
366
+ if (folders.length === 1)
367
+ registry.projects[projectId] = { folder: folders[0], entries: [] };
315
368
  }
316
369
  const recoveredProject = registry.projects[projectId];
317
370
  if (!recoveredProject)
318
371
  return;
319
372
  project = recoveredProject;
320
- const locations = await this.gitPaths(repoRoot);
321
- for (const entry of project.entries.filter((e) => e.commonDir === commonDir)) {
373
+ for (const entry of project.entries.filter((e) => !discoverOnly && e.commonDir === commonDir)) {
322
374
  if (entry.owner && entry.owner.id !== this.ownerId && (await this.ownerAlive(entry.owner)))
323
375
  continue;
324
376
  if (await pathExists(entry.repoRoot))
@@ -337,48 +389,54 @@ export class WorktreePool {
337
389
  }
338
390
  }
339
391
  }
340
- for (const location of locations) {
392
+ for (const location of candidates.filter((candidate) => key(candidate.folder) === key(project.folder))) {
341
393
  if (project.entries.some((e) => key(e.repoRoot) === key(location.path)))
342
394
  continue;
343
- const parent = dirname(location.path);
344
- if (dirname(parent)
345
- .split(/[\\\\/]/)
346
- .pop() !== project.folder ||
347
- parent.split(/[\\\\/]/).pop() !== 'worktrees')
348
- continue;
349
395
  const reservation = await this.git.branchStore(location.path).read();
350
- if (reservation?.decision.projectId !== projectId)
396
+ if (reservation && reservation.decision.projectId !== projectId)
351
397
  continue;
352
398
  const source = await this.git.sourceIdentity(location.path);
353
- const slotText = location.path
354
- .split(/[\\\\/]/)
355
- .pop()
356
- ?.match(/-worktree([0-9]+)$/)?.[1];
357
- if (!source || !slotText)
399
+ if (!source)
358
400
  continue;
359
401
  project.entries.push({
360
402
  projectId,
361
403
  repoFingerprint: await this.git.fingerprint(repoRoot),
362
404
  commonDir,
363
405
  repoRoot: await canonicalPath(location.path),
364
- slot: Number(slotText),
406
+ slot: location.slot,
365
407
  managed: true,
366
- externalTaskId: reservation.decision.externalTaskId,
367
- taskId: reservation.decision.taskId,
368
- branch: reservation.decision.branch,
408
+ externalTaskId: reservation?.decision.externalTaskId ??
409
+ 'unregistered-' + sha256(key(location.path)).slice(0, 20),
410
+ taskId: reservation?.decision.taskId,
411
+ branch: reservation?.decision.branch ?? (await this.git.currentBranch(location.path)),
412
+ recoveredWithoutReservation: !reservation,
369
413
  baseCommit: source.sourceCommit,
370
414
  owner: null,
371
415
  generation: randomUUID(),
372
416
  lastActivityAt: 0,
373
417
  phase: 'quarantined',
374
418
  pendingDelivery: true,
375
- reason: 'Recovered a Git reservation missing from the registry. Restore its original task source and delivery state before reuse.',
419
+ reason: reservation
420
+ ? 'Recovered a Git reservation missing from the registry. Restore its original task source and delivery state before reuse.'
421
+ : 'Unregistered checkout: prior ownership and delivery are unknown. After prior work has finished, use worktree.reconcile with releaseUnowned through its native confirmation.',
376
422
  });
377
423
  }
378
- for (const entry of project.entries.filter((e) => e.commonDir === commonDir)) {
424
+ for (const entry of project.entries.filter((e) => !discoverOnly && e.commonDir === commonDir)) {
379
425
  if (entry.owner && (await this.ownerAlive(entry.owner)) && entry.owner.id !== this.ownerId)
380
426
  continue;
381
427
  try {
428
+ if (entry.recoveredWithoutReservation &&
429
+ !entry.owner &&
430
+ !entry.taskId &&
431
+ !(await this.git.branchStore(entry.repoRoot).read())) {
432
+ const source = await this.git.sourceIdentity(entry.repoRoot);
433
+ const branch = await this.git.currentBranch(entry.repoRoot);
434
+ if (source && (source.sourceCommit !== entry.baseCommit || branch !== entry.branch)) {
435
+ entry.baseCommit = source.sourceCommit;
436
+ entry.branch = branch;
437
+ entry.generation = randomUUID();
438
+ }
439
+ }
382
440
  await this.repairCreating(entry);
383
441
  if (await pathExists(entry.repoRoot))
384
442
  await this.assertIdentity(entry);
@@ -391,7 +449,7 @@ export class WorktreePool {
391
449
  }
392
450
  await this.save(registry);
393
451
  });
394
- return this.list(projectId, policy);
452
+ return discoverOnly ? [] : this.list(projectId, policy);
395
453
  }
396
454
  async entry(input, commonDir, repoRoot, slot, managed) {
397
455
  const generation = randomUUID();
package/skill/SKILL.md CHANGED
@@ -13,6 +13,8 @@ Mandatory behavior:
13
13
 
14
14
  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, read [questionnaires.md](references/questionnaires.md), then use `questionnaire.ask` to open a durable native MCP form and `questionnaire.resume` to return to the same unanswered question. 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.
15
15
 
16
+ 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.
17
+
16
18
  Do not block independent task work on `memory.propose_revision` drafting, submission or approval. Follow [memory-updates.md](references/memory-updates.md): use background agents or concurrent tools when the host permits them and continue useful work; without concurrency, checkpoint the pending draft and defer submission until needed. Wait only at the operation that depends on the proposal or revision. This scheduling rule applies to every project and AI host, while native approval and required task verification remain in force. Apply the same dependency rule to reconciliation, self-review, scaffold receipts, inventory upload, completed-unit evidence and test-result reporting. A bridge result with deliveryStatus pending means durable local recording, so continue independent work without polling or resending. Keep claim acquisition, required approval, edit leases and final verification as real dependencies.
17
19
 
18
20
  1. Discover the repository binding through `session.entry`. Bindings live in the user-level Engineering Memory state directory, outside the repository and installed runtime. No project settings file is required. For a bound repository, the project's knowledge is in the backend, so answer nothing about it before bootstrapping; the absence of local design files or records says nothing about its stored knowledge.
@@ -254,3 +254,43 @@ version is not automatically raised; projects opt in by starting a source-bound
254
254
  ## Offline source context
255
255
 
256
256
  An unexpired cached context pack may be used for implementation. The bridge records checkpoints in its outbox. Permanent proposal review, strict verification, task close, and Git commit require the backend and synchronization of the relevant task deliveries. An unrelated task or repository queue does not block this task; account switching and logout still require all deliveries to be resolved. Commit receipts are retained per task and worktree and still require an online attestation for the selected closed task and exact staged content.
257
+
258
+ ## Reuse before allocating another directory
259
+
260
+ Before starting a write task, call `worktree.list` with the bound repository. It reconciles managed
261
+ Git checkouts with local records. Then let `task.branch` choose the smallest safe slot; do not guess
262
+ a new worktree number or create a directory yourself. Allocation also performs discovery when the
263
+ caller omitted the listing. Old unregistered managed checkouts count toward the limit and remain
264
+ protected because their previous ownership and delivery are unknown. A clean checkout alone is
265
+ insufficient proof of availability.
266
+
267
+ When prior work has finished, call `worktree.reconcile` on that checkout with `releaseUnowned:true`
268
+ and the conversation language. Its native question binds the decision to the checkout, source and
269
+ ownership generation. Cancellation leaves it protected. Git reservation, dirty files, unfinished
270
+ Git operations and pending work are checked again before release. A changed source requires a new
271
+ inspection and answer. This recovery never deletes files or branches or interrupts another agent.
272
+
273
+ If the running client predates the pool tools, inspect `git worktree list` and the existing task
274
+ reservations before any explicit allocation. Do not treat an unavailable tool or an empty registry
275
+ as an empty filesystem. Preserve uncertain checkouts and report the client limitation.
276
+
277
+ ## Network and generation evidence
278
+
279
+ `network` means tests of the changed network/contract behavior, not a connection to a live provider.
280
+ Use local contract tests and controlled responses for the applicable success, errors, timeout,
281
+ retry and cancellation behavior. Report the real runner command, for example
282
+ `mvn -B -Dtest=CardDepositServiceTest test` or `./gradlew test --tests CardDepositServiceTest`;
283
+ the class name need not contain a special keyword. Report `network` only when that suite actually
284
+ covers the changed contract. A command match cannot establish behavioral coverage by itself.
285
+ Source searches, static analyzers, `echo` and a successful `curl` request do not replace these tests.
286
+ Never rename a command or invent a passing result to satisfy a category.
287
+
288
+ Handwritten files under `service`/`services` do not automatically require a separate network category,
289
+ and handwritten `model`/`models` files do not automatically require code generation. Explicit
290
+ network paths and generated `.g.dart`/`.freezed.dart` files retain their gates. On code tasks,
291
+ additional applicable `network` and `codegen` evidence is accepted; all submitted evidence must pass.
292
+ Read-only validation requirements are unchanged. Test commands that explicitly skip tests cannot
293
+ serve as test or network evidence. A live environment is a separate check when the task requires it.
294
+
295
+ If the user later reconsiders a preserved legacy checkout, repeat `worktree.reconcile` with a new
296
+ `decisionAttempt`; never reinterpret the previous preserve answer as approval.
@@ -20,6 +20,30 @@ The host owns the visible form and may impose a transport deadline or close it w
20
20
 
21
21
  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. Questions and saved answers must not contain credentials, personal data or production payloads. Authentication secrets belong in the existing browser authentication flow. Use an appropriate permitted native control for information that cannot be persisted; do not put it into a durable question's title, options or stored answer.
22
22
 
23
+ ## Host-native answer relay
24
+
25
+ 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.
26
+
27
+ For branch preferences, each role remains a separate decision bound to the exact preference
28
+ value and expectedVersion. A combined "yes to all branches" from a different question cannot
29
+ be relayed into three independent forms. Finish each original role question, then retry
30
+ project.set_git_preferences with its original arguments. The backend applies the group only
31
+ when every supplied role is approved and the version and authority still match. A concurrent
32
+ edit requires reading current preferences and fresh decisions for the new version.
33
+
34
+ Map the native result to the original option id only when the selected label identifies it
35
+ unambiguously; never substitute or omit choices to fit a host limit. If all choices and required
36
+ text cannot be represented by a permitted blocking control, leave the original question pending.
37
+ Do not use this relay to evade host approval restrictions or a user declining the owning action.
38
+ With a saved explicit defer, use a new decisionAttempt only after the user chooses to reconsider.
39
+
40
+ The relay stores host-native provenance beside the atomic answer. It does not store transcripts,
41
+ personal identities or host tool-call logs. Bounded decision text follows the existing privacy
42
+ checks; unrestricted Other text is returned once and cannot be recovered after restart. Installed
43
+ clients must restart after an upgrade to read the new relay receipt field; old receipts remain
44
+ readable by the new client. No server or SDK can independently prove an agent's claim that a
45
+ host-native form was shown; agents must use only the actual native result they observed.
46
+
23
47
  ## Authentication
24
48
 
25
49
  When no Engineering Memory session exists, ask whether to sign in, create an account, or skip Engineering Memory for this task. Explain that skip is permitted only for an unbound repository. After sign in or sign up is selected, let the bridge open the browser authentication flow. Never ask for a password in the native questionnaire or chat. If a browser link is expired, rejected, already used, or otherwise unusable, call `auth.signin_browser` with `restart: true` and present only the newly returned URL.
@@ -262,3 +286,16 @@ withdrawing it. Language changes never broaden the approved source, mode or prev
262
286
  Shared preferences use separate native decisions for development, production and test. Explicitly absent production/test values are answers. Members without canManage receive a task-specific branch choice, never an administrator question they cannot apply. The same task's accepted decision stays valid across Codex/Claude handoff after pause; return to session.resume and the recorded repoRoot.
263
287
 
264
288
  For task.branch and project.set_git_preferences, retries keep the same decisionAttempt (omit it on the first attempt). When the user explicitly reconsiders an answered deferral, use a new positive decisionAttempt to open a fresh native decision. Never reinterpret the old deferral as consent.
289
+
290
+ ## Legacy worktree availability
291
+
292
+ Use `worktree.list` before deciding another directory is needed. An unregistered legacy checkout
293
+ has unknown prior ownership and delivery, even if Git reports clean. `worktree.reconcile` with
294
+ `releaseUnowned:true` and `language` opens the native availability question for that exact checkout,
295
+ source commit and generation. The user confirms prior work and delivery have finished; current Git
296
+ safety checks still decide whether reuse is possible. Cancellation or choosing to preserve does not
297
+ release it. Resume the same form via the same operation. A changed source/generation needs a fresh
298
+ decision, and an old answer cannot release a newly owned task.
299
+
300
+ If the user later reconsiders a preserved legacy checkout, repeat `worktree.reconcile` with a new
301
+ `decisionAttempt`; never reinterpret the previous preserve answer as approval.