engineering-memory 1.11.9 → 1.11.11

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 (35) hide show
  1. package/dispatcher/sections.mjs +7 -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 +2 -0
  8. package/runtime/dist/src/git/git-inspector.js +114 -4
  9. package/runtime/dist/src/git/verification-gate.js +30 -17
  10. package/runtime/dist/src/mcp/onboarding-tools.js +110 -16
  11. package/runtime/dist/src/mcp/questionnaire-tools.js +99 -15
  12. package/runtime/dist/src/mcp/tool-annotations.js +112 -0
  13. package/runtime/dist/src/mcp/tool-definitions.js +150 -29
  14. package/runtime/dist/src/mcp/worktree-tools.js +395 -236
  15. package/runtime/dist/src/project/repository.js +26 -8
  16. package/runtime/dist/src/runtime/active-context-store.js +3 -0
  17. package/runtime/dist/src/runtime/api-client.js +1 -0
  18. package/runtime/dist/src/runtime/branch-preferences.js +0 -19
  19. package/runtime/dist/src/runtime/bridge-service.js +1320 -246
  20. package/runtime/dist/src/runtime/offline-outbox.js +33 -158
  21. package/runtime/dist/src/runtime/phase-timer.js +26 -0
  22. package/runtime/dist/src/runtime/privacy-detector.js +263 -0
  23. package/runtime/dist/src/runtime/questionnaire-store.js +210 -49
  24. package/runtime/dist/src/runtime/recovery-error.js +3 -1
  25. package/runtime/dist/src/runtime/runtime-entry.js +8 -0
  26. package/runtime/dist/src/runtime/runtime-host.js +1 -3
  27. package/runtime/dist/src/runtime/task-branch-store.js +17 -1
  28. package/runtime/dist/src/runtime/task-start.js +281 -0
  29. package/runtime/dist/src/runtime/worktree-pool.js +374 -26
  30. package/runtime/dist/src/utilities/process.js +1 -0
  31. package/skill/SKILL.md +3 -3
  32. package/skill/references/lifecycle.md +59 -19
  33. package/skill/references/memory-updates.md +9 -3
  34. package/skill/references/project-onboarding.md +19 -2
  35. package/skill/references/questionnaires.md +86 -15
@@ -1,10 +1,38 @@
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, forgetUnreadableQuestionId, } 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';
6
+ import { unreadableWorktree } from '../runtime/worktree-pool.js';
4
7
  import { askQuestionnaire } from './questionnaire-tools.js';
5
8
  const repo = z.string().min(1);
6
9
  const branch = z.string().min(1).max(255);
7
10
  const remote = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/);
11
+ const presentation = z.enum(['host_native']).optional();
12
+ function deferredStart(input, questionnaireId) {
13
+ return {
14
+ ok: true,
15
+ data: json({
16
+ status: 'deferred',
17
+ allocated: false,
18
+ externalTaskId: input.externalTaskId,
19
+ questionnaireId,
20
+ reconsider: {
21
+ tool: 'task.branch',
22
+ arguments: {
23
+ repoRoot: input.repoRoot,
24
+ externalTaskId: input.externalTaskId,
25
+ decisionAttempt: (input.decisionAttempt ?? 0) + 1,
26
+ language: input.language,
27
+ name: input.name,
28
+ base: input.base,
29
+ keepCurrent: input.keepCurrent,
30
+ },
31
+ },
32
+ 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.`,
33
+ }),
34
+ };
35
+ }
8
36
  const output = (r) => ({
9
37
  content: [{ type: 'text', text: JSON.stringify(r) }],
10
38
  ...(!r.ok ? { isError: true } : {}),
@@ -42,7 +70,7 @@ export function registerWorktreeTools(server, service) {
42
70
  }),
43
71
  }, async (input) => output(await service.projectGitPreferences(input)));
44
72
  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.',
73
+ 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
74
  inputSchema: z.strictObject({
47
75
  repoRoot: repo,
48
76
  projectId: z.string().uuid(),
@@ -52,6 +80,7 @@ export function registerWorktreeTools(server, service) {
52
80
  production: preference.optional(),
53
81
  test: preference.optional(),
54
82
  language: z.enum(['tr', 'en']).default('en'),
83
+ presentation,
55
84
  }),
56
85
  }, async (input, context) => {
57
86
  const current = await service.projectGitPreferences(input);
@@ -68,33 +97,41 @@ export function registerWorktreeTools(server, service) {
68
97
  recovery: 'task.branch',
69
98
  },
70
99
  });
71
- const changes = { expectedVersion: input.expectedVersion };
72
- for (const role of ['development', 'production', 'test']) {
100
+ const roles = ['development', 'production', 'test'].filter((role) => Boolean(input[role]));
101
+ if (roles.length === 0)
102
+ return output({ ok: true, data: { unchanged: true } });
103
+ const roleLabel = (role) => {
73
104
  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,
105
+ return input.language === 'tr'
106
+ ? role +
107
+ ' dal tercihi: ' +
108
+ (value.branch ? value.remote + '/' + value.branch : 'henüz yok') +
109
+ '. Ortak proje ayarını kaydedelim mi?'
110
+ : 'Save the shared ' +
111
+ role +
112
+ ' branch preference: ' +
113
+ (value.branch ? value.remote + '/' + value.branch : 'none yet') +
114
+ '?';
115
+ };
116
+ const digest = sha256(stableStringify({
117
+ projectId: input.projectId,
118
+ version: input.expectedVersion,
119
+ roles,
120
+ values: roles.map((role) => input[role]),
121
+ ...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
122
+ }));
123
+ const questionnaireId = 'project-branches-' + digest;
124
+ const definition = {
125
+ repoRoot: input.repoRoot,
126
+ questionnaireId,
127
+ presentation: input.presentation,
128
+ language: input.language,
129
+ message: input.language === 'tr'
130
+ ? 'Ortak dal tercihlerini kaydedelim mi? Hiçbir şey kaydedilmeden önce onayını bekliyor.'
131
+ : 'Save the shared branch preferences? Nothing is saved until you answer.',
132
+ questions: roles.map((role) => ({
133
+ id: role,
134
+ message: roleLabel(role),
98
135
  options: [
99
136
  {
100
137
  id: 'approve',
@@ -105,16 +142,31 @@ export function registerWorktreeTools(server, service) {
105
142
  label: input.language === 'tr' ? 'Şimdilik değiştirme' : 'Leave unchanged',
106
143
  },
107
144
  ],
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
- }
145
+ })),
146
+ };
147
+ const owner = {
148
+ tool: 'project.set_git_preferences',
149
+ ...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
150
+ retryArguments: json({
151
+ projectId: input.projectId,
152
+ expectedVersion: input.expectedVersion,
153
+ development: input.development,
154
+ production: input.production,
155
+ test: input.test,
156
+ language: input.language,
157
+ }),
158
+ };
159
+ const form = await askQuestionnaire(server, service, definition, context, [], owner);
160
+ const record = await service.questionnaireResume({
161
+ repoRoot: input.repoRoot,
162
+ questionnaireId,
163
+ });
164
+ if (record.status !== 'answered')
165
+ return form;
166
+ const changes = { expectedVersion: input.expectedVersion };
167
+ for (const role of roles)
168
+ if (record.answers?.[role]?.choice === 'approve')
169
+ changes[role] = json(input[role]);
118
170
  if (Object.keys(changes).length === 1)
119
171
  return output({ ok: true, data: { unchanged: true } });
120
172
  return output(await service.setProjectGitPreferences(input.projectId, changes));
@@ -128,7 +180,7 @@ export function registerWorktreeTools(server, service) {
128
180
  inputSchema: z.strictObject(policy),
129
181
  }, async (input) => output(await service.worktreePolicy(json(input))));
130
182
  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.',
183
+ 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
184
  inputSchema: z.strictObject({
133
185
  repoRoot: repo,
134
186
  externalTaskId: z.string().min(2).max(160),
@@ -137,177 +189,129 @@ export function registerWorktreeTools(server, service) {
137
189
  base: baseSchema.optional(),
138
190
  keepCurrent: z.boolean().optional(),
139
191
  language: z.enum(['tr', 'en']).default('en'),
192
+ presentation,
140
193
  }),
141
194
  }, async (input, context) => {
142
- const existing = await service.worktreeTaskAllocation(input);
143
- if (existing.ok && existing.data.allocation)
195
+ const preflight = await service.taskBranchPreflight(input);
196
+ if (!preflight.ok)
197
+ return output(preflight);
198
+ const flight = preflight.data;
199
+ if (flight.existingAllocation)
144
200
  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,
201
+ const role = input.base?.kind;
202
+ if ((role === 'development' || role === 'production' || role === 'test') &&
203
+ !configuredBase(flight.preferences, role))
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
+ },
170
212
  });
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({
213
+ const definition = taskStartDefinition({ ...input, ...flight });
214
+ const owner = {
215
+ tool: 'task.branch',
224
216
  externalTaskId: input.externalTaskId,
225
- base,
226
- name: name ?? null,
227
- keepCurrent,
228
217
  ...(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
- ],
218
+ retryArguments: json({
219
+ language: input.language,
220
+ name: input.name,
221
+ base: input.base,
222
+ keepCurrent: input.keepCurrent,
223
+ }),
253
224
  };
254
- const form = await askQuestionnaire(server, service, definition, context);
225
+ const form = await askQuestionnaire(server, service, { ...definition, repoRoot: input.repoRoot, presentation: input.presentation }, context, [], owner);
255
226
  const record = await service.questionnaireResume({
256
227
  repoRoot: input.repoRoot,
257
228
  questionnaireId: definition.questionnaireId,
258
229
  });
259
- if (record.status !== 'answered' || record.answer?.choice !== 'approve')
230
+ const choice = taskStartChoice(record);
231
+ if (!choice)
260
232
  return form;
261
- const fallbackBase = { kind: 'current', commit: prefs.currentCommit ?? undefined };
262
- const fallbackSignature = sha256(stableStringify({
233
+ if (choice.status === 'deferred')
234
+ return output(deferredStart(input, definition.questionnaireId));
235
+ const reconsider = (reason) => output({
236
+ ok: false,
237
+ error: {
238
+ kind: 'bridge_error',
239
+ retryable: false,
240
+ message: `${reason} Call task.branch with decisionAttempt ${(input.decisionAttempt ?? 0) + 1} to ask the start form again.`,
241
+ recovery: 'task.branch',
242
+ },
243
+ });
244
+ if (choice.name && choice.name !== input.name && choice.name !== flight.suggestedName) {
245
+ const unusable = await service.unusableBranchName(input.repoRoot, choice.name);
246
+ if (unusable)
247
+ return reconsider(unusable);
248
+ }
249
+ const request = {
263
250
  externalTaskId: input.externalTaskId,
264
- base: fallbackBase,
265
- name: name ?? null,
266
- keepCurrent,
251
+ name: choice.name ?? null,
252
+ keepCurrent: choice.keepCurrent,
267
253
  ...(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
254
  };
255
+ const allocate = (base, decisionId) => service.taskBranch({
256
+ repoRoot: input.repoRoot,
257
+ externalTaskId: input.externalTaskId,
258
+ name: choice.name ?? undefined,
259
+ keepCurrent: choice.keepCurrent,
260
+ inPlace: choice.inPlace,
261
+ base,
262
+ decisionId,
263
+ startId: definition.questionnaireId,
264
+ decisionAttempt: input.decisionAttempt,
265
+ });
266
+ const fallbackBase = {
267
+ kind: 'current',
268
+ ...(flight.currentCommit ? { commit: flight.currentCommit } : {}),
269
+ };
270
+ const fallbackId = 'task-branch-fallback-' +
271
+ sha256(stableStringify({ ...request, base: fallbackBase, inPlace: choice.inPlace }));
287
272
  const fallback = async () => {
288
- const form = await askQuestionnaire(server, service, fallbackDefinition, context);
289
- const record = await service.questionnaireResume({
273
+ const tr = input.language === 'tr';
274
+ const from = sourceLabel(choice.base, flight.currentBranch);
275
+ const local = sourceLabel(fallbackBase, flight.currentBranch);
276
+ const fallbackForm = await askQuestionnaire(server, service, {
290
277
  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({
278
+ questionnaireId: fallbackId,
279
+ language: input.language,
280
+ presentation: input.presentation,
281
+ allowFreeText: false,
282
+ message: tr
283
+ ? from +
284
+ ' uzak dalına ulaşılamadı. Yereldeki ' +
285
+ local +
286
+ ' üzerinden ' +
287
+ (choice.inPlace ? 'bu klasörde' : 'ayrı worktree ile') +
288
+ ' devam edilsin mi?'
289
+ : 'The remote ' +
290
+ from +
291
+ ' is unavailable. Continue from local ' +
292
+ local +
293
+ (choice.inPlace ? ' in this folder?' : ' in a separate worktree?'),
294
+ options: [
295
+ { id: 'approve', label: tr ? 'Devam et' : 'Continue' },
296
+ { id: 'defer', label: tr ? 'Şimdilik başlatma' : 'Not now' },
297
+ ],
298
+ }, context, [], owner);
299
+ const answer = await service.questionnaireResume({
296
300
  repoRoot: input.repoRoot,
297
- externalTaskId: input.externalTaskId,
298
- name,
299
- keepCurrent,
300
- base: fallbackBase,
301
- decisionId: fallbackDefinition.questionnaireId,
302
- decisionAttempt: input.decisionAttempt,
303
- }));
301
+ questionnaireId: fallbackId,
302
+ });
303
+ if (answer.status === 'answered' && answer.answer?.choice === 'defer')
304
+ return output(deferredStart(input, fallbackId));
305
+ if (answer.status !== 'answered' || answer.answer?.choice !== 'approve')
306
+ return fallbackForm;
307
+ return output(await allocate(fallbackBase, fallbackId));
304
308
  };
305
- if (base.kind === 'remote') {
309
+ if (choice.base.kind === 'remote') {
306
310
  let recorded = false;
307
311
  try {
308
312
  await service.questionnaireResume({
309
313
  repoRoot: input.repoRoot,
310
- questionnaireId: fallbackDefinition.questionnaireId,
314
+ questionnaireId: fallbackId,
311
315
  });
312
316
  recorded = true;
313
317
  }
@@ -315,16 +319,8 @@ export function registerWorktreeTools(server, service) {
315
319
  if (recorded)
316
320
  return fallback();
317
321
  }
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' &&
322
+ const attempt = await allocate(choice.base, definition.questionnaireId);
323
+ if (choice.base.kind === 'remote' &&
328
324
  !attempt.ok &&
329
325
  attempt.error?.message.startsWith('The selected remote branch could not be fetched.'))
330
326
  return fallback();
@@ -335,24 +331,153 @@ export function registerWorktreeTools(server, service) {
335
331
  repoRoot: repo,
336
332
  externalTaskId: z.string().min(2).max(160),
337
333
  generation: z.string().uuid(),
338
- delivered: z.boolean().optional(),
339
334
  });
340
335
  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.',
336
+ 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
337
  inputSchema: locationSchema,
343
338
  }, async (input) => output(await service.worktreeOperation('list', input)));
344
339
  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.',
340
+ 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. Use forgetUnreadable with the externalTaskId of a checkout that is gone or can no longer be read as a Git worktree, called from a working checkout of the same project: after its own native approval Engineering Memory stops tracking that entry, so it no longer counts toward the limit, and no file in the folder is read, moved or deleted. Otherwise never delete files or reset branches.',
346
341
  inputSchema: z.strictObject({
347
342
  repoRoot: repo,
348
343
  releaseUnowned: z.boolean().optional(),
344
+ retireLegacy: z.boolean().optional(),
345
+ forgetUnreadable: z.strictObject({ externalTaskId: z.string().min(2).max(160) }).optional(),
349
346
  decisionAttempt: z.number().int().min(0).default(0),
350
347
  language: z.enum(['tr', 'en']).default('en'),
351
348
  }),
352
349
  }, async (input, context) => {
353
350
  const inspection = await service.worktreeOperation('reconcile', input);
354
- if (!inspection.ok || !input.releaseUnowned)
351
+ if (!inspection.ok ||
352
+ (!input.releaseUnowned && !input.retireLegacy && !input.forgetUnreadable))
355
353
  return output(inspection);
354
+ if (input.forgetUnreadable) {
355
+ const item = inspection.data.items.find((entry) => entry.externalTaskId === input.forgetUnreadable.externalTaskId);
356
+ if (!item || item.activity === 'retired' || !unreadableWorktree(item))
357
+ return output({
358
+ ok: false,
359
+ error: {
360
+ kind: 'bridge_error',
361
+ retryable: false,
362
+ recovery: 'worktree.list',
363
+ message: 'Only a checkout that is missing or can no longer be read as a Git worktree is forgotten. Call worktree.list to see this task: a readable one is freed with worktree.release, and a retired slot is removed with worktree.reconcile and retireLegacy.',
364
+ },
365
+ });
366
+ const questionnaireId = forgetUnreadableQuestionId(item, input.decisionAttempt);
367
+ const folder = basename(item.repoRoot);
368
+ const tr = input.language === 'tr';
369
+ const form = await askQuestionnaire(server, service, {
370
+ repoRoot: input.repoRoot,
371
+ questionnaireId,
372
+ language: input.language,
373
+ allowFreeText: false,
374
+ message: tr
375
+ ? item.externalTaskId +
376
+ ' görevinin ' +
377
+ folder +
378
+ ' klasörü artık bir Git çalışma kopyası olarak okunamıyor. Engineering Memory bu klasörü takip etmeyi bıraksın mı? Klasördeki hiçbir dosyaya dokunulmaz, hiçbiri silinmez.' +
379
+ (item.pendingDelivery
380
+ ? ' Bu görev teslimi sonuçlanmadan kapandı; teslim edilmemiş iş varsa yalnızca bu klasörde duruyor.'
381
+ : '')
382
+ : 'The ' +
383
+ folder +
384
+ ' folder of task ' +
385
+ item.externalTaskId +
386
+ ' can no longer be read as a Git checkout. Should Engineering Memory stop tracking it? No file in the folder is touched or deleted.' +
387
+ (item.pendingDelivery
388
+ ? ' That task closed without its delivery being settled, so any undelivered work exists only in that folder.'
389
+ : ''),
390
+ options: [
391
+ {
392
+ id: 'forget',
393
+ label: tr
394
+ ? 'Takip etmeyi bırak; dosyalara dokunma'
395
+ : 'Stop tracking it; leave the files alone',
396
+ },
397
+ { id: 'keep', label: tr ? 'Takip etmeye devam et' : 'Keep tracking it' },
398
+ ],
399
+ }, context, [], {
400
+ tool: 'worktree.reconcile',
401
+ ...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
402
+ retryArguments: json({
403
+ forgetUnreadable: input.forgetUnreadable,
404
+ language: input.language,
405
+ }),
406
+ });
407
+ const decision = await service.questionnaireResume({
408
+ repoRoot: input.repoRoot,
409
+ questionnaireId,
410
+ });
411
+ if (decision.status !== 'answered')
412
+ return form;
413
+ if (decision.answer?.choice !== 'forget')
414
+ return output({ ok: true, data: json({ kept: item.externalTaskId }) });
415
+ return output(await service.worktreeOperation('forget_unreadable', {
416
+ repoRoot: input.repoRoot,
417
+ externalTaskId: item.externalTaskId,
418
+ generation: item.generation,
419
+ decisionAttempt: input.decisionAttempt,
420
+ decisionId: questionnaireId,
421
+ }));
422
+ }
423
+ if (input.retireLegacy) {
424
+ const items = inspection.data.items.filter((item) => item.activity === 'retired');
425
+ const retired = [];
426
+ const kept = [];
427
+ const refused = [];
428
+ for (const item of items) {
429
+ const questionnaireId = 'worktree-retire-' + sha256(item.repoRoot + item.generation);
430
+ const missing = item.reasons.includes('worktree directory missing');
431
+ const folder = basename(item.repoRoot);
432
+ const form = await askQuestionnaire(server, service, {
433
+ repoRoot: input.repoRoot,
434
+ questionnaireId,
435
+ language: input.language,
436
+ allowFreeText: false,
437
+ message: input.language === 'tr'
438
+ ? folder +
439
+ (missing
440
+ ? ' eski konumdaki bir worktree kaydı; klasör artık yok. Kaydı silinsin mi?'
441
+ : ' eski konumda kalan bir worktree (branch ' +
442
+ (item.branch ?? 'yok') +
443
+ '); 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.')
444
+ : folder +
445
+ (missing
446
+ ? ' is a worktree record in the old location whose folder is gone. Remove the record?'
447
+ : ' is a worktree left in the old location (branch ' +
448
+ (item.branch ?? 'none') +
449
+ '); it is no longer reused. Remove it? The branch stays. Git removes only a clean folder, and its ignored files go with it.'),
450
+ options: [
451
+ {
452
+ id: 'retire',
453
+ label: input.language === 'tr' ? 'Klasörü kaldır' : 'Remove the folder',
454
+ },
455
+ { id: 'keep', label: input.language === 'tr' ? 'Kalsın' : 'Keep it' },
456
+ ],
457
+ }, context);
458
+ const decision = await service.questionnaireResume({
459
+ repoRoot: input.repoRoot,
460
+ questionnaireId,
461
+ });
462
+ if (decision.status !== 'answered')
463
+ return form;
464
+ if (decision.answer?.choice !== 'retire') {
465
+ kept.push(item.repoRoot);
466
+ continue;
467
+ }
468
+ const result = await service.worktreeOperation('retire', {
469
+ repoRoot: input.repoRoot,
470
+ worktreePath: item.repoRoot,
471
+ generation: item.generation,
472
+ decisionId: questionnaireId,
473
+ });
474
+ if (result.ok)
475
+ retired.push(item.repoRoot);
476
+ else
477
+ refused.push({ repoRoot: item.repoRoot, message: result.error?.message ?? '' });
478
+ }
479
+ return output({ ok: true, data: json({ retired, kept, refused }) });
480
+ }
356
481
  const data = inspection.data;
357
482
  const entry = data.selected;
358
483
  if (!entry?.recoveredWithoutReservation || !entry.baseCommit)
@@ -413,43 +538,77 @@ export function registerWorktreeTools(server, service) {
413
538
  inputSchema: ownershipSchema,
414
539
  }, async (input) => output(await service.worktreeOperation('heartbeat', input)));
415
540
  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,
541
+ 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.',
542
+ inputSchema: ownershipSchema.extend({ delivered: z.boolean().optional() }),
418
543
  }, async (input) => output(await service.worktreeOperation('pause', input)));
419
544
  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,
545
+ 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.",
546
+ inputSchema: ownershipSchema.extend({
547
+ deliveryOutcome: z.enum(['delivered', 'cancelled']).optional(),
548
+ delivered: z.boolean().optional(),
549
+ language: z.enum(['tr', 'en']).default('en'),
550
+ }),
551
+ }, async (input, context) => {
552
+ if (input.deliveryOutcome !== 'cancelled')
553
+ return output(await service.worktreeOperation('release', input));
554
+ const state = await service.worktreeFolder(input);
555
+ if (!state.ok)
556
+ return output(state);
557
+ const folder = state.data;
558
+ const questionnaireId = cancelledDeliveryQuestionId({ generation: input.generation, managed: folder.managed }, folder.clean);
559
+ if (!folder.deliveryPending)
560
+ return output(await service.worktreeOperation('cancel_delivery', {
561
+ ...input,
562
+ decisionId: questionnaireId,
563
+ }));
564
+ const release = {
565
+ id: 'release',
566
+ label: input.language === 'tr'
567
+ ? 'Klasörü boşalt; branch kalsın'
568
+ : 'Free the folder; the branch stays',
569
+ };
570
+ const save = {
571
+ id: 'save_and_release',
572
+ label: input.language === 'tr'
573
+ ? 'Değişiklikleri kaydedip klasörü boşalt'
574
+ : 'Save the changes and free the folder',
575
+ };
576
+ const keep = {
577
+ id: 'keep',
578
+ label: input.language === 'tr' ? 'Klasör olduğu gibi kalsın' : 'Keep the folder as it is',
579
+ };
580
+ const form = await askQuestionnaire(server, service, {
581
+ repoRoot: input.repoRoot,
582
+ questionnaireId,
583
+ language: input.language,
584
+ allowFreeText: false,
585
+ message: input.language === 'tr'
586
+ ? input.externalTaskId +
587
+ ' teslim edilmeyecek. Bu klasör ne olsun?' +
588
+ (folder.clean
589
+ ? ''
590
+ : folder.managed
591
+ ? ' Klasörde kaydedilmemiş değişiklikler var; kaydedilirse bir Git ref’inde saklanır ve geri yüklenebilir.'
592
+ : ' Klasörde kaydedilmemiş değişiklikler var; bu senin klasörün olduğu için boşaltılsa da dosyalara dokunulmaz.')
593
+ : input.externalTaskId +
594
+ ' will not be delivered. What should happen to this folder?' +
595
+ (folder.clean
596
+ ? ''
597
+ : folder.managed
598
+ ? ' It has uncommitted changes; saving keeps them in a Git ref they can be restored from.'
599
+ : ' It has uncommitted changes; this is your own folder, so its files are never touched when it is freed.'),
600
+ options: folder.clean || !folder.managed ? [release, keep] : [save, keep],
601
+ }, context);
602
+ const decision = await service.questionnaireResume({
603
+ repoRoot: input.repoRoot,
604
+ questionnaireId,
605
+ });
606
+ if (decision.status !== 'answered')
607
+ return form;
608
+ return output(await service.worktreeOperation('cancel_delivery', {
609
+ ...input,
610
+ decisionId: questionnaireId,
611
+ }));
447
612
  });
448
- return {
449
- form,
450
- value: record.status === 'answered' && record.answer?.choice === 'set'
451
- ? record.answer.text
452
- : undefined,
453
- };
454
613
  }
455
614
  //# sourceMappingURL=worktree-tools.js.map