engineering-memory 1.11.19 → 1.11.21
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/bin/engineering-memory.mjs +14 -24
- package/install/localization.generated.mjs +18 -0
- package/package.json +1 -1
- package/runtime/build.json +1 -1
- package/runtime/dist/src/auth/browser-auth.js +15 -4
- package/runtime/dist/src/config.js +1 -0
- package/runtime/dist/src/git/git-inspector.js +10 -0
- package/runtime/dist/src/localization/catalogue.generated.js +786 -0
- package/runtime/dist/src/mcp/delivery-tools.js +15 -48
- package/runtime/dist/src/mcp/onboarding-tools.js +289 -618
- package/runtime/dist/src/mcp/questionnaire-tools.js +25 -26
- package/runtime/dist/src/mcp/status-meaning-tools.js +12 -86
- package/runtime/dist/src/mcp/status-remap-tools.js +252 -0
- package/runtime/dist/src/mcp/tool-annotations.js +1 -0
- package/runtime/dist/src/mcp/tool-definitions.js +33 -55
- package/runtime/dist/src/mcp/workflow-tools.js +17 -108
- package/runtime/dist/src/mcp/worktree-tools.js +168 -250
- package/runtime/dist/src/runtime/api-client.js +8 -2
- package/runtime/dist/src/runtime/bridge-service.js +100 -94
- package/runtime/dist/src/runtime/create-bridge-service.js +4 -2
- package/runtime/dist/src/runtime/language-store.js +6 -0
- package/runtime/dist/src/runtime/release-notes.js +9 -7
- package/runtime/dist/src/runtime/release-report.js +30 -30
- package/runtime/dist/src/runtime/task-start.js +75 -73
- package/runtime/dist/src/runtime/texts.js +135 -0
- package/runtime/dist/src/runtime/worktree-pool.js +30 -5
- package/runtime/dist/src/runtime/worktree-preparation.js +3 -2
- package/runtime/dist/src/utilities/process.js +7 -0
- package/skill/references/lifecycle.md +21 -3
- package/skill/references/project-onboarding.md +5 -2
- package/skill/references/questionnaires.md +5 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import * as z from 'zod/v4';
|
|
1
2
|
import { acceptedContent, CLIENT_CAPABILITIES_META_KEY, inputRequired, inputResponse, } from '@modelcontextprotocol/server';
|
|
3
|
+
import { texts } from '../runtime/texts.js';
|
|
2
4
|
import { questionnaireAnswerSchema, } from '../runtime/questionnaire-store.js';
|
|
3
5
|
export async function askQuestionnaire(server, service, input, context, previousDefinitions = [], owner) {
|
|
4
6
|
try {
|
|
@@ -233,30 +235,17 @@ function consumedResponses(context) {
|
|
|
233
235
|
consumed.set(context.mcpReq, keys);
|
|
234
236
|
return keys;
|
|
235
237
|
}
|
|
236
|
-
const questionnaireCopy = {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
},
|
|
248
|
-
tr: {
|
|
249
|
-
pending: 'Kararını kaydetmek için bir seçenek seç. Formu kapatmak veya iptal etmek onay sayılmaz; soru beklemede kalır.',
|
|
250
|
-
freeText: ' Bir seçenek seçmeden farklı yanıtını yazabilirsin. Serbest yanıt saklanmaz ve daha sonra geri getirilemez.',
|
|
251
|
-
storedText: ' Bu metin alanındaki yanıt kararınla birlikte saklanır; yeniden başlattığında korunur.',
|
|
252
|
-
everyQuestion: ' Karar vermek için soruları cevapla veya formu bitiren bir seçenek seç. Hiçbir seçenek seçmeden yorum da gönderebilirsin.',
|
|
253
|
-
example: 'Örnek',
|
|
254
|
-
answer: 'Yanıt',
|
|
255
|
-
freeAnswer: 'Farklı yanıtını yaz (isteğe bağlı)',
|
|
256
|
-
recommended: 'Önerilen',
|
|
257
|
-
feedback: 'Yanıtını veya yorumunu yaz (isteğe bağlı)',
|
|
258
|
-
},
|
|
259
|
-
};
|
|
238
|
+
const questionnaireCopy = z.strictObject({
|
|
239
|
+
pending: z.string().min(1),
|
|
240
|
+
freeText: z.string().min(1),
|
|
241
|
+
storedText: z.string().min(1),
|
|
242
|
+
everyQuestion: z.string().min(1),
|
|
243
|
+
example: z.string().min(1),
|
|
244
|
+
answer: z.string().min(1),
|
|
245
|
+
freeAnswer: z.string().min(1),
|
|
246
|
+
recommended: z.string().min(1),
|
|
247
|
+
feedback: z.string().min(1),
|
|
248
|
+
});
|
|
260
249
|
function formAnswers(record, content) {
|
|
261
250
|
const answers = {};
|
|
262
251
|
for (const sub of record.questions ?? []) {
|
|
@@ -274,6 +263,8 @@ function formAnswers(record, content) {
|
|
|
274
263
|
}
|
|
275
264
|
function presentationMessage(record, compact = false) {
|
|
276
265
|
const copy = copyFor(record);
|
|
266
|
+
if (record.owner?.tool === 'decision.mode' && record.binding)
|
|
267
|
+
return [record.message, record.context].filter(Boolean).join('\n');
|
|
277
268
|
if (compact)
|
|
278
269
|
return [
|
|
279
270
|
record.message,
|
|
@@ -292,13 +283,21 @@ function presentationMessage(record, compact = false) {
|
|
|
292
283
|
return [
|
|
293
284
|
record.message,
|
|
294
285
|
...explained(record),
|
|
295
|
-
|
|
286
|
+
[
|
|
287
|
+
copy.pending,
|
|
288
|
+
record.questions && copy.everyQuestion,
|
|
289
|
+
record.allowFreeText && copy.freeText,
|
|
290
|
+
record.textField && copy.storedText,
|
|
291
|
+
]
|
|
292
|
+
.filter(Boolean)
|
|
293
|
+
.join(' '),
|
|
296
294
|
]
|
|
297
295
|
.filter(Boolean)
|
|
298
296
|
.join('\n\n');
|
|
299
297
|
}
|
|
300
298
|
function copyFor(record) {
|
|
301
|
-
return questionnaireCopy
|
|
299
|
+
return (questionnaireCopy.safeParse(texts('questionnaire', record.language ?? 'en')).data ??
|
|
300
|
+
questionnaireCopy.parse(texts('questionnaire', 'en')));
|
|
302
301
|
}
|
|
303
302
|
function optionLabels(options, copy) {
|
|
304
303
|
const recommended = options.find((option) => option.label.includes(`(${copy.recommended})`))?.id ?? options[0].id;
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import * as z from 'zod/v4';
|
|
2
2
|
import { languageTag } from '../runtime/questionnaire-store.js';
|
|
3
3
|
import { assertSafeToPersist } from '../runtime/offline-outbox.js';
|
|
4
|
+
import { texts } from '../runtime/texts.js';
|
|
4
5
|
import { sha256, stableStringify } from '../utilities/hash.js';
|
|
5
6
|
import { answerChoice, answerData, askQuestionnaire } from './questionnaire-tools.js';
|
|
6
7
|
const field = z.enum(['meaning', 'entryRule']);
|
|
7
|
-
const navigation = z.
|
|
8
|
+
const navigation = z.strictObject({
|
|
9
|
+
label: z.string().trim().min(1).max(120),
|
|
10
|
+
description: z.string().trim().min(1).max(200),
|
|
11
|
+
});
|
|
8
12
|
const wording = z.strictObject({
|
|
9
13
|
fields: z.record(field, z.strictObject({
|
|
10
14
|
title: z.string().trim().min(1).max(80),
|
|
@@ -61,87 +65,9 @@ const pageSchema = z.object({
|
|
|
61
65
|
offset: z.number().int().min(0),
|
|
62
66
|
limit: z.literal(100),
|
|
63
67
|
});
|
|
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
68
|
export function registerStatusMeaningTools(server, service) {
|
|
143
69
|
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.
|
|
70
|
+
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. Copy comes from the product text catalogue when it has the conversation language; otherwise supply every copy field. Does not move work or fire events.',
|
|
145
71
|
inputSchema,
|
|
146
72
|
}, (input, context) => describeStatus(server, service, input, context));
|
|
147
73
|
}
|
|
@@ -156,8 +82,8 @@ async function describeStatus(server, service, input, context) {
|
|
|
156
82
|
catch {
|
|
157
83
|
return refusal('Remove sensitive text from the description and its retry arguments.', 'work_item.describe_status');
|
|
158
84
|
}
|
|
159
|
-
|
|
160
|
-
const copy = input.copy ?? (
|
|
85
|
+
await service.language(input.language);
|
|
86
|
+
const copy = input.copy ?? wording.safeParse(texts('statusMeaning', input.language)).data;
|
|
161
87
|
if (!copy)
|
|
162
88
|
return refusal('Supply every copy field in the conversation language.', 'work_item.describe_status');
|
|
163
89
|
let target;
|
|
@@ -219,8 +145,8 @@ async function describeStatus(server, service, input, context) {
|
|
|
219
145
|
inputQuestionnaireId = 'status-description-input-' + setupId;
|
|
220
146
|
const options = ['keep', 'write', 'clear'].map((id) => ({
|
|
221
147
|
id,
|
|
222
|
-
label: copy[id]
|
|
223
|
-
description: copy[id]
|
|
148
|
+
label: copy[id].label,
|
|
149
|
+
description: copy[id].description + (id === input.recommendation ? ' ' + input.reason : ''),
|
|
224
150
|
}));
|
|
225
151
|
options.sort((a, b) => Number(b.id === input.recommendation) - Number(a.id === input.recommendation));
|
|
226
152
|
const question = {
|
|
@@ -312,8 +238,8 @@ async function describeStatus(server, service, input, context) {
|
|
|
312
238
|
context: copy.review.context,
|
|
313
239
|
example: copy.review.example,
|
|
314
240
|
options: [
|
|
315
|
-
{ id: final ? 'save' : 'continue', label: action
|
|
316
|
-
{ id: 'defer', label: copy.defer
|
|
241
|
+
{ id: final ? 'save' : 'continue', label: action.label, description: action.description },
|
|
242
|
+
{ id: 'defer', label: copy.defer.label, description: copy.defer.description },
|
|
317
243
|
],
|
|
318
244
|
binding: {
|
|
319
245
|
setupId,
|
|
@@ -0,0 +1,252 @@
|
|
|
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 { texts } from '../runtime/texts.js';
|
|
5
|
+
import { sha256, stableStringify } from '../utilities/hash.js';
|
|
6
|
+
import { answerChoice, askQuestionnaire } from './questionnaire-tools.js';
|
|
7
|
+
const event = z.enum([
|
|
8
|
+
'work_started',
|
|
9
|
+
'draft_pr_opened',
|
|
10
|
+
'pr_opened',
|
|
11
|
+
'all_prs_merged',
|
|
12
|
+
'test_failed',
|
|
13
|
+
'blocked_declared',
|
|
14
|
+
]);
|
|
15
|
+
const navigation = z.strictObject({
|
|
16
|
+
label: z.string().trim().min(1).max(120),
|
|
17
|
+
description: z.string().trim().min(1).max(200),
|
|
18
|
+
});
|
|
19
|
+
const wording = z.strictObject({
|
|
20
|
+
message: z.string().trim().min(1).max(240),
|
|
21
|
+
context: z.string().trim().min(1).max(400),
|
|
22
|
+
example: z.string().trim().min(1).max(400),
|
|
23
|
+
open: z.string().trim().min(1).max(40),
|
|
24
|
+
archived: z.string().trim().min(1).max(40),
|
|
25
|
+
rolesStay: z.string().trim().min(1).max(240),
|
|
26
|
+
roles: z.record(event, z.string().trim().min(1).max(80)),
|
|
27
|
+
reason: z.string().trim().min(1).max(40),
|
|
28
|
+
page: z.string().trim().min(1).max(40),
|
|
29
|
+
next: navigation,
|
|
30
|
+
defer: navigation,
|
|
31
|
+
apply: navigation,
|
|
32
|
+
});
|
|
33
|
+
const inputSchema = z.strictObject({
|
|
34
|
+
repoRoot: z.string().min(1),
|
|
35
|
+
projectId: z.string().uuid(),
|
|
36
|
+
externalTaskId: z.string().trim().min(2).max(160),
|
|
37
|
+
requestKey: z.string().regex(/^[A-Za-z0-9_-]{1,80}$/),
|
|
38
|
+
expectedSnapshot: z.string().regex(/^[a-f0-9]{64}$/),
|
|
39
|
+
moves: z
|
|
40
|
+
.array(z.strictObject({ fromStatusId: z.string().uuid(), toStatusId: z.string().uuid() }))
|
|
41
|
+
.min(1)
|
|
42
|
+
.max(40)
|
|
43
|
+
.refine((values) => new Set(values.map((value) => value.fromStatusId)).size === values.length),
|
|
44
|
+
reason: z.string().trim().min(1).max(400),
|
|
45
|
+
language: languageTag,
|
|
46
|
+
copy: wording.optional(),
|
|
47
|
+
decisionAttempt: z.number().int().min(1).max(10000).optional(),
|
|
48
|
+
presentation: z.literal('host_native').optional(),
|
|
49
|
+
});
|
|
50
|
+
const stage = z.object({
|
|
51
|
+
id: z.string().uuid(),
|
|
52
|
+
name: z.string().min(1).max(120),
|
|
53
|
+
slug: z.string().min(1).max(64),
|
|
54
|
+
archivedAt: z.string().nullable(),
|
|
55
|
+
activeItems: z.number().int().min(0),
|
|
56
|
+
archivedItems: z.number().int().min(0),
|
|
57
|
+
});
|
|
58
|
+
const pageSchema = z.object({
|
|
59
|
+
snapshot: z.string().regex(/^[a-f0-9]{64}$/),
|
|
60
|
+
items: z.array(stage).max(100),
|
|
61
|
+
total: z.number().int().min(0),
|
|
62
|
+
offset: z.number().int().min(0),
|
|
63
|
+
limit: z.literal(100),
|
|
64
|
+
eventRules: z.array(z.object({
|
|
65
|
+
event,
|
|
66
|
+
targetStatus: z.object({ id: z.string().uuid() }).nullable(),
|
|
67
|
+
})),
|
|
68
|
+
});
|
|
69
|
+
export function registerStatusRemapTools(server, service) {
|
|
70
|
+
server.registerTool('work_item.remap_statuses', {
|
|
71
|
+
description: 'Move all work in chosen project stages to other active stages in one confirmed operation. Resume the bound write task and read every work_item.workflow page first; supply that exact snapshot, the explicit from/to stage pairs the user decided and a short reason in the conversation language. Sources may be archived stages; targets must be active; a stage cannot be both a source and a target. The review shows each group with its open and archived counts and any roles still aimed at a source stage; approval is bound to those counts and applies only if they are unchanged. It is critical under the task mode; feedback never applies it. Retry identical input to resume; reconsider a deferred attempt only when authorized. Copy comes from the product text catalogue when it has the conversation language; otherwise supply every copy field. Roles, stage definitions, order and archive state are not changed.',
|
|
72
|
+
inputSchema,
|
|
73
|
+
}, (input, context) => remapStatuses(server, service, input, context));
|
|
74
|
+
}
|
|
75
|
+
async function remapStatuses(server, service, input, context) {
|
|
76
|
+
const scope = await service.workItemSetupContext(input);
|
|
77
|
+
if (!scope.ok)
|
|
78
|
+
return output(scope);
|
|
79
|
+
const { repoRoot: _repoRoot, ...retryArguments } = input;
|
|
80
|
+
try {
|
|
81
|
+
assertSafeToPersist(retryArguments, '', '', true);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return refusal('Remove sensitive text from the reason and its retry arguments.', 'work_item.remap_statuses');
|
|
85
|
+
}
|
|
86
|
+
await service.language(input.language);
|
|
87
|
+
const copy = input.copy ?? wording.safeParse(texts('statusRemap', input.language)).data;
|
|
88
|
+
if (!copy)
|
|
89
|
+
return refusal('Supply every copy field in the conversation language.', 'work_item.remap_statuses');
|
|
90
|
+
const stages = [];
|
|
91
|
+
let rules = [];
|
|
92
|
+
let total;
|
|
93
|
+
for (let offset = 0; total === undefined || offset < total; offset += 100) {
|
|
94
|
+
const response = await service.workItemWorkflow({
|
|
95
|
+
projectId: input.projectId,
|
|
96
|
+
snapshot: input.expectedSnapshot,
|
|
97
|
+
includeArchived: true,
|
|
98
|
+
limit: 100,
|
|
99
|
+
offset,
|
|
100
|
+
});
|
|
101
|
+
if (!response.ok)
|
|
102
|
+
return output(response);
|
|
103
|
+
const parsed = pageSchema.safeParse(response.data);
|
|
104
|
+
if (!parsed.success)
|
|
105
|
+
return refusal('The workflow response is incomplete. Reload it before reviewing the move.', 'work_item.workflow');
|
|
106
|
+
const page = parsed.data;
|
|
107
|
+
if (page.snapshot !== input.expectedSnapshot ||
|
|
108
|
+
page.offset !== offset ||
|
|
109
|
+
(total !== undefined && page.total !== total) ||
|
|
110
|
+
page.items.length !== Math.min(100, Math.max(0, page.total - offset)))
|
|
111
|
+
return refusal('The workflow pages changed or were incomplete. Reload every page before reviewing the move.', 'work_item.workflow');
|
|
112
|
+
total = page.total;
|
|
113
|
+
rules = page.eventRules;
|
|
114
|
+
stages.push(...page.items);
|
|
115
|
+
}
|
|
116
|
+
const find = (id) => stages.find((item) => item.id === id);
|
|
117
|
+
const sourceIds = input.moves.map((move) => move.fromStatusId);
|
|
118
|
+
const targetIds = input.moves.map((move) => move.toStatusId);
|
|
119
|
+
if (new Set(stages.map((item) => item.id)).size !== stages.length ||
|
|
120
|
+
sourceIds.some((id) => targetIds.includes(id) || !find(id)) ||
|
|
121
|
+
targetIds.some((id) => find(id)?.archivedAt !== null))
|
|
122
|
+
return refusal('Move work from stages of this project to its active stages, without using a stage as both a source and a target.', 'work_item.workflow');
|
|
123
|
+
const moves = input.moves.map((move) => ({
|
|
124
|
+
...move,
|
|
125
|
+
expectedActiveItems: find(move.fromStatusId).activeItems,
|
|
126
|
+
expectedArchivedItems: find(move.fromStatusId).archivedItems,
|
|
127
|
+
}));
|
|
128
|
+
if (moves.every((move) => move.expectedActiveItems + move.expectedArchivedItems === 0))
|
|
129
|
+
return output({ ok: true, data: { status: 'unchanged', applied: false } });
|
|
130
|
+
const label = (id) => find(id).name + ' (' + find(id).slug + ')';
|
|
131
|
+
const kept = rules
|
|
132
|
+
.filter((rule) => rule.targetStatus && sourceIds.includes(rule.targetStatus.id))
|
|
133
|
+
.map((rule) => copy.roles[rule.event] + ' → ' + label(rule.targetStatus.id));
|
|
134
|
+
const lines = [
|
|
135
|
+
...moves.map((move) => label(move.fromStatusId) +
|
|
136
|
+
' → ' +
|
|
137
|
+
label(move.toStatusId) +
|
|
138
|
+
': ' +
|
|
139
|
+
copy.open +
|
|
140
|
+
' ' +
|
|
141
|
+
move.expectedActiveItems +
|
|
142
|
+
', ' +
|
|
143
|
+
copy.archived +
|
|
144
|
+
' ' +
|
|
145
|
+
move.expectedArchivedItems),
|
|
146
|
+
...(kept.length ? ['', copy.rolesStay, ...kept] : []),
|
|
147
|
+
'',
|
|
148
|
+
copy.reason + ': ' + input.reason,
|
|
149
|
+
];
|
|
150
|
+
try {
|
|
151
|
+
assertSafeToPersist(lines, '', '', true);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return refusal('A stage name contains content that cannot be included in a durable question.', 'work_item.workflow');
|
|
155
|
+
}
|
|
156
|
+
const pages = [];
|
|
157
|
+
for (const line of lines) {
|
|
158
|
+
const last = pages.length - 1;
|
|
159
|
+
if (last >= 0 && pages[last].length + 1 + line.length <= 1400)
|
|
160
|
+
pages[last] += '\n' + line;
|
|
161
|
+
else
|
|
162
|
+
pages.push(line);
|
|
163
|
+
}
|
|
164
|
+
const namespace = scope.data.namespace;
|
|
165
|
+
const { presentation: _presentation, ...decisionInput } = retryArguments;
|
|
166
|
+
const proposal = {
|
|
167
|
+
projectId: input.projectId,
|
|
168
|
+
expectedSnapshot: input.expectedSnapshot,
|
|
169
|
+
moves,
|
|
170
|
+
};
|
|
171
|
+
const proposalDigest = sha256(stableStringify(proposal));
|
|
172
|
+
const setupId = sha256(stableStringify({ namespace, decisionInput, proposalDigest, pages }));
|
|
173
|
+
const reviewQuestionnaireIds = pages.map((_, index) => 'status-remap-review-' + sha256(stableStringify({ setupId, index })));
|
|
174
|
+
const owner = {
|
|
175
|
+
tool: 'work_item.remap_statuses',
|
|
176
|
+
externalTaskId: input.externalTaskId,
|
|
177
|
+
decisionAttempt: input.decisionAttempt,
|
|
178
|
+
retryArguments,
|
|
179
|
+
};
|
|
180
|
+
for (let index = 0; index < pages.length; index++) {
|
|
181
|
+
if (context.mcpReq.signal.aborted)
|
|
182
|
+
return output({ ok: true, data: { status: 'pending', reason: 'interrupted' } });
|
|
183
|
+
const final = index === pages.length - 1;
|
|
184
|
+
const action = final ? copy.apply : copy.next;
|
|
185
|
+
const question = {
|
|
186
|
+
questionnaireId: reviewQuestionnaireIds[index],
|
|
187
|
+
language: input.language,
|
|
188
|
+
impact: 'critical',
|
|
189
|
+
message: copy.message +
|
|
190
|
+
'\n\n' +
|
|
191
|
+
pages[index] +
|
|
192
|
+
'\n\n' +
|
|
193
|
+
copy.page +
|
|
194
|
+
' ' +
|
|
195
|
+
(index + 1) +
|
|
196
|
+
'/' +
|
|
197
|
+
pages.length,
|
|
198
|
+
context: copy.context,
|
|
199
|
+
example: copy.example,
|
|
200
|
+
options: [
|
|
201
|
+
{ id: final ? 'apply' : 'continue', label: action.label, description: action.description },
|
|
202
|
+
{ id: 'defer', label: copy.defer.label, description: copy.defer.description },
|
|
203
|
+
],
|
|
204
|
+
binding: { setupId, namespace, proposalDigest, reviewQuestionnaireIds, index },
|
|
205
|
+
};
|
|
206
|
+
const answer = await askQuestionnaire(server, service, { ...question, repoRoot: input.repoRoot, presentation: input.presentation }, context, [], owner);
|
|
207
|
+
const choice = answerChoice(answer);
|
|
208
|
+
if (choice === 'defer')
|
|
209
|
+
return deferred(input);
|
|
210
|
+
if (choice !== (final ? 'apply' : 'continue'))
|
|
211
|
+
return answer;
|
|
212
|
+
}
|
|
213
|
+
return output(await service.workItemApplyStatusRemap({
|
|
214
|
+
...proposal,
|
|
215
|
+
repoRoot: input.repoRoot,
|
|
216
|
+
externalTaskId: input.externalTaskId,
|
|
217
|
+
reviewQuestionnaireIds,
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
function deferred(input) {
|
|
221
|
+
return output({
|
|
222
|
+
ok: true,
|
|
223
|
+
data: {
|
|
224
|
+
status: 'deferred',
|
|
225
|
+
applied: false,
|
|
226
|
+
...(input.decisionAttempt === 10000
|
|
227
|
+
? {}
|
|
228
|
+
: {
|
|
229
|
+
reconsider: {
|
|
230
|
+
tool: 'work_item.remap_statuses',
|
|
231
|
+
arguments: { ...input, decisionAttempt: (input.decisionAttempt ?? 0) + 1 },
|
|
232
|
+
},
|
|
233
|
+
}),
|
|
234
|
+
nextAction: input.decisionAttempt === 10000
|
|
235
|
+
? 'This attempt remains deferred. Use a new requestKey only after reconsideration is explicitly authorized.'
|
|
236
|
+
: 'No work was moved. Identical retries keep this deferral. Reconsider only after explicit user intent or a reasoned current delegated decision.',
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
function refusal(message, recovery) {
|
|
241
|
+
return output({
|
|
242
|
+
ok: false,
|
|
243
|
+
error: { kind: 'status_remap', message, recovery, retryable: false },
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
function output(result) {
|
|
247
|
+
return {
|
|
248
|
+
content: [{ type: 'text', text: JSON.stringify(result) }],
|
|
249
|
+
isError: !result.ok,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
//# sourceMappingURL=status-remap-tools.js.map
|
|
@@ -111,6 +111,7 @@ export const toolAnnotations = {
|
|
|
111
111
|
'work_item.configure_workflow': write,
|
|
112
112
|
'work_item.setup_workflow': write,
|
|
113
113
|
'work_item.describe_status': write,
|
|
114
|
+
'work_item.remap_statuses': write,
|
|
114
115
|
'work_item.update_status': write,
|
|
115
116
|
'work_item.create_status': write,
|
|
116
117
|
'work_item.archive_status': write,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { registerDeliveryTools } from './delivery-tools.js';
|
|
2
2
|
import { registerStatusMeaningTools } from './status-meaning-tools.js';
|
|
3
|
+
import { registerStatusRemapTools } from './status-remap-tools.js';
|
|
3
4
|
import { registerWorkflowTools } from './workflow-tools.js';
|
|
4
5
|
import { registerReleaseTools } from './release-tools.js';
|
|
5
6
|
import { ensureDecisionMode, registerDecisionTools } from './decision-tools.js';
|
|
@@ -8,6 +9,7 @@ import { validationIds } from '../runtime/bridge-service.js';
|
|
|
8
9
|
import { stableStringify } from '../utilities/hash.js';
|
|
9
10
|
import { hostAnswerSchema, languageTag, questionnaireDefinitionSchema, } from '../runtime/questionnaire-store.js';
|
|
10
11
|
import { answerChoice, answerQuestionnaireFromHost, askFailed, askQuestionnaire, resumeQuestionnaire, } from './questionnaire-tools.js';
|
|
12
|
+
import { copies, format } from '../runtime/texts.js';
|
|
11
13
|
const optionalRepoRoot = z.string().min(1).optional();
|
|
12
14
|
const stringList = z.array(z.string().min(1));
|
|
13
15
|
const jsonValue = z.lazy(() => z.union([
|
|
@@ -146,6 +148,7 @@ export const engineeringMemoryToolNames = [
|
|
|
146
148
|
'work_item.configure_workflow',
|
|
147
149
|
'work_item.setup_workflow',
|
|
148
150
|
'work_item.describe_status',
|
|
151
|
+
'work_item.remap_statuses',
|
|
149
152
|
'work_item.update_status',
|
|
150
153
|
'work_item.create_status',
|
|
151
154
|
'work_item.archive_status',
|
|
@@ -178,43 +181,23 @@ const reconciliationEntry = z.object({
|
|
|
178
181
|
.describe('Optional for approved_revision; resolved from the approved proposal when omitted.'),
|
|
179
182
|
reason: z.string().optional(),
|
|
180
183
|
});
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
require: [
|
|
193
|
-
'Keep the UI check required',
|
|
194
|
-
'Nothing is skipped. The task stays unverified until a widget, golden or screenshot test covers these files.',
|
|
195
|
-
],
|
|
196
|
-
},
|
|
197
|
-
tr: {
|
|
198
|
-
ask: (task) => `${task}: bu görev için UI kontrolünü (widget, golden veya ekran görüntüsü testi) atlayalım mı?`,
|
|
199
|
-
files: (listed) => `Arayüzü çizen değişen dosyalar: ${listed}`,
|
|
200
|
-
reason: (reason) => `Verdiğin gerekçe: ${reason}`,
|
|
201
|
-
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.',
|
|
202
|
-
example: 'Bu değişiklikte bozulan bir yerleşim doğrulamadan geçer ve ancak uygulamayı açan biri fark ettiğinde ortaya çıkar.',
|
|
203
|
-
waive: [
|
|
204
|
-
'UI kontrolünü atla',
|
|
205
|
-
'Görev, bu dosyalar için widget, golden veya ekran görüntüsü testi olmadan doğrulanır ve atlama kaydedilir.',
|
|
206
|
-
],
|
|
207
|
-
require: [
|
|
208
|
-
'UI kontrolü zorunlu kalsın',
|
|
209
|
-
'Hiçbir şey atlanmaz. Bu dosyaları kapsayan bir widget, golden veya ekran görüntüsü testi gelene kadar görev doğrulanmaz.',
|
|
210
|
-
],
|
|
211
|
-
},
|
|
212
|
-
};
|
|
184
|
+
const text = z.string().min(1);
|
|
185
|
+
const option = z.strictObject({ label: text, description: text });
|
|
186
|
+
const waiverWording = z.strictObject({
|
|
187
|
+
ask: text,
|
|
188
|
+
files: text,
|
|
189
|
+
reason: text,
|
|
190
|
+
context: text,
|
|
191
|
+
example: text,
|
|
192
|
+
waive: option,
|
|
193
|
+
require: option,
|
|
194
|
+
});
|
|
213
195
|
export function registerQuestionnaireTools(server, service) {
|
|
214
196
|
registerReleaseTools(server, service);
|
|
215
197
|
registerDecisionTools(server, service);
|
|
216
198
|
registerWorkflowTools(server, service);
|
|
217
199
|
registerStatusMeaningTools(server, service);
|
|
200
|
+
registerStatusRemapTools(server, service);
|
|
218
201
|
server.registerTool('questionnaire.answer_from_host', {
|
|
219
202
|
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.',
|
|
220
203
|
inputSchema: hostAnswerSchema.extend({
|
|
@@ -576,10 +559,7 @@ export function registerEngineeringMemoryTools(server, service) {
|
|
|
576
559
|
.max(500),
|
|
577
560
|
}),
|
|
578
561
|
}, async (input, context) => {
|
|
579
|
-
const
|
|
580
|
-
? 'tr'
|
|
581
|
-
: 'en';
|
|
582
|
-
const review = { ...input, language };
|
|
562
|
+
const review = { ...input, language: await service.language(input.language) };
|
|
583
563
|
let questions;
|
|
584
564
|
try {
|
|
585
565
|
questions = await service.ruleDeviationQuestions(review);
|
|
@@ -709,7 +689,7 @@ export function registerEngineeringMemoryTools(server, service) {
|
|
|
709
689
|
}),
|
|
710
690
|
}, async (input, context) => {
|
|
711
691
|
const { language: told, ...request } = input;
|
|
712
|
-
const
|
|
692
|
+
const wording = copies(waiverWording, 'uiWaiver', await service.language(told));
|
|
713
693
|
for (const waiver of request.waivers ?? []) {
|
|
714
694
|
const subject = await service.validationWaiverSubject({
|
|
715
695
|
repoRoot: request.repoRoot,
|
|
@@ -721,20 +701,21 @@ export function registerEngineeringMemoryTools(server, service) {
|
|
|
721
701
|
return toolResult(subject);
|
|
722
702
|
const { questionnaireId, externalTaskId, paths, reason } = subject.data;
|
|
723
703
|
const listed = paths.slice(0, 20).join(', ') + (paths.length > 20 ? ` (+${paths.length - 20})` : '');
|
|
724
|
-
const definitions =
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
language:
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
704
|
+
const definitions = wording.map(({ language, copy }) => ({
|
|
705
|
+
questionnaireId,
|
|
706
|
+
language,
|
|
707
|
+
message: [
|
|
708
|
+
format(copy.ask, language, { task: externalTaskId }),
|
|
709
|
+
format(copy.files, language, { files: listed }),
|
|
710
|
+
format(copy.reason, language, { reason }),
|
|
711
|
+
].join('\n'),
|
|
712
|
+
context: copy.context,
|
|
713
|
+
example: copy.example,
|
|
714
|
+
options: [
|
|
715
|
+
{ id: 'waive', label: copy.waive.label, description: copy.waive.description },
|
|
716
|
+
{ id: 'require', label: copy.require.label, description: copy.require.description },
|
|
717
|
+
],
|
|
718
|
+
}));
|
|
738
719
|
const earlier = [
|
|
739
720
|
{
|
|
740
721
|
questionnaireId,
|
|
@@ -755,10 +736,7 @@ export function registerEngineeringMemoryTools(server, service) {
|
|
|
755
736
|
],
|
|
756
737
|
},
|
|
757
738
|
];
|
|
758
|
-
const form = await askQuestionnaire(server, service, {
|
|
759
|
-
...definitions.find((definition) => definition.language === language),
|
|
760
|
-
repoRoot: request.repoRoot,
|
|
761
|
-
}, context, [...definitions, ...earlier]);
|
|
739
|
+
const form = await askQuestionnaire(server, service, { ...definitions[0], repoRoot: request.repoRoot }, context, [...definitions, ...earlier]);
|
|
762
740
|
if (askFailed(form))
|
|
763
741
|
return form;
|
|
764
742
|
const record = await service.questionnaireResume({
|