engineering-memory 1.11.10 → 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.
- package/dispatcher/sections.mjs +1 -0
- package/package.json +1 -1
- package/runtime/build.json +1 -1
- package/runtime/dist/src/config.js +1 -0
- package/runtime/dist/src/git/git-inspector.js +32 -1
- package/runtime/dist/src/mcp/onboarding-tools.js +107 -0
- package/runtime/dist/src/mcp/tool-annotations.js +1 -0
- package/runtime/dist/src/mcp/tool-definitions.js +2 -1
- package/runtime/dist/src/mcp/worktree-tools.js +75 -3
- package/runtime/dist/src/project/repository.js +15 -3
- package/runtime/dist/src/runtime/api-client.js +1 -0
- package/runtime/dist/src/runtime/bridge-service.js +197 -34
- package/runtime/dist/src/runtime/worktree-pool.js +41 -3
- package/skill/references/lifecycle.md +15 -0
- package/skill/references/questionnaires.md +9 -0
package/dispatcher/sections.mjs
CHANGED
|
@@ -8,6 +8,7 @@ Call \`session.entry\` before you answer anything in a repository, whatever the
|
|
|
8
8
|
- No decision recorded: ask which organization and then which project, whatever the message was. The other answer is switching Engineering Memory off in this repository, and it is remembered with \`session.set_decision\`.
|
|
9
9
|
- Switched off: say nothing about Engineering Memory, offer nothing, hint at nothing, however useful it would be for what was asked. They turned it off; raising it again is what this prevents. Record it back on only when they ask.
|
|
10
10
|
- Bound: continue into the lifecycle.
|
|
11
|
+
- Project lives elsewhere: binding the chosen project is refused because its code moved to another repository, or \`session.entry\` reports the project moved away from this checkout. Follow the skill's lifecycle reference: a project owner moves the project here with \`project.move_repository\`, anyone else is told that an owner has to. Never create a second project for moved code and never bind a different one to get past the refusal.
|
|
11
12
|
|
|
12
13
|
\`session.entry\` also reports this client's version. When it says an update is available, offer once to run \`npx engineering-memory@latest install\` and tell the user that the running bridge switches to the new release by itself on its next call, so nothing needs restarting; only a bridge older than 1.11.9 needs the client restarted once after that install. If they decline, record it with \`session.decline_update\` and never raise that version again. When it says an update is required, there is no choice to offer: say that this client is older than the backend supports, give the command, and do not carry on as though the lifecycle works.
|
|
13
14
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "engineering-memory",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.11",
|
|
4
4
|
"description": "Installs the Engineering Memory skill and its local MCP bridge. Sign in after installing; your organization and project are resolved from your account.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
package/runtime/build.json
CHANGED
|
@@ -67,6 +67,7 @@ export const endpoints = {
|
|
|
67
67
|
projectList: '/projects',
|
|
68
68
|
projectResolve: '/projects/resolve',
|
|
69
69
|
projectBind: (projectId) => `/projects/${projectId}/bind`,
|
|
70
|
+
projectRepository: (projectId) => `/projects/${projectId}/repository`,
|
|
70
71
|
projectUpdate: (projectId) => `/projects/${projectId}`,
|
|
71
72
|
workItemRuns: (projectId, workItemId) => `/projects/${projectId}/work-items/${workItemId}/runs`,
|
|
72
73
|
workItemRestore: (projectId, workItemId) => `/projects/${projectId}/work-items/${workItemId}/restore`,
|
|
@@ -4,6 +4,7 @@ import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
|
4
4
|
import { assertManagedPath, canonicalPath } from '../utilities/files.js';
|
|
5
5
|
import { sha256, stableStringify } from '../utilities/hash.js';
|
|
6
6
|
import { NativeCommandRunner } from '../utilities/process.js';
|
|
7
|
+
import { restrictedValueKind } from '../runtime/privacy-detector.js';
|
|
7
8
|
import { TaskBranchStore } from '../runtime/task-branch-store.js';
|
|
8
9
|
export const temporaryScaffoldingMarker = 'ENGINEERING-MEMORY-TEMPORARY';
|
|
9
10
|
export const temporaryScaffoldingPattern = `${temporaryScaffoldingMarker}:`;
|
|
@@ -35,6 +36,18 @@ export class GitInspector {
|
|
|
35
36
|
}
|
|
36
37
|
return sha256(identity);
|
|
37
38
|
}
|
|
39
|
+
async remoteIdentity(repoRoot) {
|
|
40
|
+
const remote = await this.gitValue(repoRoot, ['config', '--get', 'remote.origin.url']);
|
|
41
|
+
if (!remote)
|
|
42
|
+
return null;
|
|
43
|
+
const identity = canonicalRemoteIdentity(remote);
|
|
44
|
+
return !identity.startsWith('local/') &&
|
|
45
|
+
identity.length <= 500 &&
|
|
46
|
+
/^[a-z0-9][a-z0-9.-]*(:\d{1,5})?\/[^\s@\\]+$/.test(identity) &&
|
|
47
|
+
!restrictedValueKind(identity, 'remoteIdentity')
|
|
48
|
+
? identity
|
|
49
|
+
: null;
|
|
50
|
+
}
|
|
38
51
|
branchStore(repoRoot) {
|
|
39
52
|
return new TaskBranchStore(repoRoot, this.runner);
|
|
40
53
|
}
|
|
@@ -72,11 +85,13 @@ export class GitInspector {
|
|
|
72
85
|
throw new Error('The worktree directory already belongs to another repository or branch. Choose a different directory');
|
|
73
86
|
}
|
|
74
87
|
const exists = await this.branchExists(repoRoot, name);
|
|
88
|
+
const lost = (await this.lostRegistration(repoRoot, path, name)) ? ['-f'] : [];
|
|
75
89
|
const args = exists
|
|
76
|
-
? ['worktree', 'add', '--', path, name]
|
|
90
|
+
? ['worktree', 'add', ...lost, '--', path, name]
|
|
77
91
|
: [
|
|
78
92
|
'worktree',
|
|
79
93
|
'add',
|
|
94
|
+
...lost,
|
|
80
95
|
'--no-track',
|
|
81
96
|
'-b',
|
|
82
97
|
name,
|
|
@@ -89,6 +104,22 @@ export class GitInspector {
|
|
|
89
104
|
throw new Error(`Git could not create the worktree: ${result.stderr.trim()}`);
|
|
90
105
|
return await this.findRoot(path);
|
|
91
106
|
}
|
|
107
|
+
async lostRegistration(repoRoot, path, name) {
|
|
108
|
+
const listed = await this.runner.run('git', ['worktree', 'list', '--porcelain'], {
|
|
109
|
+
cwd: repoRoot,
|
|
110
|
+
});
|
|
111
|
+
if (listed.exitCode !== 0)
|
|
112
|
+
return false;
|
|
113
|
+
const registered = listed.stdout.split(/\r?\n\r?\n/).map((block) => ({
|
|
114
|
+
path: /^worktree (.+)$/m.exec(block)?.[1] ?? '',
|
|
115
|
+
branch: /^branch refs\/heads\/(.+)$/m.exec(block)?.[1] ?? null,
|
|
116
|
+
}));
|
|
117
|
+
const same = (left, right) => process.platform === 'win32'
|
|
118
|
+
? resolve(left).toLowerCase() === resolve(right).toLowerCase()
|
|
119
|
+
: resolve(left) === resolve(right);
|
|
120
|
+
return (registered.some((entry) => entry.path && same(entry.path, path)) &&
|
|
121
|
+
!registered.some((entry) => entry.branch === name && !same(entry.path, path)));
|
|
122
|
+
}
|
|
92
123
|
async fetchBranchCommit(repoRoot, remote, branch) {
|
|
93
124
|
await this.validateBranch(repoRoot, branch);
|
|
94
125
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/.test(remote))
|
|
@@ -133,7 +133,114 @@ const onboardingInput = z.strictObject({
|
|
|
133
133
|
projectId: uuid.optional(),
|
|
134
134
|
reworkBehavior: z.enum(['preserve', 'redesign']).optional(),
|
|
135
135
|
});
|
|
136
|
+
const moveCopy = {
|
|
137
|
+
tr: {
|
|
138
|
+
ask: (name) => `"${name}" projesini bu depoya taşıyalım mı?`,
|
|
139
|
+
from: 'Projenin şu anki deposu',
|
|
140
|
+
to: 'Bu klasörün deposu',
|
|
141
|
+
unknown: 'adresi kayıtlı değil',
|
|
142
|
+
keeps: (tasks, records, items) => `Proje olduğu gibi devam eder: ${tasks} görev, ${records} hafıza kaydı ve ${items} iş kalemi projede kalır, hiçbir şey silinmez ve yeniden öğrenilmez.`,
|
|
143
|
+
after: 'Eski depo projenin geçmişinde saklanır. Hâlâ eski depoda çalışan ekip arkadaşları açık görevlerini bitirebilir, yeni görevi yeni depodan açar. Proje ileride yeniden taşınabilir.',
|
|
144
|
+
approve: 'Projeyi bu depoya taşı',
|
|
145
|
+
decline: 'Taşıma',
|
|
146
|
+
},
|
|
147
|
+
en: {
|
|
148
|
+
ask: (name) => `Move the project "${name}" to this repository?`,
|
|
149
|
+
from: 'The project lives in',
|
|
150
|
+
to: 'This folder points at',
|
|
151
|
+
unknown: 'address not recorded',
|
|
152
|
+
keeps: (tasks, records, items) => `The project continues as it is: ${tasks} tasks, ${records} memory records and ${items} work items stay with it; nothing is deleted or learned again.`,
|
|
153
|
+
after: 'The old repository is kept in the project history. Teammates still working in it can finish their open tasks and start new ones from the new repository. The project can be moved again later.',
|
|
154
|
+
approve: 'Move the project here',
|
|
155
|
+
decline: 'Do not move',
|
|
156
|
+
},
|
|
157
|
+
};
|
|
136
158
|
export function registerOnboardingTools(server, service) {
|
|
159
|
+
server.registerTool('project.move_repository', {
|
|
160
|
+
description: 'Move an existing project to the repository this checkout points at, after its code moved (another host, a new remote, a history restart). Project owners only. Opens ONE durable native form naming the old and the new repository; on approval the project keeps every task, memory record, work item and open session, the old repository is kept in its history, and this checkout is bound. Use it when project.resolve with bind refuses with recovery project.move_repository; never create a second project for moved code. Reconsider a declined move with a new decisionAttempt; retries keep the same attempt.',
|
|
161
|
+
inputSchema: z.strictObject({
|
|
162
|
+
repoRoot,
|
|
163
|
+
projectId: uuid,
|
|
164
|
+
decisionAttempt: z.number().int().min(1).max(10000).optional(),
|
|
165
|
+
language: z.enum(['tr', 'en']).default('en'),
|
|
166
|
+
presentation: z.enum(['host_native']).optional(),
|
|
167
|
+
}),
|
|
168
|
+
}, async (input, context) => {
|
|
169
|
+
const preflight = await service.projectRepositoryMovePreflight(input);
|
|
170
|
+
if (!preflight.ok)
|
|
171
|
+
return output(preflight);
|
|
172
|
+
const flight = preflight.data;
|
|
173
|
+
if (flight.authentication)
|
|
174
|
+
return output(preflight);
|
|
175
|
+
if (flight.alreadyHere || !flight.history.current)
|
|
176
|
+
return output(await service.projectResolve({
|
|
177
|
+
repoRoot: input.repoRoot,
|
|
178
|
+
bind: true,
|
|
179
|
+
projectId: input.projectId,
|
|
180
|
+
}));
|
|
181
|
+
if (flight.history.role !== 'owner')
|
|
182
|
+
return output({
|
|
183
|
+
ok: false,
|
|
184
|
+
error: {
|
|
185
|
+
kind: 'bridge_error',
|
|
186
|
+
message: 'Only a project owner can move a project to another repository. List the members with project.member_list and ask an owner to run project.move_repository from a checkout of the new repository. Nothing was changed.',
|
|
187
|
+
retryable: false,
|
|
188
|
+
recovery: 'project.member_list',
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
const copy = moveCopy[input.language];
|
|
192
|
+
const questionnaireId = 'project-move-' +
|
|
193
|
+
sha256(stableStringify({
|
|
194
|
+
projectId: input.projectId,
|
|
195
|
+
from: flight.history.current.repoFingerprint,
|
|
196
|
+
to: flight.checkout.repoFingerprint,
|
|
197
|
+
version: flight.history.lockVersion,
|
|
198
|
+
...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
|
|
199
|
+
}));
|
|
200
|
+
const form = await askQuestionnaire(server, service, {
|
|
201
|
+
repoRoot: input.repoRoot,
|
|
202
|
+
questionnaireId,
|
|
203
|
+
presentation: input.presentation,
|
|
204
|
+
language: input.language,
|
|
205
|
+
message: [
|
|
206
|
+
copy.ask(flight.history.name),
|
|
207
|
+
`${copy.from}: ${flight.history.current.remoteIdentity ?? copy.unknown}`,
|
|
208
|
+
`${copy.to}: ${flight.checkout.remoteIdentity ?? copy.unknown}`,
|
|
209
|
+
copy.keeps(flight.history.records.tasks, flight.history.records.memoryRecords, flight.history.records.workItems),
|
|
210
|
+
copy.after,
|
|
211
|
+
].join('\n'),
|
|
212
|
+
options: [
|
|
213
|
+
{ id: 'approve', label: copy.approve },
|
|
214
|
+
{ id: 'decline', label: copy.decline },
|
|
215
|
+
],
|
|
216
|
+
}, context, [], {
|
|
217
|
+
tool: 'project.move_repository',
|
|
218
|
+
...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
|
|
219
|
+
retryArguments: { projectId: input.projectId, language: input.language },
|
|
220
|
+
});
|
|
221
|
+
const record = await service.questionnaireResume({
|
|
222
|
+
repoRoot: input.repoRoot,
|
|
223
|
+
questionnaireId,
|
|
224
|
+
});
|
|
225
|
+
if (record.status !== 'answered')
|
|
226
|
+
return form;
|
|
227
|
+
if (record.answer?.choice !== 'approve')
|
|
228
|
+
return output({
|
|
229
|
+
ok: true,
|
|
230
|
+
data: {
|
|
231
|
+
moved: false,
|
|
232
|
+
status: 'declined',
|
|
233
|
+
nextAction: 'The project was not moved and nothing changed. Tell the user in one line. If they later want the move, call project.move_repository with decisionAttempt ' +
|
|
234
|
+
((input.decisionAttempt ?? 0) + 1) +
|
|
235
|
+
'.',
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
return output(await service.projectMoveRepository({
|
|
239
|
+
repoRoot: input.repoRoot,
|
|
240
|
+
projectId: input.projectId,
|
|
241
|
+
expectedVersion: flight.history.lockVersion,
|
|
242
|
+
}));
|
|
243
|
+
});
|
|
137
244
|
server.registerTool('memory.source_targets', {
|
|
138
245
|
description: 'Read bounded Git ref candidates without assuming environment roles from their names. Confirm only the chosen committed sources with native memory.sync_start questionnaires; an explicit ref can select a source outside this preview.',
|
|
139
246
|
inputSchema: z.object({ repoRoot: z.string().optional() }),
|
|
@@ -84,6 +84,7 @@ export const toolAnnotations = {
|
|
|
84
84
|
'project.setup': write,
|
|
85
85
|
'project.list': read,
|
|
86
86
|
'project.resolve': repeatableWrite, // binds an explicitly selected project
|
|
87
|
+
'project.move_repository': write,
|
|
87
88
|
'project.archive': destructive,
|
|
88
89
|
'project.restore': write,
|
|
89
90
|
'project.member_add': write,
|
|
@@ -114,6 +114,7 @@ export const engineeringMemoryToolNames = [
|
|
|
114
114
|
'project.setup',
|
|
115
115
|
'project.list',
|
|
116
116
|
'project.resolve',
|
|
117
|
+
'project.move_repository',
|
|
117
118
|
'project.archive',
|
|
118
119
|
'project.restore',
|
|
119
120
|
'project.member_add',
|
|
@@ -772,7 +773,7 @@ export function registerEngineeringMemoryTools(server, service) {
|
|
|
772
773
|
inputSchema: z.object({ includeArchived: z.boolean().optional() }),
|
|
773
774
|
}, async (input) => toolResult(await service.projectList(input)));
|
|
774
775
|
server.registerTool('project.resolve', {
|
|
775
|
-
description: 'Resolve the
|
|
776
|
+
description: 'Resolve the project this checkout belongs to, or bind an explicitly selected project, then store the user-local binding only after backend authorization succeeds. A result with projectMoved means this checkout points at a repository the project left; a bind refused with recovery project.move_repository means the project lives in another repository and an owner can move it here.',
|
|
776
777
|
inputSchema: z.object({
|
|
777
778
|
repoRoot: optionalRepoRoot,
|
|
778
779
|
bind: z.boolean().optional(),
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
2
|
import * as z from 'zod/v4';
|
|
3
|
-
import { cancelledDeliveryQuestionId, } from '../runtime/bridge-service.js';
|
|
3
|
+
import { cancelledDeliveryQuestionId, forgetUnreadableQuestionId, } from '../runtime/bridge-service.js';
|
|
4
4
|
import { configuredBase, sourceLabel, taskStartChoice, taskStartDefinition, } from '../runtime/task-start.js';
|
|
5
5
|
import { sha256, stableStringify } from '../utilities/hash.js';
|
|
6
|
+
import { unreadableWorktree } from '../runtime/worktree-pool.js';
|
|
6
7
|
import { askQuestionnaire } from './questionnaire-tools.js';
|
|
7
8
|
const repo = z.string().min(1);
|
|
8
9
|
const branch = z.string().min(1).max(255);
|
|
@@ -336,18 +337,89 @@ export function registerWorktreeTools(server, service) {
|
|
|
336
337
|
inputSchema: locationSchema,
|
|
337
338
|
}, async (input) => output(await service.worktreeOperation('list', input)));
|
|
338
339
|
server.registerTool('worktree.reconcile', {
|
|
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.',
|
|
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.',
|
|
340
341
|
inputSchema: z.strictObject({
|
|
341
342
|
repoRoot: repo,
|
|
342
343
|
releaseUnowned: z.boolean().optional(),
|
|
343
344
|
retireLegacy: z.boolean().optional(),
|
|
345
|
+
forgetUnreadable: z.strictObject({ externalTaskId: z.string().min(2).max(160) }).optional(),
|
|
344
346
|
decisionAttempt: z.number().int().min(0).default(0),
|
|
345
347
|
language: z.enum(['tr', 'en']).default('en'),
|
|
346
348
|
}),
|
|
347
349
|
}, async (input, context) => {
|
|
348
350
|
const inspection = await service.worktreeOperation('reconcile', input);
|
|
349
|
-
if (!inspection.ok ||
|
|
351
|
+
if (!inspection.ok ||
|
|
352
|
+
(!input.releaseUnowned && !input.retireLegacy && !input.forgetUnreadable))
|
|
350
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
|
+
}
|
|
351
423
|
if (input.retireLegacy) {
|
|
352
424
|
const items = inspection.data.items.filter((item) => item.activity === 'retired');
|
|
353
425
|
const retired = [];
|
|
@@ -69,11 +69,23 @@ export class RepositoryResolver {
|
|
|
69
69
|
legacyFingerprints: binding ? [] : [await this.git.fingerprint(repoRoot, 1)],
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
|
-
async
|
|
72
|
+
async checkoutFingerprints(repoRoot) {
|
|
73
|
+
return [
|
|
74
|
+
...new Set([
|
|
75
|
+
await this.git.fingerprint(repoRoot, 2),
|
|
76
|
+
await this.git.fingerprint(repoRoot, 1),
|
|
77
|
+
]),
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
async writeBinding(repoRoot, projectId, fingerprint, livesIn) {
|
|
73
81
|
assertProjectId(projectId);
|
|
74
|
-
const
|
|
82
|
+
const root = await this.git.findRoot(repoRoot);
|
|
83
|
+
const home = livesIn !== undefined &&
|
|
84
|
+
/^[0-9a-f]{64}$/.test(fingerprint) &&
|
|
85
|
+
(await this.checkoutFingerprints(root)).includes(livesIn);
|
|
86
|
+
const repository = await this.resolve(root, home ? undefined : projectId);
|
|
75
87
|
const older = repository.legacyFingerprints.includes(fingerprint);
|
|
76
|
-
if (fingerprint !== repository.repoFingerprint && !older) {
|
|
88
|
+
if (fingerprint !== repository.repoFingerprint && !older && !home) {
|
|
77
89
|
throw new Error('The confirmed project fingerprint does not identify this repository');
|
|
78
90
|
}
|
|
79
91
|
const repositoryKey = await this.keyFor(repository.repoRoot);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { unreadableWorktree, } from './worktree-pool.js';
|
|
1
2
|
import { validWorktreePolicy } from './worktree-policy.js';
|
|
2
3
|
import { resolveTaskBase } from './branch-preferences.js';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
@@ -263,13 +264,7 @@ export class BridgeService {
|
|
|
263
264
|
});
|
|
264
265
|
let projectId = repository.projectId;
|
|
265
266
|
if (!repository.bindingPath) {
|
|
266
|
-
const resolved = await this.dependencies.client.request(endpoints.projectResolve, {
|
|
267
|
-
method: 'POST',
|
|
268
|
-
body: {
|
|
269
|
-
repoFingerprint: repository.repoFingerprint,
|
|
270
|
-
legacyFingerprints: repository.legacyFingerprints,
|
|
271
|
-
},
|
|
272
|
-
});
|
|
267
|
+
const resolved = await this.dependencies.client.request(endpoints.projectResolve, { method: 'POST', body: await this.checkoutIdentity(repository) });
|
|
273
268
|
const project = objectValue(resolved.data);
|
|
274
269
|
const restoreRequired = Boolean(project?.archivedAt);
|
|
275
270
|
const restoreGuidance = archivedProjectGuidance(project);
|
|
@@ -283,6 +278,7 @@ export class BridgeService {
|
|
|
283
278
|
projects: projects.data,
|
|
284
279
|
outbox: { recoveredDeliveries, ...objectOrEmpty(outbox) },
|
|
285
280
|
...(restoreRequired ? restoreGuidance : {}),
|
|
281
|
+
...movedProjectGuidance(project),
|
|
286
282
|
});
|
|
287
283
|
}
|
|
288
284
|
if (!projectId) {
|
|
@@ -308,7 +304,7 @@ export class BridgeService {
|
|
|
308
304
|
this.poolAllocations.set(repository.repoRoot, poolAllocation);
|
|
309
305
|
}
|
|
310
306
|
const checkpointId = deterministicUuid('session.bootstrap', projectId, repository.repoFingerprint, input.externalTaskId);
|
|
311
|
-
const response = await this.
|
|
307
|
+
const response = await this.requestWithNewerFields(endpoints.sessionBootstrap, ['checkoutFingerprints'], {
|
|
312
308
|
method: 'POST',
|
|
313
309
|
idempotencyKey: checkpointId,
|
|
314
310
|
body: cleanJson({
|
|
@@ -321,6 +317,7 @@ export class BridgeService {
|
|
|
321
317
|
workItemId: persistedBootstrap.workItemId,
|
|
322
318
|
mode: persistedBootstrap.mode ?? 'write',
|
|
323
319
|
repoFingerprint: repository.repoFingerprint,
|
|
320
|
+
checkoutFingerprints: await this.dependencies.repositories.checkoutFingerprints(repository.repoRoot),
|
|
324
321
|
...(input.mode === 'read_only'
|
|
325
322
|
? { baselineDiffHash: repository.git.worktreeHash }
|
|
326
323
|
: {}),
|
|
@@ -2305,15 +2302,19 @@ export class BridgeService {
|
|
|
2305
2302
|
? {
|
|
2306
2303
|
nextAction: 'worktree.reconcile with retireLegacy when the user wants the old folder removed; it is never reused.',
|
|
2307
2304
|
}
|
|
2308
|
-
: item
|
|
2305
|
+
: unreadableWorktree(item)
|
|
2309
2306
|
? {
|
|
2310
|
-
nextAction:
|
|
2307
|
+
nextAction: `This checkout can no longer be read as a Git worktree. worktree.reconcile with forgetUnreadable {"externalTaskId": "${item.externalTaskId}"} asks the user whether Engineering Memory stops tracking it; no file in the folder is touched.`,
|
|
2311
2308
|
}
|
|
2312
|
-
:
|
|
2309
|
+
: item.phase === 'delivery'
|
|
2313
2310
|
? {
|
|
2314
|
-
nextAction: '
|
|
2311
|
+
nextAction: 'Ask the user whether it was delivered; call worktree.release with deliveryOutcome delivered or cancelled (the product asks the user what happens to the folder).',
|
|
2315
2312
|
}
|
|
2316
|
-
:
|
|
2313
|
+
: !item.managed && item.phase !== 'released'
|
|
2314
|
+
? {
|
|
2315
|
+
nextAction: 'The user’s own folder, held by this task. Resume the task here to continue it. For another task here, task.branch asks whether this one should move out; a closed or abandoned task is let go when the next task starts here.',
|
|
2316
|
+
}
|
|
2317
|
+
: {}),
|
|
2317
2318
|
}));
|
|
2318
2319
|
const retired = items.some((item) => item.activity === 'retired');
|
|
2319
2320
|
const selected = await pool.forPath(projectId, repository.repoRoot);
|
|
@@ -2363,6 +2364,42 @@ export class BridgeService {
|
|
|
2363
2364
|
await pool.releaseUnowned(projectId, repository.repoRoot, input.generation, input.sourceCommit);
|
|
2364
2365
|
return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
|
|
2365
2366
|
}
|
|
2367
|
+
if (operation === 'forget_unreadable') {
|
|
2368
|
+
const entry = input.externalTaskId
|
|
2369
|
+
? await pool.forSlug(projectId, input.externalTaskId)
|
|
2370
|
+
: null;
|
|
2371
|
+
if (!entry || entry.generation !== input.generation || !input.decisionId)
|
|
2372
|
+
throw refuse('The unreadable checkout changed after it was asked about. Call worktree.list again before forgetting it.', 'worktree.list');
|
|
2373
|
+
if (input.decisionId !== forgetUnreadableQuestionId(entry, input.decisionAttempt ?? 0))
|
|
2374
|
+
throw refuse('The decision does not match this task and checkout. Repeat worktree.reconcile with forgetUnreadable.', 'worktree.reconcile');
|
|
2375
|
+
const decision = await this.questionnaireResume({
|
|
2376
|
+
repoRoot: repository.repoRoot,
|
|
2377
|
+
questionnaireId: input.decisionId,
|
|
2378
|
+
});
|
|
2379
|
+
if (decision.status !== 'answered' || decision.answer?.choice !== 'forget')
|
|
2380
|
+
throw refuse('A checkout stops being tracked only after the user approves it in the native worktree.reconcile form.', 'worktree.reconcile');
|
|
2381
|
+
const forgotten = await pool.forgetUnreadable(projectId, entry.externalTaskId, entry.generation);
|
|
2382
|
+
this.poolAllocations.delete(forgotten.repoRoot);
|
|
2383
|
+
const folderPresent = await lstat(forgotten.repoRoot).then(() => true, () => false);
|
|
2384
|
+
const pointer = await this.dependencies.activeContexts.loadForSlug(forgotten.repoFingerprint, forgotten.externalTaskId);
|
|
2385
|
+
if (pointer?.worktreeId === sha256(forgotten.repoRoot))
|
|
2386
|
+
await this.parkPointer(pointer);
|
|
2387
|
+
return asJsonValue({
|
|
2388
|
+
operation,
|
|
2389
|
+
complete: true,
|
|
2390
|
+
forgotten: {
|
|
2391
|
+
externalTaskId: forgotten.externalTaskId,
|
|
2392
|
+
repoRoot: forgotten.repoRoot,
|
|
2393
|
+
branch: forgotten.branch,
|
|
2394
|
+
pendingDelivery: forgotten.pendingDelivery,
|
|
2395
|
+
},
|
|
2396
|
+
filesTouched: false,
|
|
2397
|
+
folderPresent,
|
|
2398
|
+
nextAction: folderPresent
|
|
2399
|
+
? `Engineering Memory no longer tracks that folder and it no longer counts toward the worktree limit. Nothing in it was deleted or changed; the task itself stays open. Tell the user that its files are still on disk in that folder, and start its next worktree with task.branch when they want to continue.`
|
|
2400
|
+
: `Engineering Memory no longer tracks that folder and it no longer counts toward the worktree limit. The folder was already gone before this call, so anything in it that was never committed is not on disk any more${forgotten.pendingDelivery ? '; this task closed with its delivery unsettled, so say that plainly' : ''}. Whatever was committed is still on${forgotten.branch ? ' branch ' + forgotten.branch : ' its branch'}. The task itself stays open; start its next worktree with task.branch when the user wants to continue.`,
|
|
2401
|
+
});
|
|
2402
|
+
}
|
|
2366
2403
|
if (operation === 'retire') {
|
|
2367
2404
|
const entry = input.worktreePath ? await pool.forPath(projectId, input.worktreePath) : null;
|
|
2368
2405
|
if (!entry ||
|
|
@@ -2857,8 +2894,8 @@ export class BridgeService {
|
|
|
2857
2894
|
organizationId: input.organizationId,
|
|
2858
2895
|
projectName: input.projectName,
|
|
2859
2896
|
projectSlug: input.projectSlug ?? slugify(input.projectName),
|
|
2860
|
-
|
|
2861
|
-
|
|
2897
|
+
...(await this.checkoutIdentity(repository)),
|
|
2898
|
+
remoteIdentity: await this.dependencies.repositories.git.remoteIdentity(repository.repoRoot),
|
|
2862
2899
|
framework: input.framework,
|
|
2863
2900
|
developmentArea: input.developmentArea,
|
|
2864
2901
|
figmaConfig: input.figmaConfig,
|
|
@@ -2869,7 +2906,7 @@ export class BridgeService {
|
|
|
2869
2906
|
}
|
|
2870
2907
|
const persistentBody = normalizeRepositoryPaths(body, repository.repoRoot);
|
|
2871
2908
|
assertSafeToPersist(persistentBody);
|
|
2872
|
-
const response = await this.
|
|
2909
|
+
const response = await this.requestWithNewerFields(endpoints.projectSetup, ['remoteIdentity'], {
|
|
2873
2910
|
method: 'POST',
|
|
2874
2911
|
body: persistentBody,
|
|
2875
2912
|
});
|
|
@@ -2912,7 +2949,7 @@ export class BridgeService {
|
|
|
2912
2949
|
else if (stableStringify(current.branches) !== stableStringify(choice.gitPreferences))
|
|
2913
2950
|
throw refuse('The project already has different shared branch preferences. Review them before changing the project settings.', 'project.git_preferences');
|
|
2914
2951
|
}
|
|
2915
|
-
const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, project.id, project.repoFingerprint);
|
|
2952
|
+
const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, project.id, project.repoFingerprint, livesIn(project));
|
|
2916
2953
|
return asJsonValue({ ...data, marker: null, markerPath: null, bindingPath });
|
|
2917
2954
|
});
|
|
2918
2955
|
}
|
|
@@ -3219,12 +3256,12 @@ export class BridgeService {
|
|
|
3219
3256
|
repository: publicRepository(repository),
|
|
3220
3257
|
});
|
|
3221
3258
|
}
|
|
3222
|
-
const response = await this.
|
|
3259
|
+
const response = await this.requestWithNewerFields(endpoints.projectBind(input.projectId), ['remoteIdentity'], {
|
|
3223
3260
|
method: 'POST',
|
|
3224
|
-
body: {
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
},
|
|
3261
|
+
body: cleanJson({
|
|
3262
|
+
...(await this.checkoutIdentity(repository)),
|
|
3263
|
+
remoteIdentity: await this.dependencies.repositories.git.remoteIdentity(repository.repoRoot),
|
|
3264
|
+
}),
|
|
3228
3265
|
});
|
|
3229
3266
|
const project = objectValue(response.data);
|
|
3230
3267
|
if (!project ||
|
|
@@ -3232,7 +3269,7 @@ export class BridgeService {
|
|
|
3232
3269
|
typeof project.repoFingerprint !== 'string') {
|
|
3233
3270
|
throw refuse('Project bind response does not match the selected project', 'project.resolve');
|
|
3234
3271
|
}
|
|
3235
|
-
const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, input.projectId, project.repoFingerprint);
|
|
3272
|
+
const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, input.projectId, project.repoFingerprint, livesIn(project));
|
|
3236
3273
|
return asJsonValue({
|
|
3237
3274
|
resolved: true,
|
|
3238
3275
|
bound: true,
|
|
@@ -3245,25 +3282,101 @@ export class BridgeService {
|
|
|
3245
3282
|
}
|
|
3246
3283
|
const response = await this.dependencies.client.request(endpoints.projectResolve, {
|
|
3247
3284
|
method: 'POST',
|
|
3248
|
-
body:
|
|
3249
|
-
repoFingerprint: repository.repoFingerprint,
|
|
3250
|
-
legacyFingerprints: repository.legacyFingerprints,
|
|
3251
|
-
},
|
|
3285
|
+
body: await this.checkoutIdentity(repository),
|
|
3252
3286
|
});
|
|
3253
3287
|
const project = objectValue(response.data);
|
|
3254
3288
|
const restoreRequired = Boolean(project?.archivedAt);
|
|
3255
3289
|
const restoreGuidance = archivedProjectGuidance(project);
|
|
3290
|
+
const moved = project?.checkout === 'retired';
|
|
3256
3291
|
return asJsonValue({
|
|
3257
|
-
resolved: project !== null && !restoreRequired,
|
|
3292
|
+
resolved: project !== null && !restoreRequired && !moved,
|
|
3258
3293
|
project,
|
|
3259
3294
|
markerPath: null,
|
|
3260
3295
|
repository: publicRepository(repository),
|
|
3261
3296
|
selectionRequired: project === null,
|
|
3262
3297
|
restoreRequired,
|
|
3263
3298
|
...(restoreRequired ? restoreGuidance : {}),
|
|
3299
|
+
...movedProjectGuidance(project),
|
|
3300
|
+
});
|
|
3301
|
+
});
|
|
3302
|
+
}
|
|
3303
|
+
async projectRepositoryMovePreflight(input) {
|
|
3304
|
+
return await this.execute(async () => {
|
|
3305
|
+
const authentication = await this.dependencies.browserAuth.ensureAuthenticated();
|
|
3306
|
+
if (authentication)
|
|
3307
|
+
return asJsonValue({ authentication });
|
|
3308
|
+
const repository = await this.dependencies.repositories.resolve(input.repoRoot);
|
|
3309
|
+
const history = await this.dependencies.client.request(endpoints.projectRepository(input.projectId));
|
|
3310
|
+
const identity = await this.checkoutIdentity(repository);
|
|
3311
|
+
const current = objectValue(objectValue(history.data)?.current);
|
|
3312
|
+
return asJsonValue({
|
|
3313
|
+
history: history.data,
|
|
3314
|
+
checkout: {
|
|
3315
|
+
...identity,
|
|
3316
|
+
remoteIdentity: await this.dependencies.repositories.git.remoteIdentity(repository.repoRoot),
|
|
3317
|
+
},
|
|
3318
|
+
alreadyHere: typeof current?.repoFingerprint === 'string' &&
|
|
3319
|
+
[identity.repoFingerprint, ...identity.legacyFingerprints].includes(current.repoFingerprint),
|
|
3320
|
+
});
|
|
3321
|
+
});
|
|
3322
|
+
}
|
|
3323
|
+
async projectMoveRepository(input) {
|
|
3324
|
+
return await this.execute(async () => {
|
|
3325
|
+
const repository = await this.dependencies.repositories.resolve(input.repoRoot);
|
|
3326
|
+
const response = await this.dependencies.client.request(endpoints.projectRepository(input.projectId), {
|
|
3327
|
+
method: 'POST',
|
|
3328
|
+
body: cleanJson({
|
|
3329
|
+
...(await this.checkoutIdentity(repository)),
|
|
3330
|
+
remoteIdentity: await this.dependencies.repositories.git.remoteIdentity(repository.repoRoot),
|
|
3331
|
+
expectedVersion: input.expectedVersion,
|
|
3332
|
+
}),
|
|
3333
|
+
});
|
|
3334
|
+
const project = objectValue(response.data);
|
|
3335
|
+
if (!project ||
|
|
3336
|
+
project.id !== input.projectId ||
|
|
3337
|
+
typeof project.repoFingerprint !== 'string' ||
|
|
3338
|
+
!livesIn(project)) {
|
|
3339
|
+
throw refuse('Project move response does not match the selected project', 'project.move_repository');
|
|
3340
|
+
}
|
|
3341
|
+
const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, input.projectId, project.repoFingerprint, livesIn(project));
|
|
3342
|
+
return asJsonValue({
|
|
3343
|
+
moved: true,
|
|
3344
|
+
bound: true,
|
|
3345
|
+
project,
|
|
3346
|
+
bindingPath,
|
|
3347
|
+
repository: publicRepository(await this.dependencies.repositories.resolve(repository.repoRoot)),
|
|
3348
|
+
nextAction: 'The project now lives in this repository and this checkout is bound to it. Every task, memory record and work item stayed with the project. Continue with session.bootstrap or session.resume.',
|
|
3264
3349
|
});
|
|
3265
3350
|
});
|
|
3266
3351
|
}
|
|
3352
|
+
async checkoutIdentity(repository) {
|
|
3353
|
+
const [current, ...older] = await this.dependencies.repositories.checkoutFingerprints(repository.repoRoot);
|
|
3354
|
+
return {
|
|
3355
|
+
repoFingerprint: current,
|
|
3356
|
+
legacyFingerprints: [
|
|
3357
|
+
...new Set([...older, repository.repoFingerprint, ...repository.legacyFingerprints]),
|
|
3358
|
+
]
|
|
3359
|
+
.filter((fingerprint) => fingerprint !== current)
|
|
3360
|
+
.slice(0, 4),
|
|
3361
|
+
};
|
|
3362
|
+
}
|
|
3363
|
+
async requestWithNewerFields(path, newer, options) {
|
|
3364
|
+
try {
|
|
3365
|
+
return await this.dependencies.client.request(path, options);
|
|
3366
|
+
}
|
|
3367
|
+
catch (error) {
|
|
3368
|
+
const body = objectValue(options.body);
|
|
3369
|
+
if (!body ||
|
|
3370
|
+
!(error instanceof ApiResponseError) ||
|
|
3371
|
+
error.httpStatus !== 400 ||
|
|
3372
|
+
!newer.some((name) => error.message.includes(`property ${name} should not exist`)))
|
|
3373
|
+
throw error;
|
|
3374
|
+
return await this.dependencies.client.request(path, {
|
|
3375
|
+
...options,
|
|
3376
|
+
body: Object.fromEntries(Object.entries(body).filter(([name]) => !newer.includes(name))),
|
|
3377
|
+
});
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3267
3380
|
async sessionEntry(input = {}) {
|
|
3268
3381
|
return await this.execute(async () => {
|
|
3269
3382
|
const timer = startPhaseTimer('session.entry');
|
|
@@ -3301,7 +3414,7 @@ export class BridgeService {
|
|
|
3301
3414
|
const livePointers = authenticated && state === 'bound'
|
|
3302
3415
|
? this.dependencies.activeContexts.list(repository.repoFingerprint)
|
|
3303
3416
|
: Promise.resolve([]);
|
|
3304
|
-
const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires] = await Promise.all([
|
|
3417
|
+
const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires, moved,] = await Promise.all([
|
|
3305
3418
|
this.clientUpdate(authenticated),
|
|
3306
3419
|
livePointers,
|
|
3307
3420
|
authenticated && state === 'bound' && projectId
|
|
@@ -3319,6 +3432,9 @@ export class BridgeService {
|
|
|
3319
3432
|
this.checkoutTask(repository.repoFingerprint, repository.repoRoot),
|
|
3320
3433
|
]).then(([pointers, workedTask]) => this.questionnaireOverview(repository.repoRoot, workedTask, new Set(pointers.map((pointer) => pointer.taskSlug))))
|
|
3321
3434
|
: Promise.resolve({ mine: [], other: [], deferred: [] }),
|
|
3435
|
+
authenticated && state === 'bound' && projectId
|
|
3436
|
+
? this.movedAway(repository, projectId)
|
|
3437
|
+
: Promise.resolve({}),
|
|
3322
3438
|
]);
|
|
3323
3439
|
timer.mark('independent_lookups');
|
|
3324
3440
|
const liveTasks = liveTasksRaw.map(describePointer);
|
|
@@ -3355,19 +3471,35 @@ export class BridgeService {
|
|
|
3355
3471
|
? { deferredTaskStarts: pendingQuestionnaires.deferred }
|
|
3356
3472
|
: {}),
|
|
3357
3473
|
client,
|
|
3474
|
+
...moved,
|
|
3358
3475
|
...(incomplete && state === 'bound'
|
|
3359
3476
|
? {
|
|
3360
3477
|
shippedKnowledgeWarning: 'The backend is not serving all of the knowledge this release ships. Tell the user before relying on the rules: some are missing from the running server, so a review against them is incomplete. The counts and the reason are in client.shippedKnowledge.',
|
|
3361
3478
|
}
|
|
3362
3479
|
: {}),
|
|
3363
|
-
nextAction:
|
|
3364
|
-
?
|
|
3365
|
-
:
|
|
3366
|
-
? '
|
|
3367
|
-
:
|
|
3480
|
+
nextAction: typeof moved.nextAction === 'string'
|
|
3481
|
+
? moved.nextAction
|
|
3482
|
+
: pendingQuestionnaires.mine.length > 0
|
|
3483
|
+
? 'Resume only the pending questionnaire for this work with questionnaire.resume before taking action that depends on its answer. Unrelated pending questions never block opening a separate task. Never infer consent from a timeout, dismissal, interrupted turn or missing answer.'
|
|
3484
|
+
: taskSelectionRequired
|
|
3485
|
+
? 'Present the live runs and actionable project work items, then ask which one this chat should handle. Resume only the run they choose, or start a separate run for the chosen work item: task.branch first for write work, then session.bootstrap with its workItemId in the returned repoRoot. Another live task never blocks opening this one.'
|
|
3486
|
+
: entryNextAction(authenticated, state),
|
|
3368
3487
|
});
|
|
3369
3488
|
});
|
|
3370
3489
|
}
|
|
3490
|
+
async movedAway(repository, projectId) {
|
|
3491
|
+
try {
|
|
3492
|
+
const response = await this.dependencies.client.request(endpoints.projectResolve, {
|
|
3493
|
+
method: 'POST',
|
|
3494
|
+
body: await this.checkoutIdentity(repository),
|
|
3495
|
+
});
|
|
3496
|
+
const project = objectValue(response.data);
|
|
3497
|
+
return project?.id === projectId ? movedProjectGuidance(project, true) : {};
|
|
3498
|
+
}
|
|
3499
|
+
catch {
|
|
3500
|
+
return {};
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3371
3503
|
async actionableWorkItems(projectId) {
|
|
3372
3504
|
try {
|
|
3373
3505
|
const response = await this.dependencies.client.request(`${endpoints.workItemList(projectId)}?limit=100`);
|
|
@@ -4682,6 +4814,26 @@ function archivedProjectGuidance(project) {
|
|
|
4682
4814
|
nextAction: 'Call project.member_list with this project id, identify an owner, and ask that owner to restore the archived project.',
|
|
4683
4815
|
};
|
|
4684
4816
|
}
|
|
4817
|
+
function livesIn(project) {
|
|
4818
|
+
const repository = objectValue(project?.repository);
|
|
4819
|
+
return typeof repository?.repoFingerprint === 'string' ? repository.repoFingerprint : undefined;
|
|
4820
|
+
}
|
|
4821
|
+
function movedProjectGuidance(project, bound = false) {
|
|
4822
|
+
if (project?.checkout !== 'retired')
|
|
4823
|
+
return {};
|
|
4824
|
+
const home = objectValue(project.repository)?.remoteIdentity;
|
|
4825
|
+
return {
|
|
4826
|
+
projectMoved: true,
|
|
4827
|
+
movedTo: typeof home === 'string' ? home : null,
|
|
4828
|
+
nextAction: bound
|
|
4829
|
+
? `The project "${String(project.name)}" no longer lives in the repository this checkout points at` +
|
|
4830
|
+
(typeof home === 'string' ? `; it moved to ${home}` : '') +
|
|
4831
|
+
'. Tell the user before anything else. A task already open here can be resumed and finished. Do not call task.branch or open a new task here: new work starts from a checkout of the new repository, where the project is selected again. A project owner calls project.move_repository from here only when the code really lives in this repository again.'
|
|
4832
|
+
: `This checkout points at a repository the project "${String(project.name)}" left` +
|
|
4833
|
+
(typeof home === 'string' ? `; it now lives in ${home}` : '') +
|
|
4834
|
+
'. Tell the user. To keep working on that project, point this checkout at its new repository (or clone it) and select the project again. A project owner calls project.move_repository only when the code of the project really lives in this repository again. Do not create a second project for it.',
|
|
4835
|
+
};
|
|
4836
|
+
}
|
|
4685
4837
|
function slugify(value) {
|
|
4686
4838
|
return value
|
|
4687
4839
|
.trim()
|
|
@@ -4791,6 +4943,17 @@ function isOfflineLeaseUsable(lease, sessionId, changedPaths, baselineDiffHash)
|
|
|
4791
4943
|
return false;
|
|
4792
4944
|
}
|
|
4793
4945
|
}
|
|
4946
|
+
export function forgetUnreadableQuestionId(entry, decisionAttempt) {
|
|
4947
|
+
return ('worktree-forget-unreadable-' +
|
|
4948
|
+
sha256(stableStringify({
|
|
4949
|
+
projectId: entry.projectId,
|
|
4950
|
+
externalTaskId: entry.externalTaskId,
|
|
4951
|
+
repoRoot: entry.repoRoot,
|
|
4952
|
+
generation: entry.generation,
|
|
4953
|
+
pendingDelivery: entry.pendingDelivery,
|
|
4954
|
+
decisionAttempt,
|
|
4955
|
+
})));
|
|
4956
|
+
}
|
|
4794
4957
|
export function cancelledDeliveryQuestionId(entry, clean) {
|
|
4795
4958
|
return ('worktree-delivery-cancel-' +
|
|
4796
4959
|
sha256(entry.generation + ':' + (clean ? 'clean' : entry.managed ? 'modified' : 'own-folder')));
|
|
@@ -283,6 +283,10 @@ export class WorktreePool {
|
|
|
283
283
|
return this.transaction(async (registry) => structuredClone(registry.projects[projectId]?.entries.find((e) => key(e.repoRoot) === key(repoRoot)) ??
|
|
284
284
|
null));
|
|
285
285
|
}
|
|
286
|
+
async forSlug(projectId, externalTaskId) {
|
|
287
|
+
return this.transaction(async (registry) => structuredClone(registry.projects[projectId]?.entries.find((e) => e.externalTaskId === externalTaskId) ??
|
|
288
|
+
null));
|
|
289
|
+
}
|
|
286
290
|
async validateCommit(projectId, taskId, repoRoot, generation) {
|
|
287
291
|
return this.transaction(async (registry) => {
|
|
288
292
|
const entry = registry.projects[projectId]?.entries.find((e) => key(e.repoRoot) === key(repoRoot));
|
|
@@ -457,6 +461,34 @@ export class WorktreePool {
|
|
|
457
461
|
await this.save(registry);
|
|
458
462
|
});
|
|
459
463
|
}
|
|
464
|
+
async forgetUnreadable(projectId, externalTaskId, generation) {
|
|
465
|
+
return this.transaction(async (registry) => {
|
|
466
|
+
const project = registry.projects[projectId];
|
|
467
|
+
const entry = project?.entries.find((e) => e.externalTaskId === externalTaskId && e.generation === generation);
|
|
468
|
+
if (!project || !entry)
|
|
469
|
+
throw refuse('This is no longer the same registry entry for that task. List the checkouts again with worktree.list before forgetting one.');
|
|
470
|
+
if (await this.ownedElsewhere(entry))
|
|
471
|
+
throw refuse('Another live client still owns this task. Stop or pause it there first; worktree.list shows the checkout meanwhile.');
|
|
472
|
+
if (!(await this.unreadable(entry)))
|
|
473
|
+
throw new BridgeRecoveryError('This checkout can still be read as a Git worktree, so it is not forgotten. Settle its delivery and free it with worktree.release.', 'worktree.release');
|
|
474
|
+
project.entries = project.entries.filter((e) => e !== entry);
|
|
475
|
+
await this.save(registry);
|
|
476
|
+
return structuredClone(entry);
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
async unreadable(entry) {
|
|
480
|
+
if (!(await pathExists(entry.repoRoot)))
|
|
481
|
+
return true;
|
|
482
|
+
try {
|
|
483
|
+
if (key(await this.git.findRoot(entry.repoRoot)) !== key(await canonicalPath(entry.repoRoot)))
|
|
484
|
+
return true;
|
|
485
|
+
await this.commonDirectory(entry.repoRoot);
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
460
492
|
async releaseUnowned(projectId, repoRoot, generation, sourceCommit) {
|
|
461
493
|
await this.transaction(async (registry) => {
|
|
462
494
|
const entry = registry.projects[projectId]?.entries.find((e) => key(e.repoRoot) === key(repoRoot));
|
|
@@ -832,12 +864,12 @@ export class WorktreePool {
|
|
|
832
864
|
}
|
|
833
865
|
async fileProtection(entry, allowBranchChange = false) {
|
|
834
866
|
if (!(await pathExists(entry.repoRoot)))
|
|
835
|
-
return [
|
|
867
|
+
return [missingDirectory];
|
|
836
868
|
try {
|
|
837
869
|
await this.assertIdentity(entry, allowBranchChange);
|
|
838
870
|
}
|
|
839
871
|
catch {
|
|
840
|
-
return [
|
|
872
|
+
return [(await this.unreadable(entry)) ? unreadableGit : changedIdentity];
|
|
841
873
|
}
|
|
842
874
|
const status = await this.runner.run('git', ['status', '--porcelain=v1', '--untracked-files=all'], { cwd: entry.repoRoot });
|
|
843
875
|
const reasons = [];
|
|
@@ -877,7 +909,7 @@ export class WorktreePool {
|
|
|
877
909
|
activity: 'retired',
|
|
878
910
|
reasons: [
|
|
879
911
|
'outside the managed root ' + managedBase(activeRoot, 2) + '; not reused',
|
|
880
|
-
...((await pathExists(entry.repoRoot)) ? [] : [
|
|
912
|
+
...((await pathExists(entry.repoRoot)) ? [] : [missingDirectory]),
|
|
881
913
|
],
|
|
882
914
|
};
|
|
883
915
|
const reasons = await this.fileProtection(entry);
|
|
@@ -1314,6 +1346,12 @@ function processAlive(pid) {
|
|
|
1314
1346
|
}
|
|
1315
1347
|
const changedFiles = 'uncommitted, staged or untracked files';
|
|
1316
1348
|
const pendingTaskWork = 'task delivery or intent pending';
|
|
1349
|
+
const missingDirectory = 'worktree directory missing';
|
|
1350
|
+
const changedIdentity = 'Git identity or reservation changed; reconcile this checkout';
|
|
1351
|
+
const unreadableGit = 'Git metadata missing or unreadable; this folder is no longer a checkout';
|
|
1352
|
+
export function unreadableWorktree(view) {
|
|
1353
|
+
return view.reasons.includes(missingDirectory) || view.reasons.includes(unreadableGit);
|
|
1354
|
+
}
|
|
1317
1355
|
function protectedRefusal(reasons, next) {
|
|
1318
1356
|
if (reasons.every((reason) => reason === pendingTaskWork))
|
|
1319
1357
|
return new BridgeRecoveryError('Engineering Memory still holds an undelivered record for this task: a checkpoint, correction or close waiting in its local outbox, or an unfinished verification or close. The folder was left as it is. Settle it with task.resolve_pending_delivery (session.resume finishes an interrupted verification or close), then call worktree.release again.', 'task.resolve_pending_delivery');
|
|
@@ -6,6 +6,14 @@ Sign-in decides nothing beyond who the user is. The organization and the project
|
|
|
6
6
|
|
|
7
7
|
Use the binding returned by `session.entry` as the authority. The bridge stores the selected project and repository identity under `~/.engineering-memory/origins/<api-hash>/project-bindings/`, outside the installed runtime and working tree. It imports a legacy `.engineering-memory/project.json` once, preserving its schema and exact backend fingerprint for existing tasks and receipts. It leaves that file unchanged; after migration the file is optional and may be removed through the repository's normal Git workflow. New bindings never create it. A checkout that never imported the marker still finds a project bound under the older identity: the bridge presents that identity alongside the current one and stores whichever the backend confirms. Never infer a selection from a directory or remote, and never edit binding records by hand. Separate clones or a changed remote require a new explicit selection; worktrees of the same checkout share the local binding.
|
|
8
8
|
|
|
9
|
+
A project is not tied to one repository forever. When its code moves — to another host, under a new remote, into a history that starts again from one commit — the project moves with it and keeps everything: tasks, memory, work items, open sessions, the local state of every checkout. The old repository stays in the project's history. Recognise the three shapes this takes and never answer any of them by creating a second project:
|
|
10
|
+
|
|
11
|
+
- Binding the selected project is refused with recovery `project.move_repository`. The project lives in another repository and the user is one of its owners. Call `project.move_repository` with the project id; it opens one native form that names both repositories and what stays with the project, and on approval moves the project and binds this checkout. A declined form changes nothing.
|
|
12
|
+
- The same refusal arrives with recovery `project.member_list`. Moving needs project-owner authority, which the organization owner also holds. Check the roles, say that an owner has to run the move from a checkout of the new repository, and continue only work that does not need the binding. Do not name a person.
|
|
13
|
+
- `session.entry`, `project.resolve` or bootstrap reports `projectMoved`, or bootstrap refuses with "Project moved". This checkout still points at a repository the project has left; the message names where it lives now. Tasks already open here can be resumed and finished. New work starts from the new repository: the user points this checkout's origin at it, or clones it, and selects the project again. An owner calls `project.move_repository` from here only when the code truly lives in this repository again.
|
|
14
|
+
|
|
15
|
+
After a move nothing else changes for the agent. The binding keeps reporting the identity the project has always had, so resume, verification, receipts and the commit gate behave as they did before.
|
|
16
|
+
|
|
9
17
|
An archived project is recoverable state, not a missing or conflicting binding. When an owner receives `project.restore`, use the `projectId` and `expectedVersion` in its data and call that operation before retrying. A non-owner receives `project.member_list` instead: check the available roles and explain that project-owner authority is required to restore this exact project before retrying. Do not name a person or disclose contact details. Use `project.list` with `includeArchived: true` when an owner must first select the archived project. Never create or bind a replacement project to escape archive state.
|
|
10
18
|
|
|
11
19
|
For a bound repository, call `session.bootstrap` before producing a plan or changing files. Supply the current repository root, project ID, task ID or stable local task slug, objective, task kind, task mode, and current Git diff hash. `externalTaskId`, `objective`, `taskKind` and `workItemKey` are each one line with a fixed maximum length (160/240/80/120 characters); the bridge refuses an over-length or multi-line value locally, in milliseconds, before any Git or backend work, and names the limit in the refusal — put detail that does not fit in checkpoints, not in the objective. Use `read_only` for review, diagnosis, planning, or reporting without write authority; use `scaffold` when the task applies organization architecture templates to a new project; use `write` when the request authorizes repository changes. The bridge records the read-only Git diff hash as the immutable task baseline, including a pre-existing dirty worktree. Use the returned task ID, task version, context session ID, project profile, engineering rules, prior task documents, quality gates, and current deviations; `deferredResources` names what did not fit, each with the `revisionId` to read it by.
|
|
@@ -295,6 +303,13 @@ ownership generation. Cancellation leaves it protected. Git reservation, dirty f
|
|
|
295
303
|
Git operations and pending work are checked again before release. A changed source requires a new
|
|
296
304
|
inspection and answer. This recovery never deletes files or branches or interrupts another agent.
|
|
297
305
|
|
|
306
|
+
When a recorded checkout is gone or can no longer be read as a Git worktree — its `.git` was
|
|
307
|
+
deleted, or the repository it was linked to was removed — `worktree.list` names `worktree.reconcile`
|
|
308
|
+
with `forgetUnreadable` and that task's `externalTaskId`, called from a working checkout of the same
|
|
309
|
+
project. After its own native approval Engineering Memory only stops tracking that entry, so it
|
|
310
|
+
stops counting toward the limit; no file in the folder is moved or deleted, the branch and the task
|
|
311
|
+
stay, and a readable checkout is still freed with `worktree.release`.
|
|
312
|
+
|
|
298
313
|
If the running client predates the pool tools, inspect `git worktree list` and the existing task
|
|
299
314
|
reservations before any explicit allocation. Do not treat an unavailable tool or an empty registry
|
|
300
315
|
as an empty filesystem. Preserve uncertain checkouts and report the client limitation.
|
|
@@ -119,6 +119,8 @@ Once the organization is chosen, call `project.list` and offer that organization
|
|
|
119
119
|
|
|
120
120
|
The normal list omits archived projects. If repository entry, setup, binding or task open reports `project.restore`, use the project id and expected version returned with that refusal and restore it before continuing. If it reports `project.member_list`, the current member cannot restore the project: call that operation to check the available roles and explain that the versioned restore requires project-owner authority. Do not identify a person, reconstruct an identity from an account identifier or disclose contact details. If an owner does not yet have the project id and expected version, call `project.list` with `includeArchived: true` and let them select the archived project. Archive state never authorizes creating a duplicate project.
|
|
121
121
|
|
|
122
|
+
When the user picks an existing project and binding it is refused because the project lives in another repository, do not offer to create a new project for the same code. An owner is asked once, through the form `project.move_repository` opens, whether the project moves to this repository; the form already says which repository it leaves, which one it joins and how many tasks, memory records and work items stay with it, so add no second question of your own. Anyone else is told that a project owner runs the move, and is not asked anything.
|
|
123
|
+
|
|
122
124
|
Ask both questions again whenever a chat starts in a repository whose binding you have not confirmed in this session.
|
|
123
125
|
|
|
124
126
|
## Switching Organization or Project
|
|
@@ -359,5 +361,12 @@ safety checks still decide whether reuse is possible. Cancellation or choosing t
|
|
|
359
361
|
release it. Resume the same form via the same operation. A changed source/generation needs a fresh
|
|
360
362
|
decision, and an old answer cannot release a newly owned task.
|
|
361
363
|
|
|
364
|
+
A checkout that is gone, or that can no longer be read as a Git worktree, is left with
|
|
365
|
+
`worktree.reconcile` and `forgetUnreadable: {"externalTaskId": "..."}` from a working checkout of the
|
|
366
|
+
same project: its native question names the task and the folder name, says that Engineering Memory
|
|
367
|
+
only stops tracking it and touches no file, and warns when that task closed with its delivery
|
|
368
|
+
unsettled. Cancelling or keeping it leaves it tracked, and a readable checkout is refused there and
|
|
369
|
+
freed with `worktree.release` instead.
|
|
370
|
+
|
|
362
371
|
If the user later reconsiders a preserved legacy checkout, repeat `worktree.reconcile` with a new
|
|
363
372
|
`decisionAttempt`; never reinterpret the previous preserve answer as approval.
|