engineering-memory 1.11.9 → 1.11.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dispatcher/sections.mjs +7 -2
  2. package/install/codex-approval.mjs +457 -0
  3. package/install/files.mjs +5 -1
  4. package/install/installer.mjs +24 -1
  5. package/package.json +1 -1
  6. package/runtime/build.json +1 -1
  7. package/runtime/dist/src/config.js +2 -0
  8. package/runtime/dist/src/git/git-inspector.js +114 -4
  9. package/runtime/dist/src/git/verification-gate.js +30 -17
  10. package/runtime/dist/src/mcp/onboarding-tools.js +110 -16
  11. package/runtime/dist/src/mcp/questionnaire-tools.js +99 -15
  12. package/runtime/dist/src/mcp/tool-annotations.js +112 -0
  13. package/runtime/dist/src/mcp/tool-definitions.js +150 -29
  14. package/runtime/dist/src/mcp/worktree-tools.js +395 -236
  15. package/runtime/dist/src/project/repository.js +26 -8
  16. package/runtime/dist/src/runtime/active-context-store.js +3 -0
  17. package/runtime/dist/src/runtime/api-client.js +1 -0
  18. package/runtime/dist/src/runtime/branch-preferences.js +0 -19
  19. package/runtime/dist/src/runtime/bridge-service.js +1320 -246
  20. package/runtime/dist/src/runtime/offline-outbox.js +33 -158
  21. package/runtime/dist/src/runtime/phase-timer.js +26 -0
  22. package/runtime/dist/src/runtime/privacy-detector.js +263 -0
  23. package/runtime/dist/src/runtime/questionnaire-store.js +210 -49
  24. package/runtime/dist/src/runtime/recovery-error.js +3 -1
  25. package/runtime/dist/src/runtime/runtime-entry.js +8 -0
  26. package/runtime/dist/src/runtime/runtime-host.js +1 -3
  27. package/runtime/dist/src/runtime/task-branch-store.js +17 -1
  28. package/runtime/dist/src/runtime/task-start.js +281 -0
  29. package/runtime/dist/src/runtime/worktree-pool.js +374 -26
  30. package/runtime/dist/src/utilities/process.js +1 -0
  31. package/skill/SKILL.md +3 -3
  32. package/skill/references/lifecycle.md +59 -19
  33. package/skill/references/memory-updates.md +9 -3
  34. package/skill/references/project-onboarding.md +19 -2
  35. package/skill/references/questionnaires.md +86 -15
@@ -1,13 +1,26 @@
1
1
  import { acceptedContent, CLIENT_CAPABILITIES_META_KEY, inputRequired, inputResponse, } from '@modelcontextprotocol/server';
2
2
  import { questionnaireAnswerSchema, } from '../runtime/questionnaire-store.js';
3
- export async function askQuestionnaire(server, service, input, context, previousDefinitions = []) {
3
+ export async function askQuestionnaire(server, service, input, context, previousDefinitions = [], owner) {
4
4
  try {
5
- return await present(server, service, await service.questionnaireAsk(input, previousDefinitions), input.repoRoot, context, input.preparation);
5
+ return await present(server, service, await service.questionnaireAsk(input, previousDefinitions, owner), input.repoRoot, context, input.preparation, input.presentation);
6
6
  }
7
7
  catch (error) {
8
8
  return failure(error);
9
9
  }
10
10
  }
11
+ export function answerChoice(result) {
12
+ return answerData(result)?.choice;
13
+ }
14
+ export function answerData(result) {
15
+ const response = result;
16
+ const text = response.content?.find((item) => item.type === 'text')?.text;
17
+ if (!text)
18
+ return undefined;
19
+ const value = JSON.parse(text);
20
+ return value.ok && value.data?.status === 'answered' && value.data.answerAvailable
21
+ ? value.data.answer
22
+ : undefined;
23
+ }
11
24
  export async function resumeQuestionnaire(server, service, input, context) {
12
25
  try {
13
26
  return await present(server, service, await service.questionnaireResume(input), input.repoRoot, context, input.preparation, input.presentation);
@@ -19,7 +32,7 @@ export async function resumeQuestionnaire(server, service, input, context) {
19
32
  export async function answerQuestionnaireFromHost(service, input) {
20
33
  try {
21
34
  const resolved = await service.questionnaireAnswerFromHost(input);
22
- return answered(resolved.record, resolved.answer, resolved.replayed);
35
+ return answered(resolved.record, resolved.answer, resolved.replayed, resolved.retry);
23
36
  }
24
37
  catch (error) {
25
38
  return failure(error);
@@ -33,8 +46,11 @@ async function present(server, service, record, repoRoot, context, preparation,
33
46
  answerAvailable: false,
34
47
  nextAction: 'This decision was explicitly withdrawn. Do not continue dependent work. Use a new questionnaireId or onboarding requestKey only if the user resumes that work.',
35
48
  });
36
- if (record.status === 'answered')
37
- return answered(record, record.answer, true);
49
+ if (record.status === 'answered') {
50
+ if (inputResponse(context.mcpReq.inputResponses, record.requestKey).kind !== 'missing')
51
+ consumedResponses(context).add(record.requestKey);
52
+ return answered(record, record.questions ? record.answers : record.answer, true);
53
+ }
38
54
  if (context.mcpReq.signal.aborted)
39
55
  return pending(record, 'interrupted');
40
56
  if (presentation === 'host_native')
@@ -42,22 +58,27 @@ async function present(server, service, record, repoRoot, context, preparation,
42
58
  const responses = context.mcpReq.inputResponses;
43
59
  const response = inputResponse(responses, record.requestKey);
44
60
  if (response.kind === 'elicit' && response.action === 'accept') {
45
- const answer = acceptedContent(responses, record.requestKey, questionnaireAnswerSchema(record));
61
+ const content = acceptedContent(responses, record.requestKey);
62
+ const parsed = questionnaireAnswerSchema(record).safeParse(record.questions && content ? formAnswers(record, content) : content);
63
+ const answer = parsed.success ? parsed.data : undefined;
46
64
  if (!answer)
47
65
  return pending(record, 'invalid_response');
66
+ consumedResponses(context).add(record.requestKey);
48
67
  const resolved = await service.questionnaireAccept({
49
68
  repoRoot,
50
69
  preparation,
51
70
  questionnaireId: record.questionnaireId,
52
71
  requestKey: record.requestKey,
53
72
  scope: record.scope,
54
- answer,
73
+ answer: answer,
55
74
  });
56
75
  return answered(resolved.record, resolved.answer, resolved.replayed);
57
76
  }
58
77
  if (response.kind === 'elicit')
59
78
  return pending(record, response.action);
60
- if (responses !== undefined || context.mcpReq.droppedInputResponseKeys?.length)
79
+ if (response.kind !== 'missing' ||
80
+ context.mcpReq.droppedInputResponseKeys?.length ||
81
+ Object.keys(responses ?? {}).some((key) => !consumedResponses(context).has(key)))
61
82
  return pending(record, 'missing_or_stale_response');
62
83
  const envelope = context.mcpReq.envelope;
63
84
  const capabilities = envelope?.[CLIENT_CAPABILITIES_META_KEY] ?? server.server.getClientCapabilities();
@@ -65,6 +86,35 @@ async function present(server, service, record, repoRoot, context, preparation,
65
86
  if (!elicitation || (!elicitation.form && elicitation.url !== undefined))
66
87
  return pending(record, 'native_form_unavailable');
67
88
  const copy = questionnaireCopy[record.language ?? 'en'];
89
+ if (record.questions) {
90
+ const properties = {};
91
+ const required = [];
92
+ for (const sub of record.questions) {
93
+ properties[sub.id] = {
94
+ type: 'string',
95
+ title: sub.message,
96
+ enum: sub.options.map((option) => option.id),
97
+ enumNames: sub.options.map((option) => option.label),
98
+ };
99
+ required.push(sub.id);
100
+ if (sub.textField)
101
+ properties[`${sub.id}_text`] = {
102
+ type: 'string',
103
+ title: sub.textField.title,
104
+ minLength: 1,
105
+ maxLength: sub.textField.maxLength,
106
+ };
107
+ }
108
+ return inputRequired({
109
+ inputRequests: {
110
+ [record.requestKey]: inputRequired.elicit({
111
+ mode: 'form',
112
+ message: presentationMessage(record),
113
+ requestedSchema: { type: 'object', properties, required },
114
+ }),
115
+ },
116
+ });
117
+ }
68
118
  return inputRequired({
69
119
  inputRequests: {
70
120
  [record.requestKey]: inputRequired.elicit({
@@ -112,11 +162,18 @@ async function present(server, service, record, repoRoot, context, preparation,
112
162
  },
113
163
  });
114
164
  }
165
+ const consumed = new WeakMap();
166
+ function consumedResponses(context) {
167
+ const keys = consumed.get(context.mcpReq) ?? new Set();
168
+ consumed.set(context.mcpReq, keys);
169
+ return keys;
170
+ }
115
171
  const questionnaireCopy = {
116
172
  en: {
117
173
  pending: 'Choose an answer to resolve this decision. Closing or cancelling the form leaves it pending.',
118
174
  freeText: ' For another answer choose Other and fill in text. Free text is not stored and cannot be replayed.',
119
175
  storedText: ' The bounded text field is stored as part of this exact decision and is replayed after restart.',
176
+ multi: ' AskUserQuestion takes up to 4 questions per call. Show every question in as few calls as needed, then relay all answers in one questionnaire.answer_from_host.',
120
177
  answer: 'Answer',
121
178
  other: 'Other',
122
179
  otherAnswer: 'Other answer',
@@ -125,17 +182,33 @@ const questionnaireCopy = {
125
182
  pending: 'Kararını kaydetmek için bir seçenek seç. Formu kapatmak veya iptal etmek onay sayılmaz; soru beklemede kalır.',
126
183
  freeText: ' Farklı bir yanıt için Diğer seçeneğini seçip metin alanını doldur. Serbest yanıt saklanmaz ve daha sonra geri getirilemez.',
127
184
  storedText: ' Bu metin alanındaki yanıt kararınla birlikte saklanır; yeniden başlattığında korunur.',
185
+ multi: ' AskUserQuestion tek çağrıda en fazla 4 soru alır. Her soruyu en az çağrıyla göster, sonra tüm yanıtları tek bir questionnaire.answer_from_host çağrısında ilet.',
128
186
  answer: 'Yanıt',
129
187
  other: 'Diğer',
130
188
  otherAnswer: 'Diğer yanıt',
131
189
  },
132
190
  };
191
+ function formAnswers(record, content) {
192
+ const answers = {};
193
+ for (const sub of record.questions ?? []) {
194
+ if (content[sub.id] === undefined)
195
+ continue;
196
+ const text = content[`${sub.id}_text`];
197
+ answers[sub.id] = {
198
+ choice: content[sub.id],
199
+ ...(text !== undefined && sub.textField?.requiredForChoice === content[sub.id]
200
+ ? { text }
201
+ : {}),
202
+ };
203
+ }
204
+ return answers;
205
+ }
133
206
  function presentationMessage(record) {
134
207
  const notice = /^memory-batch-[a-f0-9]{64}-[0-9]+$/.test(record.questionnaireId)
135
208
  ? '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
209
  : undefined;
137
210
  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 : ''}`;
211
+ return `${record.message}${notice ? `\n\n${notice}` : ''}\n\n${copy.pending}${record.allowFreeText ? copy.freeText : ''}${record.textField ? copy.storedText : ''}${record.questions ? copy.multi : ''}`;
139
212
  }
140
213
  function pending(record, reason) {
141
214
  return result({
@@ -149,25 +222,36 @@ function pending(record, reason) {
149
222
  requestKey: record.requestKey,
150
223
  contentHash: record.contentHash,
151
224
  message: presentationMessage(record),
152
- options: record.options,
153
225
  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.',
226
+ ...(record.questions
227
+ ? {
228
+ questions: record.questions,
229
+ ...(record.terminalChoices ? { terminalChoices: record.terminalChoices } : {}),
230
+ }
231
+ : {
232
+ options: record.options,
233
+ allowFreeText: record.allowFreeText,
234
+ ...(record.textField ? { textField: record.textField } : {}),
235
+ }),
236
+ instructions: record.questions
237
+ ? 'Only if the host permits a blocking native control for this decision, display every question, its options and notices in AskUserQuestion or request_user_input, one call at a time if needed. Relay every answer together in one questionnaire.answer_from_host call as `answers`, keyed by question id. Closing, declining, timeout, prose consent and missing answers are not native answers for any question. Do not reopen a dismissed question in a loop or label decline as proof of host incompatibility. The relay records agent-reported provenance, not MCP transport attestation. Retry the owning operation after acceptance; its authority and version checks still apply.'
238
+ : '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
239
  },
158
240
  nextAction: reason === 'native_form_unavailable'
159
241
  ? '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.'
160
242
  : '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.',
161
243
  });
162
244
  }
163
- function answered(record, answer, replayed) {
245
+ function answered(record, answer, replayed, retry) {
246
+ const key = record.questions ? 'answers' : 'answer';
164
247
  return result({
165
248
  questionnaireId: record.questionnaireId,
166
249
  status: 'answered',
167
250
  answerAvailable: answer !== undefined,
168
- ...(answer ? { answer } : {}),
251
+ ...(answer ? { [key]: answer } : {}),
169
252
  replayed,
170
253
  ...(record.answerSource ? { answerSource: record.answerSource } : {}),
254
+ ...(retry ? { retry } : {}),
171
255
  ...(!answer
172
256
  ? {
173
257
  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.',
@@ -0,0 +1,112 @@
1
+ const read = { readOnlyHint: true, openWorldHint: false }; // receipts and registry upkeep still read
2
+ const write = {
3
+ readOnlyHint: false,
4
+ destructiveHint: false,
5
+ openWorldHint: false,
6
+ };
7
+ const repeatableWrite = { ...write, idempotentHint: true };
8
+ const destructive = {
9
+ readOnlyHint: false,
10
+ destructiveHint: true,
11
+ openWorldHint: false,
12
+ };
13
+ const outward = {
14
+ readOnlyHint: false,
15
+ destructiveHint: false,
16
+ openWorldHint: true,
17
+ };
18
+ export const toolAnnotations = {
19
+ 'project.git_preferences': read,
20
+ 'project.set_git_preferences': write,
21
+ 'worktree.policy': read,
22
+ 'worktree.set_policy': write,
23
+ 'worktree.list': read,
24
+ 'worktree.reconcile': destructive, // releaseUnowned hands a recovered checkout back
25
+ 'task.heartbeat': repeatableWrite,
26
+ 'task.pause': write,
27
+ 'worktree.release': destructive,
28
+ 'audit.list': read,
29
+ 'questionnaire.ask': repeatableWrite,
30
+ 'questionnaire.resume': repeatableWrite, // records an accepted answer
31
+ 'questionnaire.answer_from_host': repeatableWrite,
32
+ 'questionnaire.withdraw': destructive,
33
+ 'session.entry': read,
34
+ 'session.set_decision': repeatableWrite,
35
+ 'session.decline_update': repeatableWrite,
36
+ 'session.answer_shadow_notice': repeatableWrite,
37
+ 'session.bootstrap': write,
38
+ 'session.resume': write,
39
+ 'context.prepare_change': write,
40
+ 'context.refresh': write,
41
+ 'project.inspect': read,
42
+ 'project.onboard': write,
43
+ 'project.onboarding_status': read,
44
+ 'project.rework_plan': write,
45
+ 'project.initialize': write,
46
+ 'memory.source': read,
47
+ 'memory.sync_start': repeatableWrite,
48
+ 'memory.sync_status': read,
49
+ 'memory.sync_inventory': write,
50
+ 'memory.sync_plan': write,
51
+ 'memory.sync_next': write,
52
+ 'memory.sync_submit': write,
53
+ 'memory.sync_reopen': write,
54
+ 'memory.sync_delta': read,
55
+ 'memory.sync_verify': repeatableWrite,
56
+ 'memory.catalog': read,
57
+ 'memory.sources': read,
58
+ 'memory.source_targets': read,
59
+ 'memory.publish_task': write,
60
+ 'memory.read_revisions': read,
61
+ 'memory.review_batch': write,
62
+ 'memory.query': read,
63
+ 'memory.history': read,
64
+ 'memory.propose_revision': write,
65
+ 'memory.list_proposals': read,
66
+ 'memory.review_proposal': write,
67
+ 'task.checkpoint': write,
68
+ 'task.record_correction': write,
69
+ 'task.self_review': write,
70
+ 'task.reconcile': write,
71
+ 'task.resolve_pending_delivery': destructive, // discards a pending delivery
72
+ 'task.verify': write,
73
+ 'task.close': write,
74
+ 'task.abandon': destructive,
75
+ 'task.branch': outward, // fetches the chosen base from the Git remote
76
+ 'architecture.plan': read,
77
+ 'architecture.module': read,
78
+ 'architecture.record_application': write,
79
+ 'organization.list': read,
80
+ 'organization.create': write,
81
+ 'organization.update': write,
82
+ 'organization.member_list': read,
83
+ 'organization.member_upsert': write,
84
+ 'project.setup': write,
85
+ 'project.list': read,
86
+ 'project.resolve': repeatableWrite, // binds an explicitly selected project
87
+ 'project.move_repository': write,
88
+ 'project.archive': destructive,
89
+ 'project.restore': write,
90
+ 'project.member_add': write,
91
+ 'project.member_list': read,
92
+ 'work_item.create': write,
93
+ 'work_item.update': write,
94
+ 'work_item.archive': destructive,
95
+ 'work_item.restore': write,
96
+ 'work_item.runs': read,
97
+ 'work_item.record_test': write,
98
+ 'work_item.test_results': read,
99
+ 'work_item.plan': read,
100
+ 'work_item.confirm_plan': write,
101
+ 'project.update': write,
102
+ 'work_item.list': read,
103
+ 'work_item.get': read,
104
+ 'project.clone': outward,
105
+ 'project.link': write,
106
+ 'project.unlink': destructive,
107
+ 'project.links': read,
108
+ 'auth.status': read,
109
+ 'auth.signin_browser': outward,
110
+ 'auth.logout': destructive,
111
+ };
112
+ //# sourceMappingURL=tool-annotations.js.map
@@ -1,7 +1,7 @@
1
1
  import * as z from 'zod/v4';
2
2
  import { validationIds } from '../runtime/bridge-service.js';
3
3
  import { hostAnswerSchema, questionnaireDefinitionSchema } from '../runtime/questionnaire-store.js';
4
- import { answerQuestionnaireFromHost, askQuestionnaire, resumeQuestionnaire, } from './questionnaire-tools.js';
4
+ import { answerChoice, 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([
@@ -114,6 +114,7 @@ export const engineeringMemoryToolNames = [
114
114
  'project.setup',
115
115
  'project.list',
116
116
  'project.resolve',
117
+ 'project.move_repository',
117
118
  'project.archive',
118
119
  'project.restore',
119
120
  'project.member_add',
@@ -149,7 +150,10 @@ const reconciliationEntry = z.object({
149
150
  resourceId: z.string().min(1),
150
151
  type: z.enum(['approved_revision', 'no_semantic_memory_change', 'scaffold_applied']),
151
152
  proposalId: z.string().optional(),
152
- revisionId: z.string().optional(),
153
+ revisionId: z
154
+ .string()
155
+ .optional()
156
+ .describe('Optional for approved_revision; resolved from the approved proposal when omitted.'),
153
157
  reason: z.string().optional(),
154
158
  });
155
159
  export function registerQuestionnaireTools(server, service) {
@@ -191,10 +195,11 @@ export function registerQuestionnaireTools(server, service) {
191
195
  }
192
196
  });
193
197
  server.registerTool('questionnaire.ask', {
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.',
198
+ 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. After the first decline in this session, pass presentation host_native to skip the doomed elicitation round trip.',
195
199
  inputSchema: questionnaireDefinitionSchema.extend({
196
200
  repoRoot: optionalRepoRoot,
197
201
  preparation: z.boolean().optional(),
202
+ presentation: z.literal('host_native').optional(),
198
203
  }),
199
204
  }, async (input, context) => await askQuestionnaire(server, service, input, context));
200
205
  server.registerTool('questionnaire.resume', {
@@ -259,14 +264,32 @@ export function registerEngineeringMemoryTools(server, service) {
259
264
  }),
260
265
  }, async (input) => toolResult(await service.sessionAnswerShadowNotice(input)));
261
266
  server.registerTool('session.bootstrap', {
262
- description: 'Authenticate, resolve the repository project, open or resume a write or read-only task, and load mandatory engineering context before planning.',
267
+ description: 'Authenticate, resolve the repository project, open or resume a write or read-only task, and load mandatory engineering context before planning. objective is one line of at most 240 characters. contextPack holds only what fit the response budget; deferredResources lists what did not, each entry carrying its revisionId so it can be read directly with memory.read_revisions rather than looked up again through memory.catalog.',
263
268
  inputSchema: z.object({
264
269
  repoRoot: optionalRepoRoot,
265
270
  projectId: z.string().optional(),
266
- externalTaskId: z.string().min(2),
267
- objective: z.string().min(2),
268
- taskKind: z.string().min(2),
269
- workItemKey: z.string().min(1).optional(),
271
+ externalTaskId: z
272
+ .string()
273
+ .min(2)
274
+ .max(160)
275
+ .regex(/^[^\r\n]+$/, 'externalTaskId must be a single line of at most 160 characters.'),
276
+ objective: z
277
+ .string()
278
+ .min(2)
279
+ .max(240)
280
+ .regex(/^[^\r\n]+$/, 'objective must be a single line of at most 240 characters.')
281
+ .describe('One line, 2-240 characters: what this task delivers. Put detail in checkpoints, not here.'),
282
+ taskKind: z
283
+ .string()
284
+ .min(2)
285
+ .max(80)
286
+ .regex(/^[^\r\n]+$/, 'taskKind must be a single line of at most 80 characters.'),
287
+ workItemKey: z
288
+ .string()
289
+ .min(2)
290
+ .max(120)
291
+ .regex(/^[^\r\n]+$/, 'workItemKey must be a single line of at most 120 characters.')
292
+ .optional(),
270
293
  workItemId: z.string().uuid().optional(),
271
294
  mode: z.enum(['write', 'read_only', 'scaffold']).optional(),
272
295
  linkedSources: z
@@ -300,14 +323,14 @@ export function registerEngineeringMemoryTools(server, service) {
300
323
  }),
301
324
  }, async (input) => toolResult(await service.contextPrepareChange(input)));
302
325
  server.registerTool('context.refresh', {
303
- description: 'Refresh pins within the task fixed code source after memory approval. Use the task repoRoot; source changes require a new task.',
326
+ description: 'Refresh pins within the task fixed code source after memory approval. Use the task repoRoot; source changes require a new task. contextPack carries only the revisions that changed since the session pinned them; unchangedResources lists the rest with the revisionId to reread one through memory.read_revisions, and deferredResources entries carry theirs too.',
304
327
  inputSchema: z.object({
305
328
  repoRoot: optionalRepoRoot,
306
329
  sessionId: z.string().min(1),
307
330
  }),
308
331
  }, async (input) => toolResult(await service.contextRefresh(input)));
309
332
  server.registerTool('memory.query', {
310
- description: 'Read the smallest project memory pack needed for screens, components, services, navigation, localization or state work.',
333
+ description: 'Read the smallest project memory pack needed for screens, components, services, navigation, localization or state work. resources holds what fits one response; deferredResources lists the rest, each with the revisionId to read it through memory.read_revisions.',
311
334
  inputSchema: z.object({
312
335
  projectId: z.string().min(1),
313
336
  sessionId: z.string().min(1),
@@ -319,17 +342,19 @@ export function registerEngineeringMemoryTools(server, service) {
319
342
  }),
320
343
  }, async (input) => toolResult(await service.memoryQuery(input)));
321
344
  server.registerTool('memory.history', {
322
- description: 'Read why the code you are about to change is the way it is: the revision history of the screen, component or contract records covering the given paths or keys, each revision with the task that produced it and the stated reason, plus the earlier tasks that touched those records and how many corrections each of them took. Also returns the task references found in the Git history of those paths. Verification refuses while a record with earlier work on it was never read.',
345
+ description: 'Read why the code you are about to change is the way it is: the revision history of the screen, component or contract records covering the given paths, keys or ids, each revision with the task that produced it and the stated reason, plus the earlier tasks that touched those records and how many corrections each of them took. Also returns the task references found in the Git history of those paths. Verification refuses while a record with earlier work on it was never read; required:true reads every such record of the current lease in one call. A path no record covers comes back in uncoveredPaths instead of an error.',
323
346
  inputSchema: z.object({
324
347
  repoRoot: optionalRepoRoot,
325
348
  sessionId: z.string().min(1),
326
349
  resourceKeys: z.array(z.string().min(1)).optional(),
350
+ resourceIds: z.array(z.string().uuid()).max(50).optional(),
327
351
  paths: z.array(z.string().min(1)).optional(),
352
+ required: z.boolean().optional(),
328
353
  depth: z.number().int().min(1).max(50).optional(),
329
354
  }),
330
355
  }, async (input) => toolResult(await service.memoryHistory(input)));
331
356
  server.registerTool('memory.propose_revision', {
332
- description: 'Create an inactive, reviewable proposal for permanent product, organization or project memory. Do not block independent task work on drafting, submission or approval. Use authorized host background agents or concurrent tools and continue useful work; collect the result only before an operation needs its proposal or revision ID. Without concurrency, checkpoint the pending draft and defer this call until needed. Keep one writer per resource and preserve baseRevision and task/source identity. A response distinguishes stored (queued:false) from outbox-queued; neither means approved. Native user approval and task verification remain required.',
357
+ description: "Create an inactive, reviewable proposal for permanent product, organization or project memory. Do not block independent task work on drafting, submission or approval. Use authorized host background agents or concurrent tools and continue useful work; collect the result only before an operation needs its proposal or revision ID. Without concurrency, checkpoint the pending draft and defer this call until needed. Keep one writer per resource and preserve baseRevision and task/source identity. A response distinguishes stored (queued:false) from outbox-queued; neither means approved. A stored proposal reports reviewableByCaller: ask this user to approve it only when that is true; otherwise report it as submitted, with its id, to the reviewPath it names (product_release, organization_owner or project_maintainer). Task verification remains required. metadata carries only the structured fields the resource kind defines, such as a project profile's pathRoles, architecture and resourceDiscovery, appliesToStacks, or an architecture template's manifest; classification, containsPii, containsSecrets and resourceDescriptor are computed by the server and ignored when sent, so metadata copied from a served revision is accepted. A project profile's metadata.architecture, when present, is exactly {adopted: [{structure, pressure}], declined: [{structure, why}]} (structure up to 80 characters, pressure/why up to 600, at most 40 entries each) and no other key; a free-text style such as 'MVVM' is not that shape and goes in metadata.architectureStyle (a plain string) or the profile content instead.",
333
358
  inputSchema: z.object({
334
359
  repoRoot: optionalRepoRoot,
335
360
  sourceRunId: z.string().uuid().optional(),
@@ -374,10 +399,15 @@ export function registerEngineeringMemoryTools(server, service) {
374
399
  }),
375
400
  }, async (input) => toolResult(await service.memoryProposeRevision(input)));
376
401
  server.registerTool('memory.list_proposals', {
377
- description: 'List the permanent memory proposals still waiting for review, so an authorized reviewer can find them without database access. Pass proposalId to read one proposal with its proposed content.',
402
+ description: "List the permanent memory proposals still waiting for review, so an authorized reviewer can find them without database access. Each entry carries a reasonPreview, not the full reason; pass proposalId to read one proposal in full, with its reason and proposed content. Narrow a long list with taskId, scope or status (defaults to pending); page through the rest with limit (default 50, max 100) and afterId, set to the response's nextAfterId to continue, which is null once nothing more is waiting. Each proposal reports reviewableByCaller; when it is false, the caller cannot approve it, so never ask this user for approval: report it as waiting on the reviewPath it names.",
378
403
  inputSchema: z.object({
379
404
  projectId: z.string().min(1),
380
405
  proposalId: z.string().min(1).optional(),
406
+ taskId: z.string().min(1).optional(),
407
+ scope: z.enum(['project', 'organization', 'product']).optional(),
408
+ status: z.enum(['pending', 'approved', 'rejected', 'superseded']).optional(),
409
+ afterId: z.string().min(1).optional(),
410
+ limit: z.number().int().min(1).max(100).optional(),
381
411
  }),
382
412
  }, async (input) => toolResult(await service.memoryListProposals(input)));
383
413
  server.registerTool('memory.review_proposal', {
@@ -415,21 +445,55 @@ export function registerEngineeringMemoryTools(server, service) {
415
445
  }),
416
446
  }, async (input) => toolResult(await service.taskRecordCorrection(input)));
417
447
  server.registerTool('task.self_review', {
418
- description: 'Record that the changed code was read back against the rules that govern it, naming the knowledge resources reviewed and every conflict found with how it was resolved. Returns a durable pending receipt without waiting for backend delivery; continue independent local validation. task.verify waits for required delivery and refuses until this exists for the current diff, so any further edit requires reviewing again.',
448
+ description: 'Record that each changed file was read back against the rules that govern it: one entry per changed file (deleted files excepted), naming every rule context.prepare_change returned for that path in governingRules, and for a file no role maps, the engineering rules you read it against. Each rule gets an outcome: follows; fixed, with the issue and the change you made; or user_accepted_deviation, with the rule resourceKey and the issue. A rule conflict is a question for the user, never a judgment call: for every deviation this tool asks the user natively whether to keep the code, records the deviation only on their approval, and refuses the review when they choose to change the code. Matching the surrounding code is not an outcome; existing code is evidence, not authority. Set language to the conversation language (tr or en) for the question. Returns a durable pending receipt without waiting for backend delivery; task.verify refuses until a review covers the current diff, so any further edit requires reviewing again.',
419
449
  inputSchema: z.object({
420
450
  repoRoot: optionalRepoRoot,
421
451
  taskId: z.string().min(1),
422
- reviewedResourceIds: z.array(z.string().min(1)),
423
- findings: z.array(z.object({
424
- path: z.string().min(1),
425
- rule: z.string().min(1),
426
- issue: z.string().min(8),
427
- resolution: z.string().min(8),
428
- })),
452
+ language: z.enum(['tr', 'en']).optional(),
453
+ files: z
454
+ .array(z.object({
455
+ path: z.string().min(1).max(512),
456
+ rules: z
457
+ .array(z.discriminatedUnion('outcome', [
458
+ z.object({
459
+ resourceId: z.string().uuid(),
460
+ outcome: z.literal('follows'),
461
+ }),
462
+ z.object({
463
+ resourceId: z.string().uuid(),
464
+ outcome: z.literal('fixed'),
465
+ issue: z.string().min(8).max(2000),
466
+ change: z.string().min(8).max(2000),
467
+ }),
468
+ z.object({
469
+ resourceId: z.string().uuid(),
470
+ outcome: z.literal('user_accepted_deviation'),
471
+ resourceKey: z.string().min(1).max(240),
472
+ issue: z.string().min(8).max(1000),
473
+ }),
474
+ ]))
475
+ .min(1)
476
+ .max(50),
477
+ }))
478
+ .max(500),
429
479
  }),
430
- }, async (input) => toolResult(await service.taskSelfReview(input)));
480
+ }, async (input, context) => {
481
+ let questions;
482
+ try {
483
+ questions = service.ruleDeviationQuestions(input);
484
+ }
485
+ catch {
486
+ return toolResult(await service.taskSelfReview(input));
487
+ }
488
+ for (const { definition, previousDefinitions } of questions) {
489
+ const form = await askQuestionnaire(server, service, { ...definition, repoRoot: input.repoRoot }, context, previousDefinitions);
490
+ if (!answerChoice(form))
491
+ return form;
492
+ }
493
+ return toolResult(await service.taskSelfReview(input));
494
+ });
431
495
  server.registerTool('task.reconcile', {
432
- description: 'Reconcile changed screens and components with an approved revision or an explicit no-semantic-memory-change reason. Pass every record the task touched as `entries` in one call rather than calling once per record; the whole set is applied together and rejected together. A pending result is a durable local receipt; continue independent work without polling. Required delivery is checked before verification.',
496
+ description: 'Reconcile changed screens and components with an approved revision or an explicit no-semantic-memory-change reason. Pass every record the task touched as `entries` in one call rather than calling once per record; the whole set is applied together and rejected together. After memory.review_proposal approves a proposal, pass its reconcileEntry ({resourceId, type: approved_revision, proposalId, revisionId}) straight through here — revisionId is optional and is resolved from the approved proposal when omitted. A pending result is a durable local receipt; continue independent work without polling. Required delivery is checked before verification.',
433
497
  inputSchema: z
434
498
  .object({
435
499
  repoRoot: optionalRepoRoot,
@@ -464,10 +528,10 @@ export function registerEngineeringMemoryTools(server, service) {
464
528
  return;
465
529
  }
466
530
  for (const entry of entries) {
467
- if (entry.type === 'approved_revision' && (!entry.proposalId || !entry.revisionId)) {
531
+ if (entry.type === 'approved_revision' && !entry.proposalId) {
468
532
  context.addIssue({
469
533
  code: 'custom',
470
- message: 'Approved reconciliation requires proposalId and revisionId',
534
+ message: 'Approved reconciliation requires proposalId',
471
535
  });
472
536
  }
473
537
  if (entry.type === 'no_semantic_memory_change' &&
@@ -498,11 +562,15 @@ export function registerEngineeringMemoryTools(server, service) {
498
562
  }),
499
563
  }, async (input) => toolResult(await service.taskResolvePendingDelivery(input)));
500
564
  server.registerTool('task.verify', {
501
- description: 'Verify a write task with its lease or a read-only task against its pinned Git baseline, mandatory checkpoints, structured validations and synchronized outbox.',
565
+ description: 'Verify a write task with its lease or a read-only task against its pinned Git baseline, mandatory checkpoints, structured validations and synchronized outbox. A refusal lists every unmet requirement at once. If the user decides a required check is not needed for this task, pass it in waivers; the tool asks them natively and records the answer.',
502
566
  inputSchema: z.object({
503
567
  repoRoot: optionalRepoRoot,
504
568
  taskId: z.string().min(1),
505
569
  sessionId: z.string().min(1),
570
+ language: z
571
+ .enum(['tr', 'en'])
572
+ .optional()
573
+ .describe('Language of the current conversation, for the waiver question.'),
506
574
  leaseId: z.string().min(1).optional(),
507
575
  changedPaths: stringList.min(1).optional(),
508
576
  validations: z
@@ -525,8 +593,61 @@ export function registerEngineeringMemoryTools(server, service) {
525
593
  .max(20)
526
594
  .optional()
527
595
  .describe('Architectural structures this task adds that the project did not have: a cache, a broker, a read replica, a second deployable, a projection, an event store. Verification refuses any the project profile has not recorded under architecture.adopted with the pressure it relieves.'),
528
- }),
529
- }, async (input) => toolResult(await service.taskVerify(input)));
596
+ waivers: z
597
+ .array(z.object({
598
+ validationId: z.enum(['ui']),
599
+ reason: z.string().min(10).max(500),
600
+ }))
601
+ .max(3)
602
+ .optional()
603
+ .describe('Only when the user decides a required ui check is not needed for this change. Never inferred from prose; the tool asks the user and verifies only after they agree.'),
604
+ }),
605
+ }, async (input, context) => {
606
+ const { language = 'en', ...request } = input;
607
+ for (const waiver of request.waivers ?? []) {
608
+ const subject = await service.validationWaiverSubject({
609
+ repoRoot: request.repoRoot,
610
+ taskId: request.taskId,
611
+ sessionId: request.sessionId,
612
+ ...waiver,
613
+ });
614
+ if (!subject.ok)
615
+ return toolResult(subject);
616
+ const { questionnaireId, externalTaskId, paths, reason } = subject.data;
617
+ const listed = paths.slice(0, 20).join(', ') + (paths.length > 20 ? ` (+${paths.length - 20})` : '');
618
+ const definitions = [
619
+ {
620
+ questionnaireId,
621
+ language: 'tr',
622
+ message: `${externalTaskId}: Bu görev için arayüz (UI) kontrolü atlansın mı? Değişen şu dosyalar ekranda görünen arayüzü oluşturuyor: ${listed}. Belirtilen gerekçe: ${reason}. Görev, bu dosyaların widget, golden veya ekran görüntüsü testi olmadan doğrulanır. Karar görevin denetim kaydına yazılır.`,
623
+ options: [
624
+ { id: 'waive', label: 'Bu dosyalar için UI kontrolünü atla' },
625
+ { id: 'require', label: 'UI kontrolü zorunlu kalsın' },
626
+ ],
627
+ },
628
+ {
629
+ questionnaireId,
630
+ language: 'en',
631
+ message: `${externalTaskId}: skip the UI check for this task? These changed files render UI: ${listed}. Reason given: ${reason}. The task will verify without a widget, golden or screenshot check of them. The decision is stored in the task's audit trail.`,
632
+ options: [
633
+ { id: 'waive', label: 'Skip the UI check for these files' },
634
+ { id: 'require', label: 'Keep the UI check required' },
635
+ ],
636
+ },
637
+ ];
638
+ const form = await askQuestionnaire(server, service, {
639
+ ...definitions.find((definition) => definition.language === language),
640
+ repoRoot: request.repoRoot,
641
+ }, context, definitions);
642
+ const record = await service.questionnaireResume({
643
+ repoRoot: request.repoRoot,
644
+ questionnaireId,
645
+ });
646
+ if (record.status !== 'answered' || record.answer?.choice !== 'waive')
647
+ return form;
648
+ }
649
+ return toolResult(await service.taskVerify(request));
650
+ });
530
651
  server.registerTool('task.close', {
531
652
  description: 'Close a task only when backend verification still matches the current Git diff hash.',
532
653
  inputSchema: z.object({
@@ -652,7 +773,7 @@ export function registerEngineeringMemoryTools(server, service) {
652
773
  inputSchema: z.object({ includeArchived: z.boolean().optional() }),
653
774
  }, async (input) => toolResult(await service.projectList(input)));
654
775
  server.registerTool('project.resolve', {
655
- description: 'Resolve the current repository fingerprint or bind an explicitly selected project, then store the user-local binding only after backend authorization succeeds.',
776
+ description: 'Resolve the project this checkout belongs to, or bind an explicitly selected project, then store the user-local binding only after backend authorization succeeds. A result with projectMoved means this checkout points at a repository the project left; a bind refused with recovery project.move_repository means the project lives in another repository and an owner can move it here.',
656
777
  inputSchema: z.object({
657
778
  repoRoot: optionalRepoRoot,
658
779
  bind: z.boolean().optional(),