engineering-memory 1.11.17 → 1.11.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dispatcher/sections.mjs +7 -3
- package/package.json +1 -1
- package/runtime/build.json +1 -1
- package/runtime/dist/src/config.js +1 -0
- package/runtime/dist/src/mcp/decision-tools.js +119 -0
- package/runtime/dist/src/mcp/delivery-tools.js +343 -0
- package/runtime/dist/src/mcp/questionnaire-tools.js +14 -4
- package/runtime/dist/src/mcp/release-tools.js +27 -0
- package/runtime/dist/src/mcp/status-meaning-tools.js +374 -0
- package/runtime/dist/src/mcp/tool-annotations.js +15 -0
- package/runtime/dist/src/mcp/tool-definitions.js +164 -10
- package/runtime/dist/src/mcp/workflow-tools.js +401 -0
- package/runtime/dist/src/mcp/worktree-tools.js +22 -1
- package/runtime/dist/src/runtime/api-client.js +35 -4
- package/runtime/dist/src/runtime/bridge-service.js +541 -7
- package/runtime/dist/src/runtime/create-bridge-service.js +2 -0
- package/runtime/dist/src/runtime/decision-mode-store.js +88 -0
- package/runtime/dist/src/runtime/questionnaire-store.js +27 -3
- package/runtime/dist/src/runtime/release-notes.js +284 -0
- package/runtime/dist/src/runtime/release-report.js +59 -0
- package/runtime/dist/src/runtime/worktree-editor.js +6 -3
- package/runtime/dist/src/runtime/worktree-pool.js +42 -9
- package/runtime/dist/src/runtime/worktree-preparation.js +73 -3
- package/skill/SKILL.md +7 -3
- package/skill/references/lifecycle.md +48 -5
- package/skill/references/memory-updates.md +6 -2
- package/skill/references/questionnaires.md +24 -3
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import * as z from 'zod/v4';
|
|
2
|
+
import { languageTag } from '../runtime/questionnaire-store.js';
|
|
3
|
+
import { sha256, stableStringify } from '../utilities/hash.js';
|
|
4
|
+
import { answerChoice, askQuestionnaire } from './questionnaire-tools.js';
|
|
5
|
+
import { assertSafeToPersist } from '../runtime/offline-outbox.js';
|
|
6
|
+
const role = z.enum([
|
|
7
|
+
'work_started',
|
|
8
|
+
'pr_opened',
|
|
9
|
+
'all_prs_merged',
|
|
10
|
+
'test_failed',
|
|
11
|
+
'blocked_declared',
|
|
12
|
+
]);
|
|
13
|
+
const mode = z.enum(['automatic', 'confirm', 'ignore']);
|
|
14
|
+
const navigation = z.tuple([z.string().trim().min(1).max(120), z.string().trim().min(1).max(400)]);
|
|
15
|
+
const wording = z
|
|
16
|
+
.object({
|
|
17
|
+
roles: z.record(role, z
|
|
18
|
+
.object({
|
|
19
|
+
title: z.string().trim().min(1).max(60),
|
|
20
|
+
message: z.string().trim().min(1).max(300),
|
|
21
|
+
context: z.string().trim().min(1).max(600),
|
|
22
|
+
example: z.string().trim().min(1).max(400),
|
|
23
|
+
})
|
|
24
|
+
.strict()),
|
|
25
|
+
modes: z.record(mode, z.string().trim().min(1).max(80)),
|
|
26
|
+
page: z.string().trim().min(1).max(80),
|
|
27
|
+
next: navigation,
|
|
28
|
+
previous: navigation,
|
|
29
|
+
defer: navigation,
|
|
30
|
+
approve: navigation,
|
|
31
|
+
review: z
|
|
32
|
+
.object({
|
|
33
|
+
message: z.string().trim().min(1).max(300),
|
|
34
|
+
context: z.string().trim().min(1).max(400),
|
|
35
|
+
example: z.string().trim().min(1).max(400),
|
|
36
|
+
})
|
|
37
|
+
.strict(),
|
|
38
|
+
})
|
|
39
|
+
.strict();
|
|
40
|
+
const setupInput = z
|
|
41
|
+
.object({
|
|
42
|
+
repoRoot: z.string().min(1),
|
|
43
|
+
projectId: z.string().uuid(),
|
|
44
|
+
externalTaskId: z.string().trim().min(2).max(160),
|
|
45
|
+
requestKey: z.string().regex(/^[A-Za-z0-9_-]{1,80}$/),
|
|
46
|
+
expectedSnapshot: z.string().regex(/^[a-f0-9]{64}$/),
|
|
47
|
+
recommendations: z
|
|
48
|
+
.array(z
|
|
49
|
+
.object({
|
|
50
|
+
event: role,
|
|
51
|
+
statusId: z.string().uuid(),
|
|
52
|
+
mode,
|
|
53
|
+
reason: z.string().trim().min(1).max(200),
|
|
54
|
+
})
|
|
55
|
+
.strict())
|
|
56
|
+
.length(5)
|
|
57
|
+
.refine((values) => new Set(values.map((value) => value.event)).size === 5),
|
|
58
|
+
language: languageTag,
|
|
59
|
+
copy: wording.optional(),
|
|
60
|
+
decisionAttempt: z.number().int().min(1).max(10000).optional(),
|
|
61
|
+
presentation: z.literal('host_native').optional(),
|
|
62
|
+
})
|
|
63
|
+
.strict();
|
|
64
|
+
const stage = z.object({
|
|
65
|
+
id: z.string().uuid(),
|
|
66
|
+
name: z.string().min(1).max(120),
|
|
67
|
+
slug: z.string().min(1).max(64),
|
|
68
|
+
meaning: z.string().max(4000),
|
|
69
|
+
archivedAt: z.string().nullable(),
|
|
70
|
+
});
|
|
71
|
+
const workflowPage = z.object({
|
|
72
|
+
snapshot: z.string().regex(/^[a-f0-9]{64}$/),
|
|
73
|
+
items: z.array(stage).max(100),
|
|
74
|
+
total: z.number().int().min(0),
|
|
75
|
+
offset: z.number().int().min(0),
|
|
76
|
+
limit: z.number().int().min(1).max(100),
|
|
77
|
+
});
|
|
78
|
+
const copy = {
|
|
79
|
+
tr: {
|
|
80
|
+
roles: {
|
|
81
|
+
work_started: {
|
|
82
|
+
title: 'Geliştirme',
|
|
83
|
+
message: 'Geliştirmeye başlayınca hangi kolon kullanılsın?',
|
|
84
|
+
context: 'Yeni işin açıldığı kolondan ayrı olarak, geliştirmeye başlama adımını ayarlıyoruz.',
|
|
85
|
+
example: 'Ödeme hatasını düzeltmeye başladığında kullanılacak kolonu seçiyorsun.',
|
|
86
|
+
},
|
|
87
|
+
pr_opened: {
|
|
88
|
+
title: 'İncelemeye hazır PR',
|
|
89
|
+
message: 'PR incelemeye hazır olunca hangi kolon kullanılsın?',
|
|
90
|
+
context: 'Taslak PR davranışı aynı kalır; burada incelemeye hazır PR adımını ayarlıyoruz.',
|
|
91
|
+
example: 'Değişikliğini bitirip ekip arkadaşının incelemesine açtığında kullanılacak kolon.',
|
|
92
|
+
},
|
|
93
|
+
all_prs_merged: {
|
|
94
|
+
title: 'Bütün PR’lar birleşti',
|
|
95
|
+
message: 'İşin bütün PR’ları birleşince hangi kolon kullanılsın?',
|
|
96
|
+
context: 'Bu işe bağlı bütün PR’ların birleştiği adımı ayarlıyoruz.',
|
|
97
|
+
example: 'Hem web hem sunucu değişiklikleri birleştirildiğinde kullanılacak kolon.',
|
|
98
|
+
},
|
|
99
|
+
test_failed: {
|
|
100
|
+
title: 'Başarısız test',
|
|
101
|
+
message: 'Test başarısız olunca hangi kolon kullanılsın?',
|
|
102
|
+
context: 'Başarısız testin ardından yapılacak düzeltmeler için kolon seçiyoruz.',
|
|
103
|
+
example: 'Ödeme testinde hata bulundu; düzeltme bekleyen iş için kullanılacak kolon.',
|
|
104
|
+
},
|
|
105
|
+
blocked_declared: {
|
|
106
|
+
title: 'Park edilen işler',
|
|
107
|
+
message: 'Beklemeye alınan işler hangi kolonda park edilsin?',
|
|
108
|
+
context: 'Projenin aktif park kolonunu seçiyoruz; mevcut işler yerinde kalacak.',
|
|
109
|
+
example: 'Dış servisten yanıt beklediğin için işi beklemeye aldığında kullanılacak kolon.',
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
modes: { automatic: 'Otomatik', confirm: 'Onay sor', ignore: 'Olayı dikkate alma' },
|
|
113
|
+
page: 'Sayfa',
|
|
114
|
+
next: ['Sonraki kolonlar', 'Aynı kararın sonraki kolonlarını göster.'],
|
|
115
|
+
previous: ['Önceki kolonlar', 'Aynı kararın önceki kolonlarına dön.'],
|
|
116
|
+
defer: ['Şimdi kaydetme', 'Seçimleri uygulama; bu deneme ertelenmiş olarak kalır.'],
|
|
117
|
+
approve: [
|
|
118
|
+
'Bu ayarları kaydet',
|
|
119
|
+
'Gösterilen beş tercihi birlikte kaydet; mevcut işleri taşıma.',
|
|
120
|
+
],
|
|
121
|
+
review: {
|
|
122
|
+
message: 'Bu akış ayarlarını birlikte kaydedelim mi?',
|
|
123
|
+
context: 'Beş kolon ve davranış aşağıda. Taslak PR ve yeni işin başlangıç kolonu değişmez. Bu işlem yalnızca ayarları kaydeder; olay çalıştırmaz.',
|
|
124
|
+
example: 'Beklemeye alınan işler için Beklemede kolonunu seçtiysen proje bundan sonra bu tercihi saklar; önceki işler yerinde kalır.',
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
en: {
|
|
128
|
+
roles: {
|
|
129
|
+
work_started: {
|
|
130
|
+
title: 'Development',
|
|
131
|
+
message: 'Which stage should represent starting development?',
|
|
132
|
+
context: 'This is separate from the stage where new work is created.',
|
|
133
|
+
example: 'Choose the stage used when you begin fixing a payment bug.',
|
|
134
|
+
},
|
|
135
|
+
pr_opened: {
|
|
136
|
+
title: 'PR ready for review',
|
|
137
|
+
message: 'Which stage should represent a PR ready for review?',
|
|
138
|
+
context: 'Draft PR behavior stays unchanged; this configures the ready-for-review step.',
|
|
139
|
+
example: 'Use this stage when your completed change is ready for a teammate to review.',
|
|
140
|
+
},
|
|
141
|
+
all_prs_merged: {
|
|
142
|
+
title: 'All PRs merged',
|
|
143
|
+
message: 'Which stage should represent all PRs being merged?',
|
|
144
|
+
context: 'This configures the step after every PR belonging to the work has merged.',
|
|
145
|
+
example: 'Both the web change and its backend change have been merged.',
|
|
146
|
+
},
|
|
147
|
+
test_failed: {
|
|
148
|
+
title: 'Failed test',
|
|
149
|
+
message: 'Which stage should represent a failed test?',
|
|
150
|
+
context: 'Choose the stage for work that needs changes after a failed test.',
|
|
151
|
+
example: 'A payment test found a problem and the work needs a correction.',
|
|
152
|
+
},
|
|
153
|
+
blocked_declared: {
|
|
154
|
+
title: 'Parked work',
|
|
155
|
+
message: 'Which stage should hold parked work?',
|
|
156
|
+
context: 'Choose the active parked stage; existing work stays where it is.',
|
|
157
|
+
example: 'You put a task on hold while waiting for an external service.',
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
modes: { automatic: 'Automatic', confirm: 'Ask for approval', ignore: 'Ignore this event' },
|
|
161
|
+
page: 'Page',
|
|
162
|
+
next: ['Next stages', 'Show the next stages for this same decision.'],
|
|
163
|
+
previous: ['Previous stages', 'Return to the previous stages for this same decision.'],
|
|
164
|
+
defer: ['Do not save now', 'Apply nothing; this attempt remains deferred.'],
|
|
165
|
+
approve: [
|
|
166
|
+
'Save these settings',
|
|
167
|
+
'Save the five displayed choices together without moving existing work.',
|
|
168
|
+
],
|
|
169
|
+
review: {
|
|
170
|
+
message: 'Save these workflow settings together?',
|
|
171
|
+
context: 'The five stages and behaviors appear below. Draft PR and new-work initial settings stay unchanged. This saves configuration only; no event is executed.',
|
|
172
|
+
example: 'Choosing On Hold for parked work stores that preference for the project; earlier tasks remain in place.',
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
function stageQuestion(input, text, language, stages, recommendation, setupId, selected, page, visit) {
|
|
177
|
+
const recommended = stages.find((item) => item.id === recommendation.statusId);
|
|
178
|
+
const others = stages.filter((item) => item.id !== recommendation.statusId);
|
|
179
|
+
const visible = [recommended, ...others.slice(page * 8, page * 8 + 8)];
|
|
180
|
+
const count = Math.max(1, Math.ceil(others.length / 8));
|
|
181
|
+
const question = text.roles[recommendation.event];
|
|
182
|
+
const options = visible.map((item) => ({
|
|
183
|
+
id: item.id,
|
|
184
|
+
label: item.name + ' (' + item.slug + ')',
|
|
185
|
+
description: [
|
|
186
|
+
text.modes[recommendation.mode],
|
|
187
|
+
item.id === recommendation.statusId ? recommendation.reason : '',
|
|
188
|
+
item.meaning,
|
|
189
|
+
]
|
|
190
|
+
.filter(Boolean)
|
|
191
|
+
.join(' · ')
|
|
192
|
+
.slice(0, 400),
|
|
193
|
+
}));
|
|
194
|
+
if (page > 0)
|
|
195
|
+
options.push({ id: 'previous', label: text.previous[0], description: text.previous[1] });
|
|
196
|
+
if (page + 1 < count)
|
|
197
|
+
options.push({ id: 'next', label: text.next[0], description: text.next[1] });
|
|
198
|
+
options.push({ id: 'defer', label: text.defer[0], description: text.defer[1] });
|
|
199
|
+
return {
|
|
200
|
+
questionnaireId: 'workflow-role-' +
|
|
201
|
+
sha256(stableStringify({ setupId, selected, event: recommendation.event, page, visit })),
|
|
202
|
+
language,
|
|
203
|
+
impact: 'critical',
|
|
204
|
+
message: question.message,
|
|
205
|
+
context: question.context + ' ' + text.page + ' ' + (page + 1) + '/' + count + '.',
|
|
206
|
+
example: question.example,
|
|
207
|
+
options,
|
|
208
|
+
binding: {
|
|
209
|
+
setupId,
|
|
210
|
+
projectId: input.projectId,
|
|
211
|
+
expectedSnapshot: input.expectedSnapshot,
|
|
212
|
+
event: recommendation.event,
|
|
213
|
+
mode: recommendation.mode,
|
|
214
|
+
page,
|
|
215
|
+
visit,
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
export function registerWorkflowTools(server, service) {
|
|
220
|
+
server.registerTool('work_item.setup_workflow', {
|
|
221
|
+
description: 'Review five project workflow roles in durable native forms and save them together. First read work_item.workflow and supply its exact snapshot plus five deliberate recommendations, explicit modes and reasons in the user language. The current task must be resumed in this repository. All active stages are reachable through pages; existing work and draft PR settings stay unchanged. Questions are critical and follow the task mode; feedback is not consent. Retry identical input to resume. After deferral or a mode-invalidated agent answer, use a new decisionAttempt only when reconsideration is authorized. Use copy for languages other than Turkish/English; translate every field. Never silently replace a stale snapshot. This does not fire events.',
|
|
222
|
+
inputSchema: setupInput,
|
|
223
|
+
}, async (input, context) => setupWorkflow(server, service, input, context));
|
|
224
|
+
}
|
|
225
|
+
async function setupWorkflow(server, service, input, context) {
|
|
226
|
+
const scope = await service.workItemSetupContext(input);
|
|
227
|
+
if (!scope.ok)
|
|
228
|
+
return output(scope);
|
|
229
|
+
const { repoRoot: _repoRoot, ...retryArguments } = input;
|
|
230
|
+
try {
|
|
231
|
+
assertSafeToPersist(retryArguments, '', '', true);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return refusal('Remove sensitive content from the workflow question and its retry arguments before continuing.', 'work_item.setup_workflow');
|
|
235
|
+
}
|
|
236
|
+
const language = input.language ?? (await service.language());
|
|
237
|
+
if (!language)
|
|
238
|
+
return refusal('Read session.entry with the conversation language first.', 'session.entry');
|
|
239
|
+
const baseLanguage = language.split('-')[0];
|
|
240
|
+
const text = input.copy ?? (baseLanguage === 'tr' || baseLanguage === 'en' ? copy[baseLanguage] : undefined);
|
|
241
|
+
if (!text)
|
|
242
|
+
return refusal('Supply every copy field in the conversation language, preserving this snapshot and requestKey.', 'work_item.setup_workflow');
|
|
243
|
+
const stages = [];
|
|
244
|
+
let total;
|
|
245
|
+
for (let offset = 0; total === undefined || offset < total; offset += 100) {
|
|
246
|
+
const response = await service.workItemWorkflow({
|
|
247
|
+
projectId: input.projectId,
|
|
248
|
+
snapshot: input.expectedSnapshot,
|
|
249
|
+
limit: 100,
|
|
250
|
+
offset,
|
|
251
|
+
});
|
|
252
|
+
if (!response.ok)
|
|
253
|
+
return output(response);
|
|
254
|
+
const parsed = workflowPage.safeParse(response.data);
|
|
255
|
+
if (!parsed.success)
|
|
256
|
+
return refusal('The workflow response is incomplete. Reload its contract before opening choices.', 'work_item.workflow');
|
|
257
|
+
const page = parsed.data;
|
|
258
|
+
if (page.snapshot !== input.expectedSnapshot ||
|
|
259
|
+
page.offset !== offset ||
|
|
260
|
+
page.limit !== 100 ||
|
|
261
|
+
(total !== undefined && page.total !== total) ||
|
|
262
|
+
page.items.some((item) => item.archivedAt !== null) ||
|
|
263
|
+
page.items.length !== Math.min(100, Math.max(0, page.total - offset)))
|
|
264
|
+
return refusal('The workflow pages changed or were incomplete. Reload every page before reviewing choices.', 'work_item.workflow');
|
|
265
|
+
total = page.total;
|
|
266
|
+
stages.push(...page.items);
|
|
267
|
+
}
|
|
268
|
+
if (new Set(stages.map((item) => item.id)).size !== stages.length ||
|
|
269
|
+
input.recommendations.some((entry) => !stages.some((item) => item.id === entry.statusId)))
|
|
270
|
+
return refusal('Each recommendation must name an active stage from this complete project workflow.', 'work_item.workflow');
|
|
271
|
+
const setupId = sha256(stableStringify({
|
|
272
|
+
namespace: scope.data.namespace,
|
|
273
|
+
projectId: input.projectId,
|
|
274
|
+
externalTaskId: input.externalTaskId,
|
|
275
|
+
requestKey: input.requestKey,
|
|
276
|
+
snapshot: input.expectedSnapshot,
|
|
277
|
+
recommendations: input.recommendations,
|
|
278
|
+
decisionAttempt: input.decisionAttempt ?? 0,
|
|
279
|
+
language,
|
|
280
|
+
copy: text,
|
|
281
|
+
}));
|
|
282
|
+
const owner = {
|
|
283
|
+
tool: 'work_item.setup_workflow',
|
|
284
|
+
externalTaskId: input.externalTaskId,
|
|
285
|
+
decisionAttempt: input.decisionAttempt,
|
|
286
|
+
retryArguments,
|
|
287
|
+
};
|
|
288
|
+
const selected = [];
|
|
289
|
+
const selectionQuestionnaireIds = [];
|
|
290
|
+
for (const event of role.options) {
|
|
291
|
+
const recommendation = input.recommendations.find((entry) => entry.event === event);
|
|
292
|
+
let page = 0;
|
|
293
|
+
let visit = 0;
|
|
294
|
+
for (;;) {
|
|
295
|
+
if (context.mcpReq.signal.aborted)
|
|
296
|
+
return output({ ok: true, data: { status: 'pending', reason: 'interrupted' } });
|
|
297
|
+
const question = stageQuestion(input, text, language, stages, recommendation, setupId, selected, page, visit);
|
|
298
|
+
const answer = await askQuestionnaire(server, service, { ...question, repoRoot: input.repoRoot, presentation: input.presentation }, context, [], owner);
|
|
299
|
+
const choice = answerChoice(answer);
|
|
300
|
+
if (!choice)
|
|
301
|
+
return answer;
|
|
302
|
+
if (choice === 'defer')
|
|
303
|
+
return deferred(input);
|
|
304
|
+
if (choice === 'next' || choice === 'previous') {
|
|
305
|
+
page += choice === 'next' ? 1 : -1;
|
|
306
|
+
visit++;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (!question.options.some((option) => option.id === choice) ||
|
|
310
|
+
!stages.some((item) => item.id === choice))
|
|
311
|
+
return refusal('The selected stage does not belong to this exact question.', 'questionnaire.resume');
|
|
312
|
+
selected.push({ event, toStatusId: choice, mode: recommendation.mode });
|
|
313
|
+
selectionQuestionnaireIds.push(question.questionnaireId);
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const proposal = {
|
|
318
|
+
projectId: input.projectId,
|
|
319
|
+
expectedSnapshot: input.expectedSnapshot,
|
|
320
|
+
rules: selected,
|
|
321
|
+
};
|
|
322
|
+
const proposalDigest = sha256(stableStringify(proposal));
|
|
323
|
+
const questionnaireId = 'workflow-apply-' +
|
|
324
|
+
sha256(stableStringify({ setupId, proposalDigest, selectionQuestionnaireIds }));
|
|
325
|
+
const review = {
|
|
326
|
+
questionnaireId,
|
|
327
|
+
language,
|
|
328
|
+
impact: 'critical',
|
|
329
|
+
context: text.review.context,
|
|
330
|
+
message: text.review.message +
|
|
331
|
+
'\n' +
|
|
332
|
+
selected
|
|
333
|
+
.map((item) => {
|
|
334
|
+
const target = stages.find((entry) => entry.id === item.toStatusId);
|
|
335
|
+
return (text.roles[item.event].title +
|
|
336
|
+
' ' +
|
|
337
|
+
target.name +
|
|
338
|
+
' (' +
|
|
339
|
+
target.slug +
|
|
340
|
+
') · ' +
|
|
341
|
+
text.modes[item.mode]);
|
|
342
|
+
})
|
|
343
|
+
.join('\n'),
|
|
344
|
+
example: text.review.example,
|
|
345
|
+
options: [
|
|
346
|
+
{ id: 'approve', label: text.approve[0], description: text.approve[1] },
|
|
347
|
+
{ id: 'defer', label: text.defer[0], description: text.defer[1] },
|
|
348
|
+
],
|
|
349
|
+
binding: {
|
|
350
|
+
setupId,
|
|
351
|
+
proposalDigest,
|
|
352
|
+
selectionQuestionnaireIds,
|
|
353
|
+
namespace: scope.data.namespace,
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
const approval = await askQuestionnaire(server, service, { ...review, repoRoot: input.repoRoot, presentation: input.presentation }, context, [], owner);
|
|
357
|
+
if (answerChoice(approval) === 'defer')
|
|
358
|
+
return deferred(input);
|
|
359
|
+
if (answerChoice(approval) !== 'approve')
|
|
360
|
+
return approval;
|
|
361
|
+
return output(await service.workItemApplyWorkflowSetup({
|
|
362
|
+
...proposal,
|
|
363
|
+
repoRoot: input.repoRoot,
|
|
364
|
+
externalTaskId: input.externalTaskId,
|
|
365
|
+
questionnaireId,
|
|
366
|
+
selectionQuestionnaireIds,
|
|
367
|
+
}));
|
|
368
|
+
}
|
|
369
|
+
function deferred(input) {
|
|
370
|
+
return output({
|
|
371
|
+
ok: true,
|
|
372
|
+
data: {
|
|
373
|
+
status: 'deferred',
|
|
374
|
+
applied: false,
|
|
375
|
+
...(input.decisionAttempt === 10000
|
|
376
|
+
? {}
|
|
377
|
+
: {
|
|
378
|
+
reconsider: {
|
|
379
|
+
tool: 'work_item.setup_workflow',
|
|
380
|
+
arguments: { ...input, decisionAttempt: (input.decisionAttempt ?? 0) + 1 },
|
|
381
|
+
},
|
|
382
|
+
}),
|
|
383
|
+
nextAction: input.decisionAttempt === 10000
|
|
384
|
+
? 'This attempt remains deferred. Use a new requestKey only after reconsideration is explicitly authorized.'
|
|
385
|
+
: 'No workflow settings were changed. Identical retries retain this deferral. Reconsider only after the user or current delegated decision explicitly resumes it.',
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
function refusal(message, recovery) {
|
|
390
|
+
return output({
|
|
391
|
+
ok: false,
|
|
392
|
+
error: { kind: 'workflow_setup', message, recovery, retryable: false },
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
function output(result) {
|
|
396
|
+
return {
|
|
397
|
+
content: [{ type: 'text', text: JSON.stringify(result) }],
|
|
398
|
+
isError: !result.ok,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
//# sourceMappingURL=workflow-tools.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ensureDecisionMode } from './decision-tools.js';
|
|
1
2
|
import { basename } from 'node:path';
|
|
2
3
|
import * as z from 'zod/v4';
|
|
3
4
|
import { cancelledDeliveryQuestionId, forgetUnreadableQuestionId, retireQuestionId, } from '../runtime/bridge-service.js';
|
|
@@ -227,6 +228,23 @@ const baseSchema = z.discriminatedUnion('kind', [
|
|
|
227
228
|
z.strictObject({ kind: z.literal('local'), ref: branch }),
|
|
228
229
|
]);
|
|
229
230
|
export function registerWorktreeTools(server, service) {
|
|
231
|
+
server.registerTool('worktree.prepare_files', {
|
|
232
|
+
description: "Inspect the current managed task worktree's paged Git-ignored file candidates without reading private values. After inspecting tracked runtime/build references, submit necessary relative paths and the exact expectedInventory to prepare them through normal same-clone, generation, staging and no-overwrite protections. An empty list records that no additional ignored files are needed. This is local file preparation, not a request to publish secrets. Resolve runtimeReview.required before claiming the worktree is ready.",
|
|
233
|
+
inputSchema: z
|
|
234
|
+
.strictObject({
|
|
235
|
+
repoRoot: repo,
|
|
236
|
+
externalTaskId: z.string().min(1).max(160),
|
|
237
|
+
generation: z.string().uuid(),
|
|
238
|
+
paths: z.array(z.string().min(1).max(500)).max(100).optional(),
|
|
239
|
+
expectedInventory: z
|
|
240
|
+
.string()
|
|
241
|
+
.regex(/^[a-f0-9]{64}$/)
|
|
242
|
+
.optional(),
|
|
243
|
+
offset: z.number().int().min(0).optional(),
|
|
244
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
245
|
+
})
|
|
246
|
+
.refine((input) => input.paths === undefined || input.expectedInventory !== undefined, 'A file selection requires expectedInventory.'),
|
|
247
|
+
}, async (input) => output(await service.worktreePrepareFiles(input)));
|
|
230
248
|
server.registerTool('project.git_preferences', {
|
|
231
249
|
description: 'Read the project development/production/test branch preferences and which questions remain unanswered. Only canManage actors may change shared choices; other members may select a task-specific base.',
|
|
232
250
|
inputSchema: z.strictObject({
|
|
@@ -358,7 +376,7 @@ export function registerWorktreeTools(server, service) {
|
|
|
358
376
|
inputSchema: z.strictObject(policy),
|
|
359
377
|
}, async (input) => output(await service.worktreePolicy(json(input))));
|
|
360
378
|
server.registerTool('task.branch', {
|
|
361
|
-
description: 'Start a write task
|
|
379
|
+
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.',
|
|
362
380
|
inputSchema: z.strictObject({
|
|
363
381
|
repoRoot: repo,
|
|
364
382
|
externalTaskId: z.string().min(2).max(160),
|
|
@@ -377,6 +395,9 @@ export function registerWorktreeTools(server, service) {
|
|
|
377
395
|
const flight = preflight.data;
|
|
378
396
|
if (flight.existingAllocation)
|
|
379
397
|
return output(await service.taskBranch(input));
|
|
398
|
+
const mode = await ensureDecisionMode(server, service, input, context);
|
|
399
|
+
if (mode)
|
|
400
|
+
return mode;
|
|
380
401
|
const role = input.base?.kind;
|
|
381
402
|
if ((role === 'development' || role === 'production' || role === 'test') &&
|
|
382
403
|
!configuredBase(flight.preferences, role))
|
|
@@ -21,6 +21,7 @@ export const backendRecoveryOperationNames = [
|
|
|
21
21
|
'project.member_add',
|
|
22
22
|
'work_item.list',
|
|
23
23
|
'work_item.statuses',
|
|
24
|
+
'work_item.workflow',
|
|
24
25
|
'work_item.get',
|
|
25
26
|
'session.bootstrap',
|
|
26
27
|
'session.resume',
|
|
@@ -91,7 +92,11 @@ export class ApiClient {
|
|
|
91
92
|
this.options = options;
|
|
92
93
|
this.fetchImplementation = options.fetchImplementation ?? fetch;
|
|
93
94
|
}
|
|
95
|
+
get namespace() {
|
|
96
|
+
return sha256(this.options.baseUrl.replace(/\/+$/, ''));
|
|
97
|
+
}
|
|
94
98
|
async request(path, request = {}) {
|
|
99
|
+
request.signal?.throwIfAborted();
|
|
95
100
|
const method = request.method ?? 'GET';
|
|
96
101
|
const cacheKey = request.cacheKey ?? this.cacheKey(method, path, request.body);
|
|
97
102
|
const cached = request.cacheKey ? await this.options.cache.get(cacheKey) : null;
|
|
@@ -123,7 +128,9 @@ export class ApiClient {
|
|
|
123
128
|
method,
|
|
124
129
|
headers,
|
|
125
130
|
...(request.body === undefined ? {} : { body: JSON.stringify(request.body) }),
|
|
126
|
-
signal:
|
|
131
|
+
signal: request.signal
|
|
132
|
+
? AbortSignal.any([controller.signal, request.signal])
|
|
133
|
+
: controller.signal,
|
|
127
134
|
});
|
|
128
135
|
}
|
|
129
136
|
catch (error) {
|
|
@@ -148,7 +155,7 @@ export class ApiClient {
|
|
|
148
155
|
}
|
|
149
156
|
let envelope;
|
|
150
157
|
try {
|
|
151
|
-
envelope = await this.parseEnvelope(response);
|
|
158
|
+
envelope = await this.parseEnvelope(response, request.maxResponseBytes);
|
|
152
159
|
}
|
|
153
160
|
catch (error) {
|
|
154
161
|
if (request.allowStaleOnUnavailable &&
|
|
@@ -249,10 +256,11 @@ export class ApiClient {
|
|
|
249
256
|
await this.options.credentials.set('refresh-token', envelope.data.refreshToken);
|
|
250
257
|
}
|
|
251
258
|
}
|
|
252
|
-
async parseEnvelope(response) {
|
|
259
|
+
async parseEnvelope(response, maxBytes) {
|
|
253
260
|
let value;
|
|
254
261
|
try {
|
|
255
|
-
value =
|
|
262
|
+
value =
|
|
263
|
+
maxBytes === undefined ? await response.json() : await this.boundedJson(response, maxBytes);
|
|
256
264
|
}
|
|
257
265
|
catch {
|
|
258
266
|
throw new ApiResponseError(`Backend returned a non-JSON response with status ${response.status}`, response.status, null, response.status >= 500, null, null);
|
|
@@ -262,6 +270,29 @@ export class ApiClient {
|
|
|
262
270
|
}
|
|
263
271
|
return value;
|
|
264
272
|
}
|
|
273
|
+
async boundedJson(response, maxBytes) {
|
|
274
|
+
const reader = response.body?.getReader();
|
|
275
|
+
if (!reader)
|
|
276
|
+
throw new Error('Empty response');
|
|
277
|
+
const chunks = [];
|
|
278
|
+
let size = 0;
|
|
279
|
+
try {
|
|
280
|
+
while (true) {
|
|
281
|
+
const part = await reader.read();
|
|
282
|
+
if (part.done)
|
|
283
|
+
break;
|
|
284
|
+
size += part.value.byteLength;
|
|
285
|
+
if (size > maxBytes)
|
|
286
|
+
throw new Error('Response exceeds the byte limit');
|
|
287
|
+
chunks.push(part.value);
|
|
288
|
+
}
|
|
289
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
await reader.cancel().catch(() => undefined);
|
|
293
|
+
reader.releaseLock();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
265
296
|
cacheKey(method, path, body) {
|
|
266
297
|
return sha256(`${method}\n${path}\n${stableStringify(body ?? null)}`);
|
|
267
298
|
}
|