engineering-memory 1.11.22 → 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 +1 -1
- package/runtime/build.json +1 -1
- package/runtime/dist/src/config.js +6 -0
- package/runtime/dist/src/git/verification-gate.js +2 -0
- package/runtime/dist/src/localization/catalogue.generated.js +22 -0
- package/runtime/dist/src/mcp/delivery-tools.js +226 -22
- package/runtime/dist/src/mcp/tool-annotations.js +3 -0
- package/runtime/dist/src/mcp/tool-definitions.js +22 -1
- package/runtime/dist/src/mcp/worktree-tools.js +16 -2
- package/runtime/dist/src/runtime/api-client.js +3 -0
- package/runtime/dist/src/runtime/branch-preferences.js +15 -0
- package/runtime/dist/src/runtime/bridge-service.js +168 -10
- package/runtime/dist/src/runtime/create-bridge-service.js +4 -2
- package/runtime/dist/src/runtime/questionnaire-store.js +7 -0
- package/runtime/dist/src/runtime/task-start.js +45 -15
- package/runtime/dist/src/runtime/worktree-pool.js +97 -20
- package/skill/references/lifecycle.md +26 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "engineering-memory",
|
|
3
|
-
"version": "1.11.
|
|
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",
|
package/runtime/build.json
CHANGED
|
@@ -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 =
|
|
192
|
-
const base = await
|
|
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
|
-
|
|
274
|
-
|
|
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.
|
|
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 &&
|
|
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)
|