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
|
@@ -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';
|
|
@@ -12,6 +13,7 @@ import { startPhaseTimer } from './phase-timer.js';
|
|
|
12
13
|
import { sha256, stableStringify } from '../utilities/hash.js';
|
|
13
14
|
import { ApiResponseError, BackendUnavailableError, } from './api-client.js';
|
|
14
15
|
import { assertSafeToPersist, normalizeRepositoryPaths } from './offline-outbox.js';
|
|
16
|
+
import { restrictedValueKind } from './privacy-detector.js';
|
|
15
17
|
import { principalFingerprint } from './principal-state.js';
|
|
16
18
|
import { sameHolder, suggestedBranchNames, taskStartChoice } from './task-start.js';
|
|
17
19
|
import { BridgeRecoveryError } from './recovery-error.js';
|
|
@@ -48,13 +50,21 @@ export class BridgeService {
|
|
|
48
50
|
managesWorktrees() {
|
|
49
51
|
return Boolean(this.dependencies.worktreePool);
|
|
50
52
|
}
|
|
53
|
+
async language(told) {
|
|
54
|
+
const accessToken = await this.dependencies.credentials.get('access-token');
|
|
55
|
+
const principalHash = accessToken ? principalFingerprint(accessToken) : sha256('anonymous');
|
|
56
|
+
if (told)
|
|
57
|
+
await this.dependencies.languages.remember(principalHash, told);
|
|
58
|
+
return told ?? (await this.dependencies.languages.read(principalHash));
|
|
59
|
+
}
|
|
51
60
|
async questionnaireAsk(input, previousDefinitions = [], owner) {
|
|
52
61
|
const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
|
|
53
62
|
const { repoRoot: _repoRoot, preparation: _preparation, presentation: _presentation, ...definition } = input;
|
|
54
63
|
const askingTask = owner || input.preparation
|
|
55
64
|
? null
|
|
56
65
|
: await this.checkoutTask(scope.repoFingerprint, await this.dependencies.repositories.git.findRoot(input.repoRoot ?? process.cwd()));
|
|
57
|
-
|
|
66
|
+
const record = await this.dependencies.questionnaires.ask(scope, definition, previousDefinitions, owner ?? (askingTask ? { tool: 'questionnaire.ask', externalTaskId: askingTask } : undefined));
|
|
67
|
+
return { ...record, language: record.language ?? (await this.language()) };
|
|
58
68
|
}
|
|
59
69
|
async checkoutTask(repoFingerprint, repoRoot) {
|
|
60
70
|
const worktreeId = sha256(await canonicalPath(repoRoot));
|
|
@@ -70,7 +80,7 @@ export class BridgeService {
|
|
|
70
80
|
const record = await this.dependencies.questionnaires.get(scope, input.questionnaireId);
|
|
71
81
|
if (!record)
|
|
72
82
|
throw refuse('No questionnaire exists for this account and repository binding.', 'session.entry');
|
|
73
|
-
return record;
|
|
83
|
+
return { ...record, language: record.language ?? (await this.language()) };
|
|
74
84
|
}
|
|
75
85
|
async questionnaireWithdraw(input) {
|
|
76
86
|
const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
|
|
@@ -263,13 +273,7 @@ export class BridgeService {
|
|
|
263
273
|
});
|
|
264
274
|
let projectId = repository.projectId;
|
|
265
275
|
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
|
-
});
|
|
276
|
+
const resolved = await this.dependencies.client.request(endpoints.projectResolve, { method: 'POST', body: await this.checkoutIdentity(repository) });
|
|
273
277
|
const project = objectValue(resolved.data);
|
|
274
278
|
const restoreRequired = Boolean(project?.archivedAt);
|
|
275
279
|
const restoreGuidance = archivedProjectGuidance(project);
|
|
@@ -283,6 +287,7 @@ export class BridgeService {
|
|
|
283
287
|
projects: projects.data,
|
|
284
288
|
outbox: { recoveredDeliveries, ...objectOrEmpty(outbox) },
|
|
285
289
|
...(restoreRequired ? restoreGuidance : {}),
|
|
290
|
+
...movedProjectGuidance(project),
|
|
286
291
|
});
|
|
287
292
|
}
|
|
288
293
|
if (!projectId) {
|
|
@@ -308,7 +313,7 @@ export class BridgeService {
|
|
|
308
313
|
this.poolAllocations.set(repository.repoRoot, poolAllocation);
|
|
309
314
|
}
|
|
310
315
|
const checkpointId = deterministicUuid('session.bootstrap', projectId, repository.repoFingerprint, input.externalTaskId);
|
|
311
|
-
const response = await this.
|
|
316
|
+
const response = await this.requestWithNewerFields(endpoints.sessionBootstrap, ['checkoutFingerprints'], {
|
|
312
317
|
method: 'POST',
|
|
313
318
|
idempotencyKey: checkpointId,
|
|
314
319
|
body: cleanJson({
|
|
@@ -321,6 +326,7 @@ export class BridgeService {
|
|
|
321
326
|
workItemId: persistedBootstrap.workItemId,
|
|
322
327
|
mode: persistedBootstrap.mode ?? 'write',
|
|
323
328
|
repoFingerprint: repository.repoFingerprint,
|
|
329
|
+
checkoutFingerprints: await this.dependencies.repositories.checkoutFingerprints(repository.repoRoot),
|
|
324
330
|
...(input.mode === 'read_only'
|
|
325
331
|
? { baselineDiffHash: repository.git.worktreeHash }
|
|
326
332
|
: {}),
|
|
@@ -1245,7 +1251,9 @@ export class BridgeService {
|
|
|
1245
1251
|
? await this.dependencies.questionnaires.get(scope, input.questionnaireId)
|
|
1246
1252
|
: null;
|
|
1247
1253
|
const digest = sha256(stableStringify(input.choice));
|
|
1248
|
-
if (question?.answer?.choice !== 'approve' ||
|
|
1254
|
+
if (question?.answer?.choice !== 'approve' ||
|
|
1255
|
+
(question.binding?.choiceDigest !== digest &&
|
|
1256
|
+
!question.message.includes(digest)))
|
|
1249
1257
|
throw refuse('Approve the exact mode and profile through project.onboard before continuing.', 'session.entry');
|
|
1250
1258
|
return asJsonValue(await this.dependencies.onboarding.save(scope, input.choice));
|
|
1251
1259
|
}
|
|
@@ -1612,14 +1620,45 @@ export class BridgeService {
|
|
|
1612
1620
|
}
|
|
1613
1621
|
return [...pending];
|
|
1614
1622
|
}
|
|
1615
|
-
ruleDeviationQuestions(input) {
|
|
1623
|
+
async ruleDeviationQuestions(input) {
|
|
1624
|
+
const files = reviewedFiles(input);
|
|
1625
|
+
if (!files.some(({ rules }) => rules.some(isDeviation)))
|
|
1626
|
+
return [];
|
|
1616
1627
|
const language = input.language ?? 'en';
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1628
|
+
const other = language === 'tr' ? 'en' : 'tr';
|
|
1629
|
+
const titles = await this.governingRuleTitles(input).catch(() => new Map());
|
|
1630
|
+
return files.flatMap(({ path, rules }) => rules.filter(isDeviation).map((rule) => {
|
|
1631
|
+
const title = titles.get(rule.resourceId);
|
|
1632
|
+
return {
|
|
1633
|
+
definition: ruleDeviationQuestion(input.taskId, path, rule, title, language),
|
|
1634
|
+
previousDefinitions: [
|
|
1635
|
+
ruleDeviationQuestion(input.taskId, path, rule, title, other),
|
|
1636
|
+
earlierRuleDeviationQuestion(input.taskId, path, rule, 'en'),
|
|
1637
|
+
earlierRuleDeviationQuestion(input.taskId, path, rule, 'tr'),
|
|
1638
|
+
],
|
|
1639
|
+
};
|
|
1640
|
+
}));
|
|
1641
|
+
}
|
|
1642
|
+
async governingRuleTitles(input) {
|
|
1643
|
+
const { repoFingerprint } = await this.dependencies.repositories.resolveIdentity(input.repoRoot ?? process.cwd());
|
|
1644
|
+
const pointer = await this.dependencies.activeContexts.loadForTask(repoFingerprint, input.taskId);
|
|
1645
|
+
const prepared = pointer
|
|
1646
|
+
? await this.dependencies.client.readCached(resumeCacheKey(pointer.sessionId, repoFingerprint, 0))
|
|
1647
|
+
: null;
|
|
1648
|
+
const groups = objectValue(prepared?.data)?.governingRules;
|
|
1649
|
+
return new Map((Array.isArray(groups) ? groups : [])
|
|
1650
|
+
.flatMap((group) => {
|
|
1651
|
+
const rules = objectValue(group)?.rules;
|
|
1652
|
+
return Array.isArray(rules) ? rules : [];
|
|
1653
|
+
})
|
|
1654
|
+
.flatMap((entry) => {
|
|
1655
|
+
const rule = objectValue(entry);
|
|
1656
|
+
return typeof rule?.resourceId === 'string' &&
|
|
1657
|
+
typeof rule.title === 'string' &&
|
|
1658
|
+
!restrictedValueKind(rule.title, 'message', true)
|
|
1659
|
+
? [[rule.resourceId, rule.title]]
|
|
1660
|
+
: [];
|
|
1661
|
+
}));
|
|
1623
1662
|
}
|
|
1624
1663
|
async taskSelfReview(input) {
|
|
1625
1664
|
return await this.execute(async () => {
|
|
@@ -2305,15 +2344,19 @@ export class BridgeService {
|
|
|
2305
2344
|
? {
|
|
2306
2345
|
nextAction: 'worktree.reconcile with retireLegacy when the user wants the old folder removed; it is never reused.',
|
|
2307
2346
|
}
|
|
2308
|
-
: item
|
|
2347
|
+
: unreadableWorktree(item)
|
|
2309
2348
|
? {
|
|
2310
|
-
nextAction:
|
|
2349
|
+
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
2350
|
}
|
|
2312
|
-
:
|
|
2351
|
+
: item.phase === 'delivery'
|
|
2313
2352
|
? {
|
|
2314
|
-
nextAction: '
|
|
2353
|
+
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
2354
|
}
|
|
2316
|
-
:
|
|
2355
|
+
: !item.managed && item.phase !== 'released'
|
|
2356
|
+
? {
|
|
2357
|
+
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.',
|
|
2358
|
+
}
|
|
2359
|
+
: {}),
|
|
2317
2360
|
}));
|
|
2318
2361
|
const retired = items.some((item) => item.activity === 'retired');
|
|
2319
2362
|
const selected = await pool.forPath(projectId, repository.repoRoot);
|
|
@@ -2363,6 +2406,42 @@ export class BridgeService {
|
|
|
2363
2406
|
await pool.releaseUnowned(projectId, repository.repoRoot, input.generation, input.sourceCommit);
|
|
2364
2407
|
return asJsonValue({ operation, complete: true, repoRoot: repository.repoRoot });
|
|
2365
2408
|
}
|
|
2409
|
+
if (operation === 'forget_unreadable') {
|
|
2410
|
+
const entry = input.externalTaskId
|
|
2411
|
+
? await pool.forSlug(projectId, input.externalTaskId)
|
|
2412
|
+
: null;
|
|
2413
|
+
if (!entry || entry.generation !== input.generation || !input.decisionId)
|
|
2414
|
+
throw refuse('The unreadable checkout changed after it was asked about. Call worktree.list again before forgetting it.', 'worktree.list');
|
|
2415
|
+
if (input.decisionId !== forgetUnreadableQuestionId(entry, input.decisionAttempt ?? 0))
|
|
2416
|
+
throw refuse('The decision does not match this task and checkout. Repeat worktree.reconcile with forgetUnreadable.', 'worktree.reconcile');
|
|
2417
|
+
const decision = await this.questionnaireResume({
|
|
2418
|
+
repoRoot: repository.repoRoot,
|
|
2419
|
+
questionnaireId: input.decisionId,
|
|
2420
|
+
});
|
|
2421
|
+
if (decision.status !== 'answered' || decision.answer?.choice !== 'forget')
|
|
2422
|
+
throw refuse('A checkout stops being tracked only after the user approves it in the native worktree.reconcile form.', 'worktree.reconcile');
|
|
2423
|
+
const forgotten = await pool.forgetUnreadable(projectId, entry.externalTaskId, entry.generation);
|
|
2424
|
+
this.poolAllocations.delete(forgotten.repoRoot);
|
|
2425
|
+
const folderPresent = await lstat(forgotten.repoRoot).then(() => true, () => false);
|
|
2426
|
+
const pointer = await this.dependencies.activeContexts.loadForSlug(forgotten.repoFingerprint, forgotten.externalTaskId);
|
|
2427
|
+
if (pointer?.worktreeId === sha256(forgotten.repoRoot))
|
|
2428
|
+
await this.parkPointer(pointer);
|
|
2429
|
+
return asJsonValue({
|
|
2430
|
+
operation,
|
|
2431
|
+
complete: true,
|
|
2432
|
+
forgotten: {
|
|
2433
|
+
externalTaskId: forgotten.externalTaskId,
|
|
2434
|
+
repoRoot: forgotten.repoRoot,
|
|
2435
|
+
branch: forgotten.branch,
|
|
2436
|
+
pendingDelivery: forgotten.pendingDelivery,
|
|
2437
|
+
},
|
|
2438
|
+
filesTouched: false,
|
|
2439
|
+
folderPresent,
|
|
2440
|
+
nextAction: folderPresent
|
|
2441
|
+
? `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.`
|
|
2442
|
+
: `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.`,
|
|
2443
|
+
});
|
|
2444
|
+
}
|
|
2366
2445
|
if (operation === 'retire') {
|
|
2367
2446
|
const entry = input.worktreePath ? await pool.forPath(projectId, input.worktreePath) : null;
|
|
2368
2447
|
if (!entry ||
|
|
@@ -2857,8 +2936,8 @@ export class BridgeService {
|
|
|
2857
2936
|
organizationId: input.organizationId,
|
|
2858
2937
|
projectName: input.projectName,
|
|
2859
2938
|
projectSlug: input.projectSlug ?? slugify(input.projectName),
|
|
2860
|
-
|
|
2861
|
-
|
|
2939
|
+
...(await this.checkoutIdentity(repository)),
|
|
2940
|
+
remoteIdentity: await this.dependencies.repositories.git.remoteIdentity(repository.repoRoot),
|
|
2862
2941
|
framework: input.framework,
|
|
2863
2942
|
developmentArea: input.developmentArea,
|
|
2864
2943
|
figmaConfig: input.figmaConfig,
|
|
@@ -2869,7 +2948,7 @@ export class BridgeService {
|
|
|
2869
2948
|
}
|
|
2870
2949
|
const persistentBody = normalizeRepositoryPaths(body, repository.repoRoot);
|
|
2871
2950
|
assertSafeToPersist(persistentBody);
|
|
2872
|
-
const response = await this.
|
|
2951
|
+
const response = await this.requestWithNewerFields(endpoints.projectSetup, ['remoteIdentity'], {
|
|
2873
2952
|
method: 'POST',
|
|
2874
2953
|
body: persistentBody,
|
|
2875
2954
|
});
|
|
@@ -2912,7 +2991,7 @@ export class BridgeService {
|
|
|
2912
2991
|
else if (stableStringify(current.branches) !== stableStringify(choice.gitPreferences))
|
|
2913
2992
|
throw refuse('The project already has different shared branch preferences. Review them before changing the project settings.', 'project.git_preferences');
|
|
2914
2993
|
}
|
|
2915
|
-
const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, project.id, project.repoFingerprint);
|
|
2994
|
+
const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, project.id, project.repoFingerprint, livesIn(project));
|
|
2916
2995
|
return asJsonValue({ ...data, marker: null, markerPath: null, bindingPath });
|
|
2917
2996
|
});
|
|
2918
2997
|
}
|
|
@@ -3219,12 +3298,12 @@ export class BridgeService {
|
|
|
3219
3298
|
repository: publicRepository(repository),
|
|
3220
3299
|
});
|
|
3221
3300
|
}
|
|
3222
|
-
const response = await this.
|
|
3301
|
+
const response = await this.requestWithNewerFields(endpoints.projectBind(input.projectId), ['remoteIdentity'], {
|
|
3223
3302
|
method: 'POST',
|
|
3224
|
-
body: {
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
},
|
|
3303
|
+
body: cleanJson({
|
|
3304
|
+
...(await this.checkoutIdentity(repository)),
|
|
3305
|
+
remoteIdentity: await this.dependencies.repositories.git.remoteIdentity(repository.repoRoot),
|
|
3306
|
+
}),
|
|
3228
3307
|
});
|
|
3229
3308
|
const project = objectValue(response.data);
|
|
3230
3309
|
if (!project ||
|
|
@@ -3232,7 +3311,7 @@ export class BridgeService {
|
|
|
3232
3311
|
typeof project.repoFingerprint !== 'string') {
|
|
3233
3312
|
throw refuse('Project bind response does not match the selected project', 'project.resolve');
|
|
3234
3313
|
}
|
|
3235
|
-
const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, input.projectId, project.repoFingerprint);
|
|
3314
|
+
const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, input.projectId, project.repoFingerprint, livesIn(project));
|
|
3236
3315
|
return asJsonValue({
|
|
3237
3316
|
resolved: true,
|
|
3238
3317
|
bound: true,
|
|
@@ -3245,28 +3324,105 @@ export class BridgeService {
|
|
|
3245
3324
|
}
|
|
3246
3325
|
const response = await this.dependencies.client.request(endpoints.projectResolve, {
|
|
3247
3326
|
method: 'POST',
|
|
3248
|
-
body:
|
|
3249
|
-
repoFingerprint: repository.repoFingerprint,
|
|
3250
|
-
legacyFingerprints: repository.legacyFingerprints,
|
|
3251
|
-
},
|
|
3327
|
+
body: await this.checkoutIdentity(repository),
|
|
3252
3328
|
});
|
|
3253
3329
|
const project = objectValue(response.data);
|
|
3254
3330
|
const restoreRequired = Boolean(project?.archivedAt);
|
|
3255
3331
|
const restoreGuidance = archivedProjectGuidance(project);
|
|
3332
|
+
const moved = project?.checkout === 'retired';
|
|
3256
3333
|
return asJsonValue({
|
|
3257
|
-
resolved: project !== null && !restoreRequired,
|
|
3334
|
+
resolved: project !== null && !restoreRequired && !moved,
|
|
3258
3335
|
project,
|
|
3259
3336
|
markerPath: null,
|
|
3260
3337
|
repository: publicRepository(repository),
|
|
3261
3338
|
selectionRequired: project === null,
|
|
3262
3339
|
restoreRequired,
|
|
3263
3340
|
...(restoreRequired ? restoreGuidance : {}),
|
|
3341
|
+
...movedProjectGuidance(project),
|
|
3342
|
+
});
|
|
3343
|
+
});
|
|
3344
|
+
}
|
|
3345
|
+
async projectRepositoryMovePreflight(input) {
|
|
3346
|
+
return await this.execute(async () => {
|
|
3347
|
+
const authentication = await this.dependencies.browserAuth.ensureAuthenticated();
|
|
3348
|
+
if (authentication)
|
|
3349
|
+
return asJsonValue({ authentication });
|
|
3350
|
+
const repository = await this.dependencies.repositories.resolve(input.repoRoot);
|
|
3351
|
+
const history = await this.dependencies.client.request(endpoints.projectRepository(input.projectId));
|
|
3352
|
+
const identity = await this.checkoutIdentity(repository);
|
|
3353
|
+
const current = objectValue(objectValue(history.data)?.current);
|
|
3354
|
+
return asJsonValue({
|
|
3355
|
+
history: history.data,
|
|
3356
|
+
checkout: {
|
|
3357
|
+
...identity,
|
|
3358
|
+
remoteIdentity: await this.dependencies.repositories.git.remoteIdentity(repository.repoRoot),
|
|
3359
|
+
},
|
|
3360
|
+
alreadyHere: typeof current?.repoFingerprint === 'string' &&
|
|
3361
|
+
[identity.repoFingerprint, ...identity.legacyFingerprints].includes(current.repoFingerprint),
|
|
3362
|
+
});
|
|
3363
|
+
});
|
|
3364
|
+
}
|
|
3365
|
+
async projectMoveRepository(input) {
|
|
3366
|
+
return await this.execute(async () => {
|
|
3367
|
+
const repository = await this.dependencies.repositories.resolve(input.repoRoot);
|
|
3368
|
+
const response = await this.dependencies.client.request(endpoints.projectRepository(input.projectId), {
|
|
3369
|
+
method: 'POST',
|
|
3370
|
+
body: cleanJson({
|
|
3371
|
+
...(await this.checkoutIdentity(repository)),
|
|
3372
|
+
remoteIdentity: await this.dependencies.repositories.git.remoteIdentity(repository.repoRoot),
|
|
3373
|
+
expectedVersion: input.expectedVersion,
|
|
3374
|
+
}),
|
|
3375
|
+
});
|
|
3376
|
+
const project = objectValue(response.data);
|
|
3377
|
+
if (!project ||
|
|
3378
|
+
project.id !== input.projectId ||
|
|
3379
|
+
typeof project.repoFingerprint !== 'string' ||
|
|
3380
|
+
!livesIn(project)) {
|
|
3381
|
+
throw refuse('Project move response does not match the selected project', 'project.move_repository');
|
|
3382
|
+
}
|
|
3383
|
+
const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, input.projectId, project.repoFingerprint, livesIn(project));
|
|
3384
|
+
return asJsonValue({
|
|
3385
|
+
moved: true,
|
|
3386
|
+
bound: true,
|
|
3387
|
+
project,
|
|
3388
|
+
bindingPath,
|
|
3389
|
+
repository: publicRepository(await this.dependencies.repositories.resolve(repository.repoRoot)),
|
|
3390
|
+
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
3391
|
});
|
|
3265
3392
|
});
|
|
3266
3393
|
}
|
|
3394
|
+
async checkoutIdentity(repository) {
|
|
3395
|
+
const [current, ...older] = await this.dependencies.repositories.checkoutFingerprints(repository.repoRoot);
|
|
3396
|
+
return {
|
|
3397
|
+
repoFingerprint: current,
|
|
3398
|
+
legacyFingerprints: [
|
|
3399
|
+
...new Set([...older, repository.repoFingerprint, ...repository.legacyFingerprints]),
|
|
3400
|
+
]
|
|
3401
|
+
.filter((fingerprint) => fingerprint !== current)
|
|
3402
|
+
.slice(0, 4),
|
|
3403
|
+
};
|
|
3404
|
+
}
|
|
3405
|
+
async requestWithNewerFields(path, newer, options) {
|
|
3406
|
+
try {
|
|
3407
|
+
return await this.dependencies.client.request(path, options);
|
|
3408
|
+
}
|
|
3409
|
+
catch (error) {
|
|
3410
|
+
const body = objectValue(options.body);
|
|
3411
|
+
if (!body ||
|
|
3412
|
+
!(error instanceof ApiResponseError) ||
|
|
3413
|
+
error.httpStatus !== 400 ||
|
|
3414
|
+
!newer.some((name) => error.message.includes(`property ${name} should not exist`)))
|
|
3415
|
+
throw error;
|
|
3416
|
+
return await this.dependencies.client.request(path, {
|
|
3417
|
+
...options,
|
|
3418
|
+
body: Object.fromEntries(Object.entries(body).filter(([name]) => !newer.includes(name))),
|
|
3419
|
+
});
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3267
3422
|
async sessionEntry(input = {}) {
|
|
3268
3423
|
return await this.execute(async () => {
|
|
3269
3424
|
const timer = startPhaseTimer('session.entry');
|
|
3425
|
+
await this.language(input.language);
|
|
3270
3426
|
const authentication = await this.dependencies.browserAuth.status();
|
|
3271
3427
|
timer.mark('auth_status');
|
|
3272
3428
|
let repository;
|
|
@@ -3301,7 +3457,7 @@ export class BridgeService {
|
|
|
3301
3457
|
const livePointers = authenticated && state === 'bound'
|
|
3302
3458
|
? this.dependencies.activeContexts.list(repository.repoFingerprint)
|
|
3303
3459
|
: Promise.resolve([]);
|
|
3304
|
-
const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires] = await Promise.all([
|
|
3460
|
+
const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires, moved,] = await Promise.all([
|
|
3305
3461
|
this.clientUpdate(authenticated),
|
|
3306
3462
|
livePointers,
|
|
3307
3463
|
authenticated && state === 'bound' && projectId
|
|
@@ -3319,6 +3475,9 @@ export class BridgeService {
|
|
|
3319
3475
|
this.checkoutTask(repository.repoFingerprint, repository.repoRoot),
|
|
3320
3476
|
]).then(([pointers, workedTask]) => this.questionnaireOverview(repository.repoRoot, workedTask, new Set(pointers.map((pointer) => pointer.taskSlug))))
|
|
3321
3477
|
: Promise.resolve({ mine: [], other: [], deferred: [] }),
|
|
3478
|
+
authenticated && state === 'bound' && projectId
|
|
3479
|
+
? this.movedAway(repository, projectId)
|
|
3480
|
+
: Promise.resolve({}),
|
|
3322
3481
|
]);
|
|
3323
3482
|
timer.mark('independent_lookups');
|
|
3324
3483
|
const liveTasks = liveTasksRaw.map(describePointer);
|
|
@@ -3355,19 +3514,35 @@ export class BridgeService {
|
|
|
3355
3514
|
? { deferredTaskStarts: pendingQuestionnaires.deferred }
|
|
3356
3515
|
: {}),
|
|
3357
3516
|
client,
|
|
3517
|
+
...moved,
|
|
3358
3518
|
...(incomplete && state === 'bound'
|
|
3359
3519
|
? {
|
|
3360
3520
|
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
3521
|
}
|
|
3362
3522
|
: {}),
|
|
3363
|
-
nextAction:
|
|
3364
|
-
?
|
|
3365
|
-
:
|
|
3366
|
-
? '
|
|
3367
|
-
:
|
|
3523
|
+
nextAction: typeof moved.nextAction === 'string'
|
|
3524
|
+
? moved.nextAction
|
|
3525
|
+
: pendingQuestionnaires.mine.length > 0
|
|
3526
|
+
? '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.'
|
|
3527
|
+
: taskSelectionRequired
|
|
3528
|
+
? '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.'
|
|
3529
|
+
: entryNextAction(authenticated, state),
|
|
3368
3530
|
});
|
|
3369
3531
|
});
|
|
3370
3532
|
}
|
|
3533
|
+
async movedAway(repository, projectId) {
|
|
3534
|
+
try {
|
|
3535
|
+
const response = await this.dependencies.client.request(endpoints.projectResolve, {
|
|
3536
|
+
method: 'POST',
|
|
3537
|
+
body: await this.checkoutIdentity(repository),
|
|
3538
|
+
});
|
|
3539
|
+
const project = objectValue(response.data);
|
|
3540
|
+
return project?.id === projectId ? movedProjectGuidance(project, true) : {};
|
|
3541
|
+
}
|
|
3542
|
+
catch {
|
|
3543
|
+
return {};
|
|
3544
|
+
}
|
|
3545
|
+
}
|
|
3371
3546
|
async actionableWorkItems(projectId) {
|
|
3372
3547
|
try {
|
|
3373
3548
|
const response = await this.dependencies.client.request(`${endpoints.workItemList(projectId)}?limit=100`);
|
|
@@ -4370,8 +4545,64 @@ export function ruleDeviationQuestionnaireId(taskId, path, rule) {
|
|
|
4370
4545
|
return ('rule-deviation-' +
|
|
4371
4546
|
sha256(stableStringify({ taskId, path, resourceKey: rule.resourceKey, issue: rule.issue })));
|
|
4372
4547
|
}
|
|
4373
|
-
function ruleDeviationQuestion(taskId, path, rule, language) {
|
|
4548
|
+
function ruleDeviationQuestion(taskId, path, rule, title, language) {
|
|
4374
4549
|
const copy = ruleDeviationCopy[language];
|
|
4550
|
+
const named = rule.resourceKey && !/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(rule.resourceKey)
|
|
4551
|
+
? rule.resourceKey
|
|
4552
|
+
: undefined;
|
|
4553
|
+
return {
|
|
4554
|
+
questionnaireId: ruleDeviationQuestionnaireId(taskId, path, rule),
|
|
4555
|
+
message: [
|
|
4556
|
+
copy.ask(path),
|
|
4557
|
+
`${copy.rule}: ${title ?? named ?? copy.unnamed}`,
|
|
4558
|
+
`${copy.deviation}: ${rule.issue ?? ''}`,
|
|
4559
|
+
].join('\n'),
|
|
4560
|
+
context: copy.context,
|
|
4561
|
+
example: copy.example,
|
|
4562
|
+
language,
|
|
4563
|
+
options: [
|
|
4564
|
+
{ id: 'approve', label: copy.approve[0], description: copy.approve[1] },
|
|
4565
|
+
{ id: 'change', label: copy.change[0], description: copy.change[1] },
|
|
4566
|
+
],
|
|
4567
|
+
binding: { taskId, path, resourceId: rule.resourceId },
|
|
4568
|
+
};
|
|
4569
|
+
}
|
|
4570
|
+
const ruleDeviationCopy = {
|
|
4571
|
+
en: {
|
|
4572
|
+
ask: (path) => `${path} breaks a rule of this project. Keep the code as it is?`,
|
|
4573
|
+
rule: 'Rule',
|
|
4574
|
+
deviation: 'Deviation',
|
|
4575
|
+
unnamed: 'an engineering rule whose name is not recorded',
|
|
4576
|
+
context: "A rule conflict is yours to decide, not the agent's. Keeping the code records a deviation on this file against that rule: it travels with the self review, stays in the project memory and is what a later task reads before it touches this file.",
|
|
4577
|
+
example: 'The next task that opens this file sees the rule and this recorded deviation together, instead of reading the code as if it followed the rule.',
|
|
4578
|
+
approve: [
|
|
4579
|
+
'Keep the code and record the deviation',
|
|
4580
|
+
'The deviation is recorded for this file and the self review continues.',
|
|
4581
|
+
],
|
|
4582
|
+
change: [
|
|
4583
|
+
'Change the code to follow the rule',
|
|
4584
|
+
'Nothing is recorded. The self review stops until the code follows the rule and is reviewed again.',
|
|
4585
|
+
],
|
|
4586
|
+
},
|
|
4587
|
+
tr: {
|
|
4588
|
+
ask: (path) => `${path} dosyası bu projenin bir kuralına uymuyor. Kod olduğu gibi kalsın mı?`,
|
|
4589
|
+
rule: 'Kural',
|
|
4590
|
+
deviation: 'Sapma',
|
|
4591
|
+
unnamed: 'adı kayıtlı olmayan bir mühendislik kuralı',
|
|
4592
|
+
context: 'Kural çatışmasında kararı kodu yazan değil sen verirsin. Kodu olduğu gibi bırakırsan bu dosya için o kurala ait bir sapma kaydedilir: sapma incelemeyle birlikte gider, proje hafızasında kalır ve bu dosyaya sonradan dokunan görev önce onu okur.',
|
|
4593
|
+
example: 'Bu dosyayı sonra açan görev, kuralı ve kaydedilen bu sapmayı birlikte görür; kodu kurala uyuyormuş gibi okumaz.',
|
|
4594
|
+
approve: [
|
|
4595
|
+
'Kodu olduğu gibi bırak ve sapmayı kaydet',
|
|
4596
|
+
'Sapma bu dosya için kaydedilir ve inceleme devam eder.',
|
|
4597
|
+
],
|
|
4598
|
+
change: [
|
|
4599
|
+
'Kodu kurala uyacak şekilde değiştir',
|
|
4600
|
+
'Hiçbir şey kaydedilmez. Kod kurala uyup yeniden incelenene kadar inceleme durur.',
|
|
4601
|
+
],
|
|
4602
|
+
},
|
|
4603
|
+
};
|
|
4604
|
+
function earlierRuleDeviationQuestion(taskId, path, rule, language) {
|
|
4605
|
+
const copy = earlierRuleDeviationCopy[language];
|
|
4375
4606
|
return {
|
|
4376
4607
|
questionnaireId: ruleDeviationQuestionnaireId(taskId, path, rule),
|
|
4377
4608
|
message: copy.message(path, rule.resourceKey ?? rule.resourceId, rule.issue ?? ''),
|
|
@@ -4382,7 +4613,7 @@ function ruleDeviationQuestion(taskId, path, rule, language) {
|
|
|
4382
4613
|
],
|
|
4383
4614
|
};
|
|
4384
4615
|
}
|
|
4385
|
-
const
|
|
4616
|
+
const earlierRuleDeviationCopy = {
|
|
4386
4617
|
en: {
|
|
4387
4618
|
message: (path, resourceKey, issue) => `${path} does not follow ${resourceKey}: ${issue}`,
|
|
4388
4619
|
approve: 'Keep the code as written and record the deviation',
|
|
@@ -4682,6 +4913,26 @@ function archivedProjectGuidance(project) {
|
|
|
4682
4913
|
nextAction: 'Call project.member_list with this project id, identify an owner, and ask that owner to restore the archived project.',
|
|
4683
4914
|
};
|
|
4684
4915
|
}
|
|
4916
|
+
function livesIn(project) {
|
|
4917
|
+
const repository = objectValue(project?.repository);
|
|
4918
|
+
return typeof repository?.repoFingerprint === 'string' ? repository.repoFingerprint : undefined;
|
|
4919
|
+
}
|
|
4920
|
+
function movedProjectGuidance(project, bound = false) {
|
|
4921
|
+
if (project?.checkout !== 'retired')
|
|
4922
|
+
return {};
|
|
4923
|
+
const home = objectValue(project.repository)?.remoteIdentity;
|
|
4924
|
+
return {
|
|
4925
|
+
projectMoved: true,
|
|
4926
|
+
movedTo: typeof home === 'string' ? home : null,
|
|
4927
|
+
nextAction: bound
|
|
4928
|
+
? `The project "${String(project.name)}" no longer lives in the repository this checkout points at` +
|
|
4929
|
+
(typeof home === 'string' ? `; it moved to ${home}` : '') +
|
|
4930
|
+
'. 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.'
|
|
4931
|
+
: `This checkout points at a repository the project "${String(project.name)}" left` +
|
|
4932
|
+
(typeof home === 'string' ? `; it now lives in ${home}` : '') +
|
|
4933
|
+
'. 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.',
|
|
4934
|
+
};
|
|
4935
|
+
}
|
|
4685
4936
|
function slugify(value) {
|
|
4686
4937
|
return value
|
|
4687
4938
|
.trim()
|
|
@@ -4791,6 +5042,17 @@ function isOfflineLeaseUsable(lease, sessionId, changedPaths, baselineDiffHash)
|
|
|
4791
5042
|
return false;
|
|
4792
5043
|
}
|
|
4793
5044
|
}
|
|
5045
|
+
export function forgetUnreadableQuestionId(entry, decisionAttempt) {
|
|
5046
|
+
return ('worktree-forget-unreadable-' +
|
|
5047
|
+
sha256(stableStringify({
|
|
5048
|
+
projectId: entry.projectId,
|
|
5049
|
+
externalTaskId: entry.externalTaskId,
|
|
5050
|
+
repoRoot: entry.repoRoot,
|
|
5051
|
+
generation: entry.generation,
|
|
5052
|
+
pendingDelivery: entry.pendingDelivery,
|
|
5053
|
+
decisionAttempt,
|
|
5054
|
+
})));
|
|
5055
|
+
}
|
|
4794
5056
|
export function cancelledDeliveryQuestionId(entry, clean) {
|
|
4795
5057
|
return ('worktree-delivery-cancel-' +
|
|
4796
5058
|
sha256(entry.generation + ':' + (clean ? 'clean' : entry.managed ? 'modified' : 'own-folder')));
|
|
@@ -14,6 +14,7 @@ import { OfflineOutbox } from './offline-outbox.js';
|
|
|
14
14
|
import { ActiveContextStore } from './active-context-store.js';
|
|
15
15
|
import { RepositoryDecisionStore } from './repository-decision-store.js';
|
|
16
16
|
import { ShadowNoticeStore } from './shadow-notice-store.js';
|
|
17
|
+
import { LanguageStore } from './language-store.js';
|
|
17
18
|
import { UpdateChoiceStore } from './update-choice-store.js';
|
|
18
19
|
import { PrincipalStateGuard } from './principal-state.js';
|
|
19
20
|
import { OnboardingStore } from './onboarding-store.js';
|
|
@@ -73,6 +74,7 @@ export function createBridgeService(options = {}) {
|
|
|
73
74
|
repositoryDecisions,
|
|
74
75
|
updateChoices,
|
|
75
76
|
shadowNotices,
|
|
77
|
+
languages: new LanguageStore(stateRoot),
|
|
76
78
|
questionnaires: new QuestionnaireStore(stateRoot),
|
|
77
79
|
onboarding: new OnboardingStore(stateRoot),
|
|
78
80
|
clientVersion: options.clientVersion === undefined ? config.clientVersion : options.clientVersion,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readJson, writeJson } from '../utilities/files.js';
|
|
3
|
+
export class LanguageStore {
|
|
4
|
+
path;
|
|
5
|
+
root;
|
|
6
|
+
constructor(stateRoot) {
|
|
7
|
+
this.root = join(stateRoot, 'client');
|
|
8
|
+
this.path = join(this.root, 'language.json');
|
|
9
|
+
}
|
|
10
|
+
async read(principalHash) {
|
|
11
|
+
const remembered = await readJson(this.path, this.root);
|
|
12
|
+
return remembered?.principals[principalHash] ?? remembered?.machine;
|
|
13
|
+
}
|
|
14
|
+
async remember(principalHash, language) {
|
|
15
|
+
const remembered = await readJson(this.path, this.root);
|
|
16
|
+
if (remembered?.machine === language && remembered.principals[principalHash] === language)
|
|
17
|
+
return;
|
|
18
|
+
await writeJson(this.path, {
|
|
19
|
+
schemaVersion: 1,
|
|
20
|
+
machine: language,
|
|
21
|
+
principals: { ...remembered?.principals, [principalHash]: language },
|
|
22
|
+
}, this.root);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=language-store.js.map
|