engineering-memory 1.8.0 → 1.10.0

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.
@@ -1,6 +1,6 @@
1
1
  const shared = `This section is managed by the Engineering Memory installer.
2
2
 
3
- Search from the working directory toward the repository root for \`.engineering-memory/project.json\` before doing anything with this repository, including answering a question about it.
3
+ Repository bindings live in the user-level Engineering Memory state directory, not in the working tree. Do not require or create a repository settings file. Always discover the binding with \`session.entry\`; the absence of \`.engineering-memory/project.json\` does not mean the repository is unbound.
4
4
 
5
5
  Call \`session.entry\` before you answer anything in a repository, whatever the user asked for. It reports whether they are signed in, whether this repository is bound, what they decided about it last time, and the one thing to do now. Act on that before the message itself:
6
6
 
@@ -13,7 +13,7 @@ Call \`session.entry\` before you answer anything in a repository, whatever the
13
13
 
14
14
  The user may say at any point that they want a different organization, a different project, Engineering Memory switched off, or switched back on. Recognise what they mean rather than matching words — the request arrives in whatever language and phrasing they happen to use — and act on it. Changing the organization always asks for the project again.
15
15
 
16
- When the marker exists, load the personal \`engineering-memory\` skill and use the \`engineering-memory\` MCP server before you answer. Call \`session.bootstrap\` after the entry check — not only before planning or editing, but before telling the user anything about this project: its design source, its screens, its rules, its history, what it does or does not have.
16
+ When session.entry reports a bound repository, load the personal \`engineering-memory\` skill and use the \`engineering-memory\` MCP server before you answer. Call \`session.bootstrap\` after the entry check — not only before planning or editing, but before telling the user anything about this project: its design source, its screens, its rules, its history, what it does or does not have.
17
17
 
18
18
  Never answer a question about a bound repository from the working tree alone. What the repository holds is not what the project knows: the design file, the screen records, the engineering rules and the history of every earlier task live in Engineering Memory, and a file search will report them missing. If you are about to say that something is not recorded anywhere, that is the moment you have not asked yet.
19
19
 
@@ -21,6 +21,8 @@ After compaction, a new chat, interruption, or handoff, call \`session.resume\`
21
21
 
22
22
  Do not edit until the skill lifecycle has completed discovery, its checkpoint, and \`context.prepare_change\`. Do not claim completion until \`task.verify\` succeeds.
23
23
 
24
+ Use the host's native questionnaire for every question to the user, including implementation choices, names, clarification, branch/worktree decisions and delivery. In Codex use request_user_input when available; in Claude use AskUserQuestion. Never replace the questionnaire with a chat instruction such as 'type this', 'reply yes', or 'write X if you want Y'. Do not open a survey web page. If the required native control is unavailable or prohibited for that kind of question, follow the host's tool restrictions, explain the limitation, and continue only work already authorized; do not fabricate a survey or silently choose an answer. Existing answers remain valid through retries and handoffs.
25
+
24
26
  When the repository is unbound, use the native questionnaire required by the skill. Do not silently create or bind a project. Do not open a survey web page.`;
25
27
 
26
28
  export const dispatcherSections = Object.freeze({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engineering-memory",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
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",
@@ -50,6 +50,7 @@ export const endpoints = {
50
50
  taskScaffoldApplication: '/tasks/scaffold-application',
51
51
  organizationList: '/organizations',
52
52
  organizationCreate: '/organizations',
53
+ organizationUpdate: (organizationId) => `/organizations/${organizationId}`,
53
54
  organizationMemberList: (organizationId) => `/organizations/${organizationId}/members`,
54
55
  organizationMemberUpsert: (organizationId) => `/organizations/${organizationId}/members`,
55
56
  taskVerify: '/runtime/task/verify',
@@ -60,6 +61,11 @@ export const endpoints = {
60
61
  projectList: '/projects',
61
62
  projectResolve: '/projects/resolve',
62
63
  projectBind: (projectId) => `/projects/${projectId}/bind`,
64
+ projectUpdate: (projectId) => `/projects/${projectId}`,
65
+ workItemRuns: (projectId, workItemId) => `/projects/${projectId}/work-items/${workItemId}/runs`,
66
+ workItemRestore: (projectId, workItemId) => `/projects/${projectId}/work-items/${workItemId}/restore`,
67
+ workItemTests: (projectId, workItemId) => `/projects/${projectId}/work-items/${workItemId}/tests`,
68
+ workItemPlan: (projectId, workItemId) => `/projects/${projectId}/work-items/${workItemId}/plan`,
63
69
  projectArchive: (projectId) => `/projects/${projectId}`,
64
70
  projectRestore: (projectId) => `/projects/${projectId}/restore`,
65
71
  projectMemberAdd: (projectId) => `/projects/${projectId}/members`,
@@ -3,6 +3,7 @@ import { isAbsolute, relative, resolve, sep } from 'node:path';
3
3
  import { assertManagedPath, canonicalPath } from '../utilities/files.js';
4
4
  import { sha256, stableStringify } from '../utilities/hash.js';
5
5
  import { NativeCommandRunner } from '../utilities/process.js';
6
+ import { TaskBranchStore } from '../runtime/task-branch-store.js';
6
7
  export const temporaryScaffoldingMarker = 'ENGINEERING-MEMORY-TEMPORARY';
7
8
  export const temporaryScaffoldingPattern = `${temporaryScaffoldingMarker}:`;
8
9
  export class GitInspector {
@@ -33,6 +34,96 @@ export class GitInspector {
33
34
  }
34
35
  return sha256(identity);
35
36
  }
37
+ branchStore(repoRoot) {
38
+ return new TaskBranchStore(repoRoot, this.runner);
39
+ }
40
+ async validateBranch(repoRoot, name) {
41
+ const result = await this.runner.run('git', ['check-ref-format', '--branch', name], {
42
+ cwd: repoRoot,
43
+ });
44
+ if (name.length > 255 || result.exitCode !== 0 || result.stdout.trim() !== name)
45
+ throw new Error('Choose a valid literal Git branch name of at most 255 characters');
46
+ }
47
+ async createWorktree(repoRoot, name, target) {
48
+ await this.validateBranch(repoRoot, name);
49
+ const path = resolve(repoRoot, target);
50
+ const present = await lstat(path).then(() => true, (error) => {
51
+ if (error.code === 'ENOENT')
52
+ return false;
53
+ throw error;
54
+ });
55
+ if (present) {
56
+ const root = await this.findRoot(path);
57
+ const common = await this.gitValue(root, [
58
+ 'rev-parse',
59
+ '--path-format=absolute',
60
+ '--git-common-dir',
61
+ ]);
62
+ const sourceCommon = await this.gitValue(repoRoot, [
63
+ 'rev-parse',
64
+ '--path-format=absolute',
65
+ '--git-common-dir',
66
+ ]);
67
+ if (root === (await canonicalPath(path)) &&
68
+ common === sourceCommon &&
69
+ (await this.currentBranch(root)) === name)
70
+ return root;
71
+ throw new Error('The worktree directory already belongs to another repository or branch. Choose a different directory');
72
+ }
73
+ const exists = await this.branchExists(repoRoot, name);
74
+ const args = exists
75
+ ? ['worktree', 'add', '--', path, name]
76
+ : ['worktree', 'add', '-b', name, '--', path];
77
+ const result = await this.runner.run('git', args, { cwd: repoRoot });
78
+ if (result.exitCode !== 0)
79
+ throw new Error(`Git could not create the worktree: ${result.stderr.trim()}`);
80
+ return await this.findRoot(path);
81
+ }
82
+ async currentBranch(repoRoot) {
83
+ return await this.gitValue(repoRoot, ['symbolic-ref', '--short', '-q', 'HEAD']);
84
+ }
85
+ async defaultBranch(repoRoot) {
86
+ const ref = await this.gitValue(repoRoot, [
87
+ 'symbolic-ref',
88
+ '--short',
89
+ '-q',
90
+ 'refs/remotes/origin/HEAD',
91
+ ]);
92
+ return ref ? ref.replace(/^origin\//, '') : null;
93
+ }
94
+ async protectedBranchNames(repoRoot) {
95
+ const names = new Set(['main', 'master', 'develop', 'prod']);
96
+ const fallback = await this.defaultBranch(repoRoot);
97
+ if (fallback)
98
+ names.add(fallback);
99
+ return names;
100
+ }
101
+ async createBranch(repoRoot, name) {
102
+ await this.validateBranch(repoRoot, name);
103
+ const result = await this.runner.run('git', ['switch', '-c', name], { cwd: repoRoot });
104
+ if (result.exitCode !== 0) {
105
+ throw new Error(`Git could not create the branch ${name}: ${result.stderr.trim() || 'unknown error'}`);
106
+ }
107
+ const branch = await this.currentBranch(repoRoot);
108
+ if (branch !== name) {
109
+ throw new Error(`Git created ${name} but HEAD is on ${branch ?? 'no branch'}`);
110
+ }
111
+ return branch;
112
+ }
113
+ async branchExists(repoRoot, name) {
114
+ const result = await this.runner.run('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], {
115
+ cwd: repoRoot,
116
+ });
117
+ return result.exitCode === 0;
118
+ }
119
+ async switchBranch(repoRoot, name) {
120
+ await this.validateBranch(repoRoot, name);
121
+ const result = await this.runner.run('git', ['switch', '--', name], { cwd: repoRoot });
122
+ if (result.exitCode !== 0) {
123
+ throw new Error(`Git could not switch to the branch ${name}: ${result.stderr.trim() || 'unknown error'}`);
124
+ }
125
+ return name;
126
+ }
36
127
  async mainWorktree(repoRoot) {
37
128
  const commonDirectory = await this.gitValue(repoRoot, [
38
129
  'rev-parse',
@@ -35,8 +35,24 @@ async function run() {
35
35
  })).data);
36
36
  const result = await gate.verify(repoRoot);
37
37
  process.stderr.write(`${JSON.stringify(result)}\n`);
38
+ const advice = describe(result);
39
+ if (advice)
40
+ process.stderr.write(`${advice}\n`);
38
41
  process.exitCode = result.allowed ? 0 : 1;
39
42
  }
43
+ function describe(result) {
44
+ const [expected, actual] = result.details ?? [];
45
+ switch (result.reason) {
46
+ case 'branch_mismatch':
47
+ return `The task selected ${expected} but HEAD is on ${actual}. Run: git switch ${expected}`;
48
+ case 'branch_detached':
49
+ return `The task selected ${expected} but HEAD is detached. Run: git switch ${expected}`;
50
+ case 'online_commit_gate_rejected':
51
+ return `The backend refused the commit: ${expected ?? 'no reason given'}`;
52
+ default:
53
+ return null;
54
+ }
55
+ }
40
56
  void run().catch(() => {
41
57
  process.stderr.write('Engineering Memory Git gate failed\n');
42
58
  process.exitCode = 1;
@@ -1,16 +1,19 @@
1
1
  import { readdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { RepositoryResolver } from '../project/repository.js';
3
4
  import { ensureManagedDirectory, readJson, removeFile, safeSegment, writeJson, } from '../utilities/files.js';
4
5
  export class VerificationGate {
5
6
  git;
6
7
  outbox;
7
8
  commitGateAttestor;
8
9
  root;
10
+ repositories;
9
11
  constructor(stateRoot, git, outbox, commitGateAttestor) {
10
12
  this.git = git;
11
13
  this.outbox = outbox;
12
14
  this.commitGateAttestor = commitGateAttestor;
13
15
  this.root = join(stateRoot, 'verification-gates');
16
+ this.repositories = new RepositoryResolver(git, 2, stateRoot);
14
17
  }
15
18
  async record(input) {
16
19
  if (input.taskClosed &&
@@ -32,16 +35,17 @@ export class VerificationGate {
32
35
  taskClosed: input.taskClosed ?? false,
33
36
  taskVersion: input.taskVersion ?? null,
34
37
  taskChanges,
38
+ branch: input.branch ?? null,
35
39
  }, this.root);
36
40
  }
37
41
  async verify(repoRoot) {
38
- const [fingerprint, manifest, stagedChanges, pending, scaffolding] = await Promise.all([
39
- this.git.fingerprint(repoRoot),
40
- this.git.manifest(repoRoot),
42
+ const [repository, stagedChanges, pending, scaffolding] = await Promise.all([
43
+ this.repositories.resolve(repoRoot),
41
44
  this.git.stagedManifest(repoRoot),
42
45
  this.outbox.list(),
43
46
  this.git.temporaryScaffolding(repoRoot),
44
47
  ]);
48
+ const { repoFingerprint: fingerprint, git: manifest } = repository;
45
49
  if (pending.length > 0) {
46
50
  return { allowed: false, reason: 'offline_outbox_pending', diffHash: manifest.diffHash };
47
51
  }
@@ -79,6 +83,16 @@ export class VerificationGate {
79
83
  taskId: receipt.taskId,
80
84
  };
81
85
  }
86
+ const branch = await this.git.currentBranch(repoRoot);
87
+ if (receipt.branch && branch !== receipt.branch) {
88
+ return {
89
+ allowed: false,
90
+ reason: branch ? 'branch_mismatch' : 'branch_detached',
91
+ diffHash: manifest.diffHash,
92
+ taskId: receipt.taskId,
93
+ details: [receipt.branch, branch ?? 'HEAD'],
94
+ };
95
+ }
82
96
  if (receipt.diffHash !== manifest.diffHash) {
83
97
  return {
84
98
  allowed: false,
@@ -118,6 +132,7 @@ export class VerificationGate {
118
132
  repoFingerprint: receipt.repoFingerprint,
119
133
  diffHash: manifest.diffHash,
120
134
  expectedTaskVersion: receipt.taskVersion,
135
+ branch: branch ?? undefined,
121
136
  });
122
137
  const result = asObject(attestation);
123
138
  if (result?.allowed !== true ||
@@ -132,12 +147,13 @@ export class VerificationGate {
132
147
  };
133
148
  }
134
149
  }
135
- catch {
150
+ catch (error) {
136
151
  return {
137
152
  allowed: false,
138
153
  reason: 'online_commit_gate_rejected',
139
154
  diffHash: manifest.diffHash,
140
155
  taskId: receipt.taskId,
156
+ details: [error instanceof Error ? error.message : String(error)],
141
157
  };
142
158
  }
143
159
  return {
@@ -51,7 +51,7 @@ export function createBridgeService() {
51
51
  client,
52
52
  credentials,
53
53
  browserAuth: new BrowserAuthCoordinator(client, credentials),
54
- repositories: new RepositoryResolver(git, config.markerSchemaVersion),
54
+ repositories: new RepositoryResolver(git, config.markerSchemaVersion, stateRoot),
55
55
  journal: new JournalStore(stateRoot),
56
56
  outbox,
57
57
  gate,
@@ -66,11 +66,13 @@ export const engineeringMemoryToolNames = [
66
66
  'task.verify',
67
67
  'task.close',
68
68
  'task.abandon',
69
+ 'task.branch',
69
70
  'architecture.plan',
70
71
  'architecture.module',
71
72
  'architecture.record_application',
72
73
  'organization.list',
73
74
  'organization.create',
75
+ 'organization.update',
74
76
  'organization.member_list',
75
77
  'organization.member_upsert',
76
78
  'project.setup',
@@ -80,6 +82,16 @@ export const engineeringMemoryToolNames = [
80
82
  'project.restore',
81
83
  'project.member_add',
82
84
  'project.member_list',
85
+ 'work_item.create',
86
+ 'work_item.update',
87
+ 'work_item.archive',
88
+ 'work_item.restore',
89
+ 'work_item.runs',
90
+ 'work_item.record_test',
91
+ 'work_item.test_results',
92
+ 'work_item.plan',
93
+ 'work_item.confirm_plan',
94
+ 'project.update',
83
95
  'work_item.list',
84
96
  'work_item.get',
85
97
  'project.clone',
@@ -428,6 +440,16 @@ export function registerEngineeringMemoryTools(server, service) {
428
440
  reason: z.string().min(1).max(2000),
429
441
  }),
430
442
  }, async (input) => toolResult(await service.taskAbandon(input)));
443
+ server.registerTool('task.branch', {
444
+ description: 'Record the native questionnaire answer for the exact externalTaskId before any new write/scaffold task or read-only-to-write transition. Choose name or keepCurrent. For another task in a busy checkout supply worktreePath and name; use the returned repoRoot for all subsequent commands. Existing branches may be selected without resetting them. Decisions persist across retries and restarts.',
445
+ inputSchema: z.object({
446
+ repoRoot: optionalRepoRoot,
447
+ externalTaskId: z.string().min(2).max(160),
448
+ worktreePath: z.string().min(1).optional(),
449
+ name: z.string().min(1).max(255).optional(),
450
+ keepCurrent: z.boolean().optional(),
451
+ }),
452
+ }, async (input) => toolResult(await service.taskBranch(input)));
431
453
  server.registerTool('organization.create', {
432
454
  description: 'Create an organization the user named, with the short identifier they typed. Offer this underneath the organizations they already belong to; never invent the identifier from the name.',
433
455
  inputSchema: z.object({
@@ -445,11 +467,20 @@ export function registerEngineeringMemoryTools(server, service) {
445
467
  organizationId: z.string().uuid(),
446
468
  email: z.string().email(),
447
469
  role: z.enum(['owner', 'member']),
448
- discipline: z.enum(['backend', 'frontend', 'fullstack']),
470
+ discipline: z.enum([
471
+ 'backend',
472
+ 'frontend',
473
+ 'fullstack',
474
+ 'web',
475
+ 'mobile',
476
+ 'designer',
477
+ 'product_manager',
478
+ 'tester',
479
+ ]),
449
480
  }),
450
481
  }, async (input) => toolResult(await service.organizationMemberUpsert(input)));
451
482
  server.registerTool('project.setup', {
452
- description: 'Create a policy-ready backend project from the native questionnaire and local repository audit, then write its secret-free marker.',
483
+ description: 'Create a policy-ready backend project from the native questionnaire and local repository audit, then store its binding in user-local state.',
453
484
  inputSchema: z.object({
454
485
  repoRoot: optionalRepoRoot,
455
486
  organizationId: z.string().uuid(),
@@ -507,6 +538,14 @@ export function registerEngineeringMemoryTools(server, service) {
507
538
  .min(1),
508
539
  }),
509
540
  }, async (input) => toolResult(await service.architectureRecordApplication(input)));
541
+ server.registerTool('organization.update', {
542
+ description: 'Rename an organization as its administrator, using the last name read to avoid overwriting another rename. This does not change the organization identity or grant a global role.',
543
+ inputSchema: z.object({
544
+ organizationId: z.string().uuid(),
545
+ name: z.string().trim().min(2).max(120),
546
+ expectedName: z.string().min(2).max(120),
547
+ }),
548
+ }, async (input) => toolResult(await service.organizationUpdate(input)));
510
549
  server.registerTool('organization.list', {
511
550
  description: 'List the organizations the authenticated user belongs to so project setup can inherit an existing engineering core instead of silently creating an empty organization.',
512
551
  inputSchema: z.object({}),
@@ -516,7 +555,7 @@ export function registerEngineeringMemoryTools(server, service) {
516
555
  inputSchema: z.object({ includeArchived: z.boolean().optional() }),
517
556
  }, async (input) => toolResult(await service.projectList(input)));
518
557
  server.registerTool('project.resolve', {
519
- description: 'Resolve the current repository fingerprint or bind an explicitly selected project, then write the secret-free marker only after backend authorization succeeds.',
558
+ description: 'Resolve the current repository fingerprint or bind an explicitly selected project, then store the user-local binding only after backend authorization succeeds.',
520
559
  inputSchema: z.object({
521
560
  repoRoot: optionalRepoRoot,
522
561
  bind: z.boolean().optional(),
@@ -543,13 +582,133 @@ export function registerEngineeringMemoryTools(server, service) {
543
582
  projectId: z.string().min(1),
544
583
  email: z.string().email(),
545
584
  role: z.enum(['owner', 'maintainer', 'member', 'reader']),
546
- discipline: z.enum(['backend', 'frontend', 'fullstack']).nullable().optional(),
585
+ discipline: z
586
+ .enum([
587
+ 'backend',
588
+ 'frontend',
589
+ 'fullstack',
590
+ 'web',
591
+ 'mobile',
592
+ 'designer',
593
+ 'product_manager',
594
+ 'tester',
595
+ ])
596
+ .nullable()
597
+ .optional(),
547
598
  }),
548
599
  }, async (input) => toolResult(await service.projectMemberAdd(input)));
549
600
  server.registerTool('project.member_list', {
550
601
  description: 'List the members and roles of a project, including an archived project, so an action that requires an owner can be coordinated with the right person.',
551
602
  inputSchema: z.object({ projectId: z.string().uuid() }),
552
603
  }, async (input) => toolResult(await service.projectMemberList(input)));
604
+ const workItemLocator = { projectId: z.string().uuid(), workItemId: z.string().uuid() };
605
+ const workItemFields = {
606
+ title: z.string().trim().min(2).max(240).optional(),
607
+ description: z.string().max(20000).nullable().optional(),
608
+ priority: z.enum(['lowest', 'low', 'medium', 'high', 'highest']).optional(),
609
+ assigneeUserId: z.string().uuid().nullable().optional(),
610
+ acceptanceCriteria: z.array(z.string().trim().min(2).max(500)).max(50).optional(),
611
+ figmaUrl: z.string().url().nullable().optional(),
612
+ dueAt: z.string().datetime().nullable().optional(),
613
+ position: z.number().int().min(0).optional(),
614
+ dependencyIds: z.array(z.string().uuid()).max(50).optional(),
615
+ };
616
+ server.registerTool('work_item.create', {
617
+ description: 'Create a requirement in an authorized project. Organization administrators and assigned product managers manage work. Testers may report an unassigned bug with kind bug; no implementation permission is granted.',
618
+ inputSchema: z.object({
619
+ projectId: z.string().uuid(),
620
+ data: z.object({
621
+ ...workItemFields,
622
+ title: z.string().trim().min(2).max(240),
623
+ kind: z.enum(['task', 'bug']).optional(),
624
+ }),
625
+ }),
626
+ }, async (input) => toolResult(await service.workItemCreate(input)));
627
+ server.registerTool('work_item.update', {
628
+ description: 'Edit or assign a work item at its current version. Assignees must already be active project members; this does not grant project access.',
629
+ inputSchema: z.object({
630
+ ...workItemLocator,
631
+ data: z.object({
632
+ ...workItemFields,
633
+ expectedVersion: z.number().int().min(1),
634
+ status: z
635
+ .enum(['backlog', 'ready', 'in_progress', 'in_review', 'done', 'cancelled'])
636
+ .optional(),
637
+ }),
638
+ }),
639
+ }, async (input) => toolResult(await service.workItemUpdate(input)));
640
+ server.registerTool('work_item.archive', {
641
+ description: 'Reversibly remove a work item from the active backlog at its current version, preserving history.',
642
+ inputSchema: z.object({ ...workItemLocator, expectedVersion: z.number().int().min(1) }),
643
+ }, async (input) => toolResult(await service.workItemArchive(input)));
644
+ server.registerTool('work_item.restore', {
645
+ description: 'Restore an archived work item at its current version.',
646
+ inputSchema: z.object({ ...workItemLocator, expectedVersion: z.number().int().min(1) }),
647
+ }, async (input) => toolResult(await service.workItemRestore(input)));
648
+ server.registerTool('work_item.runs', {
649
+ description: 'List execution runs for this requirement in the current and authorized directly linked projects. A closed run in one repository does not prove another repository finished.',
650
+ inputSchema: z.object({
651
+ ...workItemLocator,
652
+ offset: z.number().int().min(0).optional(),
653
+ limit: z.number().int().min(1).max(100).optional(),
654
+ }),
655
+ }, async (input) => toolResult(await service.workItemRuns(input)));
656
+ server.registerTool('work_item.record_test', {
657
+ description: 'Record immutable, privacy-safe QA evidence for the exact work-item version. Passed does not auto-complete the work item. Reuse the same idempotency key only for an identical retry.',
658
+ inputSchema: z.object({
659
+ ...workItemLocator,
660
+ data: z.object({
661
+ expectedVersion: z.number().int().min(1),
662
+ idempotencyKey: z.string().uuid(),
663
+ outcome: z.enum(['passed', 'failed', 'blocked']),
664
+ summary: z.string().trim().min(2).max(4000),
665
+ testedRevision: z.string().min(1).max(160).optional(),
666
+ }),
667
+ }),
668
+ }, async (input) => toolResult(await service.workItemRecordTest(input)));
669
+ server.registerTool('work_item.test_results', {
670
+ description: 'Read the versioned QA evidence for an authorized work item. Older evidence remains attached to the requirement version it tested.',
671
+ inputSchema: z.object({
672
+ ...workItemLocator,
673
+ offset: z.number().int().min(0).optional(),
674
+ limit: z.number().int().min(1).max(100).optional(),
675
+ }),
676
+ }, async (input) => toolResult(await service.workItemTestResults(input)));
677
+ server.registerTool('work_item.plan', {
678
+ description: 'Inspect authorized current and linked project targets, repository URLs, implementation capabilities and the persisted shared branch before opening work. Check contracts to identify backend/web/mobile impact; capability is not user consent.',
679
+ inputSchema: z.object(workItemLocator),
680
+ }, async (input) => toolResult(await service.workItemPlan(input)));
681
+ server.registerTool('work_item.confirm_plan', {
682
+ description: 'Persist only the project scope and shared branch the user approved, through the native questionnaire or explicit existing instruction. Explain additional backend/frontend work and ask before expanding scope. Existing answers survive retries and handoffs; never fabricate confirmation.',
683
+ inputSchema: z.object({
684
+ ...workItemLocator,
685
+ data: z.object({
686
+ expectedVersion: z.number().int().min(1),
687
+ projectIds: z.array(z.string().uuid()).min(1).max(12),
688
+ branch: z
689
+ .string()
690
+ .regex(/^(?=.{1,255}$)[a-zA-Z0-9][a-zA-Z0-9_-]*(?:\/[a-zA-Z0-9][a-zA-Z0-9_-]*)*$/),
691
+ reason: z.string().trim().min(2).max(2000),
692
+ userConfirmed: z.literal(true),
693
+ }),
694
+ }),
695
+ }, async (input) => toolResult(await service.workItemConfirmPlan(input)));
696
+ server.registerTool('project.update', {
697
+ description: 'Update project details or its GitHub/GitLab HTTPS repository URL at the current version. Organization administrators can manage their organization projects without an implementation assignment.',
698
+ inputSchema: z.object({
699
+ projectId: z.string().uuid(),
700
+ data: z.object({
701
+ expectedVersion: z.number().int().min(1),
702
+ name: z.string().min(2).max(160).optional(),
703
+ framework: z.string().min(2).max(80).nullable().optional(),
704
+ repositoryUrl: z.string().url().nullable().optional(),
705
+ workItemPrefix: z
706
+ .string()
707
+ .regex(/^[A-Z][A-Z0-9]{1,11}$/)
708
+ .optional(),
709
+ }),
710
+ }),
711
+ }, async (input) => toolResult(await service.projectUpdate(input)));
553
712
  server.registerTool('work_item.list', {
554
713
  description: 'List selectable work items for a project before opening an engineering run in this chat.',
555
714
  inputSchema: z.object({
@@ -1,74 +1,111 @@
1
- import { dirname, join, parse, resolve } from 'node:path';
1
+ import { dirname, join, parse } from 'node:path';
2
2
  import { canonicalPath, pathExists, readJson, writeJson } from '../utilities/files.js';
3
+ import { sha256 } from '../utilities/hash.js';
3
4
  export class RepositoryResolver {
4
5
  git;
5
6
  markerSchemaVersion;
6
- constructor(git, markerSchemaVersion) {
7
+ stateRoot;
8
+ constructor(git, markerSchemaVersion, stateRoot) {
7
9
  this.git = git;
8
10
  this.markerSchemaVersion = markerSchemaVersion;
11
+ this.stateRoot = stateRoot;
9
12
  }
10
13
  async resolve(startPath, explicitProjectId) {
11
14
  const repoRoot = await this.git.findRoot(startPath);
12
- const markerPath = await this.findMarker(startPath, repoRoot);
13
- const marker = markerPath ? await this.readMarker(markerPath, repoRoot) : null;
14
- if (explicitProjectId) {
15
- assertProjectId(explicitProjectId);
15
+ const repositoryKey = await this.keyFor(repoRoot);
16
+ const bindingPath = this.pathFor(repositoryKey);
17
+ let binding = await readJson(bindingPath, this.stateRoot);
18
+ let markerPath = null;
19
+ if (binding) {
20
+ this.assertMarker(binding);
21
+ if (binding.repositoryKey !== repositoryKey ||
22
+ !/^[0-9a-f]{64}$/.test(binding.repoFingerprint)) {
23
+ throw new Error('Engineering Memory local project binding is invalid');
24
+ }
25
+ }
26
+ else {
27
+ markerPath = await this.findMarker(startPath, repoRoot);
28
+ if (!markerPath) {
29
+ const mainRoot = await this.git.mainWorktree(repoRoot);
30
+ if (mainRoot !== repoRoot)
31
+ markerPath = await this.findMarker(mainRoot, mainRoot);
32
+ }
33
+ if (markerPath) {
34
+ const markerRoot = await this.git.findRoot(dirname(dirname(markerPath)));
35
+ const marker = await readJson(markerPath, markerRoot);
36
+ this.assertMarker(marker);
37
+ binding = {
38
+ projectId: marker.projectId,
39
+ schemaVersion: marker.schemaVersion,
40
+ repositoryKey,
41
+ repoFingerprint: await this.git.fingerprint(repoRoot, marker.schemaVersion),
42
+ };
43
+ await writeJson(bindingPath, binding, this.stateRoot);
44
+ }
16
45
  }
17
- if (explicitProjectId && marker && explicitProjectId !== marker.projectId) {
18
- throw new Error('Explicit project does not match the repository marker');
46
+ if (explicitProjectId)
47
+ assertProjectId(explicitProjectId);
48
+ if (explicitProjectId && binding && explicitProjectId !== binding.projectId) {
49
+ throw new Error('Explicit project does not match the local repository binding');
19
50
  }
20
51
  const [repoFingerprint, git] = await Promise.all([
21
- this.git.fingerprint(repoRoot, marker?.schemaVersion ?? this.markerSchemaVersion),
52
+ binding?.repoFingerprint ?? this.git.fingerprint(repoRoot, this.markerSchemaVersion),
22
53
  this.git.manifest(repoRoot),
23
54
  ]);
24
55
  return {
25
56
  repoRoot,
26
57
  markerPath,
27
- projectId: explicitProjectId ?? marker?.projectId ?? null,
28
- schemaVersion: marker?.schemaVersion ?? null,
58
+ bindingPath: binding ? bindingPath : null,
59
+ projectId: explicitProjectId ?? binding?.projectId ?? null,
60
+ schemaVersion: binding?.schemaVersion ?? null,
29
61
  repoFingerprint,
30
62
  git,
31
63
  };
32
64
  }
33
- async writeMarker(repoRoot, projectId) {
65
+ async writeBinding(repoRoot, projectId) {
34
66
  assertProjectId(projectId);
35
- const canonicalRoot = await canonicalPath(repoRoot);
36
- const markerPath = join(canonicalRoot, '.engineering-memory', 'project.json');
37
- await writeJson(markerPath, {
67
+ const repository = await this.resolve(repoRoot, projectId);
68
+ const repositoryKey = await this.keyFor(repository.repoRoot);
69
+ const bindingPath = this.pathFor(repositoryKey);
70
+ await writeJson(bindingPath, {
38
71
  projectId,
39
- schemaVersion: this.markerSchemaVersion,
40
- }, canonicalRoot);
41
- return markerPath;
72
+ schemaVersion: repository.schemaVersion ?? this.markerSchemaVersion,
73
+ repositoryKey,
74
+ repoFingerprint: repository.repoFingerprint,
75
+ }, this.stateRoot);
76
+ return bindingPath;
77
+ }
78
+ async keyFor(repoRoot) {
79
+ const mainRoot = await this.git.mainWorktree(repoRoot);
80
+ const path = process.platform === 'win32' ? mainRoot.toLowerCase() : mainRoot;
81
+ return sha256(`${path}\n${await this.git.fingerprint(repoRoot, 2)}`);
82
+ }
83
+ pathFor(repositoryKey) {
84
+ return join(this.stateRoot, 'project-bindings', `${repositoryKey}.json`);
42
85
  }
43
86
  async findMarker(startPath, repoRoot) {
44
87
  let current = await canonicalPath(startPath);
45
88
  const root = await canonicalPath(repoRoot);
46
89
  while (true) {
47
90
  const candidate = join(current, '.engineering-memory', 'project.json');
48
- if (await pathExists(candidate, root)) {
91
+ if (await pathExists(candidate, root))
49
92
  return candidate;
50
- }
51
- if (current === root || current === parse(current).root) {
93
+ if (current === root || current === parse(current).root)
52
94
  return null;
53
- }
54
95
  current = dirname(current);
55
96
  }
56
97
  }
57
- async readMarker(path, repoRoot) {
58
- const marker = await readJson(path, repoRoot);
59
- const schemaVersion = marker?.schemaVersion;
98
+ assertMarker(marker) {
60
99
  if (!marker ||
61
100
  typeof marker.projectId !== 'string' ||
62
- typeof schemaVersion !== 'number' ||
63
- !Number.isInteger(schemaVersion) ||
64
- schemaVersion < 1) {
65
- throw new Error(`Invalid Engineering Memory project marker: ${resolve(path)}`);
101
+ !Number.isInteger(marker.schemaVersion) ||
102
+ marker.schemaVersion < 1) {
103
+ throw new Error('Invalid Engineering Memory project binding');
66
104
  }
67
- if (schemaVersion > this.markerSchemaVersion) {
68
- throw new Error(`Unsupported project marker schema: ${schemaVersion}`);
105
+ if (marker.schemaVersion > this.markerSchemaVersion) {
106
+ throw new Error(`Unsupported project binding schema: ${marker.schemaVersion}`);
69
107
  }
70
108
  assertProjectId(marker.projectId);
71
- return marker;
72
109
  }
73
110
  }
74
111
  function assertProjectId(value) {