engineering-memory 1.10.1 → 1.10.3

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.
@@ -21,7 +21,7 @@ After compaction, a new chat, interruption, or handoff, call \`session.resume\`
21
21
 
22
22
  Do not edit until the skill lifecycle has completed discovery, its checkpoint, and \`context.prepare_change\`. Do not claim completion until \`task.verify\` succeeds.
23
23
 
24
- Use the host's native questionnaire for every question to the user, including implementation choices, names, clarification, branch/worktree decisions and delivery. In Codex use request_user_input when available; in Claude use AskUserQuestion. 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. Existing answers remain valid through retries and handoffs.
24
+ 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.
25
25
 
26
26
  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.`;
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engineering-memory",
3
- "version": "1.10.1",
3
+ "version": "1.10.3",
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",
@@ -17,6 +17,7 @@ import { RepositoryDecisionStore } from './runtime/repository-decision-store.js'
17
17
  import { ShadowNoticeStore } from './runtime/shadow-notice-store.js';
18
18
  import { UpdateChoiceStore } from './runtime/update-choice-store.js';
19
19
  import { PrincipalStateGuard } from './runtime/principal-state.js';
20
+ import { QuestionnaireStore } from './runtime/questionnaire-store.js';
20
21
  export function createBridgeService() {
21
22
  const config = loadBridgeConfig();
22
23
  const apiNamespace = apiNamespaceKey(config.apiBaseUrl);
@@ -59,6 +60,7 @@ export function createBridgeService() {
59
60
  repositoryDecisions,
60
61
  updateChoices,
61
62
  shadowNotices,
63
+ questionnaires: new QuestionnaireStore(stateRoot),
62
64
  clientVersion: config.clientVersion,
63
65
  principalState,
64
66
  });
@@ -0,0 +1,131 @@
1
+ import { acceptedContent, CLIENT_CAPABILITIES_META_KEY, inputRequired, inputResponse, } from '@modelcontextprotocol/server';
2
+ import { questionnaireAnswerSchema, } from '../runtime/questionnaire-store.js';
3
+ export async function askQuestionnaire(server, service, input, context) {
4
+ try {
5
+ return await present(server, service, await service.questionnaireAsk(input), input.repoRoot, context);
6
+ }
7
+ catch (error) {
8
+ return failure(error);
9
+ }
10
+ }
11
+ export async function resumeQuestionnaire(server, service, input, context) {
12
+ try {
13
+ return await present(server, service, await service.questionnaireResume(input), input.repoRoot, context);
14
+ }
15
+ catch (error) {
16
+ return failure(error);
17
+ }
18
+ }
19
+ async function present(server, service, record, repoRoot, context) {
20
+ if (record.status === 'answered')
21
+ return answered(record, record.answer, true);
22
+ if (context.mcpReq.signal.aborted)
23
+ return pending(record, 'interrupted');
24
+ const responses = context.mcpReq.inputResponses;
25
+ const response = inputResponse(responses, record.requestKey);
26
+ if (response.kind === 'elicit' && response.action === 'accept') {
27
+ const answer = acceptedContent(responses, record.requestKey, questionnaireAnswerSchema(record));
28
+ if (!answer)
29
+ return pending(record, 'invalid_response');
30
+ const resolved = await service.questionnaireAccept({
31
+ repoRoot,
32
+ questionnaireId: record.questionnaireId,
33
+ requestKey: record.requestKey,
34
+ scope: record.scope,
35
+ answer,
36
+ });
37
+ return answered(resolved.record, resolved.answer, resolved.replayed);
38
+ }
39
+ if (response.kind === 'elicit')
40
+ return pending(record, response.action);
41
+ if (responses !== undefined || context.mcpReq.droppedInputResponseKeys?.length)
42
+ return pending(record, 'missing_or_stale_response');
43
+ const envelope = context.mcpReq.envelope;
44
+ const capabilities = envelope?.[CLIENT_CAPABILITIES_META_KEY] ?? server.server.getClientCapabilities();
45
+ const elicitation = capabilities?.elicitation;
46
+ if (!elicitation || (!elicitation.form && elicitation.url !== undefined))
47
+ return pending(record, 'native_form_unavailable');
48
+ return inputRequired({
49
+ inputRequests: {
50
+ [record.requestKey]: inputRequired.elicit({
51
+ mode: 'form',
52
+ message: `${record.message}\n\nChoose an answer to resolve this decision. Closing or cancelling the form leaves it pending.${record.allowFreeText ? ' For another answer choose Other and fill in text. Free text is not stored and cannot be replayed.' : ''}`,
53
+ requestedSchema: {
54
+ type: 'object',
55
+ properties: {
56
+ choice: {
57
+ type: 'string',
58
+ title: 'Answer',
59
+ enum: [
60
+ ...record.options.map((option) => option.id),
61
+ ...(record.allowFreeText ? ['__other__'] : []),
62
+ ],
63
+ enumNames: [
64
+ ...record.options.map((option) => option.label),
65
+ ...(record.allowFreeText ? ['Other'] : []),
66
+ ],
67
+ },
68
+ ...(record.allowFreeText
69
+ ? {
70
+ text: {
71
+ type: 'string',
72
+ title: 'Other answer',
73
+ minLength: 1,
74
+ maxLength: 2000,
75
+ },
76
+ }
77
+ : {}),
78
+ },
79
+ required: ['choice'],
80
+ },
81
+ }),
82
+ },
83
+ });
84
+ }
85
+ function pending(record, reason) {
86
+ return result({
87
+ questionnaireId: record.questionnaireId,
88
+ status: 'pending',
89
+ reason,
90
+ answerAvailable: false,
91
+ nextAction: reason === 'native_form_unavailable'
92
+ ? '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.'
93
+ : '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.',
94
+ });
95
+ }
96
+ function answered(record, answer, replayed) {
97
+ return result({
98
+ questionnaireId: record.questionnaireId,
99
+ status: 'answered',
100
+ answerAvailable: answer !== undefined,
101
+ ...(answer ? { answer } : {}),
102
+ replayed,
103
+ ...(!answer
104
+ ? {
105
+ 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.',
106
+ }
107
+ : {}),
108
+ });
109
+ }
110
+ function result(data) {
111
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, data }) }] };
112
+ }
113
+ function failure(error) {
114
+ return {
115
+ content: [
116
+ {
117
+ type: 'text',
118
+ text: JSON.stringify({
119
+ ok: false,
120
+ error: {
121
+ kind: 'questionnaire_error',
122
+ message: error instanceof Error ? error.message : 'Questionnaire operation failed.',
123
+ recovery: 'session.entry',
124
+ },
125
+ }),
126
+ },
127
+ ],
128
+ isError: true,
129
+ };
130
+ }
131
+ //# sourceMappingURL=questionnaire-tools.js.map
@@ -1,11 +1,12 @@
1
1
  import { McpServer } from '@modelcontextprotocol/server';
2
- import { registerEngineeringMemoryTools } from './tool-definitions.js';
2
+ import { registerEngineeringMemoryTools, registerQuestionnaireTools } from './tool-definitions.js';
3
3
  export function createEngineeringMemoryServer(service) {
4
4
  const server = new McpServer({
5
5
  name: 'engineering-memory',
6
6
  version: '0.1.0',
7
7
  });
8
8
  registerEngineeringMemoryTools(server, service);
9
+ registerQuestionnaireTools(server, service);
9
10
  return server;
10
11
  }
11
12
  //# sourceMappingURL=server.js.map
@@ -1,5 +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
5
  const optionalRepoRoot = z.string().min(1).optional();
4
6
  const stringList = z.array(z.string().min(1));
5
7
  const jsonValue = z.lazy(() => z.union([
@@ -45,6 +47,8 @@ const checkpointBase = {
45
47
  };
46
48
  export const engineeringMemoryToolNames = [
47
49
  'audit.list',
50
+ 'questionnaire.ask',
51
+ 'questionnaire.resume',
48
52
  'session.entry',
49
53
  'session.set_decision',
50
54
  'session.decline_update',
@@ -116,6 +120,19 @@ const reconciliationEntry = z.object({
116
120
  revisionId: z.string().optional(),
117
121
  reason: z.string().optional(),
118
122
  });
123
+ export function registerQuestionnaireTools(server, service) {
124
+ server.registerTool('questionnaire.ask', {
125
+ description: 'Persist a required decision before displaying a native questionnaire. 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.',
126
+ inputSchema: questionnaireDefinitionSchema.extend({ repoRoot: optionalRepoRoot }),
127
+ }, async (input, context) => await askQuestionnaire(server, service, input, context));
128
+ server.registerTool('questionnaire.resume', {
129
+ 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.',
130
+ inputSchema: z.strictObject({
131
+ repoRoot: optionalRepoRoot,
132
+ questionnaireId: z.string().regex(/^[A-Za-z0-9_-]{1,100}$/),
133
+ }),
134
+ }, async (input, context) => await resumeQuestionnaire(server, service, input, context));
135
+ }
119
136
  export function registerEngineeringMemoryTools(server, service) {
120
137
  server.registerTool('audit.list', {
121
138
  description: 'List one authorized organization or project audit trail with an exact cursor. Choose exactly one scope and send both cursor fields together.',
@@ -59,19 +59,24 @@ export class RepositoryResolver {
59
59
  projectId: explicitProjectId ?? binding?.projectId ?? null,
60
60
  schemaVersion: binding?.schemaVersion ?? null,
61
61
  repoFingerprint,
62
+ legacyFingerprints: binding ? [] : [await this.git.fingerprint(repoRoot, 1)],
62
63
  git,
63
64
  };
64
65
  }
65
- async writeBinding(repoRoot, projectId) {
66
+ async writeBinding(repoRoot, projectId, fingerprint) {
66
67
  assertProjectId(projectId);
67
68
  const repository = await this.resolve(repoRoot, projectId);
69
+ const older = repository.legacyFingerprints.includes(fingerprint);
70
+ if (fingerprint !== repository.repoFingerprint && !older) {
71
+ throw new Error('The confirmed project fingerprint does not identify this repository');
72
+ }
68
73
  const repositoryKey = await this.keyFor(repository.repoRoot);
69
74
  const bindingPath = this.pathFor(repositoryKey);
70
75
  await writeJson(bindingPath, {
71
76
  projectId,
72
- schemaVersion: repository.schemaVersion ?? this.markerSchemaVersion,
77
+ schemaVersion: older ? 1 : (repository.schemaVersion ?? this.markerSchemaVersion),
73
78
  repositoryKey,
74
- repoFingerprint: repository.repoFingerprint,
79
+ repoFingerprint: fingerprint,
75
80
  }, this.stateRoot);
76
81
  return bindingPath;
77
82
  }
@@ -6,6 +6,7 @@ import { endpoints } from '../config.js';
6
6
  import { sha256, stableStringify } from '../utilities/hash.js';
7
7
  import { ApiResponseError, BackendUnavailableError, } from './api-client.js';
8
8
  import { assertSafeToPersist, normalizeRepositoryPaths } from './offline-outbox.js';
9
+ import { principalFingerprint } from './principal-state.js';
9
10
  import { BridgeRecoveryError } from './recovery-error.js';
10
11
  export const validationIds = [
11
12
  'format',
@@ -32,6 +33,56 @@ export class BridgeService {
32
33
  this.dependencies = dependencies;
33
34
  this.deferDeliveries = dependencies.deferDeliveries ?? true;
34
35
  }
36
+ async questionnaireAsk(input) {
37
+ const scope = await this.questionnaireScope(input.repoRoot);
38
+ const { repoRoot: _repoRoot, ...definition } = input;
39
+ return await this.dependencies.questionnaires.ask(scope, definition);
40
+ }
41
+ async questionnaireResume(input) {
42
+ const scope = await this.questionnaireScope(input.repoRoot);
43
+ const record = await this.dependencies.questionnaires.get(scope, input.questionnaireId);
44
+ if (!record)
45
+ throw refuse('No questionnaire exists for this account and repository binding.', 'session.entry');
46
+ return record;
47
+ }
48
+ async questionnaireAccept(input) {
49
+ const scope = await this.questionnaireScope(input.repoRoot);
50
+ if (stableStringify(scope) !== stableStringify(input.scope)) {
51
+ throw refuse('The account or repository binding changed while the questionnaire was open.', 'session.entry');
52
+ }
53
+ return await this.dependencies.questionnaires.accept(scope, input.questionnaireId, input.requestKey, input.answer);
54
+ }
55
+ async questionnaireScope(repoRoot) {
56
+ await this.dependencies.principalState?.ensure();
57
+ const accessToken = await this.dependencies.credentials.get('access-token');
58
+ const repository = await this.dependencies.repositories.resolve(repoRoot ?? process.cwd());
59
+ const decision = await this.dependencies.repositoryDecisions.load(repository.repoFingerprint);
60
+ if (decision?.state === 'disabled')
61
+ throw refuse('Engineering Memory is disabled in this repository.', 'session.entry');
62
+ const principalHash = accessToken ? principalFingerprint(accessToken) : sha256('anonymous');
63
+ const currentCredential = await this.dependencies.credentials.get('access-token');
64
+ if (principalHash !==
65
+ (currentCredential ? principalFingerprint(currentCredential) : sha256('anonymous'))) {
66
+ throw refuse('The account changed while the questionnaire scope was being resolved.', 'session.entry');
67
+ }
68
+ return {
69
+ principalHash,
70
+ repoFingerprint: repository.repoFingerprint,
71
+ projectId: repository.projectId,
72
+ };
73
+ }
74
+ async pendingQuestionnaires(repoRoot) {
75
+ const scope = await this.questionnaireScope(repoRoot);
76
+ return (await this.dependencies.questionnaires.pending(scope)).map((record) => ({
77
+ questionnaireId: record.questionnaireId,
78
+ message: record.message,
79
+ options: record.options,
80
+ allowFreeText: record.allowFreeText,
81
+ createdAt: record.createdAt,
82
+ status: record.status,
83
+ nextAction: 'Call questionnaire.resume with this questionnaireId. No answer, elapsed time, dismissal or interruption is consent. Continue only work that is independent of the pending decision.',
84
+ }));
85
+ }
35
86
  deliverInBackground() {
36
87
  if (!this.deferDeliveries)
37
88
  return;
@@ -70,7 +121,10 @@ export class BridgeService {
70
121
  if (!repository.bindingPath) {
71
122
  const resolved = await this.dependencies.client.request(endpoints.projectResolve, {
72
123
  method: 'POST',
73
- body: { repoFingerprint: repository.repoFingerprint },
124
+ body: {
125
+ repoFingerprint: repository.repoFingerprint,
126
+ legacyFingerprints: repository.legacyFingerprints,
127
+ },
74
128
  });
75
129
  const project = objectValue(resolved.data);
76
130
  const restoreRequired = Boolean(project?.archivedAt);
@@ -182,6 +236,23 @@ export class BridgeService {
182
236
  async sessionResume(input) {
183
237
  return await this.execute(async () => {
184
238
  const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd(), input.projectId);
239
+ const decision = await this.dependencies.repositoryDecisions.load(repository.repoFingerprint);
240
+ if (decision?.state === 'disabled') {
241
+ return asJsonValue({
242
+ decision: 'disabled',
243
+ nextAction: entryNextAction(false, 'disabled'),
244
+ });
245
+ }
246
+ const pendingQuestionnaires = await this.pendingQuestionnaires(repository.repoRoot);
247
+ const signedIn = (await this.dependencies.browserAuth.status()).authenticated;
248
+ if (!signedIn && pendingQuestionnaires.length > 0) {
249
+ return asJsonValue({
250
+ authenticated: false,
251
+ pendingQuestionnaires,
252
+ repository: publicRepository(repository),
253
+ nextAction: 'Resume only the pending sign-in questionnaire for this work. Signing in is still required before accessing project memory.',
254
+ });
255
+ }
185
256
  const authentication = await this.dependencies.browserAuth.ensureAuthenticated();
186
257
  if (authentication) {
187
258
  return asJsonValue({ authentication, repository: publicRepository(repository) });
@@ -201,6 +272,7 @@ export class BridgeService {
201
272
  return asJsonValue({
202
273
  taskChoiceRequired: true,
203
274
  requestedTaskSlug: input.taskSlug,
275
+ pendingQuestionnaires,
204
276
  liveTasks: live.map(describePointer),
205
277
  repository: publicRepository(repository),
206
278
  });
@@ -209,6 +281,7 @@ export class BridgeService {
209
281
  return asJsonValue({
210
282
  taskChoiceRequired: true,
211
283
  requestedSessionId: input.sessionId,
284
+ pendingQuestionnaires,
212
285
  liveTasks: live.map(describePointer),
213
286
  repository: publicRepository(repository),
214
287
  });
@@ -216,6 +289,7 @@ export class BridgeService {
216
289
  if (!pointer && live.length > 1) {
217
290
  return asJsonValue({
218
291
  taskChoiceRequired: true,
292
+ pendingQuestionnaires,
219
293
  liveTasks: live.map(describePointer),
220
294
  repository: publicRepository(repository),
221
295
  });
@@ -224,6 +298,13 @@ export class BridgeService {
224
298
  const taskSlug = input.taskSlug ?? pointer?.taskSlug;
225
299
  const sessionId = input.sessionId ?? pointer?.sessionId;
226
300
  if (!projectId || !taskSlug || !sessionId) {
301
+ if (pendingQuestionnaires.length > 0) {
302
+ return asJsonValue({
303
+ pendingQuestionnaires,
304
+ repository: publicRepository(repository),
305
+ nextAction: 'Resume only the pending questionnaire for this work. Unrelated pending questions never block a separate task; continue independently authorized work.',
306
+ });
307
+ }
227
308
  throw refuse('This repository has no live task to resume.', 'session.bootstrap');
228
309
  }
229
310
  if ((pointer &&
@@ -237,6 +318,7 @@ export class BridgeService {
237
318
  const recoveredClose = await this.retryCloseIntent(repository, pointer);
238
319
  return asJsonValue({
239
320
  backend: { close: recoveredClose },
321
+ pendingQuestionnaires,
240
322
  repository: publicRepository(repository),
241
323
  localJournal: await this.dependencies.journal.load(projectId, taskSlug),
242
324
  synchronization: {
@@ -408,6 +490,7 @@ export class BridgeService {
408
490
  backend,
409
491
  repository: publicRepository(repository),
410
492
  localJournal,
493
+ pendingQuestionnaires,
411
494
  synchronization: {
412
495
  backendSource: responseSource,
413
496
  backendFresh: responseSource !== 'stale_cache',
@@ -1361,6 +1444,7 @@ export class BridgeService {
1361
1444
  projectName: input.projectName,
1362
1445
  projectSlug: input.projectSlug ?? slugify(input.projectName),
1363
1446
  repoFingerprint: repository.repoFingerprint,
1447
+ legacyFingerprints: repository.legacyFingerprints,
1364
1448
  framework: input.framework,
1365
1449
  developmentArea: input.developmentArea,
1366
1450
  figmaConfig: input.figmaConfig,
@@ -1384,6 +1468,7 @@ export class BridgeService {
1384
1468
  const marker = objectValue(data?.marker);
1385
1469
  if (!project ||
1386
1470
  typeof project.id !== 'string' ||
1471
+ typeof project.repoFingerprint !== 'string' ||
1387
1472
  !profileResource ||
1388
1473
  !profileRevision ||
1389
1474
  !policy ||
@@ -1392,7 +1477,7 @@ export class BridgeService {
1392
1477
  readDiscoveryUnits(policy).length === 0) {
1393
1478
  throw refuse('Project setup response is not policy-ready', 'project.setup');
1394
1479
  }
1395
- const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, project.id);
1480
+ const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, project.id, project.repoFingerprint);
1396
1481
  return asJsonValue({ ...data, marker: null, markerPath: null, bindingPath });
1397
1482
  });
1398
1483
  }
@@ -1689,13 +1774,18 @@ export class BridgeService {
1689
1774
  }
1690
1775
  const response = await this.dependencies.client.request(endpoints.projectBind(input.projectId), {
1691
1776
  method: 'POST',
1692
- body: { repoFingerprint: repository.repoFingerprint },
1777
+ body: {
1778
+ repoFingerprint: repository.repoFingerprint,
1779
+ legacyFingerprints: repository.legacyFingerprints,
1780
+ },
1693
1781
  });
1694
1782
  const project = objectValue(response.data);
1695
- if (!project || project.id !== input.projectId) {
1783
+ if (!project ||
1784
+ project.id !== input.projectId ||
1785
+ typeof project.repoFingerprint !== 'string') {
1696
1786
  throw refuse('Project bind response does not match the selected project', 'project.resolve');
1697
1787
  }
1698
- const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, input.projectId);
1788
+ const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, input.projectId, project.repoFingerprint);
1699
1789
  return asJsonValue({
1700
1790
  resolved: true,
1701
1791
  bound: true,
@@ -1708,7 +1798,10 @@ export class BridgeService {
1708
1798
  }
1709
1799
  const response = await this.dependencies.client.request(endpoints.projectResolve, {
1710
1800
  method: 'POST',
1711
- body: { repoFingerprint: repository.repoFingerprint },
1801
+ body: {
1802
+ repoFingerprint: repository.repoFingerprint,
1803
+ legacyFingerprints: repository.legacyFingerprints,
1804
+ },
1712
1805
  });
1713
1806
  const project = objectValue(response.data);
1714
1807
  const restoreRequired = Boolean(project?.archivedAt);
@@ -1759,6 +1852,7 @@ export class BridgeService {
1759
1852
  ? await this.actionableWorkItems(projectId)
1760
1853
  : [];
1761
1854
  const taskSelectionRequired = liveTasks.length > 0 || workItems.length > 0;
1855
+ const pendingQuestionnaires = state !== 'disabled' ? await this.pendingQuestionnaires(repository.repoRoot) : [];
1762
1856
  return asJsonValue({
1763
1857
  authenticated,
1764
1858
  repository: publicRepository(repository),
@@ -1767,15 +1861,18 @@ export class BridgeService {
1767
1861
  liveTasks,
1768
1862
  workItems,
1769
1863
  taskSelectionRequired,
1864
+ ...(state !== 'disabled' ? { pendingQuestionnaires } : {}),
1770
1865
  client,
1771
1866
  ...(incomplete && state === 'bound'
1772
1867
  ? {
1773
1868
  shippedKnowledgeWarning: 'The backend is not serving all of the knowledge this release ships. Tell the user before relying on the rules: some are missing from the running server, so a review against them is incomplete. The counts and the reason are in client.shippedKnowledge.',
1774
1869
  }
1775
1870
  : {}),
1776
- nextAction: taskSelectionRequired
1777
- ? 'Present the live runs and actionable project work items, then ask which one this chat should handle. Resume only the run they choose, or bootstrap a separate run with the chosen workItemId. Another live task never blocks opening this one.'
1778
- : entryNextAction(authenticated, state),
1871
+ nextAction: pendingQuestionnaires.length > 0
1872
+ ? 'Resume only the pending questionnaire for this work with questionnaire.resume before taking action that depends on its answer. Unrelated pending questions never block opening a separate task. Never infer consent from a timeout, dismissal, interrupted turn or missing answer.'
1873
+ : taskSelectionRequired
1874
+ ? 'Present the live runs and actionable project work items, then ask which one this chat should handle. Resume only the run they choose, or bootstrap a separate run with the chosen workItemId. Another live task never blocks opening this one.'
1875
+ : entryNextAction(authenticated, state),
1779
1876
  });
1780
1877
  });
1781
1878
  }
@@ -0,0 +1,185 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { link, readdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import * as z from 'zod/v4';
5
+ import { assertManagedPath, ensureManagedDirectory, isNodeError, readJson, removeFile, writeJson, } from '../utilities/files.js';
6
+ import { sha256, stableStringify } from '../utilities/hash.js';
7
+ import { assertSafeToPersist } from './offline-outbox.js';
8
+ const identifier = z.string().regex(/^[A-Za-z0-9_-]{1,100}$/);
9
+ export const questionnaireDefinitionSchema = z.strictObject({
10
+ questionnaireId: identifier,
11
+ message: z.string().trim().min(1).max(2000),
12
+ options: z
13
+ .array(z.strictObject({
14
+ id: z
15
+ .string()
16
+ .regex(/^[A-Za-z0-9_-]{1,80}$/)
17
+ .refine((value) => value !== '__other__'),
18
+ label: z.string().trim().min(1).max(240),
19
+ }))
20
+ .min(2)
21
+ .max(12)
22
+ .refine((values) => new Set(values.map((value) => value.id)).size === values.length),
23
+ allowFreeText: z.boolean().default(false),
24
+ });
25
+ const scopeSchema = z.strictObject({
26
+ principalHash: z.string().regex(/^[0-9a-f]{64}$/),
27
+ repoFingerprint: z.string().regex(/^[0-9a-f]{64}$/),
28
+ projectId: z.string().uuid().nullable(),
29
+ });
30
+ const storedQuestionSchema = questionnaireDefinitionSchema.extend({
31
+ schemaVersion: z.literal(1),
32
+ scope: scopeSchema,
33
+ contentHash: z.string().regex(/^[0-9a-f]{64}$/),
34
+ requestKey: z.string().regex(/^questionnaire_[0-9a-f]{64}$/),
35
+ createdAt: z.string().datetime(),
36
+ });
37
+ const storedAnswerSchema = z.strictObject({
38
+ requestKey: z.string().regex(/^questionnaire_[0-9a-f]{64}$/),
39
+ answeredAt: z.string().datetime(),
40
+ answerAvailable: z.boolean(),
41
+ answer: z.strictObject({ choice: z.string() }).optional(),
42
+ });
43
+ export function questionnaireAnswerSchema(record) {
44
+ return z
45
+ .strictObject({
46
+ choice: z.enum([
47
+ ...record.options.map((option) => option.id),
48
+ ...(record.allowFreeText ? ['__other__'] : []),
49
+ ]),
50
+ text: z.preprocess((value) => (typeof value === 'string' && value.trim() === '' ? undefined : value), record.allowFreeText ? z.string().trim().min(1).max(2000).optional() : z.never().optional()),
51
+ })
52
+ .refine((answer) => answer.choice === '__other__' ? typeof answer.text === 'string' : answer.text === undefined);
53
+ }
54
+ export class QuestionnaireStore {
55
+ root;
56
+ constructor(stateRoot) {
57
+ this.root = join(stateRoot, 'questionnaires');
58
+ }
59
+ async ask(scope, input) {
60
+ const definition = questionnaireDefinitionSchema.parse(input);
61
+ scopeSchema.parse(scope);
62
+ assertSafeToPersist(definition);
63
+ const contentHash = sha256(stableStringify(definition));
64
+ const question = {
65
+ ...definition,
66
+ schemaVersion: 1,
67
+ scope,
68
+ contentHash,
69
+ requestKey: `questionnaire_${sha256(`${stableStringify(scope)}\n${contentHash}\n${randomUUID()}`)}`,
70
+ createdAt: new Date().toISOString(),
71
+ };
72
+ await this.publish(this.pathFor(scope, definition.questionnaireId, 'pending.json'), question);
73
+ const record = await this.get(scope, definition.questionnaireId);
74
+ if (!record || record.contentHash !== contentHash) {
75
+ throw new Error('Questionnaire id was reused with different content. Resume the original question or use a new id.');
76
+ }
77
+ return record;
78
+ }
79
+ async get(scope, questionnaireId) {
80
+ const raw = await readJson(this.pathFor(scope, questionnaireId, 'pending.json'), this.root);
81
+ if (!raw)
82
+ return null;
83
+ const question = storedQuestionSchema.parse(raw);
84
+ const definition = questionnaireDefinitionSchema.parse({
85
+ questionnaireId: question.questionnaireId,
86
+ message: question.message,
87
+ options: question.options,
88
+ allowFreeText: question.allowFreeText,
89
+ });
90
+ if (stableStringify(question.scope) !== stableStringify(scope) ||
91
+ question.questionnaireId !== questionnaireId ||
92
+ question.contentHash !== sha256(stableStringify(definition))) {
93
+ throw new Error('Questionnaire scope or content does not match its durable record.');
94
+ }
95
+ assertSafeToPersist(question);
96
+ const rawAnswer = await readJson(this.pathFor(scope, questionnaireId, 'answer.json'), this.root);
97
+ if (!rawAnswer)
98
+ return { ...question, status: 'pending' };
99
+ const answer = storedAnswerSchema.parse(rawAnswer);
100
+ if (answer.requestKey !== question.requestKey ||
101
+ (answer.answerAvailable
102
+ ? !answer.answer || !question.options.some((option) => option.id === answer.answer.choice)
103
+ : !question.allowFreeText || answer.answer !== undefined)) {
104
+ throw new Error('Questionnaire answer does not match its durable question.');
105
+ }
106
+ assertSafeToPersist(answer);
107
+ return { ...question, ...answer, status: 'answered' };
108
+ }
109
+ async pending(scope) {
110
+ const directory = this.scopeRoot(scope);
111
+ await ensureManagedDirectory(this.root, directory);
112
+ const entries = await readdir(directory, { withFileTypes: true });
113
+ const records = [];
114
+ for (const entry of entries) {
115
+ if (!/^[0-9a-f]{64}$/.test(entry.name))
116
+ continue;
117
+ if (!entry.isDirectory() || entry.isSymbolicLink())
118
+ throw new Error('Unsafe questionnaire directory.');
119
+ const path = await assertManagedPath(this.root, join(directory, entry.name, 'pending.json'), true);
120
+ const raw = await readJson(path, this.root);
121
+ if (!raw)
122
+ continue;
123
+ const question = storedQuestionSchema.parse(raw);
124
+ if (sha256(question.questionnaireId) !== entry.name)
125
+ throw new Error('Questionnaire directory does not match its id.');
126
+ const record = await this.get(scope, question.questionnaireId);
127
+ if (record?.status === 'pending')
128
+ records.push(record);
129
+ }
130
+ return records.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
131
+ }
132
+ async accept(scope, questionnaireId, requestKey, input) {
133
+ const record = await this.get(scope, questionnaireId);
134
+ if (!record || record.requestKey !== requestKey)
135
+ throw new Error('Questionnaire response is stale or belongs to a different scope.');
136
+ const answer = questionnaireAnswerSchema(record).parse(input);
137
+ if (record.status === 'answered') {
138
+ if (record.answer && stableStringify(record.answer) !== stableStringify(answer))
139
+ throw new Error('Questionnaire was already answered differently.');
140
+ return { record, answer: record.answer, replayed: true };
141
+ }
142
+ const receipt = {
143
+ requestKey,
144
+ answeredAt: new Date().toISOString(),
145
+ answerAvailable: answer.choice !== '__other__',
146
+ ...(answer.choice !== '__other__' ? { answer: { choice: answer.choice } } : {}),
147
+ };
148
+ const published = await this.publish(this.pathFor(scope, questionnaireId, 'answer.json'), receipt);
149
+ const resolved = (await this.get(scope, questionnaireId));
150
+ if (!published &&
151
+ resolved.answer &&
152
+ stableStringify(resolved.answer) !== stableStringify(answer))
153
+ throw new Error('Questionnaire was already answered differently.');
154
+ return { record: resolved, answer: published ? answer : resolved.answer, replayed: !published };
155
+ }
156
+ scopeRoot(scope) {
157
+ scopeSchema.parse(scope);
158
+ return join(this.root, scope.principalHash, sha256(`${scope.repoFingerprint}\n${scope.projectId ?? ''}`));
159
+ }
160
+ pathFor(scope, questionnaireId, filename) {
161
+ identifier.parse(questionnaireId);
162
+ return join(this.scopeRoot(scope), sha256(questionnaireId), filename);
163
+ }
164
+ async publish(path, value) {
165
+ assertSafeToPersist(value);
166
+ const temporaryPath = `${path}.${randomUUID()}.tmp`;
167
+ await writeJson(temporaryPath, value, this.root);
168
+ try {
169
+ const target = await assertManagedPath(this.root, path, true);
170
+ try {
171
+ await link(temporaryPath, target);
172
+ return true;
173
+ }
174
+ catch (error) {
175
+ if (isNodeError(error) && error.code === 'EEXIST')
176
+ return false;
177
+ throw error;
178
+ }
179
+ }
180
+ finally {
181
+ await removeFile(temporaryPath, this.root);
182
+ }
183
+ }
184
+ }
185
+ //# sourceMappingURL=questionnaire-store.js.map
@@ -136,7 +136,14 @@ export async function ensureManagedDirectory(root, directory) {
136
136
  if (!isNodeError(error) || error.code !== 'ENOENT') {
137
137
  throw error;
138
138
  }
139
- await mkdir(current, { mode: 0o700 });
139
+ try {
140
+ await mkdir(current, { mode: 0o700 });
141
+ }
142
+ catch (creationError) {
143
+ if (!isNodeError(creationError) || creationError.code !== 'EEXIST') {
144
+ throw creationError;
145
+ }
146
+ }
140
147
  const created = await lstat(current);
141
148
  if (!created.isDirectory() || created.isSymbolicLink()) {
142
149
  throw new Error(`Managed directory could not be created safely: ${current}`);
package/skill/SKILL.md CHANGED
@@ -11,7 +11,7 @@ Read [lifecycle.md](references/lifecycle.md) before acting in a bound repository
11
11
 
12
12
  Mandatory behavior:
13
13
 
14
- Use the host's native questionnaire for every question to the user, including implementation choices, names, clarification, branch/worktree decisions and delivery. In Codex use request_user_input when available; in Claude use AskUserQuestion. 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. Existing answers remain valid through retries and handoffs.
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
16
  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.
17
17
  2. Call `session.entry` before answering anything in a repository, and act on what it reports before the message itself: sign in when it says so, ask for organization and project when nothing has been decided, and stay completely silent about Engineering Memory in a repository where the user switched it off. Record every one of those answers with `session.set_decision`, and only ever from something the user actually said.
@@ -4,7 +4,7 @@
4
4
 
5
5
  Sign-in decides nothing beyond who the user is. The organization and the project are chosen after it, through the questionnaires in `questionnaires.md`, and both listings end with an option to create a new one. Ask for both whenever this session has not already confirmed them, and ask again the moment the user says they want to change either — changing the organization always means choosing the project again.
6
6
 
7
- Use the binding returned by `session.entry` as the authority. The bridge stores the selected project and repository identity under `~/.engineering-memory/origins/<api-hash>/project-bindings/`, outside the installed runtime and working tree. It imports a legacy `.engineering-memory/project.json` once, preserving its schema and exact backend fingerprint for existing tasks and receipts. It leaves that file unchanged; after migration the file is optional and may be removed through the repository's normal Git workflow. New bindings never create it. Never infer a selection from a directory or remote, and never edit binding records by hand. Separate clones or a changed remote require a new explicit selection; worktrees of the same checkout share the local binding.
7
+ Use the binding returned by `session.entry` as the authority. The bridge stores the selected project and repository identity under `~/.engineering-memory/origins/<api-hash>/project-bindings/`, outside the installed runtime and working tree. It imports a legacy `.engineering-memory/project.json` once, preserving its schema and exact backend fingerprint for existing tasks and receipts. It leaves that file unchanged; after migration the file is optional and may be removed through the repository's normal Git workflow. New bindings never create it. A checkout that never imported the marker still finds a project bound under the older identity: the bridge presents that identity alongside the current one and stores whichever the backend confirms. Never infer a selection from a directory or remote, and never edit binding records by hand. Separate clones or a changed remote require a new explicit selection; worktrees of the same checkout share the local binding.
8
8
 
9
9
  An archived project is recoverable state, not a missing or conflicting binding. When an owner receives `project.restore`, use the `projectId` and `expectedVersion` in its data and call that operation before retrying. A non-owner receives `project.member_list` instead: list the members, identify a project owner and explain that the owner must restore the exact project before this session can retry. Use `project.list` with `includeArchived: true` when an owner must first select the archived project. Never create or bind a replacement project to escape archive state.
10
10
 
@@ -22,6 +22,8 @@ A task nobody is going to finish is abandoned rather than inherited. Ask the use
22
22
 
23
23
  If an existing task is identified or execution resumes after compaction, call `session.resume`. Reconcile backend sequence, local outbox, Markdown projections, current Git diff, pinned revisions, and the active lease before any further action.
24
24
 
25
+ Reconcile pending questions too. A required question remains unanswered across a timeout, dismissed form, interruption or restart. Use `questionnaire.resume` for its recorded identifier and wait for an explicit valid response before dependent work. Follow `questionnaires.md`; do not create a replacement async prompt or consume an unrelated task's answer.
26
+
25
27
  ## Discovery
26
28
 
27
29
  Perform only read operations until the relevant current code, tests, Figma evidence, Git diff, and returned memory have been inspected.
@@ -1,6 +1,24 @@
1
1
  # Native Questionnaires
2
2
 
3
- Use the host's native questionnaire for every question to the user, including implementation choices, names, clarification, branch/worktree decisions and delivery. In Codex use request_user_input when available; in Claude use AskUserQuestion. 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. Existing answers remain valid through retries and handoffs. Web pages are limited to sign in, sign up, initial password change, and email verification.
3
+ Use the host's native questionnaire for every question to the user, including implementation choices, names, clarification, branch/worktree decisions and delivery. Existing answers remain valid through retries and handoffs. Web pages are limited to sign in, sign up, initial password change, and email verification.
4
+
5
+ ## Required decisions stay pending
6
+
7
+ Use `questionnaire.ask` for a required decision. It records the question before opening a native MCP form. Use a stable question identifier belonging to the current task and decision, so a retry returns to the same question instead of creating another one. A new decision needs its own identifier; an answer to an earlier proposal, branch or delivery does not approve a later one.
8
+
9
+ Pass `repoRoot`, `questionnaireId`, `message` and two to twelve `options`, each with an `id` and `label`. Identifiers use letters, digits, underscores or hyphens. 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.
10
+
11
+ 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.
12
+
13
+ Read pending questions returned by `session.entry` and `session.resume`. Call `questionnaire.resume` for the question belonging to this work. Do not silently adopt a pending question from another task. Resuming uses the original question; do not rewrite its options under the same identifier.
14
+
15
+ 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.
16
+
17
+ 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.
18
+
19
+ The host owns the visible form and may impose a transport deadline or close it when the application exits. Engineering Memory preserves the decision without an expiry; it cannot promise that every host keeps a window visible indefinitely. If the host cannot display the form, report that limitation and the pending question identifier. Use a blocking native control only where the host permits it: request_user_input in Codex or AskUserQuestion in Claude. Follow the host's tool restrictions. Never fabricate an answer, silently fall back to an asynchronous question, or begin work that requires the missing decision.
20
+
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.
4
22
 
5
23
  ## Authentication
6
24