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.
@@ -69,11 +69,23 @@ export class RepositoryResolver {
69
69
  legacyFingerprints: binding ? [] : [await this.git.fingerprint(repoRoot, 1)],
70
70
  };
71
71
  }
72
- async writeBinding(repoRoot, projectId, fingerprint) {
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 repository = await this.resolve(repoRoot, projectId);
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);
@@ -15,6 +15,7 @@ export const backendRecoveryOperationNames = [
15
15
  'project.resolve',
16
16
  'project.list',
17
17
  'project.restore',
18
+ 'project.move_repository',
18
19
  'project.member_list',
19
20
  'organization.list',
20
21
  'project.member_add',
@@ -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
- return await this.dependencies.questionnaires.ask(scope, definition, previousDefinitions, owner ?? (askingTask ? { tool: 'questionnaire.ask', externalTaskId: askingTask } : undefined));
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.dependencies.client.request(endpoints.sessionBootstrap, {
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' || !question.message.includes(digest))
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
- return reviewedFiles(input).flatMap(({ path, rules }) => rules.filter(isDeviation).map((rule) => ({
1618
- definition: ruleDeviationQuestion(input.taskId, path, rule, language),
1619
- previousDefinitions: ['en', 'tr']
1620
- .filter((other) => other !== language)
1621
- .map((other) => ruleDeviationQuestion(input.taskId, path, rule, other)),
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.phase === 'delivery'
2347
+ : unreadableWorktree(item)
2309
2348
  ? {
2310
- 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).',
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
- : !item.managed && item.phase !== 'released'
2351
+ : item.phase === 'delivery'
2313
2352
  ? {
2314
- 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.',
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
- repoFingerprint: repository.repoFingerprint,
2861
- legacyFingerprints: repository.legacyFingerprints,
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.dependencies.client.request(endpoints.projectSetup, {
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.dependencies.client.request(endpoints.projectBind(input.projectId), {
3301
+ const response = await this.requestWithNewerFields(endpoints.projectBind(input.projectId), ['remoteIdentity'], {
3223
3302
  method: 'POST',
3224
- body: {
3225
- repoFingerprint: repository.repoFingerprint,
3226
- legacyFingerprints: repository.legacyFingerprints,
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: pendingQuestionnaires.mine.length > 0
3364
- ? '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.'
3365
- : taskSelectionRequired
3366
- ? '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.'
3367
- : entryNextAction(authenticated, state),
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 ruleDeviationCopy = {
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