engineering-memory 1.11.12 → 1.11.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engineering-memory",
3
- "version": "1.11.12",
3
+ "version": "1.11.14",
4
4
  "description": "Installs the Engineering Memory skill and its local MCP bridge. Sign in after installing; your organization and project are resolved from your account.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -11,7 +11,7 @@
11
11
  "engineering-memory": "bin/engineering-memory.mjs"
12
12
  },
13
13
  "files": [
14
- "bin",
14
+ "bin/engineering-memory.mjs",
15
15
  "dispatcher",
16
16
  "lib",
17
17
  "install",
@@ -1,3 +1,3 @@
1
1
  {
2
- "gitHead": "de721a5d678590e09a9f1085f8f3b976a1791b35"
2
+ "gitHead": "df9e70a543ee3b3f57a102e251df657ef6a62618"
3
3
  }
@@ -2,7 +2,7 @@ import * as z from 'zod/v4';
2
2
  import { sha256, stableStringify } from '../utilities/hash.js';
3
3
  import { restrictedValueKind } from '../runtime/privacy-detector.js';
4
4
  import { languageTag } from '../runtime/questionnaire-store.js';
5
- import { answerChoice, answerData, askQuestionnaire, resumeQuestionnaire, } from './questionnaire-tools.js';
5
+ import { answerChoice, answerData, askFailed, askQuestionnaire, resumeQuestionnaire, } from './questionnaire-tools.js';
6
6
  const uuid = z.string().uuid();
7
7
  const hash = z.string().regex(/^[a-f0-9]{64}$/);
8
8
  const project = { projectId: uuid };
@@ -411,18 +411,30 @@ export function registerOnboardingTools(server, service) {
411
411
  ],
412
412
  };
413
413
  });
414
- const form = await askQuestionnaire(server, service, {
415
- ...definitions.find((entry) => entry.language === language),
414
+ const binding = { decision: questionnaireId };
415
+ const bound = definitions.map((entry) => ({ ...entry, binding }));
416
+ const asked = {
416
417
  repoRoot: input.repoRoot,
418
+ questionnaireId,
417
419
  presentation: input.presentation,
418
- }, context, [...definitions, ...previousWording], {
419
- tool: 'project.move_repository',
420
- ...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
421
- retryArguments: {
422
- projectId: input.projectId,
423
- ...(input.language ? { language: input.language } : {}),
424
- },
425
- });
420
+ };
421
+ const stored = await service.questionnaireResume(asked).catch(() => undefined);
422
+ const form = stored?.binding && stableStringify(stored.binding) === stableStringify(binding)
423
+ ? await resumeQuestionnaire(server, service, asked, context)
424
+ : await askQuestionnaire(server, service, {
425
+ ...bound.find((entry) => entry.language === language),
426
+ repoRoot: input.repoRoot,
427
+ presentation: input.presentation,
428
+ }, context, [...bound, ...definitions, ...previousWording], {
429
+ tool: 'project.move_repository',
430
+ ...(input.decisionAttempt ? { decisionAttempt: input.decisionAttempt } : {}),
431
+ retryArguments: {
432
+ projectId: input.projectId,
433
+ ...(input.language ? { language: input.language } : {}),
434
+ },
435
+ });
436
+ if (askFailed(form))
437
+ return form;
426
438
  const record = await service.questionnaireResume({
427
439
  repoRoot: input.repoRoot,
428
440
  questionnaireId,
@@ -8,18 +8,29 @@ export async function askQuestionnaire(server, service, input, context, previous
8
8
  return failure(error);
9
9
  }
10
10
  }
11
+ export function askFailed(result) {
12
+ return payload(result)?.ok === false;
13
+ }
11
14
  export function answerChoice(result) {
12
15
  return answerData(result)?.choice;
13
16
  }
14
17
  export function answerData(result) {
18
+ const value = payload(result);
19
+ return value?.ok && value.data?.status === 'answered' && value.data.answerAvailable
20
+ ? value.data.answer
21
+ : undefined;
22
+ }
23
+ function payload(result) {
15
24
  const response = result;
16
25
  const text = response.content?.find((item) => item.type === 'text')?.text;
17
26
  if (!text)
18
27
  return undefined;
19
- const value = JSON.parse(text);
20
- return value.ok && value.data?.status === 'answered' && value.data.answerAvailable
21
- ? value.data.answer
22
- : undefined;
28
+ try {
29
+ return JSON.parse(text);
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
23
34
  }
24
35
  export async function resumeQuestionnaire(server, service, input, context) {
25
36
  try {
@@ -2,7 +2,7 @@ import * as z from 'zod/v4';
2
2
  import { validationIds } from '../runtime/bridge-service.js';
3
3
  import { stableStringify } from '../utilities/hash.js';
4
4
  import { hostAnswerSchema, languageTag, questionnaireDefinitionSchema, } from '../runtime/questionnaire-store.js';
5
- import { answerChoice, answerQuestionnaireFromHost, askQuestionnaire, resumeQuestionnaire, } from './questionnaire-tools.js';
5
+ import { answerChoice, answerQuestionnaireFromHost, askFailed, askQuestionnaire, resumeQuestionnaire, } from './questionnaire-tools.js';
6
6
  const optionalRepoRoot = z.string().min(1).optional();
7
7
  const stringList = z.array(z.string().min(1));
8
8
  const jsonValue = z.lazy(() => z.union([
@@ -726,6 +726,8 @@ export function registerEngineeringMemoryTools(server, service) {
726
726
  ...definitions.find((definition) => definition.language === language),
727
727
  repoRoot: request.repoRoot,
728
728
  }, context, [...definitions, ...earlier]);
729
+ if (askFailed(form))
730
+ return form;
729
731
  const record = await service.questionnaireResume({
730
732
  repoRoot: request.repoRoot,
731
733
  questionnaireId,
@@ -930,15 +932,13 @@ export function registerEngineeringMemoryTools(server, service) {
930
932
  }),
931
933
  }, async (input) => toolResult(await service.workItemCreate(input)));
932
934
  server.registerTool('work_item.update', {
933
- description: 'Edit or assign a work item at its current version. Assignees must already be active project members; this does not grant project access.',
935
+ description: "Edit or assign a work item at its current version. Assignees must already be active project members; this does not grant project access. Status is a slug from the project's own status catalogue; a backend that still keeps the six legacy values (backlog, ready, in_progress, in_review, done, cancelled) refuses anything else and names what it accepts.",
934
936
  inputSchema: z.object({
935
937
  ...workItemLocator,
936
938
  data: z.object({
937
939
  ...workItemFields,
938
940
  expectedVersion: z.number().int().min(1),
939
- status: z
940
- .enum(['backlog', 'ready', 'in_progress', 'in_review', 'done', 'cancelled'])
941
- .optional(),
941
+ status: z.string().trim().min(1).max(64).optional(),
942
942
  }),
943
943
  }),
944
944
  }, async (input) => toolResult(await service.workItemUpdate(input)));
@@ -1016,12 +1016,10 @@ export function registerEngineeringMemoryTools(server, service) {
1016
1016
  }),
1017
1017
  }, async (input) => toolResult(await service.projectUpdate(input)));
1018
1018
  server.registerTool('work_item.list', {
1019
- description: 'List selectable work items for a project before opening an engineering run in this chat.',
1019
+ description: "List selectable work items for a project before opening an engineering run in this chat. The status filter is a slug from the project's own status catalogue; a backend that still keeps the six legacy values (backlog, ready, in_progress, in_review, done, cancelled) refuses anything else and names what it accepts.",
1020
1020
  inputSchema: z.object({
1021
1021
  projectId: z.string().uuid(),
1022
- status: z
1023
- .enum(['backlog', 'ready', 'in_progress', 'in_review', 'done', 'cancelled'])
1024
- .optional(),
1022
+ status: z.string().trim().min(1).max(64).optional(),
1025
1023
  priority: z.enum(['lowest', 'low', 'medium', 'high', 'highest']).optional(),
1026
1024
  assigneeUserId: z.string().uuid().optional(),
1027
1025
  includeArchived: z.boolean().optional(),
@@ -1,10 +1,10 @@
1
1
  import { basename } from 'node:path';
2
2
  import * as z from 'zod/v4';
3
- import { cancelledDeliveryQuestionId, forgetUnreadableQuestionId, } from '../runtime/bridge-service.js';
3
+ import { cancelledDeliveryQuestionId, forgetUnreadableQuestionId, retireQuestionId, } from '../runtime/bridge-service.js';
4
4
  import { configuredBase, sourceLabel, taskStartChoice, taskStartDefinition, } from '../runtime/task-start.js';
5
5
  import { sha256, stableStringify } from '../utilities/hash.js';
6
6
  import { unreadableWorktree } from '../runtime/worktree-pool.js';
7
- import { askQuestionnaire } from './questionnaire-tools.js';
7
+ import { askFailed, askQuestionnaire } from './questionnaire-tools.js';
8
8
  import { languageTag } from '../runtime/questionnaire-store.js';
9
9
  const repo = z.string().min(1);
10
10
  const branch = z.string().min(1).max(255);
@@ -333,6 +333,8 @@ export function registerWorktreeTools(server, service) {
333
333
  repoRoot: input.repoRoot,
334
334
  presentation: input.presentation,
335
335
  }, context, [definition(language !== 'tr'), previous(true), previous(false)], owner);
336
+ if (askFailed(form))
337
+ return form;
336
338
  const record = await service.questionnaireResume({
337
339
  repoRoot: input.repoRoot,
338
340
  questionnaireId,
@@ -400,11 +402,15 @@ export function registerWorktreeTools(server, service) {
400
402
  }),
401
403
  };
402
404
  const form = await askQuestionnaire(server, service, { ...definition, repoRoot: input.repoRoot, presentation: input.presentation }, context, [previous], owner);
403
- const record = await service.questionnaireResume({
405
+ const record = await service
406
+ .questionnaireResume({
404
407
  repoRoot: input.repoRoot,
405
408
  questionnaireId: definition.questionnaireId,
406
- });
407
- const choice = taskStartChoice(record);
409
+ })
410
+ .catch(() => undefined);
411
+ const choice = record?.binding && stableStringify(record.binding) === stableStringify(definition.binding)
412
+ ? taskStartChoice(record)
413
+ : null;
408
414
  if (!choice)
409
415
  return form;
410
416
  if (choice.status === 'deferred')
@@ -490,6 +496,8 @@ export function registerWorktreeTools(server, service) {
490
496
  repoRoot: input.repoRoot,
491
497
  presentation: input.presentation,
492
498
  }, context, [definition(language !== 'tr'), previousFallback(true), previousFallback(false)], owner);
499
+ if (askFailed(fallbackForm))
500
+ return fallbackForm;
493
501
  const answer = await service.questionnaireResume({
494
502
  repoRoot: input.repoRoot,
495
503
  questionnaireId: fallbackId,
@@ -531,7 +539,7 @@ export function registerWorktreeTools(server, service) {
531
539
  inputSchema: locationSchema,
532
540
  }, async (input) => output(await service.worktreeOperation('list', input)));
533
541
  server.registerTool('worktree.reconcile', {
534
- description: 'Reconcile Git with the local registry, including unregistered legacy checkouts. Use releaseUnowned only on a selected unreserved recovered checkout after prior work has finished; a native source-bound confirmation and fresh safety checks precede reuse. Use retireLegacy only when the user wants old-location slots gone: each retired folder is removed with git worktree remove after its own native approval, and the branch stays. Use forgetUnreadable with the externalTaskId of a checkout that is gone or can no longer be read as a Git worktree, called from a working checkout of the same project: after its own native approval Engineering Memory stops tracking that entry, so it no longer counts toward the limit, and no file in the folder is read, moved or deleted. Otherwise never delete files or reset branches.',
542
+ description: 'Reconcile Git with the local registry, including unregistered legacy checkouts. Use releaseUnowned only on a selected unreserved recovered checkout after prior work has finished; a native source-bound confirmation and fresh safety checks precede reuse. Use retireLegacy only when the user wants old-location slots gone: each retired folder is removed with git worktree remove after its own native approval, once every retired folder has an answer, and the branch stays. Reconsider a retired folder with a new decisionAttempt; retries keep the same attempt. Use forgetUnreadable with the externalTaskId of a checkout that is gone or can no longer be read as a Git worktree, called from a working checkout of the same project: after its own native approval Engineering Memory stops tracking that entry, so it no longer counts toward the limit, and no file in the folder is read, moved or deleted. Otherwise never delete files or reset branches.',
535
543
  inputSchema: z.strictObject({
536
544
  repoRoot: repo,
537
545
  releaseUnowned: z.boolean().optional(),
@@ -613,6 +621,8 @@ export function registerWorktreeTools(server, service) {
613
621
  language: input.language,
614
622
  }),
615
623
  });
624
+ if (askFailed(form))
625
+ return form;
616
626
  const decision = await service.questionnaireResume({
617
627
  repoRoot: input.repoRoot,
618
628
  questionnaireId,
@@ -634,9 +644,10 @@ export function registerWorktreeTools(server, service) {
634
644
  const retired = [];
635
645
  const kept = [];
636
646
  const refused = [];
647
+ const approved = [];
637
648
  for (const item of items) {
638
- const questionnaireId = 'worktree-retire-' + sha256(item.repoRoot + item.generation);
639
649
  const missing = item.reasons.includes('worktree directory missing');
650
+ const questionnaireId = retireQuestionId(item, !missing, input.decisionAttempt);
640
651
  const folder = basename(item.repoRoot);
641
652
  const definition = (turkish) => {
642
653
  const copy = retireCopy[turkish ? 'tr' : 'en'];
@@ -677,20 +688,30 @@ export function registerWorktreeTools(server, service) {
677
688
  ],
678
689
  });
679
690
  const form = await askQuestionnaire(server, service, { ...definition(language === 'tr'), repoRoot: input.repoRoot }, context, [definition(language !== 'tr'), previous(true), previous(false)]);
691
+ if (askFailed(form)) {
692
+ refused.push({
693
+ repoRoot: item.repoRoot,
694
+ message: 'This folder was not asked about and nothing was removed: the decision stored under its question could not be opened.',
695
+ });
696
+ continue;
697
+ }
680
698
  const decision = await service.questionnaireResume({
681
699
  repoRoot: input.repoRoot,
682
700
  questionnaireId,
683
701
  });
684
702
  if (decision.status !== 'answered')
685
703
  return form;
686
- if (decision.answer?.choice !== 'retire') {
704
+ if (decision.answer?.choice === 'retire')
705
+ approved.push({ item, questionnaireId });
706
+ else
687
707
  kept.push(item.repoRoot);
688
- continue;
689
- }
708
+ }
709
+ for (const { item, questionnaireId } of approved) {
690
710
  const result = await service.worktreeOperation('retire', {
691
711
  repoRoot: input.repoRoot,
692
712
  worktreePath: item.repoRoot,
693
713
  generation: item.generation,
714
+ decisionAttempt: input.decisionAttempt,
694
715
  decisionId: questionnaireId,
695
716
  });
696
717
  if (result.ok)
@@ -753,6 +774,8 @@ export function registerWorktreeTools(server, service) {
753
774
  ],
754
775
  });
755
776
  const form = await askQuestionnaire(server, service, { ...definition(language === 'tr'), repoRoot: input.repoRoot }, context, [definition(language !== 'tr'), previous(true), previous(false)]);
777
+ if (askFailed(form))
778
+ return form;
756
779
  const decision = await service.questionnaireResume({
757
780
  repoRoot: input.repoRoot,
758
781
  questionnaireId,
@@ -857,6 +880,8 @@ export function registerWorktreeTools(server, service) {
857
880
  ],
858
881
  });
859
882
  const form = await askQuestionnaire(server, service, { ...definition(language === 'tr'), repoRoot: input.repoRoot }, context, [definition(language !== 'tr'), previous(true), previous(false)]);
883
+ if (askFailed(form))
884
+ return form;
860
885
  const decision = await service.questionnaireResume({
861
886
  repoRoot: input.repoRoot,
862
887
  questionnaireId,
@@ -6,7 +6,7 @@ import { lstat, readFile } from 'node:fs/promises';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { SourceSnapshot } from '../git/source-snapshot.js';
8
8
  import { ProjectIntake } from '../project/project-intake.js';
9
- import { canonicalPath } from '../utilities/files.js';
9
+ import { canonicalPath, pathExists } from '../utilities/files.js';
10
10
  import { minimatch } from 'minimatch';
11
11
  import { endpoints } from '../config.js';
12
12
  import { startPhaseTimer } from './phase-timer.js';
@@ -2444,9 +2444,10 @@ export class BridgeService {
2444
2444
  }
2445
2445
  if (operation === 'retire') {
2446
2446
  const entry = input.worktreePath ? await pool.forPath(projectId, input.worktreePath) : null;
2447
+ const folderPresent = entry ? await pathExists(entry.repoRoot) : false;
2447
2448
  if (!entry ||
2448
2449
  entry.generation !== input.generation ||
2449
- input.decisionId !== 'worktree-retire-' + sha256(entry.repoRoot + entry.generation))
2450
+ input.decisionId !== retireQuestionId(entry, folderPresent, input.decisionAttempt ?? 0))
2450
2451
  throw refuse('The retired folder changed after its removal was asked about. List the worktrees again.', 'worktree.list');
2451
2452
  const decision = await this.questionnaireResume({
2452
2453
  repoRoot: repository.repoRoot,
@@ -2454,7 +2455,7 @@ export class BridgeService {
2454
2455
  });
2455
2456
  if (decision.status !== 'answered' || decision.answer?.choice !== 'retire')
2456
2457
  throw refuse('A retired folder is removed only after the user approves it in the native worktree.reconcile form.', 'worktree.reconcile');
2457
- await pool.retire(projectId, repository.repoRoot, entry.repoRoot, entry.generation);
2458
+ await pool.retire(projectId, repository.repoRoot, entry.repoRoot, entry.generation, folderPresent);
2458
2459
  return asJsonValue({ operation, complete: true, removed: entry.repoRoot });
2459
2460
  }
2460
2461
  const entry = await pool.forPath(projectId, repository.repoRoot);
@@ -3544,21 +3545,30 @@ export class BridgeService {
3544
3545
  }
3545
3546
  }
3546
3547
  async actionableWorkItems(projectId) {
3548
+ const path = `${endpoints.workItemList(projectId)}?limit=100`;
3547
3549
  try {
3548
- const response = await this.dependencies.client.request(`${endpoints.workItemList(projectId)}?limit=100`);
3549
- const items = objectValue(response.data)?.items;
3550
- if (!Array.isArray(items))
3550
+ const chosen = await this.workItemPage(`${path}&actionable=true`).catch((error) => {
3551
+ if (error instanceof ApiResponseError && error.httpStatus === 400)
3552
+ return null;
3553
+ throw error;
3554
+ });
3555
+ if (chosen && chosen.length > 0)
3556
+ return chosen;
3557
+ const every = await this.workItemPage(path);
3558
+ if (chosen && every.some((entry) => objectValue(objectValue(entry)?.projectStatus) !== null))
3551
3559
  return [];
3552
3560
  const actionable = new Set(['backlog', 'ready', 'in_progress', 'in_review']);
3553
- return items.filter((entry) => {
3554
- const item = objectValue(entry);
3555
- return item ? actionable.has(String(item.status)) : false;
3556
- });
3561
+ return every.filter((entry) => actionable.has(String(objectValue(entry)?.status)));
3557
3562
  }
3558
3563
  catch {
3559
3564
  return [];
3560
3565
  }
3561
3566
  }
3567
+ async workItemPage(path) {
3568
+ const response = await this.dependencies.client.request(path);
3569
+ const items = objectValue(response.data)?.items;
3570
+ return Array.isArray(items) ? items : [];
3571
+ }
3562
3572
  async clientUpdate(authenticated) {
3563
3573
  const installed = this.dependencies.clientVersion;
3564
3574
  if (!authenticated) {
@@ -5057,6 +5067,13 @@ export function cancelledDeliveryQuestionId(entry, clean) {
5057
5067
  return ('worktree-delivery-cancel-' +
5058
5068
  sha256(entry.generation + ':' + (clean ? 'clean' : entry.managed ? 'modified' : 'own-folder')));
5059
5069
  }
5070
+ export function retireQuestionId(entry, folderPresent, decisionAttempt) {
5071
+ return ('worktree-retire-' +
5072
+ sha256(entry.repoRoot +
5073
+ entry.generation +
5074
+ (folderPresent ? '' : ':record') +
5075
+ (decisionAttempt ? ':' + decisionAttempt : '')));
5076
+ }
5060
5077
  function refuse(message, recovery, details) {
5061
5078
  return new BridgeRecoveryError(message, recovery, details);
5062
5079
  }
@@ -429,7 +429,7 @@ export class WorktreePool {
429
429
  await this.save(registry);
430
430
  });
431
431
  }
432
- async retire(projectId, repoRoot, path, generation) {
432
+ async retire(projectId, repoRoot, path, generation, folderPresent) {
433
433
  const commonDir = await this.commonDirectory(repoRoot);
434
434
  const activeRoot = key(await this.documentsDirectory());
435
435
  const legacyRoot = await this.legacyDocumentsDirectory();
@@ -443,7 +443,10 @@ export class WorktreePool {
443
443
  entry.commonDir !== commonDir ||
444
444
  !this.outsideActiveRoot(entry, activeRoot, legacyRoot))
445
445
  throw refuse('This folder is no longer the same retired slot. List the worktrees again before removing it.');
446
- if (await pathExists(entry.repoRoot)) {
446
+ const present = await pathExists(entry.repoRoot);
447
+ if (present !== folderPresent)
448
+ throw refuse('The retired folder changed after its removal was asked about. List the worktrees again.');
449
+ if (present) {
447
450
  const reasons = await this.fileProtection(entry);
448
451
  if (reasons.length)
449
452
  throw refuse('The retired folder is protected: ' +
@@ -1,29 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { readFile } from 'node:fs/promises';
3
- import test from 'node:test';
4
-
5
- const entryPoint = new URL('./engineering-memory.mjs', import.meta.url);
6
- const manifest = new URL('../package.json', import.meta.url);
7
-
8
- test('the published entry point tells the installer which version it is', async () => {
9
- const source = await readFile(entryPoint, 'utf8');
10
-
11
- assert.match(
12
- source,
13
- /'--client-version',\s*await publishedVersion\(\)/,
14
- 'The installer is never told the version, so every install reports itself as unknown and no update is ever offered',
15
- );
16
- });
17
-
18
- test('the published entry point tells the installer which backend it was built for', async () => {
19
- const source = await readFile(entryPoint, 'utf8');
20
-
21
- assert.match(source, /'--api-url',\s*await publishedApiUrl\(\)/);
22
- });
23
-
24
- test('the manifest carries a version the entry point can stamp', async () => {
25
- const declared = JSON.parse(await readFile(manifest, 'utf8'));
26
-
27
- assert.match(String(declared.version), /^\d+\.\d+\.\d+$/);
28
- assert.equal(declared.name, 'engineering-memory');
29
- });