engineering-memory 1.11.21 → 1.11.23

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engineering-memory",
3
- "version": "1.11.21",
3
+ "version": "1.11.23",
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",
@@ -1,3 +1,3 @@
1
1
  {
2
- "gitHead": "9df251d4cf095e0f9ed2e7da4e480bbf60142c81"
2
+ "gitHead": "c1f7c36543eddba364aa3e853bd8ffe0a80bcb43"
3
3
  }
@@ -65,6 +65,11 @@ export const endpoints = {
65
65
  taskAbandon: '/runtime/task/abandon',
66
66
  taskStatus: (taskId) => `/runtime/task/${taskId}/status`,
67
67
  taskCommitGate: '/runtime/task/commit-gate',
68
+ projectDeliveries: (projectId) => `/projects/${projectId}/deliveries`,
69
+ taskDelivery: (projectId, taskId) => `/projects/${projectId}/tasks/${taskId}/delivery`,
70
+ taskDeliveryAnswer: (projectId, taskId) => `/projects/${projectId}/tasks/${taskId}/delivery/answer`,
71
+ taskDeliveryDeliver: (projectId, taskId) => `/projects/${projectId}/tasks/${taskId}/delivery/deliver`,
72
+ taskDeliveryCancel: (projectId, taskId) => `/projects/${projectId}/tasks/${taskId}/delivery/cancel`,
68
73
  projectSetup: '/projects/setup',
69
74
  projectList: '/projects',
70
75
  projectResolve: '/projects/resolve',
@@ -74,6 +79,7 @@ export const endpoints = {
74
79
  workItemRuns: (projectId, workItemId) => `/projects/${projectId}/work-items/${workItemId}/runs`,
75
80
  workItemRestore: (projectId, workItemId) => `/projects/${projectId}/work-items/${workItemId}/restore`,
76
81
  workItemTests: (projectId, workItemId) => `/projects/${projectId}/work-items/${workItemId}/tests`,
82
+ workItemComments: (projectId, workItemId) => `/projects/${projectId}/work-items/${workItemId}/comments`,
77
83
  workItemPlan: (projectId, workItemId) => `/projects/${projectId}/work-items/${workItemId}/plan`,
78
84
  projectArchive: (projectId) => `/projects/${projectId}`,
79
85
  projectRestore: (projectId) => `/projects/${projectId}/restore`,
@@ -257,6 +257,8 @@ export class VerificationGate {
257
257
  const receipts = await this.receiptsFor(repository.repoFingerprint, worktreeIdentity(repository.repoRoot));
258
258
  const receipt = receipts.find((candidate) => candidate.taskId === taskId &&
259
259
  candidate.worktreeId === worktreeIdentity(repository.repoRoot));
260
+ if (receipt?.taskClosed && !receipt.source)
261
+ return null;
260
262
  if (!receipt?.source ||
261
263
  !receipt.taskClosed ||
262
264
  !receipt.projectId ||
@@ -41,6 +41,7 @@ export const bundledTexts = {
41
41
  'delivery.destination': 'Branch / remote repository',
42
42
  'delivery.example': 'Commit keeps work on the local branch. PR/MR pushes that branch and opens it for team review.',
43
43
  'delivery.message': 'How should we deliver these changes?',
44
+ 'delivery.recorded': 'Already answered in Engineering Memory',
44
45
  'forgetWorktree.context': 'A checkout that cannot be read is still counted against the worktree limit although nothing can use it; forgetting it frees that slot. No file in the folder is touched or deleted, and the branch stays.',
45
46
  'forgetWorktree.example': 'After this, {task} no longer appears in the worktree list and the {folder} folder stays on disk exactly as it is.',
46
47
  'forgetWorktree.forget': 'Stop tracking it; leave the files alone',
@@ -316,6 +317,14 @@ export const bundledTexts = {
316
317
  'statusRemap.roles.test_failed': 'Failed test',
317
318
  'statusRemap.roles.work_started': 'Starting development',
318
319
  'statusRemap.rolesStay': 'These roles keep pointing at stages being emptied; change them separately through role setup:',
320
+ 'taskDelivery.abandon.description': 'Nothing is delivered, for example because the files are no longer reachable. The delivery is closed for everyone.',
321
+ 'taskDelivery.abandon.label': 'Conclude without delivery',
322
+ 'taskDelivery.context': "Nobody has concluded this delivery yet. Your answer is recorded in Engineering Memory for the whole project. The Git action itself happens on the computer that holds the task's files, when task.close runs there.",
323
+ 'taskDelivery.example': 'Choosing Commit and push records that choice; the member with the files is asked there before anything is pushed.',
324
+ 'taskDelivery.message': '{task} is closed. How should it be delivered?',
325
+ 'taskStart.base.continue': 'Continue {branch}',
326
+ 'taskStart.base.continueDetail': 'No new branch; fetched from origin when this clone lacks it.',
327
+ 'taskStart.base.continueOpenDetail': 'No new branch. The previous round’s delivery is still open, so its last commits may not be on origin yet.',
319
328
  'taskStart.base.fetchDetail': 'Fetch the latest commit.',
320
329
  'taskStart.base.field': 'Branch on origin (only for another branch)',
321
330
  'taskStart.base.local': 'Local: {branch}',
@@ -323,6 +332,7 @@ export const bundledTexts = {
323
332
  'taskStart.base.message': 'Which starting branch?',
324
333
  'taskStart.base.other': 'Another branch on origin',
325
334
  'taskStart.base.otherDetail': 'Enter it below.',
335
+ 'taskStart.continuePlan': "Continue {branch}, the branch of this work item's confirmed plan",
326
336
  'taskStart.example': 'A worktree keeps this task in its own folder.',
327
337
  'taskStart.keepContext': 'Continue on the current branch.',
328
338
  'taskStart.location.busy': 'Task {task} is still running in another client in this folder, so the new branch cannot start here. Once it is paused there, this folder can be chosen too.',
@@ -339,6 +349,7 @@ export const bundledTexts = {
339
349
  'taskStart.location.worktree.label': 'Separate worktree',
340
350
  'taskStart.message': 'Start task',
341
351
  'taskStart.newBranch': 'New branch: {name}',
352
+ 'taskStart.newBranchOrContinue': 'New branch: {name}, unless you continue an existing one',
342
353
  'taskStart.stay': 'Nothing new is created. The work continues on {source} in this folder, so every commit lands on that branch.',
343
354
  'uiWaiver.ask': '{task}: skip the UI check (widget, golden or screenshot test) for this task?',
344
355
  'uiWaiver.context': 'The UI check is what proves the screens this task changed still render. Skipping it means nothing in this task opens them, and the skip is stored in the task audit trail with the reason above.',
@@ -432,6 +443,7 @@ export const bundledTexts = {
432
443
  'delivery.destination': 'Dal / uzak depo',
433
444
  'delivery.example': 'Commit yerel dalda kalır. PR/MR seçersen değişiklikler uzak dala gönderilir ve ekip incelemesine açılır.',
434
445
  'delivery.message': 'Değişiklikleri nasıl teslim edelim?',
446
+ 'delivery.recorded': 'Engineering Memory’de verilmiş cevap',
435
447
  'forgetWorktree.context': 'Okunamayan bir çalışma kopyası hiçbir işe yaramadığı hâlde worktree sınırına sayılmaya devam eder; takipten çıkarılırsa o yer boşalır. Klasördeki hiçbir dosyaya dokunulmaz, hiçbiri silinmez; dal da kalır.',
436
448
  'forgetWorktree.example': 'Bundan sonra {task} worktree listesinde görünmez, {folder} klasörü ise diskte olduğu gibi kalır.',
437
449
  'forgetWorktree.forget': 'Takip etmeyi bırak; dosyalara dokunma',
@@ -707,6 +719,14 @@ export const bundledTexts = {
707
719
  'statusRemap.roles.test_failed': 'Başarısız test',
708
720
  'statusRemap.roles.work_started': 'Geliştirmeye başlama',
709
721
  'statusRemap.rolesStay': 'Bu roller taşınan kolonları hedeflemeye devam eder; değiştirmek için rol kurulumu ayrıca yapılır:',
722
+ 'taskDelivery.abandon.description': 'Hiçbir şey teslim edilmez; örneğin dosyalara artık ulaşılamıyorsa. Teslim herkes için kapanır.',
723
+ 'taskDelivery.abandon.label': 'Teslim etmeden kapat',
724
+ 'taskDelivery.context': 'Bu teslim henüz kimse tarafından sonuçlandırılmadı. Cevabın Engineering Memory’de tüm proje için kaydedilir. Git işlemi ise görevin dosyalarının olduğu bilgisayarda, orada task.close çalıştığında yapılır.',
725
+ 'taskDelivery.example': 'Commit ve push seçersen bu seçim kaydedilir; dosyaların olduğu bilgisayardaki üyeye bir şey gönderilmeden önce orada sorulur.',
726
+ 'taskDelivery.message': '{task} kapandı. Nasıl teslim edilsin?',
727
+ 'taskStart.base.continue': '{branch} dalını sürdür',
728
+ 'taskStart.base.continueDetail': 'Yeni dal açılmaz; bu klonda yoksa origin’den çekilir.',
729
+ 'taskStart.base.continueOpenDetail': 'Yeni dal açılmaz. Önceki turun teslimi henüz açık; son commit’leri origin’de olmayabilir.',
710
730
  'taskStart.base.fetchDetail': 'Güncel hali çekilir.',
711
731
  'taskStart.base.field': 'origin dalı (yalnızca başka dal için)',
712
732
  'taskStart.base.local': 'Yerel: {branch}',
@@ -714,6 +734,7 @@ export const bundledTexts = {
714
734
  'taskStart.base.message': 'Hangi daldan başlayalım?',
715
735
  'taskStart.base.other': 'origin üzerinde başka dal',
716
736
  'taskStart.base.otherDetail': 'Alttaki alana yaz.',
737
+ 'taskStart.continuePlan': '{branch} dalında devam: bu iş kaleminin onaylı planındaki dal',
717
738
  'taskStart.example': 'Ayrı worktree, işi ayrı klasörde tutar.',
718
739
  'taskStart.keepContext': 'Mevcut dalda devam edilecek.',
719
740
  'taskStart.location.busy': '{task} bu klasörde başka bir oturumda hâlâ çalışıyor; yeni branch burada açılamaz. O oturumda duraklatılırsa bu klasör de seçilebilir.',
@@ -730,6 +751,7 @@ export const bundledTexts = {
730
751
  'taskStart.location.worktree.label': 'Ayrı worktree',
731
752
  'taskStart.message': 'Göreve başla',
732
753
  'taskStart.newBranch': 'Yeni dal: {name}',
754
+ 'taskStart.newBranchOrContinue': 'Yeni dal: {name} (var olan bir dalı sürdürmezsen)',
733
755
  'taskStart.stay': 'Yeni bir şey oluşturulmaz. İş bu klasörde {source} üzerinde sürer; her commit o dala gider.',
734
756
  'uiWaiver.ask': '{task}: bu görev için UI kontrolünü (widget, golden veya ekran görüntüsü testi) atlayalım mı?',
735
757
  'uiWaiver.context': 'UI kontrolü, bu görevde değişen ekranların hâlâ doğru çizildiğini gösteren testtir. Atlarsan bu görevde o ekranları açan hiçbir test kalmaz ve atlama kararı yukarıdaki gerekçesiyle görevin denetim kaydına yazılır.',
@@ -1,6 +1,6 @@
1
1
  import * as z from 'zod/v4';
2
- import { languageTag } from '../runtime/questionnaire-store.js';
3
- import { texts } from '../runtime/texts.js';
2
+ import { answerSourceName, languageTag, } from '../runtime/questionnaire-store.js';
3
+ import { format, texts } from '../runtime/texts.js';
4
4
  import { sha256, stableStringify } from '../utilities/hash.js';
5
5
  import { answerChoice, answerData, askQuestionnaire } from './questionnaire-tools.js';
6
6
  const option = z.strictObject({
@@ -21,6 +21,7 @@ const copySchema = z.strictObject({
21
21
  baseContext: z.string().trim().min(1).max(500),
22
22
  baseTitle: z.string().trim().min(1).max(100),
23
23
  baseWrite: option,
24
+ recorded: z.string().trim().min(1).max(100).optional(),
24
25
  });
25
26
  const closedSchema = z.object({
26
27
  closed: z.literal(true),
@@ -39,8 +40,31 @@ const closedSchema = z.object({
39
40
  headCommit: z.string().nullable(),
40
41
  }),
41
42
  }),
43
+ deliveryRecord: z
44
+ .object({
45
+ projectId: z.string(),
46
+ taskId: z.string(),
47
+ lockVersion: z.number().int(),
48
+ choice: z.enum(['commit', 'commit_push', 'commit_push_draft_pr', 'commit_push_pr']).nullish(),
49
+ })
50
+ .nullish(),
51
+ });
52
+ const recordWording = z.strictObject({
53
+ message: z.string().trim().min(1).max(240),
54
+ context: z.string().trim().min(1).max(500),
55
+ example: z.string().trim().min(1).max(400),
56
+ abandon: option,
57
+ });
58
+ const recordSchema = z.object({
59
+ projectId: z.string(),
60
+ taskId: z.string(),
61
+ externalTaskId: z.string(),
62
+ branch: z.string().nullable(),
63
+ state: z.enum(['unanswered', 'answered', 'delivered', 'cancelled']),
64
+ lockVersion: z.number().int(),
42
65
  });
43
66
  export function registerDeliveryTools(server, service) {
67
+ registerTaskDelivery(server, service);
44
68
  server.registerTool('task.close', {
45
69
  description: 'Close the verified task, then open its durable mode-governed Git delivery selector in the same call. Memory publication is separate. Cancellation/feedback leave delivery pending; retry identical input to resume. PR/MR is one review-request concept, not an instruction to merge. No Git action is performed by this tool. Read-only tasks need no delivery. Supply complete copy when the product text catalogue lacks the conversation language. If the user already explicitly chose Git delivery in their own message, deliveryInstruction preserves that choice with its exact relevant excerpt; it is an agent-reported instruction, never a native answer or a substitute for missing user consent. Do not use it for agent decisions or memory-publication approval.',
46
70
  inputSchema: z.object({
@@ -128,6 +152,7 @@ export function registerDeliveryTools(server, service) {
128
152
  ? ['commit', 'commit_push', 'commit_push_draft_pr', 'commit_push_pr', 'defer']
129
153
  : ['commit', 'defer'];
130
154
  const instruction = input.deliveryInstruction;
155
+ const recorded = data.deliveryRecord?.choice;
131
156
  if (instruction && !ids.some((id) => id === instruction.choice))
132
157
  return output({
133
158
  ok: false,
@@ -152,7 +177,10 @@ export function registerDeliveryTools(server, service) {
152
177
  copy.destination +
153
178
  ': ' +
154
179
  destination.branch +
155
- (canPush ? ' → ' + destination.remote + ' (' + destination.pushUrl + ')' : ''),
180
+ (canPush ? ' → ' + destination.remote + ' (' + destination.pushUrl + ')' : '') +
181
+ (recorded && copy.recorded
182
+ ? '\n' + copy.recorded + ': ' + copy[recorded].label
183
+ : ''),
156
184
  example: copy.example,
157
185
  options: ids.map((id) => ({
158
186
  id,
@@ -188,23 +216,8 @@ export function registerDeliveryTools(server, service) {
188
216
  : undefined;
189
217
  let baseQuestionnaireId;
190
218
  if ((choice === 'commit_push_draft_pr' || choice === 'commit_push_pr') && !baseBranch) {
191
- baseQuestionnaireId = questionnaireId + '-base-' + choice;
192
- const base = await askQuestionnaire(server, service, {
193
- repoRoot: input.repoRoot,
194
- presentation: input.presentation,
195
- questionnaireId: baseQuestionnaireId,
196
- language,
197
- impact: 'critical',
198
- message: copy.baseMessage,
199
- context: copy.baseContext,
200
- example: copy.example,
201
- options: [
202
- { id: 'write', label: copy.baseWrite.label, description: copy.baseWrite.description },
203
- { id: 'defer', label: copy.defer.label, description: copy.defer.description },
204
- ],
205
- textField: { title: copy.baseTitle, maxLength: 240, requiredForChoice: 'write' },
206
- binding: { ...binding, choice },
207
- }, context, [], owner);
219
+ baseQuestionnaireId = 'task-delivery-base-' + sha256(questionnaireId + choice);
220
+ const base = await askPullRequestBase(server, service, context, { ...input, questionnaireId: baseQuestionnaireId, binding: { ...binding, choice } }, language, copy, owner);
208
221
  if (!answerChoice(base))
209
222
  return base;
210
223
  if (answerChoice(base) === 'defer')
@@ -270,17 +283,41 @@ export function registerDeliveryTools(server, service) {
270
283
  retryable: false,
271
284
  },
272
285
  });
273
- if (!instruction)
274
- await service.questionnaireResume({ repoRoot: input.repoRoot, questionnaireId });
286
+ const question = instruction
287
+ ? null
288
+ : await service.questionnaireResume({ repoRoot: input.repoRoot, questionnaireId });
275
289
  if (baseQuestionnaireId)
276
290
  await service.questionnaireResume({
277
291
  repoRoot: input.repoRoot,
278
292
  questionnaireId: baseQuestionnaireId,
279
293
  });
294
+ const record = current.data.deliveryRecord;
295
+ const answer = record
296
+ ? await service.taskDeliveryAnswer({
297
+ projectId: record.projectId,
298
+ taskId: record.taskId,
299
+ expectedVersion: record.lockVersion,
300
+ choice,
301
+ ...(baseBranch ? { baseBranch } : {}),
302
+ answerSource: question ? answerSourceName(question.answerSource) : 'user_instruction',
303
+ })
304
+ : null;
305
+ const answered = answer?.ok ? answer.data : null;
306
+ const refused = answer && !answer.ok ? answer.error : null;
307
+ const other = refused?.details?.delivery;
280
308
  return output({
281
309
  ok: true,
282
310
  data: {
283
311
  ...fresh.data,
312
+ ...(answered?.data ? { deliveryRecord: answered.data } : {}),
313
+ ...(answered?.queued
314
+ ? {
315
+ deliveryRecordNotice: 'Engineering Memory is unreachable, so this answer is queued. It is recorded when Engineering Memory is reachable again, unless another answer was recorded first.',
316
+ }
317
+ : {}),
318
+ ...(refused
319
+ ? { ...(other ? { deliveryRecord: other } : {}), deliveryRecordNotice: refused.message }
320
+ : {}),
284
321
  deliveryStatus: 'selected',
285
322
  selectedDelivery: {
286
323
  choice,
@@ -301,6 +338,173 @@ export function registerDeliveryTools(server, service) {
301
338
  });
302
339
  });
303
340
  }
341
+ function registerTaskDelivery(server, service) {
342
+ server.registerTool('task.delivery', {
343
+ description: 'Answer how a closed task is delivered, or conclude its delivery without delivering, from any chat — for example a delivery session.entry lists as open. The answer is recorded in Engineering Memory for the whole project; the Git action itself happens only where the task files are, through task.close. When the task folder is on this computer it points to task.close there instead of asking. Defer records nothing.',
344
+ inputSchema: z.strictObject({
345
+ repoRoot: z.string().min(1).optional(),
346
+ projectId: z.string().uuid(),
347
+ taskId: z.string().uuid(),
348
+ language: languageTag.optional(),
349
+ decisionAttempt: z.number().int().min(1).max(10000).optional(),
350
+ presentation: z.literal('host_native').optional(),
351
+ }),
352
+ }, async (input, context) => {
353
+ const language = await service.language(input.language);
354
+ const copy = language ? copySchema.safeParse(texts('delivery', language)).data : undefined;
355
+ const wording = language
356
+ ? recordWording.safeParse(texts('taskDelivery', language)).data
357
+ : undefined;
358
+ if (!language || !copy || !wording)
359
+ return output({
360
+ ok: false,
361
+ error: {
362
+ kind: 'delivery',
363
+ message: 'The delivery question has no text in the conversation language. Ask again in a language Engineering Memory offers.',
364
+ recovery: 'task.delivery',
365
+ retryable: false,
366
+ },
367
+ });
368
+ const found = await service.taskDeliveryRecord(input);
369
+ if (!found.ok)
370
+ return output(found);
371
+ const { record: raw, folder } = found.data;
372
+ const record = recordSchema.parse(raw);
373
+ if (folder)
374
+ return output({
375
+ ok: true,
376
+ data: {
377
+ deliveryRecord: raw,
378
+ repoRoot: folder,
379
+ nextAction: `The files of ${record.externalTaskId} are on this computer in ${folder}. Call task.close there with taskId ${record.taskId}; it asks how to deliver and performs the Git action there.`,
380
+ },
381
+ });
382
+ if (record.state === 'delivered' || record.state === 'cancelled')
383
+ return output({
384
+ ok: true,
385
+ data: {
386
+ deliveryRecord: raw,
387
+ nextAction: 'This delivery is already concluded. Tell the user what was recorded.',
388
+ },
389
+ });
390
+ const binding = { taskId: record.taskId, lockVersion: record.lockVersion };
391
+ const questionnaireId = 'task-delivery-record-' +
392
+ sha256(stableStringify({ ...binding, decisionAttempt: input.decisionAttempt ?? 0 }));
393
+ const owner = {
394
+ tool: 'task.delivery',
395
+ externalTaskId: record.externalTaskId,
396
+ decisionAttempt: input.decisionAttempt,
397
+ retryArguments: {
398
+ projectId: input.projectId,
399
+ taskId: input.taskId,
400
+ language: input.language,
401
+ },
402
+ };
403
+ const ids = [
404
+ 'commit',
405
+ 'commit_push',
406
+ 'commit_push_draft_pr',
407
+ 'commit_push_pr',
408
+ 'abandon',
409
+ 'defer',
410
+ ];
411
+ const form = await askQuestionnaire(server, service, {
412
+ repoRoot: input.repoRoot,
413
+ presentation: input.presentation,
414
+ questionnaireId,
415
+ language,
416
+ impact: 'critical',
417
+ message: format(wording.message, language, { task: record.externalTaskId }),
418
+ context: wording.context + (record.branch ? '\n' + copy.destination + ': ' + record.branch : ''),
419
+ example: wording.example,
420
+ options: ids.map((id) => ({
421
+ id,
422
+ label: id === 'abandon' ? wording.abandon.label : copy[id].label,
423
+ description: id === 'abandon' ? wording.abandon.description : copy[id].description,
424
+ })),
425
+ binding,
426
+ }, context, [], owner);
427
+ const choice = answerChoice(form);
428
+ if (!choice)
429
+ return form;
430
+ if (choice === 'defer')
431
+ return output({
432
+ ok: true,
433
+ data: {
434
+ deliveryRecord: raw,
435
+ deliveryStatus: 'deferred',
436
+ nextAction: 'Nothing was recorded; the delivery stays open.',
437
+ },
438
+ });
439
+ if (choice === 'abandon')
440
+ return output(await service.taskDeliveryCancel({
441
+ projectId: input.projectId,
442
+ taskId: input.taskId,
443
+ reason: 'abandoned',
444
+ }));
445
+ let baseBranch;
446
+ if (choice === 'commit_push_draft_pr' || choice === 'commit_push_pr') {
447
+ const base = await askPullRequestBase(server, service, context, {
448
+ ...input,
449
+ questionnaireId: 'task-delivery-base-' + sha256(questionnaireId + choice),
450
+ binding: { ...binding, choice },
451
+ }, language, copy, owner);
452
+ if (!answerChoice(base))
453
+ return base;
454
+ if (answerChoice(base) === 'defer')
455
+ return output({
456
+ ok: true,
457
+ data: {
458
+ deliveryRecord: raw,
459
+ deliveryStatus: 'deferred',
460
+ nextAction: 'Nothing was recorded; the delivery stays open.',
461
+ },
462
+ });
463
+ baseBranch = answerData(base)?.text;
464
+ if (!baseBranch)
465
+ return base;
466
+ }
467
+ const question = await service.questionnaireResume({
468
+ repoRoot: input.repoRoot,
469
+ questionnaireId,
470
+ });
471
+ const answer = await service.taskDeliveryAnswer({
472
+ projectId: input.projectId,
473
+ taskId: input.taskId,
474
+ expectedVersion: record.lockVersion,
475
+ choice,
476
+ ...(baseBranch ? { baseBranch } : {}),
477
+ answerSource: answerSourceName(question.answerSource),
478
+ });
479
+ if (!answer.ok)
480
+ return output(answer);
481
+ return output({
482
+ ok: true,
483
+ data: {
484
+ ...answer.data,
485
+ nextAction: `The answer is recorded for the whole project. The Git action happens where the files of ${record.externalTaskId} are, when task.close runs there.`,
486
+ },
487
+ });
488
+ });
489
+ }
490
+ function askPullRequestBase(server, service, context, input, language, copy, owner) {
491
+ return askQuestionnaire(server, service, {
492
+ repoRoot: input.repoRoot,
493
+ presentation: input.presentation,
494
+ questionnaireId: input.questionnaireId,
495
+ language,
496
+ impact: 'critical',
497
+ message: copy.baseMessage,
498
+ context: copy.baseContext,
499
+ example: copy.example,
500
+ options: [
501
+ { id: 'write', label: copy.baseWrite.label, description: copy.baseWrite.description },
502
+ { id: 'defer', label: copy.defer.label, description: copy.defer.description },
503
+ ],
504
+ textField: { title: copy.baseTitle, maxLength: 240, requiredForChoice: 'write' },
505
+ binding: input.binding,
506
+ }, context, [], owner);
507
+ }
304
508
  function output(result) {
305
509
  return {
306
510
  content: [{ type: 'text', text: JSON.stringify(result) }],
@@ -77,6 +77,7 @@ export const toolAnnotations = {
77
77
  'task.resolve_pending_delivery': destructive, // discards a pending delivery
78
78
  'task.verify': write,
79
79
  'task.close': write,
80
+ 'task.delivery': write,
80
81
  'task.abandon': destructive,
81
82
  'task.branch': outward, // fetches the chosen base from the Git remote
82
83
  'architecture.plan': read,
@@ -102,6 +103,8 @@ export const toolAnnotations = {
102
103
  'work_item.runs': read,
103
104
  'work_item.record_test': write,
104
105
  'work_item.test_results': read,
106
+ 'work_item.comment': write,
107
+ 'work_item.comments': read,
105
108
  'work_item.plan': read,
106
109
  'work_item.confirm_plan': write,
107
110
  'project.update': write,
@@ -115,6 +115,7 @@ export const engineeringMemoryToolNames = [
115
115
  'task.resolve_pending_delivery',
116
116
  'task.verify',
117
117
  'task.close',
118
+ 'task.delivery',
118
119
  'task.abandon',
119
120
  'task.branch',
120
121
  'architecture.plan',
@@ -140,6 +141,8 @@ export const engineeringMemoryToolNames = [
140
141
  'work_item.runs',
141
142
  'work_item.record_test',
142
143
  'work_item.test_results',
144
+ 'work_item.comment',
145
+ 'work_item.comments',
143
146
  'work_item.plan',
144
147
  'work_item.confirm_plan',
145
148
  'project.update',
@@ -969,7 +972,7 @@ export function registerEngineeringMemoryTools(server, service) {
969
972
  ...workItemLocator,
970
973
  data: z.object({
971
974
  expectedVersion: z.number().int().min(1),
972
- idempotencyKey: z.string().uuid(),
975
+ idempotencyKey: z.uuidv4(),
973
976
  outcome: z.enum(['passed', 'failed', 'blocked']),
974
977
  summary: z.string().trim().min(2).max(4000),
975
978
  testedRevision: z.string().min(1).max(160).optional(),
@@ -984,6 +987,24 @@ export function registerEngineeringMemoryTools(server, service) {
984
987
  limit: z.number().int().min(1).max(100).optional(),
985
988
  }),
986
989
  }, async (input) => toolResult(await service.workItemTestResults(input)));
990
+ server.registerTool('work_item.comment', {
991
+ description: 'Write a comment on an authorized work item, such as the reason a tester sent it back to Rework. Comments are permanent: correct one with a new comment. Reuse the same idempotency key only for an identical retry.',
992
+ inputSchema: z.object({
993
+ ...workItemLocator,
994
+ data: z.object({
995
+ idempotencyKey: z.uuidv4(),
996
+ body: z.string().trim().min(1).max(4000),
997
+ }),
998
+ }),
999
+ }, async (input) => toolResult(await service.workItemComment(input)));
1000
+ server.registerTool('work_item.comments', {
1001
+ description: 'Read the comments of an authorized work item, newest first. Read them before starting a round on work that came back from testing.',
1002
+ inputSchema: z.object({
1003
+ ...workItemLocator,
1004
+ offset: z.number().int().min(0).optional(),
1005
+ limit: z.number().int().min(1).max(100).optional(),
1006
+ }),
1007
+ }, async (input) => toolResult(await service.workItemComments(input)));
987
1008
  server.registerTool('work_item.plan', {
988
1009
  description: 'Inspect authorized current and linked project targets, repository URLs, implementation capabilities and the persisted shared branch before opening work. Check contracts to identify backend/web/mobile impact; capability is not user consent.',
989
1010
  inputSchema: z.object(workItemLocator),
@@ -30,6 +30,8 @@ function deferredStart(input, questionnaireId) {
30
30
  name: input.name,
31
31
  base: input.base,
32
32
  keepCurrent: input.keepCurrent,
33
+ workItemId: input.workItemId,
34
+ workItemProjectId: input.workItemProjectId,
33
35
  },
34
36
  },
35
37
  nextAction: `Nothing was created, reserved or saved. Tell the user in one line that starting ${input.externalTaskId} is on hold. Continue only read-only work. When the user wants to start, call reconsider exactly as given; it asks the start form again.`,
@@ -285,7 +287,7 @@ export function registerWorktreeTools(server, service) {
285
287
  inputSchema: z.strictObject(policy),
286
288
  }, async (input) => output(await service.worktreePolicy(json(input))));
287
289
  server.registerTool('task.branch', {
288
- description: 'Start a write task by selecting its decision mode in one short native form, then resolve two short questions for the starting branch (unless base is given) and the folder. A unique branch name is generated automatically unless name is supplied. Folder options: a separate managed worktree, this folder as a new branch when it is clean (moving out an unfinished task that holds it, named in the option), this folder on its current branch, or not now. Nothing is created until a valid native or mode-delegated start answer is recorded; the next call with the same arguments allocates. A remote base is fetched and its exact commit pinned. Retry/resume preserves the allocation. Not now returns status deferred with the exact reconsider call; retries keep the same decisionAttempt. Use the returned repoRoot for ALL commands. A read-only task allocates only when transitioning to write.',
290
+ description: 'Start a write task by selecting its decision mode in one short native form, then resolve two short questions for the starting branch (unless base is given) and the folder. A unique branch name is generated automatically unless name is supplied. Pass workItemId (and workItemProjectId when the item lives in a linked project) for work on a work item: the branch question then also offers continuing the branch of its plan or of its earlier rounds, fetched from origin when this clone lacks it, and a planned item that has no branch yet gets its plan branch as the new name. Folder options: a separate managed worktree, this folder as a new branch when it is clean (moving out an unfinished task that holds it, named in the option), this folder on its current branch, or not now. Nothing is created until a valid native or mode-delegated start answer is recorded; the next call with the same arguments allocates. A remote base is fetched and its exact commit pinned. Retry/resume preserves the allocation. Not now returns status deferred with the exact reconsider call; retries keep the same decisionAttempt. Use the returned repoRoot for ALL commands. A read-only task allocates only when transitioning to write.',
289
291
  inputSchema: z.strictObject({
290
292
  repoRoot: repo,
291
293
  externalTaskId: z.string().min(2).max(160),
@@ -293,6 +295,8 @@ export function registerWorktreeTools(server, service) {
293
295
  name: branch.optional(),
294
296
  base: baseSchema.optional(),
295
297
  keepCurrent: z.boolean().optional(),
298
+ workItemId: z.string().uuid().optional(),
299
+ workItemProjectId: z.string().uuid().optional(),
296
300
  language: languageTag.optional(),
297
301
  presentation,
298
302
  }),
@@ -319,9 +323,14 @@ export function registerWorktreeTools(server, service) {
319
323
  recovery: 'task.branch',
320
324
  },
321
325
  });
326
+ const plan = flight.planBranch;
322
327
  const { previous, ...definition } = taskStartDefinition({
323
328
  ...input,
324
329
  ...flight,
330
+ ...(plan ? { name: plan } : {}),
331
+ ...(plan && flight.continueBranches.includes(plan)
332
+ ? { base: { kind: 'existing', branch: plan } }
333
+ : {}),
325
334
  language: told ?? 'en',
326
335
  });
327
336
  const owner = {
@@ -333,6 +342,8 @@ export function registerWorktreeTools(server, service) {
333
342
  name: input.name,
334
343
  base: input.base,
335
344
  keepCurrent: input.keepCurrent,
345
+ workItemId: input.workItemId,
346
+ workItemProjectId: input.workItemProjectId,
336
347
  }),
337
348
  };
338
349
  const form = await askQuestionnaire(server, service, { ...definition, repoRoot: input.repoRoot, presentation: input.presentation }, context, previous, owner);
@@ -359,7 +370,10 @@ export function registerWorktreeTools(server, service) {
359
370
  recovery: 'task.branch',
360
371
  },
361
372
  });
362
- if (choice.name && choice.name !== input.name && choice.name !== flight.suggestedName) {
373
+ if (choice.name &&
374
+ choice.base.kind !== 'existing' &&
375
+ choice.name !== input.name &&
376
+ choice.name !== flight.suggestedName) {
363
377
  const unusable = await service.unusableBranchName(input.repoRoot, choice.name);
364
378
  if (unusable)
365
379
  return reconsider(unusable);
@@ -23,6 +23,8 @@ export const backendRecoveryOperationNames = [
23
23
  'work_item.statuses',
24
24
  'work_item.workflow',
25
25
  'work_item.get',
26
+ 'work_item.comment',
27
+ 'work_item.comments',
26
28
  'session.bootstrap',
27
29
  'session.resume',
28
30
  'context.refresh',
@@ -47,6 +49,7 @@ export const backendRecoveryOperationNames = [
47
49
  'task.abandon',
48
50
  'task.branch',
49
51
  'task.resolve_pending_delivery',
52
+ 'task.delivery',
50
53
  ];
51
54
  const backendRecoveryOperations = new Set(backendRecoveryOperationNames);
52
55
  const browserSigninRecovery = 'auth.signin_browser';
@@ -1,5 +1,20 @@
1
1
  import { BridgeRecoveryError } from './recovery-error.js';
2
2
  export async function resolveTaskBase(git, repoRoot, base, preferences) {
3
+ if (base.kind === 'existing') {
4
+ const local = await git.sourceIdentity(repoRoot, 'refs/heads/' + base.branch);
5
+ const fetched = await git.fetchBranchCommit(repoRoot, 'origin', base.branch).catch(() => null);
6
+ const tip = !fetched ||
7
+ (local && (await git.isAncestor(repoRoot, fetched.sourceCommit, local.sourceCommit)))
8
+ ? local
9
+ : !local || (await git.isAncestor(repoRoot, local.sourceCommit, fetched.sourceCommit))
10
+ ? fetched
11
+ : null;
12
+ if (!tip)
13
+ throw new BridgeRecoveryError(local
14
+ ? `Branch ${base.branch} here and on origin went separate ways, so it cannot simply be continued. Bring the two together with Git, or call task.branch with a new decisionAttempt and start a new branch.`
15
+ : `Branch ${base.branch} is neither in this clone nor on origin, so it cannot be continued. Call task.branch with a new decisionAttempt and start a new branch.`, 'task.branch');
16
+ return { ...tip, base };
17
+ }
3
18
  if (base.kind === 'current' || base.kind === 'local') {
4
19
  const source = await git.sourceIdentity(repoRoot, base.kind === 'current' ? (base.commit ?? 'HEAD') : base.ref);
5
20
  if (!source && base.kind === 'current' && !base.commit)