engineering-memory 1.11.17 → 1.11.19

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.
@@ -0,0 +1,374 @@
1
+ import * as z from 'zod/v4';
2
+ import { languageTag } from '../runtime/questionnaire-store.js';
3
+ import { assertSafeToPersist } from '../runtime/offline-outbox.js';
4
+ import { sha256, stableStringify } from '../utilities/hash.js';
5
+ import { answerChoice, answerData, askQuestionnaire } from './questionnaire-tools.js';
6
+ const field = z.enum(['meaning', 'entryRule']);
7
+ const navigation = z.tuple([z.string().trim().min(1).max(120), z.string().trim().min(1).max(200)]);
8
+ const wording = z.strictObject({
9
+ fields: z.record(field, z.strictObject({
10
+ title: z.string().trim().min(1).max(80),
11
+ question: z.string().trim().min(1).max(240),
12
+ example: z.string().trim().min(1).max(400),
13
+ inputTitle: z.string().trim().min(1).max(120),
14
+ })),
15
+ context: z.string().trim().min(1).max(400),
16
+ current: z.string().trim().min(1).max(80),
17
+ proposed: z.string().trim().min(1).max(80),
18
+ empty: z.string().trim().min(1).max(80),
19
+ page: z.string().trim().min(1).max(80),
20
+ keep: navigation,
21
+ write: navigation,
22
+ clear: navigation,
23
+ next: navigation,
24
+ defer: navigation,
25
+ save: navigation,
26
+ review: z.strictObject({
27
+ message: z.string().trim().min(1).max(240),
28
+ context: z.string().trim().min(1).max(400),
29
+ example: z.string().trim().min(1).max(400),
30
+ }),
31
+ });
32
+ const inputSchema = z.strictObject({
33
+ repoRoot: z.string().min(1),
34
+ projectId: z.string().uuid(),
35
+ externalTaskId: z.string().trim().min(2).max(160),
36
+ statusId: z.string().uuid(),
37
+ expectedVersion: z.number().int().min(1).max(2147483647),
38
+ field,
39
+ draft: z.string().trim().max(4000).optional(),
40
+ recommendation: z.enum(['keep', 'write', 'clear']),
41
+ reason: z.string().trim().min(1).max(180),
42
+ requestKey: z.string().regex(/^[A-Za-z0-9_-]{1,80}$/),
43
+ language: languageTag,
44
+ copy: wording.optional(),
45
+ decisionAttempt: z.number().int().min(1).max(10000).optional(),
46
+ presentation: z.literal('host_native').optional(),
47
+ });
48
+ const definition = z.object({
49
+ id: z.string().uuid(),
50
+ name: z.string().min(1).max(120),
51
+ slug: z.string().min(1).max(64),
52
+ meaning: z.string().max(4000),
53
+ entryRule: z.string().max(4000),
54
+ archivedAt: z.string().nullable(),
55
+ lockVersion: z.number().int().min(1).max(2147483647),
56
+ });
57
+ const pageSchema = z.object({
58
+ snapshot: z.string().regex(/^[a-f0-9]{64}$/),
59
+ items: z.array(definition).max(100),
60
+ total: z.number().int().min(0),
61
+ offset: z.number().int().min(0),
62
+ limit: z.literal(100),
63
+ });
64
+ const copies = {
65
+ tr: {
66
+ fields: {
67
+ meaning: {
68
+ title: 'Kolonun anlamı',
69
+ question: 'Bu kolondaki iş ne aşamada olur?',
70
+ inputTitle: 'Yazmak istediğin anlam',
71
+ example: 'İncelemede: geliştirme bitti; ekip arkadaşının kodu incelemesi bekleniyor.',
72
+ },
73
+ entryRule: {
74
+ title: 'Kolona giriş koşulu',
75
+ question: 'Bir iş bu kolona hangi koşulda alınır?',
76
+ inputTitle: 'Yazmak istediğin giriş koşulu',
77
+ example: 'İncelemeye geçmek için kod hazır ve ilgili testler başarılı olmalı.',
78
+ },
79
+ },
80
+ context: 'Bu adım isteğe bağlı. Yorum veya açıklama isteği kaydedilmez; yazdığın tanımı son incelemeden sonra kaydederiz.',
81
+ current: 'Mevcut metin',
82
+ proposed: 'Yeni metin',
83
+ empty: '(boş)',
84
+ page: 'Sayfa',
85
+ keep: ['Mevcut kalsın', 'Bu alanı ve mevcut işleri değiştirme.'],
86
+ write: [
87
+ 'Tanımı yaz',
88
+ 'Metin alanına kendi tanımını yaz; kaydetmeden önce birlikte gözden geçir.',
89
+ ],
90
+ clear: [
91
+ 'Bu alanı boşalt',
92
+ 'Boş bırakma tercihini son incelemede doğrula; diğer alanlar aynı kalır.',
93
+ ],
94
+ next: ['Devamını incele', 'Metnin sonraki kısmını göster; henüz hiçbir şey kaydetme.'],
95
+ defer: ['Şimdi kaydetme', 'Bu denemeyi ertele; kolon tanımını ve işleri değiştirme.'],
96
+ save: [
97
+ 'Bu tanımı kaydet',
98
+ 'İncelediğin alanı kaydet; diğer alanlara ve mevcut işlere dokunma.',
99
+ ],
100
+ review: {
101
+ message: 'Bu alanın değişikliğini incele',
102
+ context: 'Mevcut ve yeni metin tümüyle gösterilir. Uzunsa sonraki sayfaya geç; kaydetme yalnızca son sayfadadır. Bu işlem bir işi taşımaz veya olay çalıştırmaz.',
103
+ example: 'İncelemede kolonunu “kod hazır, inceleme bekleniyor” diye tanımlarsın. Kolondaki işler aynı yerde kalır.',
104
+ },
105
+ },
106
+ en: {
107
+ fields: {
108
+ meaning: {
109
+ title: 'Stage meaning',
110
+ question: 'What does work in this stage mean?',
111
+ inputTitle: 'Your stage meaning',
112
+ example: 'In Review: development is complete and a teammate needs to review the code.',
113
+ },
114
+ entryRule: {
115
+ title: 'Stage entry condition',
116
+ question: 'When should work enter this stage?',
117
+ inputTitle: 'Your entry condition',
118
+ example: 'Code must be ready and the relevant tests must pass before review.',
119
+ },
120
+ },
121
+ context: 'This step is optional. Feedback or requests for explanation do not save anything; a written definition is reviewed before saving.',
122
+ current: 'Current text',
123
+ proposed: 'New text',
124
+ empty: '(empty)',
125
+ page: 'Page',
126
+ keep: ['Keep the current value', 'Leave this field and existing work unchanged.'],
127
+ write: ['Write a definition', 'Enter your own text and review it before saving.'],
128
+ clear: ['Clear this field', 'Review an explicit empty value; other fields remain unchanged.'],
129
+ next: ['Review the next part', 'Show the next part of the text without saving anything yet.'],
130
+ defer: ['Do not save now', 'Defer this attempt without changing the stage or existing work.'],
131
+ save: [
132
+ 'Save this definition',
133
+ 'Save the reviewed field; other fields and existing work remain unchanged.',
134
+ ],
135
+ review: {
136
+ message: 'Review this field change',
137
+ context: 'All current and proposed text is shown. Continue through longer text; only the last page can save it. This does not move work or execute an event.',
138
+ example: 'Define In Review as “code is ready and awaiting review.” Tasks in that stage remain where they are.',
139
+ },
140
+ },
141
+ };
142
+ export function registerStatusMeaningTools(server, service) {
143
+ server.registerTool('work_item.describe_status', {
144
+ description: 'Optionally review a user-authored meaning or entry condition for one status at its exact version. Resume the bound write task first. Never infer text from the name or resurvey owner-authored defaults. Supply a deliberate keep/write/clear recommendation and reason in the conversation language. Without draft, an editable native field collects up to2000 characters; explicitly authored draft arguments support the full4000-character metadata contract through complete review pages. Feedback alone never writes. Every saved change requires exact final mode-governed review, and existing metadata authority/version checks. Retry identical input to resume; reconsider a skipped/deferred attempt only when authorized. Turkish/English copy is built in; other languages require complete copy. Does not move work or fire events.',
145
+ inputSchema,
146
+ }, (input, context) => describeStatus(server, service, input, context));
147
+ }
148
+ async function describeStatus(server, service, input, context) {
149
+ const scope = await service.workItemSetupContext(input);
150
+ if (!scope.ok)
151
+ return output(scope);
152
+ const { repoRoot: _repoRoot, ...retryArguments } = input;
153
+ try {
154
+ assertSafeToPersist(retryArguments, '', '', true);
155
+ }
156
+ catch {
157
+ return refusal('Remove sensitive text from the description and its retry arguments.', 'work_item.describe_status');
158
+ }
159
+ const primary = input.language.split('-')[0];
160
+ const copy = input.copy ?? (primary === 'tr' || primary === 'en' ? copies[primary] : undefined);
161
+ if (!copy)
162
+ return refusal('Supply every copy field in the conversation language.', 'work_item.describe_status');
163
+ let target;
164
+ let snapshot;
165
+ let total;
166
+ for (let offset = 0; total === undefined || offset < total; offset += 100) {
167
+ const response = await service.workItemWorkflow({
168
+ projectId: input.projectId,
169
+ offset,
170
+ limit: 100,
171
+ includeArchived: true,
172
+ ...(snapshot ? { snapshot } : {}),
173
+ });
174
+ if (!response.ok)
175
+ return output(response);
176
+ const parsed = pageSchema.safeParse(response.data);
177
+ if (!parsed.success)
178
+ return refusal('Reload the complete status definition before reviewing it.', 'work_item.statuses');
179
+ const page = parsed.data;
180
+ if (page.offset !== offset ||
181
+ (snapshot !== undefined && snapshot !== page.snapshot) ||
182
+ (total !== undefined && total !== page.total) ||
183
+ page.items.length !== Math.min(100, Math.max(0, page.total - offset)) ||
184
+ new Set(page.items.map((item) => item.id)).size !== page.items.length)
185
+ return refusal('The status pages changed or were incomplete. Reload the definition.', 'work_item.statuses');
186
+ snapshot = page.snapshot;
187
+ total = page.total;
188
+ target = page.items.find((item) => item.id === input.statusId);
189
+ if (target)
190
+ break;
191
+ }
192
+ if (!target || target.archivedAt !== null || target.lockVersion !== input.expectedVersion)
193
+ return refusal('The definition is unavailable, archived or changed. Read its current version before reviewing.', 'work_item.statuses');
194
+ try {
195
+ assertSafeToPersist({ label: target.name, slug: target.slug, value: target[input.field] }, '', '', true);
196
+ }
197
+ catch {
198
+ return refusal('The stored definition contains content that cannot be included in a durable question.', 'work_item.statuses');
199
+ }
200
+ const namespace = scope.data.namespace;
201
+ const { presentation: _presentation, ...decisionInput } = retryArguments;
202
+ const setupId = sha256(stableStringify({
203
+ namespace,
204
+ decisionInput,
205
+ current: target[input.field],
206
+ name: target.name,
207
+ slug: target.slug,
208
+ copy,
209
+ }));
210
+ const owner = {
211
+ tool: 'work_item.describe_status',
212
+ externalTaskId: input.externalTaskId,
213
+ decisionAttempt: input.decisionAttempt,
214
+ retryArguments,
215
+ };
216
+ let value = input.draft;
217
+ let inputQuestionnaireId = null;
218
+ if (value === undefined) {
219
+ inputQuestionnaireId = 'status-description-input-' + setupId;
220
+ const options = ['keep', 'write', 'clear'].map((id) => ({
221
+ id,
222
+ label: copy[id][0],
223
+ description: copy[id][1] + (id === input.recommendation ? ' ' + input.reason : ''),
224
+ }));
225
+ options.sort((a, b) => Number(b.id === input.recommendation) - Number(a.id === input.recommendation));
226
+ const question = {
227
+ questionnaireId: inputQuestionnaireId,
228
+ language: input.language,
229
+ impact: 'critical',
230
+ message: copy.fields[input.field].question + '\n' + target.name + ' (' + target.slug + ')',
231
+ context: copy.context,
232
+ example: copy.fields[input.field].example,
233
+ options,
234
+ textField: {
235
+ title: copy.fields[input.field].inputTitle,
236
+ maxLength: 2000,
237
+ requiredForChoice: 'write',
238
+ },
239
+ binding: {
240
+ setupId,
241
+ namespace,
242
+ projectId: input.projectId,
243
+ statusId: input.statusId,
244
+ expectedVersion: input.expectedVersion,
245
+ field: input.field,
246
+ },
247
+ };
248
+ const answer = await askQuestionnaire(server, service, { ...question, repoRoot: input.repoRoot, presentation: input.presentation }, context, [], owner);
249
+ const choice = answerChoice(answer);
250
+ if (!choice)
251
+ return answer;
252
+ if (choice === 'keep')
253
+ return deferred(input, 'skipped');
254
+ if (choice === 'clear')
255
+ value = '';
256
+ else if (choice === 'write')
257
+ value = answerData(answer)?.text;
258
+ if (value === undefined)
259
+ return refusal('Resume the exact editable description question.', 'questionnaire.resume');
260
+ }
261
+ if (value === target[input.field])
262
+ return output({ ok: true, data: { status: 'unchanged', applied: false } });
263
+ const proposal = {
264
+ projectId: input.projectId,
265
+ statusId: input.statusId,
266
+ expectedVersion: input.expectedVersion,
267
+ field: input.field,
268
+ value,
269
+ };
270
+ const proposalDigest = sha256(stableStringify(proposal));
271
+ const reviewText = copy.fields[input.field].title +
272
+ '\n' +
273
+ target.name +
274
+ ' (' +
275
+ target.slug +
276
+ ')\n\n' +
277
+ copy.current +
278
+ '\n' +
279
+ (target[input.field] || copy.empty) +
280
+ '\n\n' +
281
+ copy.proposed +
282
+ '\n' +
283
+ (value || copy.empty);
284
+ const pages = [];
285
+ for (let start = 0; start < reviewText.length;) {
286
+ let end = Math.min(start + 1400, reviewText.length);
287
+ const last = reviewText.charCodeAt(end - 1);
288
+ if (end < reviewText.length && last >= 0xd800 && last <= 0xdbff)
289
+ end--;
290
+ pages.push(reviewText.slice(start, end));
291
+ start = end;
292
+ }
293
+ const reviewQuestionnaireIds = pages.map((_, index) => 'status-description-review-' + sha256(stableStringify({ setupId, proposalDigest, index })));
294
+ for (let index = 0; index < pages.length; index++) {
295
+ if (context.mcpReq.signal.aborted)
296
+ return output({ ok: true, data: { status: 'pending', reason: 'interrupted' } });
297
+ const final = index === pages.length - 1;
298
+ const action = final ? copy.save : copy.next;
299
+ const question = {
300
+ questionnaireId: reviewQuestionnaireIds[index],
301
+ language: input.language,
302
+ impact: 'critical',
303
+ message: copy.review.message +
304
+ '\n\n' +
305
+ pages[index] +
306
+ '\n\n' +
307
+ copy.page +
308
+ ' ' +
309
+ (index + 1) +
310
+ '/' +
311
+ pages.length,
312
+ context: copy.review.context,
313
+ example: copy.review.example,
314
+ options: [
315
+ { id: final ? 'save' : 'continue', label: action[0], description: action[1] },
316
+ { id: 'defer', label: copy.defer[0], description: copy.defer[1] },
317
+ ],
318
+ binding: {
319
+ setupId,
320
+ namespace,
321
+ proposalDigest,
322
+ inputQuestionnaireId,
323
+ reviewQuestionnaireIds,
324
+ index,
325
+ },
326
+ };
327
+ const answer = await askQuestionnaire(server, service, { ...question, repoRoot: input.repoRoot, presentation: input.presentation }, context, [], owner);
328
+ const choice = answerChoice(answer);
329
+ if (choice === 'defer')
330
+ return deferred(input, 'deferred');
331
+ if (choice !== (final ? 'save' : 'continue'))
332
+ return answer;
333
+ }
334
+ return output(await service.workItemApplyStatusDescription({
335
+ ...proposal,
336
+ repoRoot: input.repoRoot,
337
+ externalTaskId: input.externalTaskId,
338
+ inputQuestionnaireId,
339
+ reviewQuestionnaireIds,
340
+ }));
341
+ }
342
+ function deferred(input, status) {
343
+ return output({
344
+ ok: true,
345
+ data: {
346
+ status,
347
+ applied: false,
348
+ ...(input.decisionAttempt === 10000
349
+ ? {}
350
+ : {
351
+ reconsider: {
352
+ tool: 'work_item.describe_status',
353
+ arguments: { ...input, decisionAttempt: (input.decisionAttempt ?? 0) + 1 },
354
+ },
355
+ }),
356
+ nextAction: input.decisionAttempt === 10000
357
+ ? 'This attempt remains deferred. Use a new requestKey only after reconsideration is explicitly authorized.'
358
+ : 'Identical retries preserve this decision. Reconsider only after explicit user intent or a reasoned current delegated decision.',
359
+ },
360
+ });
361
+ }
362
+ function refusal(message, recovery) {
363
+ return output({
364
+ ok: false,
365
+ error: { kind: 'status_description', message, recovery, retryable: false },
366
+ });
367
+ }
368
+ function output(result) {
369
+ return {
370
+ content: [{ type: 'text', text: JSON.stringify(result) }],
371
+ isError: !result.ok,
372
+ };
373
+ }
374
+ //# sourceMappingURL=status-meaning-tools.js.map
@@ -18,6 +18,7 @@ const outward = {
18
18
  export const toolAnnotations = {
19
19
  'project.git_preferences': read,
20
20
  'project.set_git_preferences': write,
21
+ 'worktree.prepare_files': repeatableWrite,
21
22
  'worktree.policy': read,
22
23
  'worktree.set_policy': write,
23
24
  'worktree.list': read,
@@ -26,11 +27,16 @@ export const toolAnnotations = {
26
27
  'task.pause': write,
27
28
  'worktree.release': destructive,
28
29
  'audit.list': read,
30
+ 'decision.mode': repeatableWrite,
31
+ 'decision.mode_status': read,
32
+ 'questionnaire.decide': repeatableWrite,
29
33
  'questionnaire.ask': repeatableWrite,
30
34
  'questionnaire.resume': repeatableWrite, // records an accepted answer
31
35
  'questionnaire.answer_from_host': repeatableWrite,
32
36
  'questionnaire.withdraw': destructive,
33
37
  'session.entry': read,
38
+ 'release_notes.presented': repeatableWrite,
39
+ 'release_notes.show': read,
34
40
  'session.set_decision': repeatableWrite,
35
41
  'session.decline_update': repeatableWrite,
36
42
  'session.answer_shadow_notice': repeatableWrite,
@@ -101,6 +107,15 @@ export const toolAnnotations = {
101
107
  'project.update': write,
102
108
  'work_item.list': read,
103
109
  'work_item.statuses': read,
110
+ 'work_item.workflow': read,
111
+ 'work_item.configure_workflow': write,
112
+ 'work_item.setup_workflow': write,
113
+ 'work_item.describe_status': write,
114
+ 'work_item.update_status': write,
115
+ 'work_item.create_status': write,
116
+ 'work_item.archive_status': write,
117
+ 'work_item.restore_status': write,
118
+ 'work_item.set_initial_status': write,
104
119
  'work_item.get': read,
105
120
  'project.clone': outward,
106
121
  'project.link': write,
@@ -1,3 +1,8 @@
1
+ import { registerDeliveryTools } from './delivery-tools.js';
2
+ import { registerStatusMeaningTools } from './status-meaning-tools.js';
3
+ import { registerWorkflowTools } from './workflow-tools.js';
4
+ import { registerReleaseTools } from './release-tools.js';
5
+ import { ensureDecisionMode, registerDecisionTools } from './decision-tools.js';
1
6
  import * as z from 'zod/v4';
2
7
  import { validationIds } from '../runtime/bridge-service.js';
3
8
  import { stableStringify } from '../utilities/hash.js';
@@ -47,12 +52,18 @@ const checkpointBase = {
47
52
  idempotencyKey: z.string().uuid().optional(),
48
53
  };
49
54
  export const engineeringMemoryToolNames = [
55
+ 'release_notes.presented',
56
+ 'release_notes.show',
57
+ 'decision.mode',
58
+ 'decision.mode_status',
59
+ 'questionnaire.decide',
50
60
  'project.git_preferences',
51
61
  'project.set_git_preferences',
52
62
  'worktree.policy',
53
63
  'worktree.set_policy',
54
64
  'worktree.list',
55
65
  'worktree.reconcile',
66
+ 'worktree.prepare_files',
56
67
  'task.heartbeat',
57
68
  'task.pause',
58
69
  'worktree.release',
@@ -131,6 +142,15 @@ export const engineeringMemoryToolNames = [
131
142
  'work_item.confirm_plan',
132
143
  'project.update',
133
144
  'work_item.statuses',
145
+ 'work_item.workflow',
146
+ 'work_item.configure_workflow',
147
+ 'work_item.setup_workflow',
148
+ 'work_item.describe_status',
149
+ 'work_item.update_status',
150
+ 'work_item.create_status',
151
+ 'work_item.archive_status',
152
+ 'work_item.restore_status',
153
+ 'work_item.set_initial_status',
134
154
  'work_item.list',
135
155
  'work_item.get',
136
156
  'project.clone',
@@ -191,6 +211,10 @@ const waiverCopy = {
191
211
  },
192
212
  };
193
213
  export function registerQuestionnaireTools(server, service) {
214
+ registerReleaseTools(server, service);
215
+ registerDecisionTools(server, service);
216
+ registerWorkflowTools(server, service);
217
+ registerStatusMeaningTools(server, service);
194
218
  server.registerTool('questionnaire.answer_from_host', {
195
219
  description: 'Relay an actual answer from a host-permitted blocking native AskUserQuestion or request_user_input control to the exact durable question. First read hostFallback from the pending operation or questionnaire.resume with presentation host_native, display its unchanged question/options/notices and await the real native result. Never infer answers, use chat consent, asynchronous controls, defaults or a declined/dismissed form. The hostTool field is agent-reported provenance, not transport-verified attestation. Bind requestKey and contentHash exactly; changed scope, withdrawn questions, invalid or conflicting answers are refused. This only saves the answer; retry the owning operation for current authority and version checks.',
196
220
  inputSchema: hostAnswerSchema.extend({
@@ -229,7 +253,7 @@ export function registerQuestionnaireTools(server, service) {
229
253
  }
230
254
  });
231
255
  server.registerTool('questionnaire.ask', {
232
- description: 'Persist a required decision before displaying a native questionnaire. Write the message, context, example, labels and option descriptions in the language the user writes in, and set language to its BCP-47 tag (tr, en, pt-BR); it is remembered, and the call is refused while no language is known. Say why the decision is asked in context, give one concrete example of what it changes in example, and give every option a description of what happens next. Show a record by its title, never by an id or a hash. 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.',
256
+ description: 'Persist a required decision before displaying a native questionnaire. Write the message, context, example, labels and option descriptions in the language the user writes in, and set language to its BCP-47 tag (tr, en, pt-BR); it is remembered, and the call is refused while no language is known. Say why the decision is asked in context, give one concrete example of what it changes in example, and give every option a description of what happens next. Show a record by its title, never by an id or a hash. 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. An answer is either a schema-validated native acceptance or a reasoned questionnaire.decide receipt permitted by the selected task mode. 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.',
233
257
  inputSchema: questionnaireDefinitionSchema.extend({
234
258
  repoRoot: optionalRepoRoot,
235
259
  preparation: z.boolean().optional(),
@@ -330,9 +354,11 @@ export function registerEngineeringMemoryTools(server, service) {
330
354
  }),
331
355
  }, async (input) => toolResult(await service.sessionAnswerShadowNotice(input)));
332
356
  server.registerTool('session.bootstrap', {
333
- 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.',
357
+ description: 'First select the decision mode of a new task through a short native form, then 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.',
334
358
  inputSchema: z.object({
335
359
  repoRoot: optionalRepoRoot,
360
+ language: languageTag.optional(),
361
+ presentation: z.enum(['host_native']).optional(),
336
362
  projectId: z.string().optional(),
337
363
  externalTaskId: z
338
364
  .string()
@@ -368,7 +394,13 @@ export function registerEngineeringMemoryTools(server, service) {
368
394
  .optional(),
369
395
  knownRevisions: z.record(z.string(), z.number().int().min(0)).optional(),
370
396
  }),
371
- }, async (input) => toolResult(await service.sessionBootstrap(input)));
397
+ }, async (input, context) => {
398
+ const mode = await ensureDecisionMode(server, service, input, context);
399
+ if (mode)
400
+ return mode;
401
+ const { language: _language, presentation: _presentation, ...bootstrap } = input;
402
+ return toolResult(await service.sessionBootstrap(bootstrap));
403
+ });
372
404
  server.registerTool('session.resume', {
373
405
  description: 'Merge backend events, local journal, offline outbox, pinned context and current Git state after a new chat or context compaction.',
374
406
  inputSchema: z.object({
@@ -738,13 +770,7 @@ export function registerEngineeringMemoryTools(server, service) {
738
770
  }
739
771
  return toolResult(await service.taskVerify(request));
740
772
  });
741
- server.registerTool('task.close', {
742
- description: 'Close a task only when backend verification still matches the current Git diff hash.',
743
- inputSchema: z.object({
744
- repoRoot: optionalRepoRoot,
745
- taskId: z.string().min(1),
746
- }),
747
- }, async (input) => toolResult(await service.taskClose(input)));
773
+ registerDeliveryTools(server, service);
748
774
  server.registerTool('task.abandon', {
749
775
  description: "Abandon a task the user has said they are giving up on, so it stops holding its proposals and its local pointer. It has no effect on any other task: nobody else's work waits on this, and nothing already verified or closed is undone. Ask the user first; never abandon a task on your own judgement.",
750
776
  inputSchema: z.object({
@@ -1025,6 +1051,134 @@ export function registerEngineeringMemoryTools(server, service) {
1025
1051
  includeArchived: z.boolean().optional(),
1026
1052
  }),
1027
1053
  }, async (input) => toolResult(await service.workItemStatuses(input)));
1054
+ server.registerTool('work_item.workflow', {
1055
+ description: 'Read a coherent page of project stages and the six stored lifecycle event rules without seeding or changing anything. Pass the returned snapshot on every later page; a conflict requires rereading from the start. Page through all statuses before reviewing workflow choices. Every event target includes its human definition or null with targetUnavailable for a missing/foreign target; archived targets are unavailable. Do not infer meanings from names or show snapshot hashes as user choices.',
1056
+ inputSchema: z
1057
+ .object({
1058
+ projectId: z.string().uuid(),
1059
+ offset: z.number().int().min(0).optional(),
1060
+ limit: z.number().int().min(1).max(100).optional(),
1061
+ includeArchived: z.boolean().optional(),
1062
+ snapshot: z
1063
+ .string()
1064
+ .regex(/^[a-f0-9]{64}$/)
1065
+ .optional(),
1066
+ })
1067
+ .strict(),
1068
+ }, async (input) => toolResult(await service.workItemWorkflow(input)));
1069
+ server.registerTool('work_item.configure_workflow', {
1070
+ description: 'Apply explicit project workflow choices together against the complete snapshot from work_item.workflow. Requires current owner, maintainer or organization administration. Supply each of work_started, pr_opened, all_prs_merged, test_failed and blocked_declared exactly once, with an active same-project target and explicit automatic/confirm/ignore mode. The blocked target becomes the active parked stage. Optional draft_pr_opened preserves its stored rule when omitted; a missing draft rule starts automatic with no target. Only draft may have a null target. Existing work and the initial stage stay unchanged; no event fires. Resolve choices under the task decision mode, use human stage names, and never silently substitute a newer snapshot after conflict.',
1071
+ inputSchema: z
1072
+ .object({
1073
+ projectId: z.string().uuid(),
1074
+ expectedSnapshot: z.string().regex(/^[a-f0-9]{64}$/),
1075
+ rules: z
1076
+ .array(z
1077
+ .object({
1078
+ event: z.enum([
1079
+ 'work_started',
1080
+ 'draft_pr_opened',
1081
+ 'pr_opened',
1082
+ 'all_prs_merged',
1083
+ 'test_failed',
1084
+ 'blocked_declared',
1085
+ ]),
1086
+ toStatusId: z.string().uuid().nullable(),
1087
+ mode: z.enum(['automatic', 'confirm', 'ignore']),
1088
+ })
1089
+ .strict())
1090
+ .min(5)
1091
+ .max(6)
1092
+ .refine((rules) => new Set(rules.map((rule) => rule.event)).size === rules.length, {
1093
+ message: 'Each event may be supplied only once.',
1094
+ }),
1095
+ })
1096
+ .strict(),
1097
+ }, async (input) => toolResult(await service.workItemConfigureWorkflow(input)));
1098
+ server.registerTool('work_item.create_status', {
1099
+ description: 'Create one project status definition with an explicit stable slug, name, category and position. Requires current owner, maintainer or organization administration. Read work_item.statuses including archives first; a duplicate slug is refused, never silently renamed or restored. Meanings and entry conditions come from the project user. New definitions are not initial or parked; omitted actionable/terminal flags are false. This does not move work or configure events.',
1100
+ inputSchema: z
1101
+ .object({
1102
+ projectId: z.string().uuid(),
1103
+ slug: z
1104
+ .string()
1105
+ .trim()
1106
+ .min(1)
1107
+ .max(64)
1108
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
1109
+ name: z
1110
+ .string()
1111
+ .trim()
1112
+ .min(1)
1113
+ .max(120)
1114
+ .regex(/^[^\r\n]+$/),
1115
+ category: z.enum(['new', 'indeterminate', 'done']),
1116
+ position: z.number().int().min(0).max(2_147_483_647),
1117
+ meaning: z.string().trim().max(4000).optional(),
1118
+ entryRule: z.string().trim().max(4000).optional(),
1119
+ isActionable: z.boolean().optional(),
1120
+ isTerminal: z.boolean().optional(),
1121
+ })
1122
+ .strict(),
1123
+ }, async (input) => toolResult(await service.workItemCreateStatus(input)));
1124
+ server.registerTool('work_item.archive_status', {
1125
+ description: 'Archive a non-initial project status at its current lockVersion from work_item.statuses. Existing items stay in that column; its metadata and event targets remain unchanged. Current owner, maintainer or organization administration required. Initial definitions need separate role setup and are refused here. Never silently retry a stale version.',
1126
+ inputSchema: z
1127
+ .object({
1128
+ projectId: z.string().uuid(),
1129
+ statusId: z.string().uuid(),
1130
+ expectedVersion: z.number().int().min(1).max(2_147_483_647),
1131
+ })
1132
+ .strict(),
1133
+ }, async (input) => toolResult(await service.workItemSetStatusArchived({ ...input, archived: true })));
1134
+ server.registerTool('work_item.restore_status', {
1135
+ description: 'Restore a non-initial project status at its current lockVersion from work_item.statuses with includeArchived:true. Existing work and metadata stay intact. Current owner, maintainer or organization administration required. Restoring an initial definition requires separate role setup and is refused here. Never silently retry a stale version.',
1136
+ inputSchema: z
1137
+ .object({
1138
+ projectId: z.string().uuid(),
1139
+ statusId: z.string().uuid(),
1140
+ expectedVersion: z.number().int().min(1).max(2_147_483_647),
1141
+ })
1142
+ .strict(),
1143
+ }, async (input) => toolResult(await service.workItemSetStatusArchived({ ...input, archived: false })));
1144
+ server.registerTool('work_item.set_initial_status', {
1145
+ description: 'Choose the active project stage where newly created work starts. Read all pages of work_item.statuses first. Pass the target lockVersion and the observed active initial status id and lockVersion, or explicit null for both prior-initial fields when none exists. Requires current owner, maintainer or organization administration. Existing work, stage meanings and event targets stay unchanged; this is separate from work_started. A stale selection returns a conflict; reread instead of silently retrying.',
1146
+ inputSchema: z
1147
+ .object({
1148
+ projectId: z.string().uuid(),
1149
+ statusId: z.string().uuid(),
1150
+ expectedVersion: z.number().int().min(1).max(2_147_483_647),
1151
+ expectedInitialStatusId: z.string().uuid().nullable(),
1152
+ expectedInitialVersion: z.number().int().min(1).max(2_147_483_647).nullable(),
1153
+ })
1154
+ .strict()
1155
+ .refine((input) => (input.expectedInitialStatusId === null) === (input.expectedInitialVersion === null), {
1156
+ message: 'The prior initial status and its version must both be present or both null.',
1157
+ }),
1158
+ }, async (input) => toolResult(await service.workItemSetInitialStatus(input)));
1159
+ server.registerTool('work_item.update_status', {
1160
+ description: 'Edit one active project status definition at its current lockVersion from work_item.statuses. Only project owners, maintainers and organization administrators may edit. Use meanings and entry conditions supplied by the project user; never invent them from a name. Omitted fields stay unchanged and empty text clears a meaning or entry condition. Show human status names when reviewing changes. This never moves work items, changes the stable slug, configures event roles or fires an event. Creation, archival and role setup are not part of this tool.',
1161
+ inputSchema: z
1162
+ .object({
1163
+ projectId: z.string().uuid(),
1164
+ statusId: z.string().uuid(),
1165
+ expectedVersion: z.number().int().min(1).max(2_147_483_647),
1166
+ name: z
1167
+ .string()
1168
+ .trim()
1169
+ .min(1)
1170
+ .max(120)
1171
+ .regex(/^[^\r\n]+$/)
1172
+ .optional(),
1173
+ category: z.enum(['new', 'indeterminate', 'done']).optional(),
1174
+ meaning: z.string().trim().max(4000).optional(),
1175
+ entryRule: z.string().trim().max(4000).optional(),
1176
+ position: z.number().int().min(0).max(2_147_483_647).optional(),
1177
+ isActionable: z.boolean().optional(),
1178
+ isTerminal: z.boolean().optional(),
1179
+ })
1180
+ .strict(),
1181
+ }, async (input) => toolResult(await service.workItemUpdateStatus(input)));
1028
1182
  server.registerTool('work_item.list', {
1029
1183
  description: 'List selectable work items for a project before opening an engineering run in this chat. Read work_item.statuses for the actual project catalogue slugs and user-defined meanings; never infer workflow from status names. The status filter matches an actual slug, including a retained archived status; an unknown slug returns an empty page.',
1030
1184
  inputSchema: z.object({