engineering-memory 1.11.10 → 1.11.12
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 +3 -2
- 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 +918 -132
- package/runtime/dist/src/mcp/questionnaire-tools.js +55 -16
- package/runtime/dist/src/mcp/tool-annotations.js +1 -0
- package/runtime/dist/src/mcp/tool-definitions.js +105 -17
- package/runtime/dist/src/mcp/worktree-tools.js +416 -86
- 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 +308 -46
- package/runtime/dist/src/runtime/create-bridge-service.js +2 -0
- package/runtime/dist/src/runtime/language-store.js +25 -0
- package/runtime/dist/src/runtime/offline-outbox.js +4 -4
- package/runtime/dist/src/runtime/privacy-detector.js +14 -5
- package/runtime/dist/src/runtime/questionnaire-store.js +26 -7
- package/runtime/dist/src/runtime/task-start.js +115 -33
- package/runtime/dist/src/runtime/worktree-pool.js +41 -3
- package/skill/references/lifecycle.md +15 -0
- package/skill/references/questionnaires.md +17 -3
|
@@ -151,10 +151,10 @@ export class OfflineOutbox {
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
|
-
export function assertSafeToPersist(value, key = '', path = key) {
|
|
154
|
+
export function assertSafeToPersist(value, key = '', path = key, questionText = false) {
|
|
155
155
|
if (typeof value === 'string' || typeof value === 'number') {
|
|
156
156
|
const detector = typeof value === 'string'
|
|
157
|
-
? restrictedValueKind(value, key)
|
|
157
|
+
? restrictedValueKind(value, key, questionText)
|
|
158
158
|
: restrictedNumberKind(value, key);
|
|
159
159
|
if (detector) {
|
|
160
160
|
throw new Error(`PII or credentials cannot be persisted: ${path || '(value)'} (${detector})`);
|
|
@@ -162,7 +162,7 @@ export function assertSafeToPersist(value, key = '', path = key) {
|
|
|
162
162
|
return;
|
|
163
163
|
}
|
|
164
164
|
if (Array.isArray(value)) {
|
|
165
|
-
value.forEach((item, index) => assertSafeToPersist(item, key, `${path}[${index}]
|
|
165
|
+
value.forEach((item, index) => assertSafeToPersist(item, key, `${path}[${index}]`, questionText));
|
|
166
166
|
return;
|
|
167
167
|
}
|
|
168
168
|
if (value !== null && typeof value === 'object') {
|
|
@@ -171,7 +171,7 @@ export function assertSafeToPersist(value, key = '', path = key) {
|
|
|
171
171
|
if (field) {
|
|
172
172
|
throw new Error(`Sensitive field cannot be persisted: ${childPath(path, childKey)} (${field})`);
|
|
173
173
|
}
|
|
174
|
-
assertSafeToPersist(item, childKey, childPath(path, childKey));
|
|
174
|
+
assertSafeToPersist(item, childKey, childPath(path, childKey), questionText);
|
|
175
175
|
});
|
|
176
176
|
}
|
|
177
177
|
}
|
|
@@ -13,6 +13,14 @@ const repositoryPathFields = new Set([
|
|
|
13
13
|
'pathChanges',
|
|
14
14
|
'templatePath',
|
|
15
15
|
]);
|
|
16
|
+
const questionTextFields = new Set([
|
|
17
|
+
'message',
|
|
18
|
+
'context',
|
|
19
|
+
'example',
|
|
20
|
+
'label',
|
|
21
|
+
'description',
|
|
22
|
+
'title',
|
|
23
|
+
]);
|
|
16
24
|
const topLevelDomains = new Set([
|
|
17
25
|
'com',
|
|
18
26
|
'net',
|
|
@@ -151,8 +159,9 @@ export function restrictedNumberKind(value, key) {
|
|
|
151
159
|
? 'phone number'
|
|
152
160
|
: null;
|
|
153
161
|
}
|
|
154
|
-
export function restrictedValueKind(value, key) {
|
|
162
|
+
export function restrictedValueKind(value, key, questionText = false) {
|
|
155
163
|
const repositoryPath = repositoryPathFields.has(key);
|
|
164
|
+
const prose = questionText && questionTextFields.has(key);
|
|
156
165
|
if (hasEmailAddress(value, repositoryPath))
|
|
157
166
|
return 'email address';
|
|
158
167
|
if (privateKeyPattern.test(value))
|
|
@@ -167,9 +176,9 @@ export function restrictedValueKind(value, key) {
|
|
|
167
176
|
return 'personal home directory';
|
|
168
177
|
if (hasBearerCredential(value))
|
|
169
178
|
return 'bearer credential';
|
|
170
|
-
if (hasPhoneNumber(value))
|
|
179
|
+
if (hasPhoneNumber(value, prose))
|
|
171
180
|
return 'phone number';
|
|
172
|
-
if (!repositoryPath && hasIpv4Address(value))
|
|
181
|
+
if (!repositoryPath && !prose && hasIpv4Address(value))
|
|
173
182
|
return 'network address';
|
|
174
183
|
if (hasSecretAssignment(value))
|
|
175
184
|
return 'assigned secret';
|
|
@@ -234,11 +243,11 @@ function hasIpv4Address(value) {
|
|
|
234
243
|
}
|
|
235
244
|
return false;
|
|
236
245
|
}
|
|
237
|
-
function hasPhoneNumber(value) {
|
|
246
|
+
function hasPhoneNumber(value, prose = false) {
|
|
238
247
|
const trimmed = value.trim();
|
|
239
248
|
if (uuidPattern.test(trimmed))
|
|
240
249
|
return false;
|
|
241
|
-
if (/^\d{10,11}$/.test(trimmed))
|
|
250
|
+
if (!prose && /^\d{10,11}$/.test(trimmed))
|
|
242
251
|
return true;
|
|
243
252
|
if (/(?:^|[^A-Z0-9])0?5\d{2}[\s.-]?\d{3}[\s.-]?\d{2}[\s.-]?\d{2}(?![A-Z0-9])/i.test(value)) {
|
|
244
253
|
return true;
|
|
@@ -7,12 +7,14 @@ import { sha256, stableStringify } from '../utilities/hash.js';
|
|
|
7
7
|
import { assertSafeToPersist } from './offline-outbox.js';
|
|
8
8
|
const identifier = z.string().regex(/^[A-Za-z0-9_-]{1,100}$/);
|
|
9
9
|
const questionId = z.string().regex(/^[a-z][a-z0-9_]{0,39}$/);
|
|
10
|
+
export const languageTag = z.string().regex(/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/);
|
|
10
11
|
const optionSchema = z.strictObject({
|
|
11
12
|
id: z
|
|
12
13
|
.string()
|
|
13
14
|
.regex(/^[A-Za-z0-9_-]{1,80}$/)
|
|
14
|
-
.refine((value) => value !== '__other__'),
|
|
15
|
+
.refine((value) => value !== '__other__' && value !== '__explain__'),
|
|
15
16
|
label: z.string().trim().min(1).max(240),
|
|
17
|
+
description: z.string().trim().min(1).max(400).optional(),
|
|
16
18
|
});
|
|
17
19
|
const textFieldSchema = z.strictObject({
|
|
18
20
|
title: z.string().trim().min(1).max(120),
|
|
@@ -27,6 +29,8 @@ const subQuestionSchema = z
|
|
|
27
29
|
.strictObject({
|
|
28
30
|
id: questionId,
|
|
29
31
|
message: z.string().trim().min(1).max(600),
|
|
32
|
+
context: z.string().trim().min(1).max(1200).optional(),
|
|
33
|
+
example: z.string().trim().min(1).max(600).optional(),
|
|
30
34
|
options: z
|
|
31
35
|
.array(optionSchema)
|
|
32
36
|
.min(2)
|
|
@@ -73,10 +77,23 @@ function attachDefinitionChecks(schema) {
|
|
|
73
77
|
const questionnaireDefinitionShape = z.strictObject({
|
|
74
78
|
questionnaireId: identifier,
|
|
75
79
|
message: z.string().trim().min(1).max(2000),
|
|
76
|
-
|
|
77
|
-
.
|
|
80
|
+
context: z
|
|
81
|
+
.string()
|
|
82
|
+
.trim()
|
|
83
|
+
.min(1)
|
|
84
|
+
.max(1200)
|
|
85
|
+
.optional()
|
|
86
|
+
.describe('Why this is being asked, in the language of the conversation.'),
|
|
87
|
+
example: z
|
|
88
|
+
.string()
|
|
89
|
+
.trim()
|
|
90
|
+
.min(1)
|
|
91
|
+
.max(600)
|
|
92
|
+
.optional()
|
|
93
|
+
.describe('One concrete example of what the decision changes.'),
|
|
94
|
+
language: languageTag
|
|
78
95
|
.optional()
|
|
79
|
-
.describe('Language
|
|
96
|
+
.describe('Language the user is writing in, as a BCP-47 tag such as tr, en or pt-BR. Write the message, context, example, option labels and descriptions in that language too.'),
|
|
80
97
|
options: z
|
|
81
98
|
.array(optionSchema)
|
|
82
99
|
.min(2)
|
|
@@ -234,7 +251,7 @@ export class QuestionnaireStore {
|
|
|
234
251
|
scopeSchema.parse(scope);
|
|
235
252
|
if (owner)
|
|
236
253
|
ownerSchema.parse(owner);
|
|
237
|
-
assertSafeToPersist(definition);
|
|
254
|
+
assertSafeToPersist(definition, '', '', true);
|
|
238
255
|
const contentHash = sha256(stableStringify(definition));
|
|
239
256
|
const question = {
|
|
240
257
|
...definition,
|
|
@@ -261,6 +278,8 @@ export class QuestionnaireStore {
|
|
|
261
278
|
const definition = boundDefinitionSchema.parse({
|
|
262
279
|
questionnaireId: question.questionnaireId,
|
|
263
280
|
message: question.message,
|
|
281
|
+
context: question.context,
|
|
282
|
+
example: question.example,
|
|
264
283
|
language: question.language,
|
|
265
284
|
options: question.options,
|
|
266
285
|
allowFreeText: question.allowFreeText,
|
|
@@ -274,7 +293,7 @@ export class QuestionnaireStore {
|
|
|
274
293
|
question.contentHash !== sha256(stableStringify(definition))) {
|
|
275
294
|
throw new Error('Questionnaire scope or content does not match its durable record.');
|
|
276
295
|
}
|
|
277
|
-
assertSafeToPersist(question);
|
|
296
|
+
assertSafeToPersist(question, '', '', true);
|
|
278
297
|
const rawAnswer = await readJson(this.pathFor(scope, questionnaireId, 'answer.json'), this.root);
|
|
279
298
|
if (!rawAnswer)
|
|
280
299
|
return { ...question, status: 'pending' };
|
|
@@ -427,7 +446,7 @@ export class QuestionnaireStore {
|
|
|
427
446
|
return join(this.scopeRoot(scope), sha256(questionnaireId), filename);
|
|
428
447
|
}
|
|
429
448
|
async publish(path, value) {
|
|
430
|
-
assertSafeToPersist(value);
|
|
449
|
+
assertSafeToPersist(value, '', '', true);
|
|
431
450
|
const temporaryPath = `${path}.${randomUUID()}.tmp`;
|
|
432
451
|
await writeJson(temporaryPath, value, this.root);
|
|
433
452
|
try {
|
|
@@ -1,4 +1,44 @@
|
|
|
1
1
|
import { sha256, stableStringify } from '../utilities/hash.js';
|
|
2
|
+
const startCopy = {
|
|
3
|
+
en: {
|
|
4
|
+
start: (task) => `${task}: how should this task start?`,
|
|
5
|
+
context: 'A task works on its own branch so that unfinished work never mixes with what is already open in this folder. Nothing is created, checked out or reserved until you answer. Your answer is kept, so asking for the same task again does not ask you twice.',
|
|
6
|
+
example: 'Choosing a separate worktree creates the branch in a folder of its own and leaves this folder on the branch and the files you have open right now.',
|
|
7
|
+
keepContext: 'This task continues on the branch this folder is already on. Nothing is created, checked out or reserved until you answer. Your answer is kept, so asking for the same task again does not ask you twice.',
|
|
8
|
+
roles: { development: 'development', production: 'production', test: 'test' },
|
|
9
|
+
holder: (task, branch, when) => `${task} holds this folder (branch ${branch}, last activity ${when}). If it moves out it stays open, keeps its own branch and continues in a separate worktree when it is resumed.`,
|
|
10
|
+
unknown: 'unknown',
|
|
11
|
+
separate: 'A managed folder of its own is prepared and the new branch is created there. This folder is left on its branch and its files are not touched.',
|
|
12
|
+
inPlace: 'The new branch is created here and this folder is switched to it.',
|
|
13
|
+
moveOut: (task) => `${task} keeps its branch and its work and continues in a separate worktree later. The new branch is checked out here.`,
|
|
14
|
+
stay: (source) => `Nothing new is created. The work continues on ${source} in this folder, so every commit lands on that branch.`,
|
|
15
|
+
later: 'Nothing is created or reserved. The task waits until you ask to start it.',
|
|
16
|
+
fetched: (role, source) => `The project's ${role} branch ${source} is fetched from the remote and the new branch starts from that exact commit.`,
|
|
17
|
+
pinned: (source) => `The new branch starts where this folder is right now, ${source}. Nothing is fetched.`,
|
|
18
|
+
typedBranch: 'Write a branch name on origin below. It is fetched and the new branch starts from it.',
|
|
19
|
+
suggested: 'The branch is created with this name.',
|
|
20
|
+
typedName: 'Write the name below. A name that already exists is refused and the form is asked again.',
|
|
21
|
+
},
|
|
22
|
+
tr: {
|
|
23
|
+
start: (task) => `${task} nasıl başlasın?`,
|
|
24
|
+
context: 'Her görev kendi dalında çalışır; böylece yarım kalan iş, bu klasörde açık olan işe karışmaz. Sen cevaplayana kadar hiçbir klasör açılmaz, hiçbir dal oluşturulmaz, hiçbir yer ayrılmaz. Verdiğin cevap saklanır; aynı görev için ikinci kez sorulmaz.',
|
|
25
|
+
example: 'Ayrı worktree’yi seçersen yeni dal kendi klasöründe açılır; bu klasör şu anki dalında ve açık dosyalarıyla olduğu gibi kalır.',
|
|
26
|
+
keepContext: 'Bu görev, klasörün zaten üzerinde olduğu dalda devam eder. Sen cevaplayana kadar hiçbir şey oluşturulmaz, hiçbir yer ayrılmaz. Verdiğin cevap saklanır; aynı görev için yeniden sorulmaz.',
|
|
27
|
+
roles: { development: 'geliştirme', production: 'canlı', test: 'test' },
|
|
28
|
+
holder: (task, branch, when) => `${task} görevi bu klasörü tutuyor (dal ${branch}, son etkinlik ${when}). Buradan çıkarılırsa görev kapanmaz; kendi dalını ve işini korur, devam ettiğinde ayrı bir worktree’de açılır.`,
|
|
29
|
+
unknown: 'bilinmiyor',
|
|
30
|
+
separate: 'Görev için ayrı bir klasör hazırlanır ve yeni dal orada açılır. Bu klasör kendi dalında kalır, dosyalarına dokunulmaz.',
|
|
31
|
+
inPlace: 'Yeni dal burada açılır ve bu klasör o dala geçer.',
|
|
32
|
+
moveOut: (task) => `${task} dalını ve işini korur, sonra ayrı bir worktree’de devam eder. Yeni dal burada açılır.`,
|
|
33
|
+
stay: (source) => `Yeni bir şey oluşturulmaz. İş bu klasörde ${source} üzerinde sürer; her commit o dala gider.`,
|
|
34
|
+
later: 'Hiçbir şey oluşturulmaz, hiçbir yer ayrılmaz. Görev sen isteyene kadar beklemede kalır.',
|
|
35
|
+
fetched: (role, source) => `Projenin ${role} dalı ${source} uzak sunucudan çekilir ve yeni dal tam o commit’ten başlar.`,
|
|
36
|
+
pinned: (source) => `Yeni dal, bu klasörün şu an üzerinde olduğu ${source} noktasından başlar. Hiçbir şey çekilmez.`,
|
|
37
|
+
typedBranch: 'origin üzerindeki dal adını aşağıya yaz. O dal çekilir ve yeni dal ondan başlar.',
|
|
38
|
+
suggested: 'Dal bu adla açılır.',
|
|
39
|
+
typedName: 'Adı aşağıya yaz. Zaten var olan bir ad kabul edilmez, form yeniden sorulur.',
|
|
40
|
+
},
|
|
41
|
+
};
|
|
2
42
|
export function suggestedBranchNames(externalTaskId) {
|
|
3
43
|
const slug = externalTaskId
|
|
4
44
|
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
@@ -34,6 +74,7 @@ export function configuredBase(preferences, role) {
|
|
|
34
74
|
}
|
|
35
75
|
export function taskStartDefinition(facts) {
|
|
36
76
|
const tr = facts.language === 'tr';
|
|
77
|
+
const copy = startCopy[tr ? 'tr' : 'en'];
|
|
37
78
|
const folder = facts.folder;
|
|
38
79
|
const keepOnly = facts.keepCurrent === true || !facts.currentCommit;
|
|
39
80
|
const here = !keepOnly && !folder.managed && !folder.heldBy && folder.clean;
|
|
@@ -46,14 +87,26 @@ export function taskStartDefinition(facts) {
|
|
|
46
87
|
currentCommit: facts.currentCommit,
|
|
47
88
|
holder,
|
|
48
89
|
};
|
|
90
|
+
const heldNotice = here && folder.holder
|
|
91
|
+
? copy.holder(folder.holder.externalTaskId, folder.holder.branch ?? 'detached HEAD', readableTime(folder.holder.lastCheckpointAt, tr) ?? copy.unknown)
|
|
92
|
+
: null;
|
|
93
|
+
const withheldNotice = withheld(folder, keepOnly, tr);
|
|
94
|
+
const locationContext = [heldNotice, withheldNotice].filter(Boolean).join(' ');
|
|
49
95
|
const questions = [
|
|
50
96
|
{
|
|
51
97
|
id: 'location',
|
|
52
98
|
message: tr ? 'Nerede başlasın?' : 'Where should it start?',
|
|
99
|
+
...(locationContext ? { context: locationContext } : {}),
|
|
53
100
|
options: [
|
|
54
101
|
...(keepOnly
|
|
55
102
|
? []
|
|
56
|
-
: [
|
|
103
|
+
: [
|
|
104
|
+
{
|
|
105
|
+
id: 'worktree',
|
|
106
|
+
label: tr ? 'Ayrı bir worktree’de' : 'In a separate worktree',
|
|
107
|
+
description: copy.separate,
|
|
108
|
+
},
|
|
109
|
+
]),
|
|
57
110
|
...(here
|
|
58
111
|
? [
|
|
59
112
|
{
|
|
@@ -65,6 +118,7 @@ export function taskStartDefinition(facts) {
|
|
|
65
118
|
: tr
|
|
66
119
|
? 'Bu klasörde yeni branch olarak'
|
|
67
120
|
: 'In this folder, as a new branch',
|
|
121
|
+
description: holder ? copy.moveOut(holder.externalTaskId) : copy.inPlace,
|
|
68
122
|
},
|
|
69
123
|
]
|
|
70
124
|
: []),
|
|
@@ -75,10 +129,11 @@ export function taskStartDefinition(facts) {
|
|
|
75
129
|
label: tr
|
|
76
130
|
? 'Burada, ' + currentLabel + ' üzerinde, yeni branch açmadan'
|
|
77
131
|
: 'Here on ' + currentLabel + ', without a new branch',
|
|
132
|
+
description: copy.stay(currentLabel),
|
|
78
133
|
},
|
|
79
134
|
]
|
|
80
135
|
: []),
|
|
81
|
-
{ id: 'defer', label: tr ? 'Şimdilik başlatma' : 'Not now' },
|
|
136
|
+
{ id: 'defer', label: tr ? 'Şimdilik başlatma' : 'Not now', description: copy.later },
|
|
82
137
|
],
|
|
83
138
|
},
|
|
84
139
|
];
|
|
@@ -92,6 +147,7 @@ export function taskStartDefinition(facts) {
|
|
|
92
147
|
const roleOption = ({ role, base }) => ({
|
|
93
148
|
id: role,
|
|
94
149
|
label: base.remote + '/' + base.branch + ' (' + role + ')',
|
|
150
|
+
description: copy.fetched(copy.roles[role], base.remote + '/' + base.branch),
|
|
95
151
|
});
|
|
96
152
|
binding.bases = {
|
|
97
153
|
current: {
|
|
@@ -108,9 +164,14 @@ export function taskStartDefinition(facts) {
|
|
|
108
164
|
{
|
|
109
165
|
id: 'current',
|
|
110
166
|
label: tr ? 'Bu klasörün dalı: ' + currentLabel : 'This folder’s branch: ' + currentLabel,
|
|
167
|
+
description: copy.pinned(currentLabel),
|
|
111
168
|
},
|
|
112
169
|
...roles.slice(1).map(roleOption),
|
|
113
|
-
{
|
|
170
|
+
{
|
|
171
|
+
id: 'other',
|
|
172
|
+
label: tr ? 'Başka bir uzak dal' : 'Another remote branch',
|
|
173
|
+
description: copy.typedBranch,
|
|
174
|
+
},
|
|
114
175
|
],
|
|
115
176
|
otherAsText: true,
|
|
116
177
|
textField: {
|
|
@@ -128,8 +189,8 @@ export function taskStartDefinition(facts) {
|
|
|
128
189
|
id: 'name',
|
|
129
190
|
message: tr ? 'Yeni branch’in adı ne olsun?' : 'What should the new branch be called?',
|
|
130
191
|
options: [
|
|
131
|
-
{ id: 'suggested', label: facts.suggestedName },
|
|
132
|
-
{ id: 'custom', label: tr ? 'Başka bir ad' : 'Another name' },
|
|
192
|
+
{ id: 'suggested', label: facts.suggestedName, description: copy.suggested },
|
|
193
|
+
{ id: 'custom', label: tr ? 'Başka bir ad' : 'Another name', description: copy.typedName },
|
|
133
194
|
],
|
|
134
195
|
textField: {
|
|
135
196
|
title: tr ? 'Branch adı' : 'Branch name',
|
|
@@ -140,24 +201,7 @@ export function taskStartDefinition(facts) {
|
|
|
140
201
|
}
|
|
141
202
|
else if (!keepOnly)
|
|
142
203
|
binding.name = facts.name;
|
|
143
|
-
const
|
|
144
|
-
...(here && folder.holder
|
|
145
|
-
? [
|
|
146
|
-
tr
|
|
147
|
-
? folder.holder.externalTaskId +
|
|
148
|
-
' bu klasörü tutuyor (branch ' +
|
|
149
|
-
(folder.holder.branch ?? 'detached HEAD') +
|
|
150
|
-
', son etkinlik ' +
|
|
151
|
-
(folder.holder.lastCheckpointAt ?? 'bilinmiyor') +
|
|
152
|
-
'). Çıkarılırsa görev açık kalır ve devam ederken ayrı bir worktree’de açılır.'
|
|
153
|
-
: folder.holder.externalTaskId +
|
|
154
|
-
' holds this folder (branch ' +
|
|
155
|
-
(folder.holder.branch ?? 'detached HEAD') +
|
|
156
|
-
', last activity ' +
|
|
157
|
-
(folder.holder.lastCheckpointAt ?? 'unknown') +
|
|
158
|
-
'). If it moves out, it stays open and continues in a separate worktree when resumed.',
|
|
159
|
-
]
|
|
160
|
-
: []),
|
|
204
|
+
const chosen = [
|
|
161
205
|
...(binding.base
|
|
162
206
|
? [
|
|
163
207
|
tr
|
|
@@ -168,25 +212,57 @@ export function taskStartDefinition(facts) {
|
|
|
168
212
|
...(binding.name
|
|
169
213
|
? [tr ? 'Yeni branch: ' + binding.name + '.' : 'New branch: ' + binding.name + '.']
|
|
170
214
|
: []),
|
|
171
|
-
...(withheld(folder, keepOnly, tr) ? [withheld(folder, keepOnly, tr)] : []),
|
|
172
215
|
];
|
|
173
|
-
const
|
|
216
|
+
const shared = {
|
|
217
|
+
language: facts.language,
|
|
218
|
+
terminalChoices: [
|
|
219
|
+
{ questionId: 'location', choice: 'defer' },
|
|
220
|
+
...(keep ? [{ questionId: 'location', choice: 'keep_current' }] : []),
|
|
221
|
+
],
|
|
222
|
+
binding: JSON.parse(JSON.stringify(binding)),
|
|
223
|
+
};
|
|
224
|
+
const previous = {
|
|
174
225
|
message: [
|
|
175
226
|
tr
|
|
176
227
|
? facts.externalTaskId + ' nasıl başlasın? Sen cevaplayana kadar hiçbir şey oluşturulmaz.'
|
|
177
228
|
: facts.externalTaskId +
|
|
178
229
|
': how should this task start? Nothing is created until you answer.',
|
|
179
|
-
...
|
|
230
|
+
...(here && folder.holder
|
|
231
|
+
? [
|
|
232
|
+
tr
|
|
233
|
+
? folder.holder.externalTaskId +
|
|
234
|
+
' bu klasörü tutuyor (branch ' +
|
|
235
|
+
(folder.holder.branch ?? 'detached HEAD') +
|
|
236
|
+
', son etkinlik ' +
|
|
237
|
+
(folder.holder.lastCheckpointAt ?? 'bilinmiyor') +
|
|
238
|
+
'). Çıkarılırsa görev açık kalır ve devam ederken ayrı bir worktree’de açılır.'
|
|
239
|
+
: folder.holder.externalTaskId +
|
|
240
|
+
' holds this folder (branch ' +
|
|
241
|
+
(folder.holder.branch ?? 'detached HEAD') +
|
|
242
|
+
', last activity ' +
|
|
243
|
+
(folder.holder.lastCheckpointAt ?? 'unknown') +
|
|
244
|
+
'). If it moves out, it stays open and continues in a separate worktree when resumed.',
|
|
245
|
+
]
|
|
246
|
+
: []),
|
|
247
|
+
...chosen,
|
|
248
|
+
...(withheldNotice ? [withheldNotice] : []),
|
|
180
249
|
].join(' '),
|
|
181
|
-
|
|
250
|
+
...shared,
|
|
251
|
+
questions: questions.map(({ context: _why, options, ...question }) => ({
|
|
252
|
+
...question,
|
|
253
|
+
options: options.map(({ description: _next, ...option }) => option),
|
|
254
|
+
})),
|
|
255
|
+
};
|
|
256
|
+
const questionnaireId = 'task-start-' + sha256(stableStringify(previous));
|
|
257
|
+
return {
|
|
258
|
+
questionnaireId,
|
|
259
|
+
message: [copy.start(facts.externalTaskId), ...chosen].join(' '),
|
|
260
|
+
context: keepOnly ? copy.keepContext : copy.context,
|
|
261
|
+
example: keepOnly ? copy.stay(currentLabel) : copy.example,
|
|
262
|
+
...shared,
|
|
182
263
|
questions,
|
|
183
|
-
|
|
184
|
-
{ questionId: 'location', choice: 'defer' },
|
|
185
|
-
...(keep ? [{ questionId: 'location', choice: 'keep_current' }] : []),
|
|
186
|
-
],
|
|
187
|
-
binding: JSON.parse(JSON.stringify(binding)),
|
|
264
|
+
previous: { questionnaireId, ...previous },
|
|
188
265
|
};
|
|
189
|
-
return { questionnaireId: 'task-start-' + sha256(stableStringify(content)), ...content };
|
|
190
266
|
}
|
|
191
267
|
export function taskStartChoice(record) {
|
|
192
268
|
if (record.status !== 'answered' ||
|
|
@@ -264,6 +340,12 @@ function holderIdentity(holder) {
|
|
|
264
340
|
generation: holder.generation,
|
|
265
341
|
};
|
|
266
342
|
}
|
|
343
|
+
function readableTime(value, tr) {
|
|
344
|
+
const [, date, time] = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(value ?? '') ?? [];
|
|
345
|
+
if (!date || !time)
|
|
346
|
+
return null;
|
|
347
|
+
return (tr ? date.split('-').reverse().join('.') : date) + ' ' + time + ' UTC';
|
|
348
|
+
}
|
|
267
349
|
function withheld(folder, keepOnly, tr) {
|
|
268
350
|
if (keepOnly || folder.managed || (!folder.heldBy && folder.clean))
|
|
269
351
|
return null;
|
|
@@ -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.
|
|
@@ -6,7 +6,11 @@ Use the host's native questionnaire for every question to the user, including im
|
|
|
6
6
|
|
|
7
7
|
Use `questionnaire.ask` for a required decision. It records the question before opening a native MCP form. Use a stable question identifier belonging to the current task and decision, so a retry returns to the same question instead of creating another one. A new decision needs its own identifier; an answer to an earlier proposal, branch or delivery does not approve a later one.
|
|
8
8
|
|
|
9
|
-
Pass `repoRoot`, `questionnaireId`, `message` and two to twelve `options`, each with an `id` and `label`. Identifiers use letters, digits, underscores or hyphens.
|
|
9
|
+
Pass `repoRoot`, `questionnaireId`, `message` and two to twelve `options`, each with an `id` and `label`. Identifiers use letters, digits, underscores or hyphens.
|
|
10
|
+
|
|
11
|
+
A question has to be answerable by someone who has not followed the work. Say in `context` why it is being asked, give in `example` one concrete thing the decision changes, and give every option a `description` of what happens next if it is chosen. Show a record, a branch or a project by its title or name; an identifier or a hash in the visible text is refused, and one that really has to be shown goes inside backticks. Write all of it in the language the user is writing in and pass that language as `language`, a BCP-47 tag such as `tr`, `en` or `pt-BR`. The tag is remembered for the account and the computer, so it is asked for once: `session.entry` and every tool that opens a form take the same argument, and `questionnaire.ask` is refused while no language is known.
|
|
12
|
+
|
|
13
|
+
Every form carries one more choice, "I did not understand; explain in more detail first". It is never recorded as an answer. When a call returns `needs_explanation`, explain in chat why the question is asked, what each option leads to and one example, then show the same question again with `questionnaire.resume`. A free-text reply that says the question was not understood is handled the same way and is not passed on as the decision. Set `allowFreeText` only when the user needs to give an answer outside those options. `questionnaire.resume` takes the same `repoRoot` and `questionnaireId`. These tools collect decisions; they do not commit, publish, bind a project or perform the selected action. Apply the accepted answer through the relevant lifecycle tool, checking the current target and version first.
|
|
10
14
|
|
|
11
15
|
Fixed-choice answers can be replayed from the local receipt. Free text is returned only in the accepting call and is never stored. If a later receipt says the answer is unavailable, recover it from the user's actual answer in the conversation; never reconstruct it from the options. If it cannot be recovered, explain that it must be requested again using a new question identifier.
|
|
12
16
|
|
|
@@ -119,6 +123,8 @@ Once the organization is chosen, call `project.list` and offer that organization
|
|
|
119
123
|
|
|
120
124
|
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
125
|
|
|
126
|
+
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.
|
|
127
|
+
|
|
122
128
|
Ask both questions again whenever a chat starts in a repository whose binding you have not confirmed in this session.
|
|
123
129
|
|
|
124
130
|
## Switching Organization or Project
|
|
@@ -322,8 +328,9 @@ Saving a profile does not authorize filesystem initialization.
|
|
|
322
328
|
## Inspection confirmation language
|
|
323
329
|
|
|
324
330
|
For memory.sync_start, pass language: tr in a Turkish conversation and language: en in an English
|
|
325
|
-
conversation. Use the conversation language, never the computer locale.
|
|
326
|
-
|
|
331
|
+
conversation. Use the conversation language, never the computer locale. A call without language uses
|
|
332
|
+
the language remembered from an earlier call, and English when none is known. The bridge's own forms
|
|
333
|
+
have Turkish and English text; any other language opens them in English. The form shows a short source reference, scope, the treatment of uncommitted changes and
|
|
327
334
|
the separate approval needed for permanent memory. Full commit and request hashes stay in the durable
|
|
328
335
|
approval binding, not in the visible question. Generic questionnaire.ask messages and option labels
|
|
329
336
|
are written by the agent in the same language; pass language for the native help and field labels.
|
|
@@ -359,5 +366,12 @@ safety checks still decide whether reuse is possible. Cancellation or choosing t
|
|
|
359
366
|
release it. Resume the same form via the same operation. A changed source/generation needs a fresh
|
|
360
367
|
decision, and an old answer cannot release a newly owned task.
|
|
361
368
|
|
|
369
|
+
A checkout that is gone, or that can no longer be read as a Git worktree, is left with
|
|
370
|
+
`worktree.reconcile` and `forgetUnreadable: {"externalTaskId": "..."}` from a working checkout of the
|
|
371
|
+
same project: its native question names the task and the folder name, says that Engineering Memory
|
|
372
|
+
only stops tracking it and touches no file, and warns when that task closed with its delivery
|
|
373
|
+
unsettled. Cancelling or keeping it leaves it tracked, and a readable checkout is refused there and
|
|
374
|
+
freed with `worktree.release` instead.
|
|
375
|
+
|
|
362
376
|
If the user later reconsiders a preserved legacy checkout, repeat `worktree.reconcile` with a new
|
|
363
377
|
`decisionAttempt`; never reinterpret the previous preserve answer as approval.
|