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.
- package/dispatcher/sections.mjs +4 -2
- package/package.json +1 -1
- package/runtime/dist/src/config.js +6 -0
- package/runtime/dist/src/git/git-inspector.js +91 -0
- package/runtime/dist/src/git/pre-commit.js +16 -0
- package/runtime/dist/src/git/verification-gate.js +20 -4
- package/runtime/dist/src/index.js +1 -1
- package/runtime/dist/src/mcp/tool-definitions.js +163 -4
- package/runtime/dist/src/project/repository.js +69 -32
- package/runtime/dist/src/runtime/api-client.js +1 -0
- package/runtime/dist/src/runtime/bridge-service.js +268 -13
- package/runtime/dist/src/runtime/task-branch-store.js +111 -0
- package/skill/SKILL.md +5 -3
- package/skill/references/lifecycle.md +17 -16
- package/skill/references/questionnaires.md +6 -6
package/dispatcher/sections.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const shared = `This section is managed by the Engineering Memory installer.
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
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.
|
|
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 [
|
|
39
|
-
this.
|
|
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([
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
18
|
-
|
|
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,
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
65
|
+
async writeBinding(repoRoot, projectId) {
|
|
34
66
|
assertProjectId(projectId);
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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) {
|