engineering-memory 1.10.2 → 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.2",
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.',
@@ -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;
@@ -185,6 +236,23 @@ export class BridgeService {
185
236
  async sessionResume(input) {
186
237
  return await this.execute(async () => {
187
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
+ }
188
256
  const authentication = await this.dependencies.browserAuth.ensureAuthenticated();
189
257
  if (authentication) {
190
258
  return asJsonValue({ authentication, repository: publicRepository(repository) });
@@ -204,6 +272,7 @@ export class BridgeService {
204
272
  return asJsonValue({
205
273
  taskChoiceRequired: true,
206
274
  requestedTaskSlug: input.taskSlug,
275
+ pendingQuestionnaires,
207
276
  liveTasks: live.map(describePointer),
208
277
  repository: publicRepository(repository),
209
278
  });
@@ -212,6 +281,7 @@ export class BridgeService {
212
281
  return asJsonValue({
213
282
  taskChoiceRequired: true,
214
283
  requestedSessionId: input.sessionId,
284
+ pendingQuestionnaires,
215
285
  liveTasks: live.map(describePointer),
216
286
  repository: publicRepository(repository),
217
287
  });
@@ -219,6 +289,7 @@ export class BridgeService {
219
289
  if (!pointer && live.length > 1) {
220
290
  return asJsonValue({
221
291
  taskChoiceRequired: true,
292
+ pendingQuestionnaires,
222
293
  liveTasks: live.map(describePointer),
223
294
  repository: publicRepository(repository),
224
295
  });
@@ -227,6 +298,13 @@ export class BridgeService {
227
298
  const taskSlug = input.taskSlug ?? pointer?.taskSlug;
228
299
  const sessionId = input.sessionId ?? pointer?.sessionId;
229
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
+ }
230
308
  throw refuse('This repository has no live task to resume.', 'session.bootstrap');
231
309
  }
232
310
  if ((pointer &&
@@ -240,6 +318,7 @@ export class BridgeService {
240
318
  const recoveredClose = await this.retryCloseIntent(repository, pointer);
241
319
  return asJsonValue({
242
320
  backend: { close: recoveredClose },
321
+ pendingQuestionnaires,
243
322
  repository: publicRepository(repository),
244
323
  localJournal: await this.dependencies.journal.load(projectId, taskSlug),
245
324
  synchronization: {
@@ -411,6 +490,7 @@ export class BridgeService {
411
490
  backend,
412
491
  repository: publicRepository(repository),
413
492
  localJournal,
493
+ pendingQuestionnaires,
414
494
  synchronization: {
415
495
  backendSource: responseSource,
416
496
  backendFresh: responseSource !== 'stale_cache',
@@ -1772,6 +1852,7 @@ export class BridgeService {
1772
1852
  ? await this.actionableWorkItems(projectId)
1773
1853
  : [];
1774
1854
  const taskSelectionRequired = liveTasks.length > 0 || workItems.length > 0;
1855
+ const pendingQuestionnaires = state !== 'disabled' ? await this.pendingQuestionnaires(repository.repoRoot) : [];
1775
1856
  return asJsonValue({
1776
1857
  authenticated,
1777
1858
  repository: publicRepository(repository),
@@ -1780,15 +1861,18 @@ export class BridgeService {
1780
1861
  liveTasks,
1781
1862
  workItems,
1782
1863
  taskSelectionRequired,
1864
+ ...(state !== 'disabled' ? { pendingQuestionnaires } : {}),
1783
1865
  client,
1784
1866
  ...(incomplete && state === 'bound'
1785
1867
  ? {
1786
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.',
1787
1869
  }
1788
1870
  : {}),
1789
- nextAction: taskSelectionRequired
1790
- ? '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.'
1791
- : 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),
1792
1876
  });
1793
1877
  });
1794
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.
@@ -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