engineering-memory 1.11.11 → 1.11.13

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.
@@ -13,6 +13,7 @@ import { startPhaseTimer } from './phase-timer.js';
13
13
  import { sha256, stableStringify } from '../utilities/hash.js';
14
14
  import { ApiResponseError, BackendUnavailableError, } from './api-client.js';
15
15
  import { assertSafeToPersist, normalizeRepositoryPaths } from './offline-outbox.js';
16
+ import { restrictedValueKind } from './privacy-detector.js';
16
17
  import { principalFingerprint } from './principal-state.js';
17
18
  import { sameHolder, suggestedBranchNames, taskStartChoice } from './task-start.js';
18
19
  import { BridgeRecoveryError } from './recovery-error.js';
@@ -49,13 +50,21 @@ export class BridgeService {
49
50
  managesWorktrees() {
50
51
  return Boolean(this.dependencies.worktreePool);
51
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
+ }
52
60
  async questionnaireAsk(input, previousDefinitions = [], owner) {
53
61
  const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
54
62
  const { repoRoot: _repoRoot, preparation: _preparation, presentation: _presentation, ...definition } = input;
55
63
  const askingTask = owner || input.preparation
56
64
  ? null
57
65
  : await this.checkoutTask(scope.repoFingerprint, await this.dependencies.repositories.git.findRoot(input.repoRoot ?? process.cwd()));
58
- 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()) };
59
68
  }
60
69
  async checkoutTask(repoFingerprint, repoRoot) {
61
70
  const worktreeId = sha256(await canonicalPath(repoRoot));
@@ -71,7 +80,7 @@ export class BridgeService {
71
80
  const record = await this.dependencies.questionnaires.get(scope, input.questionnaireId);
72
81
  if (!record)
73
82
  throw refuse('No questionnaire exists for this account and repository binding.', 'session.entry');
74
- return record;
83
+ return { ...record, language: record.language ?? (await this.language()) };
75
84
  }
76
85
  async questionnaireWithdraw(input) {
77
86
  const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
@@ -1242,7 +1251,9 @@ export class BridgeService {
1242
1251
  ? await this.dependencies.questionnaires.get(scope, input.questionnaireId)
1243
1252
  : null;
1244
1253
  const digest = sha256(stableStringify(input.choice));
1245
- 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)))
1246
1257
  throw refuse('Approve the exact mode and profile through project.onboard before continuing.', 'session.entry');
1247
1258
  return asJsonValue(await this.dependencies.onboarding.save(scope, input.choice));
1248
1259
  }
@@ -1609,14 +1620,45 @@ export class BridgeService {
1609
1620
  }
1610
1621
  return [...pending];
1611
1622
  }
1612
- ruleDeviationQuestions(input) {
1623
+ async ruleDeviationQuestions(input) {
1624
+ const files = reviewedFiles(input);
1625
+ if (!files.some(({ rules }) => rules.some(isDeviation)))
1626
+ return [];
1613
1627
  const language = input.language ?? 'en';
1614
- return reviewedFiles(input).flatMap(({ path, rules }) => rules.filter(isDeviation).map((rule) => ({
1615
- definition: ruleDeviationQuestion(input.taskId, path, rule, language),
1616
- previousDefinitions: ['en', 'tr']
1617
- .filter((other) => other !== language)
1618
- .map((other) => ruleDeviationQuestion(input.taskId, path, rule, other)),
1619
- })));
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
+ }));
1620
1662
  }
1621
1663
  async taskSelfReview(input) {
1622
1664
  return await this.execute(async () => {
@@ -3380,6 +3422,7 @@ export class BridgeService {
3380
3422
  async sessionEntry(input = {}) {
3381
3423
  return await this.execute(async () => {
3382
3424
  const timer = startPhaseTimer('session.entry');
3425
+ await this.language(input.language);
3383
3426
  const authentication = await this.dependencies.browserAuth.status();
3384
3427
  timer.mark('auth_status');
3385
3428
  let repository;
@@ -3501,21 +3544,30 @@ export class BridgeService {
3501
3544
  }
3502
3545
  }
3503
3546
  async actionableWorkItems(projectId) {
3547
+ const path = `${endpoints.workItemList(projectId)}?limit=100`;
3504
3548
  try {
3505
- const response = await this.dependencies.client.request(`${endpoints.workItemList(projectId)}?limit=100`);
3506
- const items = objectValue(response.data)?.items;
3507
- if (!Array.isArray(items))
3549
+ const chosen = await this.workItemPage(`${path}&actionable=true`).catch((error) => {
3550
+ if (error instanceof ApiResponseError && error.httpStatus === 400)
3551
+ return null;
3552
+ throw error;
3553
+ });
3554
+ if (chosen && chosen.length > 0)
3555
+ return chosen;
3556
+ const every = await this.workItemPage(path);
3557
+ if (chosen && every.some((entry) => objectValue(objectValue(entry)?.projectStatus) !== null))
3508
3558
  return [];
3509
3559
  const actionable = new Set(['backlog', 'ready', 'in_progress', 'in_review']);
3510
- return items.filter((entry) => {
3511
- const item = objectValue(entry);
3512
- return item ? actionable.has(String(item.status)) : false;
3513
- });
3560
+ return every.filter((entry) => actionable.has(String(objectValue(entry)?.status)));
3514
3561
  }
3515
3562
  catch {
3516
3563
  return [];
3517
3564
  }
3518
3565
  }
3566
+ async workItemPage(path) {
3567
+ const response = await this.dependencies.client.request(path);
3568
+ const items = objectValue(response.data)?.items;
3569
+ return Array.isArray(items) ? items : [];
3570
+ }
3519
3571
  async clientUpdate(authenticated) {
3520
3572
  const installed = this.dependencies.clientVersion;
3521
3573
  if (!authenticated) {
@@ -4502,8 +4554,64 @@ export function ruleDeviationQuestionnaireId(taskId, path, rule) {
4502
4554
  return ('rule-deviation-' +
4503
4555
  sha256(stableStringify({ taskId, path, resourceKey: rule.resourceKey, issue: rule.issue })));
4504
4556
  }
4505
- function ruleDeviationQuestion(taskId, path, rule, language) {
4557
+ function ruleDeviationQuestion(taskId, path, rule, title, language) {
4506
4558
  const copy = ruleDeviationCopy[language];
4559
+ const named = rule.resourceKey && !/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(rule.resourceKey)
4560
+ ? rule.resourceKey
4561
+ : undefined;
4562
+ return {
4563
+ questionnaireId: ruleDeviationQuestionnaireId(taskId, path, rule),
4564
+ message: [
4565
+ copy.ask(path),
4566
+ `${copy.rule}: ${title ?? named ?? copy.unnamed}`,
4567
+ `${copy.deviation}: ${rule.issue ?? ''}`,
4568
+ ].join('\n'),
4569
+ context: copy.context,
4570
+ example: copy.example,
4571
+ language,
4572
+ options: [
4573
+ { id: 'approve', label: copy.approve[0], description: copy.approve[1] },
4574
+ { id: 'change', label: copy.change[0], description: copy.change[1] },
4575
+ ],
4576
+ binding: { taskId, path, resourceId: rule.resourceId },
4577
+ };
4578
+ }
4579
+ const ruleDeviationCopy = {
4580
+ en: {
4581
+ ask: (path) => `${path} breaks a rule of this project. Keep the code as it is?`,
4582
+ rule: 'Rule',
4583
+ deviation: 'Deviation',
4584
+ unnamed: 'an engineering rule whose name is not recorded',
4585
+ 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.",
4586
+ 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.',
4587
+ approve: [
4588
+ 'Keep the code and record the deviation',
4589
+ 'The deviation is recorded for this file and the self review continues.',
4590
+ ],
4591
+ change: [
4592
+ 'Change the code to follow the rule',
4593
+ 'Nothing is recorded. The self review stops until the code follows the rule and is reviewed again.',
4594
+ ],
4595
+ },
4596
+ tr: {
4597
+ ask: (path) => `${path} dosyası bu projenin bir kuralına uymuyor. Kod olduğu gibi kalsın mı?`,
4598
+ rule: 'Kural',
4599
+ deviation: 'Sapma',
4600
+ unnamed: 'adı kayıtlı olmayan bir mühendislik kuralı',
4601
+ 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.',
4602
+ example: 'Bu dosyayı sonra açan görev, kuralı ve kaydedilen bu sapmayı birlikte görür; kodu kurala uyuyormuş gibi okumaz.',
4603
+ approve: [
4604
+ 'Kodu olduğu gibi bırak ve sapmayı kaydet',
4605
+ 'Sapma bu dosya için kaydedilir ve inceleme devam eder.',
4606
+ ],
4607
+ change: [
4608
+ 'Kodu kurala uyacak şekilde değiştir',
4609
+ 'Hiçbir şey kaydedilmez. Kod kurala uyup yeniden incelenene kadar inceleme durur.',
4610
+ ],
4611
+ },
4612
+ };
4613
+ function earlierRuleDeviationQuestion(taskId, path, rule, language) {
4614
+ const copy = earlierRuleDeviationCopy[language];
4507
4615
  return {
4508
4616
  questionnaireId: ruleDeviationQuestionnaireId(taskId, path, rule),
4509
4617
  message: copy.message(path, rule.resourceKey ?? rule.resourceId, rule.issue ?? ''),
@@ -4514,7 +4622,7 @@ function ruleDeviationQuestion(taskId, path, rule, language) {
4514
4622
  ],
4515
4623
  };
4516
4624
  }
4517
- const ruleDeviationCopy = {
4625
+ const earlierRuleDeviationCopy = {
4518
4626
  en: {
4519
4627
  message: (path, resourceKey, issue) => `${path} does not follow ${resourceKey}: ${issue}`,
4520
4628
  approve: 'Keep the code as written and record the deviation',
@@ -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
@@ -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
- language: z
77
- .enum(['tr', 'en'])
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 of the current conversation: tr for Turkish, en for English. Translate the message and option labels into that language too.'),
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 {