engineering-memory 1.11.9 → 1.11.10

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.
Files changed (34) hide show
  1. package/dispatcher/sections.mjs +6 -2
  2. package/install/codex-approval.mjs +457 -0
  3. package/install/files.mjs +5 -1
  4. package/install/installer.mjs +24 -1
  5. package/package.json +1 -1
  6. package/runtime/build.json +1 -1
  7. package/runtime/dist/src/config.js +1 -0
  8. package/runtime/dist/src/git/git-inspector.js +82 -3
  9. package/runtime/dist/src/git/verification-gate.js +30 -17
  10. package/runtime/dist/src/mcp/onboarding-tools.js +3 -16
  11. package/runtime/dist/src/mcp/questionnaire-tools.js +99 -15
  12. package/runtime/dist/src/mcp/tool-annotations.js +111 -0
  13. package/runtime/dist/src/mcp/tool-definitions.js +148 -28
  14. package/runtime/dist/src/mcp/worktree-tools.js +323 -236
  15. package/runtime/dist/src/project/repository.js +11 -5
  16. package/runtime/dist/src/runtime/active-context-store.js +3 -0
  17. package/runtime/dist/src/runtime/branch-preferences.js +0 -19
  18. package/runtime/dist/src/runtime/bridge-service.js +1131 -220
  19. package/runtime/dist/src/runtime/offline-outbox.js +33 -158
  20. package/runtime/dist/src/runtime/phase-timer.js +26 -0
  21. package/runtime/dist/src/runtime/privacy-detector.js +263 -0
  22. package/runtime/dist/src/runtime/questionnaire-store.js +210 -49
  23. package/runtime/dist/src/runtime/recovery-error.js +3 -1
  24. package/runtime/dist/src/runtime/runtime-entry.js +8 -0
  25. package/runtime/dist/src/runtime/runtime-host.js +1 -3
  26. package/runtime/dist/src/runtime/task-branch-store.js +17 -1
  27. package/runtime/dist/src/runtime/task-start.js +281 -0
  28. package/runtime/dist/src/runtime/worktree-pool.js +334 -24
  29. package/runtime/dist/src/utilities/process.js +1 -0
  30. package/skill/SKILL.md +3 -3
  31. package/skill/references/lifecycle.md +44 -19
  32. package/skill/references/memory-updates.md +9 -3
  33. package/skill/references/project-onboarding.md +19 -2
  34. package/skill/references/questionnaires.md +77 -15
@@ -1,10 +1,37 @@
1
+ import { basename } from 'node:path';
1
2
  import * as z from 'zod/v4';
2
- import { branchChoices } from '../runtime/branch-preferences.js';
3
+ import { cancelledDeliveryQuestionId, } from '../runtime/bridge-service.js';
4
+ import { configuredBase, sourceLabel, taskStartChoice, taskStartDefinition, } from '../runtime/task-start.js';
3
5
  import { sha256, stableStringify } from '../utilities/hash.js';
4
6
  import { askQuestionnaire } from './questionnaire-tools.js';
5
7
  const repo = z.string().min(1);
6
8
  const branch = z.string().min(1).max(255);
7
9
  const remote = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/);
10
+ const presentation = z.enum(['host_native']).optional();
11
+ function deferredStart(input, questionnaireId) {
12
+ return {
13
+ ok: true,
14
+ data: json({
15
+ status: 'deferred',
16
+ allocated: false,
17
+ externalTaskId: input.externalTaskId,
18
+ questionnaireId,
19
+ reconsider: {
20
+ tool: 'task.branch',
21
+ arguments: {
22
+ repoRoot: input.repoRoot,
23
+ externalTaskId: input.externalTaskId,
24
+ decisionAttempt: (input.decisionAttempt ?? 0) + 1,
25
+ language: input.language,
26
+ name: input.name,
27
+ base: input.base,
28
+ keepCurrent: input.keepCurrent,
29
+ },
30
+ },
31
+ 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.`,
32
+ }),
33
+ };
34
+ }
8
35
  const output = (r) => ({
9
36
  content: [{ type: 'text', text: JSON.stringify(r) }],
10
37
  ...(!r.ok ? { isError: true } : {}),
@@ -42,7 +69,7 @@ export function registerWorktreeTools(server, service) {
42
69
  }),
43
70
  }, async (input) => output(await service.projectGitPreferences(input)));
44
71
  server.registerTool('project.set_git_preferences', {
45
- description: 'Confirm each supplied shared branch choice with a separate durable native questionnaire, then save against expectedVersion. Explicitly absent production/test branches stay answered. Reconsider a deferred choice with a new decisionAttempt; retries keep the same attempt. Requires project owner/maintainer or organization administrator. Branches are creation preferences, never memory environment labels.',
72
+ description: 'Confirm every supplied shared branch choice in one durable native questionnaire (one question per role), then save the approved roles against expectedVersion in a single PUT. A role answered "leave unchanged" stays as it was and unanswered. Reconsider with a new decisionAttempt; retries keep the same attempt. Requires project owner/maintainer or organization administrator. Branches are creation preferences, never memory environment labels.',
46
73
  inputSchema: z.strictObject({
47
74
  repoRoot: repo,
48
75
  projectId: z.string().uuid(),
@@ -52,6 +79,7 @@ export function registerWorktreeTools(server, service) {
52
79
  production: preference.optional(),
53
80
  test: preference.optional(),
54
81
  language: z.enum(['tr', 'en']).default('en'),
82
+ presentation,
55
83
  }),
56
84
  }, async (input, context) => {
57
85
  const current = await service.projectGitPreferences(input);
@@ -68,33 +96,41 @@ export function registerWorktreeTools(server, service) {
68
96
  recovery: 'task.branch',
69
97
  },
70
98
  });
71
- const changes = { expectedVersion: input.expectedVersion };
72
- for (const role of ['development', 'production', 'test']) {
99
+ const roles = ['development', 'production', 'test'].filter((role) => Boolean(input[role]));
100
+ if (roles.length === 0)
101
+ return output({ ok: true, data: { unchanged: true } });
102
+ const roleLabel = (role) => {
73
103
  const value = input[role];
74
- if (!value)
75
- continue;
76
- const digest = sha256(stableStringify({
77
- projectId: input.projectId,
78
- version: input.expectedVersion,
79
- role,
80
- value,
81
- ...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
82
- }));
83
- const question = {
84
- repoRoot: input.repoRoot,
85
- questionnaireId: 'project-branch-' + digest,
86
- message: input.language === 'tr'
87
- ? role +
88
- ' dal tercihi: ' +
89
- (value.branch ? value.remote + '/' + value.branch : 'henüz yok') +
90
- '. Ortak proje ayarını kaydedelim mi?'
91
- : 'Save the shared ' +
92
- role +
93
- ' branch preference: ' +
94
- (value.branch ? value.remote + '/' + value.branch : 'none yet') +
95
- '?',
96
- language: input.language,
97
- allowFreeText: false,
104
+ return input.language === 'tr'
105
+ ? role +
106
+ ' dal tercihi: ' +
107
+ (value.branch ? value.remote + '/' + value.branch : 'henüz yok') +
108
+ '. Ortak proje ayarını kaydedelim mi?'
109
+ : 'Save the shared ' +
110
+ role +
111
+ ' branch preference: ' +
112
+ (value.branch ? value.remote + '/' + value.branch : 'none yet') +
113
+ '?';
114
+ };
115
+ const digest = sha256(stableStringify({
116
+ projectId: input.projectId,
117
+ version: input.expectedVersion,
118
+ roles,
119
+ values: roles.map((role) => input[role]),
120
+ ...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
121
+ }));
122
+ const questionnaireId = 'project-branches-' + digest;
123
+ const definition = {
124
+ repoRoot: input.repoRoot,
125
+ questionnaireId,
126
+ presentation: input.presentation,
127
+ language: input.language,
128
+ message: input.language === 'tr'
129
+ ? 'Ortak dal tercihlerini kaydedelim mi? Hiçbir şey kaydedilmeden önce onayını bekliyor.'
130
+ : 'Save the shared branch preferences? Nothing is saved until you answer.',
131
+ questions: roles.map((role) => ({
132
+ id: role,
133
+ message: roleLabel(role),
98
134
  options: [
99
135
  {
100
136
  id: 'approve',
@@ -105,16 +141,31 @@ export function registerWorktreeTools(server, service) {
105
141
  label: input.language === 'tr' ? 'Şimdilik değiştirme' : 'Leave unchanged',
106
142
  },
107
143
  ],
108
- };
109
- const form = await askQuestionnaire(server, service, question, context);
110
- const record = await service.questionnaireResume({
111
- repoRoot: input.repoRoot,
112
- questionnaireId: question.questionnaireId,
113
- });
114
- if (record.status !== 'answered' || record.answer?.choice !== 'approve')
115
- return form;
116
- changes[role] = json(value);
117
- }
144
+ })),
145
+ };
146
+ const owner = {
147
+ tool: 'project.set_git_preferences',
148
+ ...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
149
+ retryArguments: json({
150
+ projectId: input.projectId,
151
+ expectedVersion: input.expectedVersion,
152
+ development: input.development,
153
+ production: input.production,
154
+ test: input.test,
155
+ language: input.language,
156
+ }),
157
+ };
158
+ const form = await askQuestionnaire(server, service, definition, context, [], owner);
159
+ const record = await service.questionnaireResume({
160
+ repoRoot: input.repoRoot,
161
+ questionnaireId,
162
+ });
163
+ if (record.status !== 'answered')
164
+ return form;
165
+ const changes = { expectedVersion: input.expectedVersion };
166
+ for (const role of roles)
167
+ if (record.answers?.[role]?.choice === 'approve')
168
+ changes[role] = json(input[role]);
118
169
  if (Object.keys(changes).length === 1)
119
170
  return output({ ok: true, data: { unchanged: true } });
120
171
  return output(await service.setProjectGitPreferences(input.projectId, changes));
@@ -128,7 +179,7 @@ export function registerWorktreeTools(server, service) {
128
179
  inputSchema: z.strictObject(policy),
129
180
  }, async (input) => output(await service.worktreePolicy(json(input))));
130
181
  server.registerTool('task.branch', {
131
- description: 'Choose the task base with durable native forms and allocate a separate managed worktree for every new branch. Configured remote branches are fetched and the exact commit is pinned. Retry/resume preserves the allocation. For an explicitly reconsidered deferred answer supply a new decisionAttempt; retries keep the same attempt. Use the returned repoRoot for ALL commands; keepCurrent is allowed only in a free checkout. A read-only task allocates only when transitioning to write.',
182
+ description: 'Start a write task with ONE durable native form: where the branch starts (unless base is given), the new branch name with a suggested default (unless name is given), and the folder: 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 the user answers; 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.',
132
183
  inputSchema: z.strictObject({
133
184
  repoRoot: repo,
134
185
  externalTaskId: z.string().min(2).max(160),
@@ -137,177 +188,129 @@ export function registerWorktreeTools(server, service) {
137
188
  base: baseSchema.optional(),
138
189
  keepCurrent: z.boolean().optional(),
139
190
  language: z.enum(['tr', 'en']).default('en'),
191
+ presentation,
140
192
  }),
141
193
  }, async (input, context) => {
142
- const existing = await service.worktreeTaskAllocation(input);
143
- if (existing.ok && existing.data.allocation)
194
+ const preflight = await service.taskBranchPreflight(input);
195
+ if (!preflight.ok)
196
+ return output(preflight);
197
+ const flight = preflight.data;
198
+ if (flight.existingAllocation)
144
199
  return output(await service.taskBranch(input));
145
- if (!existing.ok)
146
- return output(existing);
147
- const current = await service.projectGitPreferences(input);
148
- if (!current.ok)
149
- return output(current);
150
- const prefs = current.data;
151
- let base = input.base;
152
- let keepCurrent = input.keepCurrent === true;
153
- let name = input.name;
154
- const taskKey = sha256(input.externalTaskId + (input.decisionAttempt ? ':' + input.decisionAttempt : ''));
155
- if (!base && !keepCurrent) {
156
- const definition = {
157
- repoRoot: input.repoRoot,
158
- questionnaireId: 'task-base-select-' + taskKey + '-' + prefs.version,
159
- message: input.language === 'tr'
160
- ? 'Bu görev için başlangıç dalını seç.'
161
- : 'Choose the starting branch for this task.',
162
- language: input.language,
163
- allowFreeText: false,
164
- options: branchChoices(prefs, prefs.currentBranch),
165
- };
166
- const form = await askQuestionnaire(server, service, definition, context);
167
- const record = await service.questionnaireResume({
168
- repoRoot: input.repoRoot,
169
- questionnaireId: definition.questionnaireId,
200
+ const role = input.base?.kind;
201
+ if ((role === 'development' || role === 'production' || role === 'test') &&
202
+ !configuredBase(flight.preferences, role))
203
+ return output({
204
+ ok: false,
205
+ error: {
206
+ kind: 'bridge_error',
207
+ retryable: false,
208
+ message: 'Choose a configured or task-specific base branch.',
209
+ recovery: 'task.branch',
210
+ },
170
211
  });
171
- if (record.status !== 'answered')
172
- return form;
173
- const choice = record.answer?.choice;
174
- if (choice === 'keep') {
175
- keepCurrent = true;
176
- base = { kind: 'current' };
177
- }
178
- else if (choice === 'other') {
179
- const next = await askText(server, service, input, context, 'other-base', input.language === 'tr'
180
- ? 'Başlangıç için uzak dalın adını seç. origin remote kullanılacak.'
181
- : 'Choose the remote base branch name. The origin remote will be used.');
182
- if (!next.value)
183
- return next.form;
184
- base = { kind: 'remote', remote: 'origin', branch: next.value };
185
- }
186
- else if (choice === 'current' ||
187
- choice === 'development' ||
188
- choice === 'production' ||
189
- choice === 'test')
190
- base = { kind: choice };
191
- else
192
- return form;
193
- }
194
- if (keepCurrent) {
195
- base = { kind: 'current' };
196
- name = undefined;
197
- }
198
- base ??= { kind: 'current' };
199
- if (base.kind === 'current')
200
- base = { kind: 'current', commit: base.commit ?? prefs.currentCommit ?? undefined };
201
- if (base.kind === 'development' || base.kind === 'production' || base.kind === 'test') {
202
- const selected = prefs.branches[base.kind];
203
- if (!selected.answered || !selected.remote || !selected.branch)
204
- return output({
205
- ok: false,
206
- error: {
207
- kind: 'bridge_error',
208
- retryable: false,
209
- message: 'Choose a configured or task-specific base branch.',
210
- recovery: 'task.branch',
211
- },
212
- });
213
- base = { kind: 'remote', remote: selected.remote, branch: selected.branch };
214
- }
215
- if (!keepCurrent && !name) {
216
- const next = await askText(server, service, input, context, 'branch-name', input.language === 'tr'
217
- ? 'Yeni görev branch’inin adı ne olsun?'
218
- : 'Name the new task branch.');
219
- if (!next.value)
220
- return next.form;
221
- name = next.value;
222
- }
223
- const signature = sha256(stableStringify({
212
+ const definition = taskStartDefinition({ ...input, ...flight });
213
+ const owner = {
214
+ tool: 'task.branch',
224
215
  externalTaskId: input.externalTaskId,
225
- base,
226
- name: name ?? null,
227
- keepCurrent,
228
216
  ...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
229
- }));
230
- const sourceLabel = base.kind === 'remote'
231
- ? base.remote + '/' + base.branch
232
- : base.kind === 'local'
233
- ? base.ref
234
- : base.kind === 'current'
235
- ? (prefs.currentBranch ?? 'HEAD')
236
- : prefs.branches[base.kind].remote + '/' + prefs.branches[base.kind].branch;
237
- const definition = {
238
- repoRoot: input.repoRoot,
239
- questionnaireId: 'task-branch-confirm-' + signature,
240
- language: input.language,
241
- allowFreeText: false,
242
- message: input.language === 'tr'
243
- ? keepCurrent
244
- ? 'Bu klasörde mevcut branch üzerinde devam edilsin mi?'
245
- : sourceLabel + ' kaynağından ' + name + ' branch’i ayrı worktree’de açılsın mı?'
246
- : keepCurrent
247
- ? 'Continue in this checkout on its current branch?'
248
- : 'Create ' + name + ' from ' + sourceLabel + ' in a separate worktree?',
249
- options: [
250
- { id: 'approve', label: input.language === 'tr' ? 'Devam et' : 'Continue' },
251
- { id: 'defer', label: input.language === 'tr' ? 'Şimdilik başlatma' : 'Not now' },
252
- ],
217
+ retryArguments: json({
218
+ language: input.language,
219
+ name: input.name,
220
+ base: input.base,
221
+ keepCurrent: input.keepCurrent,
222
+ }),
253
223
  };
254
- const form = await askQuestionnaire(server, service, definition, context);
224
+ const form = await askQuestionnaire(server, service, { ...definition, repoRoot: input.repoRoot, presentation: input.presentation }, context, [], owner);
255
225
  const record = await service.questionnaireResume({
256
226
  repoRoot: input.repoRoot,
257
227
  questionnaireId: definition.questionnaireId,
258
228
  });
259
- if (record.status !== 'answered' || record.answer?.choice !== 'approve')
229
+ const choice = taskStartChoice(record);
230
+ if (!choice)
260
231
  return form;
261
- const fallbackBase = { kind: 'current', commit: prefs.currentCommit ?? undefined };
262
- const fallbackSignature = sha256(stableStringify({
232
+ if (choice.status === 'deferred')
233
+ return output(deferredStart(input, definition.questionnaireId));
234
+ const reconsider = (reason) => output({
235
+ ok: false,
236
+ error: {
237
+ kind: 'bridge_error',
238
+ retryable: false,
239
+ message: `${reason} Call task.branch with decisionAttempt ${(input.decisionAttempt ?? 0) + 1} to ask the start form again.`,
240
+ recovery: 'task.branch',
241
+ },
242
+ });
243
+ if (choice.name && choice.name !== input.name && choice.name !== flight.suggestedName) {
244
+ const unusable = await service.unusableBranchName(input.repoRoot, choice.name);
245
+ if (unusable)
246
+ return reconsider(unusable);
247
+ }
248
+ const request = {
263
249
  externalTaskId: input.externalTaskId,
264
- base: fallbackBase,
265
- name: name ?? null,
266
- keepCurrent,
250
+ name: choice.name ?? null,
251
+ keepCurrent: choice.keepCurrent,
267
252
  ...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
268
- }));
269
- const fallbackDefinition = {
270
- ...definition,
271
- questionnaireId: 'task-branch-fallback-' + fallbackSignature,
272
- message: input.language === 'tr'
273
- ? sourceLabel +
274
- ' uzak dalına ulaşılamadı. Yereldeki ' +
275
- (prefs.currentBranch ?? 'HEAD') +
276
- ' commit’i ' +
277
- (prefs.currentCommit?.slice(0, 12) ?? 'unborn HEAD') +
278
- ' üzerinden ayrı worktree ile devam edilsin mi?'
279
- : 'The remote ' +
280
- sourceLabel +
281
- ' is unavailable. Continue from local ' +
282
- (prefs.currentBranch ?? 'HEAD') +
283
- ' commit ' +
284
- (prefs.currentCommit?.slice(0, 12) ?? 'unborn HEAD') +
285
- ' in a separate worktree?',
286
253
  };
254
+ const allocate = (base, decisionId) => service.taskBranch({
255
+ repoRoot: input.repoRoot,
256
+ externalTaskId: input.externalTaskId,
257
+ name: choice.name ?? undefined,
258
+ keepCurrent: choice.keepCurrent,
259
+ inPlace: choice.inPlace,
260
+ base,
261
+ decisionId,
262
+ startId: definition.questionnaireId,
263
+ decisionAttempt: input.decisionAttempt,
264
+ });
265
+ const fallbackBase = {
266
+ kind: 'current',
267
+ ...(flight.currentCommit ? { commit: flight.currentCommit } : {}),
268
+ };
269
+ const fallbackId = 'task-branch-fallback-' +
270
+ sha256(stableStringify({ ...request, base: fallbackBase, inPlace: choice.inPlace }));
287
271
  const fallback = async () => {
288
- const form = await askQuestionnaire(server, service, fallbackDefinition, context);
289
- const record = await service.questionnaireResume({
272
+ const tr = input.language === 'tr';
273
+ const from = sourceLabel(choice.base, flight.currentBranch);
274
+ const local = sourceLabel(fallbackBase, flight.currentBranch);
275
+ const fallbackForm = await askQuestionnaire(server, service, {
290
276
  repoRoot: input.repoRoot,
291
- questionnaireId: fallbackDefinition.questionnaireId,
292
- });
293
- if (record.status !== 'answered' || record.answer?.choice !== 'approve')
294
- return form;
295
- return output(await service.taskBranch({
277
+ questionnaireId: fallbackId,
278
+ language: input.language,
279
+ presentation: input.presentation,
280
+ allowFreeText: false,
281
+ message: tr
282
+ ? from +
283
+ ' uzak dalına ulaşılamadı. Yereldeki ' +
284
+ local +
285
+ ' üzerinden ' +
286
+ (choice.inPlace ? 'bu klasörde' : 'ayrı worktree ile') +
287
+ ' devam edilsin mi?'
288
+ : 'The remote ' +
289
+ from +
290
+ ' is unavailable. Continue from local ' +
291
+ local +
292
+ (choice.inPlace ? ' in this folder?' : ' in a separate worktree?'),
293
+ options: [
294
+ { id: 'approve', label: tr ? 'Devam et' : 'Continue' },
295
+ { id: 'defer', label: tr ? 'Şimdilik başlatma' : 'Not now' },
296
+ ],
297
+ }, context, [], owner);
298
+ const answer = await service.questionnaireResume({
296
299
  repoRoot: input.repoRoot,
297
- externalTaskId: input.externalTaskId,
298
- name,
299
- keepCurrent,
300
- base: fallbackBase,
301
- decisionId: fallbackDefinition.questionnaireId,
302
- decisionAttempt: input.decisionAttempt,
303
- }));
300
+ questionnaireId: fallbackId,
301
+ });
302
+ if (answer.status === 'answered' && answer.answer?.choice === 'defer')
303
+ return output(deferredStart(input, fallbackId));
304
+ if (answer.status !== 'answered' || answer.answer?.choice !== 'approve')
305
+ return fallbackForm;
306
+ return output(await allocate(fallbackBase, fallbackId));
304
307
  };
305
- if (base.kind === 'remote') {
308
+ if (choice.base.kind === 'remote') {
306
309
  let recorded = false;
307
310
  try {
308
311
  await service.questionnaireResume({
309
312
  repoRoot: input.repoRoot,
310
- questionnaireId: fallbackDefinition.questionnaireId,
313
+ questionnaireId: fallbackId,
311
314
  });
312
315
  recorded = true;
313
316
  }
@@ -315,16 +318,8 @@ export function registerWorktreeTools(server, service) {
315
318
  if (recorded)
316
319
  return fallback();
317
320
  }
318
- const attempt = await service.taskBranch({
319
- repoRoot: input.repoRoot,
320
- externalTaskId: input.externalTaskId,
321
- name,
322
- keepCurrent,
323
- base,
324
- decisionId: definition.questionnaireId,
325
- decisionAttempt: input.decisionAttempt,
326
- });
327
- if (base.kind === 'remote' &&
321
+ const attempt = await allocate(choice.base, definition.questionnaireId);
322
+ if (choice.base.kind === 'remote' &&
328
323
  !attempt.ok &&
329
324
  attempt.error?.message.startsWith('The selected remote branch could not be fetched.'))
330
325
  return fallback();
@@ -335,24 +330,82 @@ export function registerWorktreeTools(server, service) {
335
330
  repoRoot: repo,
336
331
  externalTaskId: z.string().min(2).max(160),
337
332
  generation: z.string().uuid(),
338
- delivered: z.boolean().optional(),
339
333
  });
340
334
  server.registerTool('worktree.list', {
341
- description: 'Discover existing managed Git checkouts and list activity and protection reasons before allocating another folder. Unregistered legacy checkouts remain protected until explicitly reconciled.',
335
+ description: 'Discover existing managed Git checkouts and list activity and protection reasons before allocating another folder. Unregistered legacy checkouts remain protected until explicitly reconciled. Slots outside the managed root are retired: never reused and not counted toward the limit.',
342
336
  inputSchema: locationSchema,
343
337
  }, async (input) => output(await service.worktreeOperation('list', input)));
344
338
  server.registerTool('worktree.reconcile', {
345
- description: 'Reconcile Git with the local registry, including unregistered legacy checkouts. Use releaseUnowned only on a selected unreserved recovered checkout after prior work has finished; a native source-bound confirmation and fresh safety checks precede reuse. Never delete files or reset branches.',
339
+ description: 'Reconcile Git with the local registry, including unregistered legacy checkouts. Use releaseUnowned only on a selected unreserved recovered checkout after prior work has finished; a native source-bound confirmation and fresh safety checks precede reuse. Use retireLegacy only when the user wants old-location slots gone: each retired folder is removed with git worktree remove after its own native approval, and the branch stays. Otherwise never delete files or reset branches.',
346
340
  inputSchema: z.strictObject({
347
341
  repoRoot: repo,
348
342
  releaseUnowned: z.boolean().optional(),
343
+ retireLegacy: z.boolean().optional(),
349
344
  decisionAttempt: z.number().int().min(0).default(0),
350
345
  language: z.enum(['tr', 'en']).default('en'),
351
346
  }),
352
347
  }, async (input, context) => {
353
348
  const inspection = await service.worktreeOperation('reconcile', input);
354
- if (!inspection.ok || !input.releaseUnowned)
349
+ if (!inspection.ok || (!input.releaseUnowned && !input.retireLegacy))
355
350
  return output(inspection);
351
+ if (input.retireLegacy) {
352
+ const items = inspection.data.items.filter((item) => item.activity === 'retired');
353
+ const retired = [];
354
+ const kept = [];
355
+ const refused = [];
356
+ for (const item of items) {
357
+ const questionnaireId = 'worktree-retire-' + sha256(item.repoRoot + item.generation);
358
+ const missing = item.reasons.includes('worktree directory missing');
359
+ const folder = basename(item.repoRoot);
360
+ const form = await askQuestionnaire(server, service, {
361
+ repoRoot: input.repoRoot,
362
+ questionnaireId,
363
+ language: input.language,
364
+ allowFreeText: false,
365
+ message: input.language === 'tr'
366
+ ? folder +
367
+ (missing
368
+ ? ' eski konumdaki bir worktree kaydı; klasör artık yok. Kaydı silinsin mi?'
369
+ : ' eski konumda kalan bir worktree (branch ' +
370
+ (item.branch ?? 'yok') +
371
+ '); artık yeniden kullanılmıyor. Silinsin mi? Branch kalır. Git yalnızca temiz bir klasörü kaldırır ve içindeki .gitignore kapsamındaki dosyaları da siler.')
372
+ : folder +
373
+ (missing
374
+ ? ' is a worktree record in the old location whose folder is gone. Remove the record?'
375
+ : ' is a worktree left in the old location (branch ' +
376
+ (item.branch ?? 'none') +
377
+ '); it is no longer reused. Remove it? The branch stays. Git removes only a clean folder, and its ignored files go with it.'),
378
+ options: [
379
+ {
380
+ id: 'retire',
381
+ label: input.language === 'tr' ? 'Klasörü kaldır' : 'Remove the folder',
382
+ },
383
+ { id: 'keep', label: input.language === 'tr' ? 'Kalsın' : 'Keep it' },
384
+ ],
385
+ }, context);
386
+ const decision = await service.questionnaireResume({
387
+ repoRoot: input.repoRoot,
388
+ questionnaireId,
389
+ });
390
+ if (decision.status !== 'answered')
391
+ return form;
392
+ if (decision.answer?.choice !== 'retire') {
393
+ kept.push(item.repoRoot);
394
+ continue;
395
+ }
396
+ const result = await service.worktreeOperation('retire', {
397
+ repoRoot: input.repoRoot,
398
+ worktreePath: item.repoRoot,
399
+ generation: item.generation,
400
+ decisionId: questionnaireId,
401
+ });
402
+ if (result.ok)
403
+ retired.push(item.repoRoot);
404
+ else
405
+ refused.push({ repoRoot: item.repoRoot, message: result.error?.message ?? '' });
406
+ }
407
+ return output({ ok: true, data: json({ retired, kept, refused }) });
408
+ }
356
409
  const data = inspection.data;
357
410
  const entry = data.selected;
358
411
  if (!entry?.recoveredWithoutReservation || !entry.baseCommit)
@@ -413,43 +466,77 @@ export function registerWorktreeTools(server, service) {
413
466
  inputSchema: ownershipSchema,
414
467
  }, async (input) => output(await service.worktreeOperation('heartbeat', input)));
415
468
  server.registerTool('task.pause', {
416
- description: 'Pause without abandoning or deleting work. Stop writing until session.resume returns the new ownership generation.',
417
- inputSchema: ownershipSchema,
469
+ description: 'Pause without abandoning or deleting work. Stop writing until session.resume returns the new ownership generation. A pause records no delivery; settle delivery with worktree.release.',
470
+ inputSchema: ownershipSchema.extend({ delivered: z.boolean().optional() }),
418
471
  }, async (input) => output(await service.worktreeOperation('pause', input)));
419
472
  server.registerTool('worktree.release', {
420
- description: 'Safely release this generation only when the checkout is clean and authorized delivery is complete. Never force cleanup.',
421
- inputSchema: ownershipSchema,
422
- }, async (input) => output(await service.worktreeOperation('release', input)));
423
- }
424
- async function askText(server, service, input, context, purpose, message) {
425
- const definition = {
426
- repoRoot: input.repoRoot,
427
- questionnaireId: purpose +
428
- '-' +
429
- sha256(input.externalTaskId + (input.decisionAttempt ? ':' + input.decisionAttempt : '')),
430
- language: input.language,
431
- message,
432
- allowFreeText: false,
433
- options: [
434
- { id: 'set', label: input.language === 'tr' ? 'Bu adı kullan' : 'Use this name' },
435
- { id: 'defer', label: input.language === 'tr' ? 'Şimdilik başlatma' : 'Not now' },
436
- ],
437
- textField: {
438
- requiredForChoice: 'set',
439
- title: input.language === 'tr' ? 'Branch adı' : 'Branch name',
440
- maxLength: 255,
441
- },
442
- };
443
- const form = await askQuestionnaire(server, service, definition, context);
444
- const record = await service.questionnaireResume({
445
- repoRoot: input.repoRoot,
446
- questionnaireId: definition.questionnaireId,
473
+ description: "Release this generation once the user settled its delivery. deliveryOutcome 'delivered' states that authorized delivery happened; 'cancelled' means the user decided not to deliver, and the native question asks whether the folder is kept, released, or its changes saved to a preserved ref before release. A pending delivery needs an outcome. Never force cleanup.",
474
+ inputSchema: ownershipSchema.extend({
475
+ deliveryOutcome: z.enum(['delivered', 'cancelled']).optional(),
476
+ delivered: z.boolean().optional(),
477
+ language: z.enum(['tr', 'en']).default('en'),
478
+ }),
479
+ }, async (input, context) => {
480
+ if (input.deliveryOutcome !== 'cancelled')
481
+ return output(await service.worktreeOperation('release', input));
482
+ const state = await service.worktreeFolder(input);
483
+ if (!state.ok)
484
+ return output(state);
485
+ const folder = state.data;
486
+ const questionnaireId = cancelledDeliveryQuestionId({ generation: input.generation, managed: folder.managed }, folder.clean);
487
+ if (!folder.deliveryPending)
488
+ return output(await service.worktreeOperation('cancel_delivery', {
489
+ ...input,
490
+ decisionId: questionnaireId,
491
+ }));
492
+ const release = {
493
+ id: 'release',
494
+ label: input.language === 'tr'
495
+ ? 'Klasörü boşalt; branch kalsın'
496
+ : 'Free the folder; the branch stays',
497
+ };
498
+ const save = {
499
+ id: 'save_and_release',
500
+ label: input.language === 'tr'
501
+ ? 'Değişiklikleri kaydedip klasörü boşalt'
502
+ : 'Save the changes and free the folder',
503
+ };
504
+ const keep = {
505
+ id: 'keep',
506
+ label: input.language === 'tr' ? 'Klasör olduğu gibi kalsın' : 'Keep the folder as it is',
507
+ };
508
+ const form = await askQuestionnaire(server, service, {
509
+ repoRoot: input.repoRoot,
510
+ questionnaireId,
511
+ language: input.language,
512
+ allowFreeText: false,
513
+ message: input.language === 'tr'
514
+ ? input.externalTaskId +
515
+ ' teslim edilmeyecek. Bu klasör ne olsun?' +
516
+ (folder.clean
517
+ ? ''
518
+ : folder.managed
519
+ ? ' Klasörde kaydedilmemiş değişiklikler var; kaydedilirse bir Git ref’inde saklanır ve geri yüklenebilir.'
520
+ : ' Klasörde kaydedilmemiş değişiklikler var; bu senin klasörün olduğu için boşaltılsa da dosyalara dokunulmaz.')
521
+ : input.externalTaskId +
522
+ ' will not be delivered. What should happen to this folder?' +
523
+ (folder.clean
524
+ ? ''
525
+ : folder.managed
526
+ ? ' It has uncommitted changes; saving keeps them in a Git ref they can be restored from.'
527
+ : ' It has uncommitted changes; this is your own folder, so its files are never touched when it is freed.'),
528
+ options: folder.clean || !folder.managed ? [release, keep] : [save, keep],
529
+ }, context);
530
+ const decision = await service.questionnaireResume({
531
+ repoRoot: input.repoRoot,
532
+ questionnaireId,
533
+ });
534
+ if (decision.status !== 'answered')
535
+ return form;
536
+ return output(await service.worktreeOperation('cancel_delivery', {
537
+ ...input,
538
+ decisionId: questionnaireId,
539
+ }));
447
540
  });
448
- return {
449
- form,
450
- value: record.status === 'answered' && record.answer?.choice === 'set'
451
- ? record.answer.text
452
- : undefined,
453
- };
454
541
  }
455
542
  //# sourceMappingURL=worktree-tools.js.map