engineering-memory 0.3.0 → 1.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engineering-memory",
3
- "version": "0.3.0",
3
+ "version": "1.0.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",
@@ -57,5 +57,8 @@ export const endpoints = {
57
57
  projectResolve: '/projects/resolve',
58
58
  projectBind: (projectId) => `/projects/${projectId}/bind`,
59
59
  projectMemberAdd: (projectId) => `/projects/${projectId}/members`,
60
+ projectLink: '/projects/link',
61
+ projectUnlink: '/projects/unlink',
62
+ projectLinks: (projectId) => `/projects/${projectId}/links`,
60
63
  };
61
64
  //# sourceMappingURL=config.js.map
@@ -262,6 +262,20 @@ export class GitInspector {
262
262
  mode: fileStat.mode & 0o111 ? '100755' : '100644',
263
263
  };
264
264
  }
265
+ async clone(repositoryUrl, targetPath) {
266
+ if (!/^https:\/\//.test(repositoryUrl)) {
267
+ return {
268
+ cloned: false,
269
+ reason: 'only an https repository address is cloned',
270
+ };
271
+ }
272
+ const result = await this.runner.run('git', ['clone', repositoryUrl, targetPath], {
273
+ cwd: process.cwd(),
274
+ });
275
+ return result.exitCode === 0
276
+ ? { cloned: true, reason: '' }
277
+ : { cloned: false, reason: result.stderr.trim().slice(0, 400) };
278
+ }
265
279
  async pathHistorySubjects(repoRoot, paths) {
266
280
  if (paths.length === 0)
267
281
  return [];
@@ -73,6 +73,10 @@ export const engineeringMemoryToolNames = [
73
73
  'project.list',
74
74
  'project.resolve',
75
75
  'project.member_add',
76
+ 'project.clone',
77
+ 'project.link',
78
+ 'project.unlink',
79
+ 'project.links',
76
80
  'auth.status',
77
81
  'auth.signin_browser',
78
82
  'auth.logout',
@@ -376,8 +380,22 @@ export function registerEngineeringMemoryTools(server, service) {
376
380
  initialProjectProfile: z.object({
377
381
  title: z.string().min(2),
378
382
  content: z.string().min(2),
379
- screenPathPatterns: z.array(z.string().min(1)).min(1),
380
- componentPathPatterns: z.array(z.string().min(1)).min(1),
383
+ discoveryUnits: z
384
+ .array(z.object({
385
+ kind: z.enum([
386
+ 'screen_logic',
387
+ 'component_mapping',
388
+ 'module_logic',
389
+ 'data_model',
390
+ 'api_endpoint',
391
+ ]),
392
+ patterns: z.array(z.string().min(1)).min(1),
393
+ }))
394
+ .min(1)
395
+ .optional()
396
+ .describe('What this project calls its units and where they live. A Flutter project declares screen_logic and component_mapping; a backend declares module_logic, data_model and api_endpoint.'),
397
+ screenPathPatterns: z.array(z.string().min(1)).min(1).optional(),
398
+ componentPathPatterns: z.array(z.string().min(1)).min(1).optional(),
381
399
  metadata: jsonObject,
382
400
  provenance: jsonObject,
383
401
  }),
@@ -445,6 +463,31 @@ export function registerEngineeringMemoryTools(server, service) {
445
463
  confirmLocalOnly: z.boolean().optional(),
446
464
  }),
447
465
  }, async (input) => toolResult(await service.authLogout(input)));
466
+ server.registerTool('project.clone', {
467
+ description: "Clone a project's repository into an empty directory, for a project this account belongs to that has a repository address recorded. The clone uses the developer's own Git credentials; Engineering Memory never asks for, stores or transmits one, so an access failure is reported and left to them.",
468
+ inputSchema: z.object({
469
+ projectId: z.string().min(1),
470
+ targetPath: z.string().min(1).describe('Where to place the checkout.'),
471
+ }),
472
+ }, async (input) => toolResult(await service.projectClone(input)));
473
+ server.registerTool('project.link', {
474
+ description: 'Link a backend project to a frontend project of the same organization, so each side sees the endpoint and flow contracts of the other. Only an organization owner may do this.',
475
+ inputSchema: z.object({
476
+ backendProjectId: z.string().min(1),
477
+ frontendProjectId: z.string().min(1),
478
+ }),
479
+ }, async (input) => toolResult(await service.projectLink(input)));
480
+ server.registerTool('project.unlink', {
481
+ description: 'Remove the link between a backend and a frontend project. Only an organization owner may do this.',
482
+ inputSchema: z.object({
483
+ backendProjectId: z.string().min(1),
484
+ frontendProjectId: z.string().min(1),
485
+ }),
486
+ }, async (input) => toolResult(await service.projectUnlink(input)));
487
+ server.registerTool('project.links', {
488
+ description: 'List the projects linked to this one, and which side of the product each is.',
489
+ inputSchema: z.object({ projectId: z.string().min(1) }),
490
+ }, async (input) => toolResult(await service.projectLinks(input)));
448
491
  server.registerTool('auth.status', {
449
492
  description: 'Check whether OS credential storage contains an active local Engineering Memory session.',
450
493
  inputSchema: z.object({}),
@@ -474,6 +474,73 @@ export class BridgeService {
474
474
  });
475
475
  });
476
476
  }
477
+ async clonableProjects() {
478
+ try {
479
+ const response = await this.dependencies.client.request(endpoints.projectList);
480
+ const projects = Array.isArray(response.data) ? response.data : [];
481
+ return projects.flatMap((entry) => {
482
+ const project = objectValue(entry);
483
+ const repositoryUrl = project?.repositoryUrl;
484
+ return project && typeof repositoryUrl === 'string' && repositoryUrl
485
+ ? [
486
+ asJsonValue({
487
+ projectId: project.id,
488
+ name: project.name,
489
+ framework: project.framework ?? null,
490
+ repositoryUrl,
491
+ }),
492
+ ]
493
+ : [];
494
+ });
495
+ }
496
+ catch {
497
+ return [];
498
+ }
499
+ }
500
+ async projectClone(input) {
501
+ return await this.execute(async () => {
502
+ const projects = await this.clonableProjects();
503
+ const project = projects
504
+ .map((entry) => objectValue(entry))
505
+ .find((entry) => entry?.projectId === input.projectId);
506
+ if (!project) {
507
+ throw new Error('That project has no repository address recorded, or this account is not a member of it. An organization owner records the address.');
508
+ }
509
+ const result = await this.dependencies.repositories.git.clone(String(project.repositoryUrl), input.targetPath);
510
+ if (!result.cloned) {
511
+ throw new Error(`The repository could not be cloned: ${result.reason}. Engineering Memory never handles Git credentials, so this is between the developer's own Git configuration and the host.`);
512
+ }
513
+ return asJsonValue({
514
+ cloned: true,
515
+ projectId: input.projectId,
516
+ path: input.targetPath,
517
+ });
518
+ });
519
+ }
520
+ async projectLink(input) {
521
+ return await this.execute(async () => {
522
+ const response = await this.dependencies.client.request(endpoints.projectLink, {
523
+ method: 'POST',
524
+ body: cleanJson(input),
525
+ });
526
+ return asJsonValue({ link: response.data });
527
+ });
528
+ }
529
+ async projectUnlink(input) {
530
+ return await this.execute(async () => {
531
+ await this.dependencies.client.request(endpoints.projectUnlink, {
532
+ method: 'POST',
533
+ body: cleanJson(input),
534
+ });
535
+ return asJsonValue({ unlinked: true });
536
+ });
537
+ }
538
+ async projectLinks(input) {
539
+ return await this.execute(async () => {
540
+ const response = await this.dependencies.client.request(endpoints.projectLinks(input.projectId), { method: 'POST', body: {} });
541
+ return asJsonValue({ links: objectValue(response.data)?.links ?? [] });
542
+ });
543
+ }
477
544
  async sessionAnswerShadowNotice(input) {
478
545
  return await this.execute(async () => {
479
546
  await this.dependencies.shadowNotices.answer(input.resourceId, input.revisionNumber);
@@ -1062,8 +1129,7 @@ export class BridgeService {
1062
1129
  !policy ||
1063
1130
  !marker ||
1064
1131
  marker.projectId !== project.id ||
1065
- stringArray(policy.screenPathPatterns).length === 0 ||
1066
- stringArray(policy.componentPathPatterns).length === 0) {
1132
+ readDiscoveryUnits(policy).length === 0) {
1067
1133
  throw new Error('Project setup response is not policy-ready');
1068
1134
  }
1069
1135
  const markerPath = await this.dependencies.repositories.writeMarker(repository.repoRoot, project.id);
@@ -1222,11 +1288,16 @@ export class BridgeService {
1222
1288
  repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1223
1289
  }
1224
1290
  catch (error) {
1291
+ const authenticated = authentication.authenticated === true;
1292
+ const clonable = authenticated ? await this.clonableProjects() : [];
1225
1293
  return asJsonValue({
1226
- authenticated: authentication.authenticated === true,
1294
+ authenticated,
1227
1295
  repository: null,
1228
1296
  decision: 'none',
1229
- nextAction: 'This directory is not a Git repository, so Engineering Memory has nothing to bind. Work normally and say nothing about it.',
1297
+ clonableProjects: clonable,
1298
+ nextAction: clonable.length > 0
1299
+ ? 'This directory is not a Git repository. Offer the listed projects and clone the one the user chooses with project.clone. Do not mention Engineering Memory otherwise.'
1300
+ : 'This directory is not a Git repository, so Engineering Memory has nothing to bind. Work normally and say nothing about it.',
1230
1301
  reason: error instanceof Error ? error.message : String(error),
1231
1302
  });
1232
1303
  }
@@ -1794,6 +1865,7 @@ export class BridgeService {
1794
1865
  return await this.deliverCloseIntent(repository, body, intent.taskChanges, true);
1795
1866
  }
1796
1867
  async deliverCloseIntent(repository, body, taskChanges, recoveredAfterResponseLoss) {
1868
+ const touchedContract = taskChanges.some((change) => /\.controller\.[tj]s$/.test(change.path));
1797
1869
  let response;
1798
1870
  try {
1799
1871
  response = await this.dependencies.client.request(endpoints.taskClose, {
@@ -1828,7 +1900,7 @@ export class BridgeService {
1828
1900
  ...closedTask,
1829
1901
  repository: publicRepository(repository),
1830
1902
  recoveredAfterResponseLoss,
1831
- delivery: deliveryQuestion(),
1903
+ delivery: deliveryQuestion(touchedContract),
1832
1904
  });
1833
1905
  }
1834
1906
  async seedResumeSnapshot(pointer, backendPatch) {
@@ -2078,7 +2150,7 @@ function entryNextAction(authenticated, decision) {
2078
2150
  }
2079
2151
  return 'Ask which organization and then which project, whatever the user asked for, listing what they already have with the option to create a new one last. Switching Engineering Memory off in this repository is the other answer, and it is remembered.';
2080
2152
  }
2081
- function deliveryQuestion() {
2153
+ function deliveryQuestion(touchedContract = false) {
2082
2154
  return asJsonValue({
2083
2155
  required: true,
2084
2156
  question: 'The task is closed and nothing has been committed. Ask the user which of these to do, and do only what they choose.',
@@ -2096,6 +2168,11 @@ function deliveryQuestion() {
2096
2168
  input: 'base branch the pull request targets',
2097
2169
  },
2098
2170
  ],
2171
+ ...(touchedContract
2172
+ ? {
2173
+ alsoAsk: 'This task changed an endpoint contract. Ask whether to document the change for the linked project, and if so write what the other side must do differently with memory.propose_revision as an integration_note.',
2174
+ }
2175
+ : {}),
2099
2176
  afterPullRequest: 'Do not end the turn once a pull request exists. Check whether it merges cleanly, report the conflicting files if it does not, and ask whether to resolve them before touching anything.',
2100
2177
  });
2101
2178
  }
@@ -2291,18 +2368,11 @@ export function newMemoryResourceCandidate(entry, policy) {
2291
2368
  return [];
2292
2369
  }
2293
2370
  if (policy) {
2294
- const screen = policy.screenPathPatterns.some((pattern) => globMatches(entry.path, pattern));
2295
- const component = policy.componentPathPatterns.some((pattern) => globMatches(entry.path, pattern));
2296
- if (screen && component) {
2371
+ const matched = policy.units.filter((unit) => unit.patterns.some((pattern) => globMatches(entry.path, pattern)));
2372
+ if (matched.length > 1) {
2297
2373
  throw new Error(`Resource discovery policy is ambiguous for path: ${entry.path}`);
2298
2374
  }
2299
- if (screen) {
2300
- return [{ path: entry.path, kind: 'screen_logic' }];
2301
- }
2302
- if (component) {
2303
- return [{ path: entry.path, kind: 'component_mapping' }];
2304
- }
2305
- return [];
2375
+ return matched[0] ? [{ path: entry.path, kind: matched[0].kind }] : [];
2306
2376
  }
2307
2377
  const extension = entry.path.toLowerCase().match(/\.[^.\/]+$/)?.[0];
2308
2378
  if (!extension ||
@@ -2342,12 +2412,32 @@ function readResourceDiscoveryPolicy(snapshot, activeLease) {
2342
2412
  if (!discovery) {
2343
2413
  return null;
2344
2414
  }
2345
- const screenPathPatterns = stringArray(discovery.screenPathPatterns);
2346
- const componentPathPatterns = stringArray(discovery.componentPathPatterns);
2347
- if (screenPathPatterns.length === 0 || componentPathPatterns.length === 0) {
2415
+ const units = readDiscoveryUnits(discovery);
2416
+ if (units.length === 0) {
2348
2417
  throw new Error('Pinned resource discovery policy is incomplete');
2349
2418
  }
2350
- return { screenPathPatterns, componentPathPatterns };
2419
+ return { units };
2420
+ }
2421
+ function readDiscoveryUnits(discovery) {
2422
+ if (!discovery)
2423
+ return [];
2424
+ const declared = Array.isArray(discovery.units) ? discovery.units : null;
2425
+ if (declared) {
2426
+ return declared.flatMap((entry) => {
2427
+ const unit = objectValue(entry);
2428
+ const patterns = stringArray(unit?.patterns);
2429
+ return typeof unit?.kind === 'string' && patterns.length > 0
2430
+ ? [{ kind: unit.kind, patterns }]
2431
+ : [];
2432
+ });
2433
+ }
2434
+ return [
2435
+ { kind: 'screen_logic', patterns: stringArray(discovery.screenPathPatterns) },
2436
+ {
2437
+ kind: 'component_mapping',
2438
+ patterns: stringArray(discovery.componentPathPatterns),
2439
+ },
2440
+ ].filter((unit) => unit.patterns.length > 0);
2351
2441
  }
2352
2442
  function stringArray(value) {
2353
2443
  if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
@@ -25,7 +25,7 @@ This is what makes the difference between changing code and understanding it. A
25
25
  A request can be as short as "add the KYC flow from Figma". That is enough, and it is not a licence to guess. Work it out:
26
26
 
27
27
  1. The Figma address arrives in the core pack as the `figma_reference` record. Never ask where the design lives; read it. If the user names a different file, that is a correction to that record — propose the revision and ask for approval in the same reply, so the next session already knows.
28
- 2. Ask for existing flows with `memory.query` naming the `flow_logic` kind. If the flow is already recorded, read it and its history before touching anything.
28
+ 2. Ask for existing flows with `memory.query` naming the `flow_logic` kind. If the flow is already recorded, read it and its history before touching anything — including a record belonging to the linked project on the other side of the product, which arrives in the core pack. An approved flow record outranks re-deriving the same flow from the design, and deriving it twice is how two halves of one product end up disagreeing about what the steps are.
29
29
  3. Read the flow in Figma, at orientation depth only: which frames belong to it, the order its prototype links imply, and the node id of every screen. A file-level link is orientation, not evidence. Stop there — geometry, fills, typography and tokens belong to the screen being built, read at the moment it is built, not gathered for the whole flow up front. Read this yourself rather than delegating it; nothing else can proceed until it is answered, so a second agent only adds the wait.
30
30
  4. Read what surrounds it — the screen records the flow starts from and returns to, the navigation contract, the trackers that already exist — and pull `memory.history` wherever the header shows earlier work or a run of corrections.
31
31
  5. Decide the entry point, the order, whether a tracker is needed and exactly what it carries, and where the flow ends.
@@ -33,6 +33,21 @@ A request can be as short as "add the KYC flow from Figma". That is enough, and
33
33
 
34
34
  Then propose the `flow_logic` record and stop. The plan is not a message in the chat; it is the proposal, and the user approving it is the approval. Do not write the second screen before that approval exists — a flow's shape replicated across six screens costs six times as much to undo, and verification refuses a task that adds several screens without an approved flow record reconciled to it.
35
35
 
36
+ ### Work that spans both sides of a product
37
+
38
+ When the request covers both halves of a linked product — a flow that needs endpoints as well
39
+ as screens — and the user's discipline covers both, it is one work item and two tasks: one in
40
+ each repository. Verification and the commit gate stay per repository because a commit is.
41
+
42
+ Ask which side to start on, propose the work item key, and open the task in the repository
43
+ being worked in. When the work moves to the other side, open its task with the same
44
+ `workItemKey` and that repository's root — every tool takes `repoRoot`, and both stay open at
45
+ once. `context.prepare_change` then returns `siblingTasks`: the other task's objective, status
46
+ and recent checkpoints, so a decision taken on one side is visible on the other without anyone
47
+ repeating it.
48
+
49
+ Do not open the second task speculatively. Open it when the work actually reaches that side.
50
+
36
51
  ### A task that reports a defect
37
52
 
38
53
  Settle first whether the report is about what the user sees or where they go. If it is, the
@@ -43,6 +58,8 @@ target inferred only from surrounding code. Carry both node ids into the discove
43
58
  checkpoint as evidence. A defect in a calculation, a response shape or a service has no
44
59
  answer in the design; do not spend the read there.
45
60
 
61
+ Name the areas this task works in, against the coverage map the core pack carries. Where an area it touches is marked as having no rule, that is not a reason to stop — it is a finding to raise at self review, so the absence is reported by the product rather than found later by whoever inherits the code.
62
+
46
63
  Do not pull history for everything. Pull it for the records the task actually touches, and for anything the header shows a surprising number of corrections on. Record `task.checkpoint` with type `discovery` and update STATE, DECISIONS, DISCOVERY, and HANDOFF projections through the bridge.
47
64
 
48
65
  Send calls that do not feed each other in one batch rather than one at a time: reads of any kind, and proposals for different records. Reconcile every record the task touched in a single `task.reconcile` call with `entries`, not one call per record.
@@ -91,7 +108,9 @@ Before validation, read the changed code back against the rules that govern it.
91
108
 
92
109
  Record `task.self_review` naming the resources reviewed and, for every conflict found, the file, the rule, what was wrong and what was done about it. A review that found nothing records an empty finding list, which is a claim about the work rather than a formality.
93
110
 
94
- `task.verify` refuses without a self review of the current diff. Editing after the review invalidates it, which is the point: the last thing that happens to the code is that someone read it against the rules. This exists because a task once shipped code that broke rules it had been given the rules were present and correct, and nothing in the lifecycle ever asked whether the result matched them.
111
+ An area the task worked in that the coverage map marks as having no rule is recorded as a finding too, naming the area and what was decided in its absence. That is how the product learns which rule to write next: an area nothing has needed yet can wait, and one a real task just had to improvise in cannot.
112
+
113
+ This applies to a write task. A read-only task changed nothing, so there is nothing to read back against the rules and `task.self_review` is refused for it; go straight to verification. `task.verify` refuses a write task without a self review of the current diff. Editing after the review invalidates it, which is the point: the last thing that happens to the code is that someone read it against the rules. This exists because a task once shipped code that broke rules it had been given — the rules were present and correct, and nothing in the lifecycle ever asked whether the result matched them.
95
114
 
96
115
  The review is the agent's own job at the end of the work. Do not wait to be asked for it, and do not treat a passing test suite as a substitute: tests leave a receipt and readability does not, which is exactly why the unreviewed one is the one that degrades.
97
116
 
@@ -113,6 +113,26 @@ Offer the product option whenever the classification supports it, and say plainl
113
113
 
114
114
  For any permanent option, show the old rule, proposed rule, reason, affected areas, and regression evidence. Create a proposal but do not approve it until the user explicitly confirms the proposal review.
115
115
 
116
+ ## Work Across Both Sides
117
+
118
+ When a request covers a linked backend and frontend and the user's discipline covers both, say
119
+ so and ask which side to start on and what to call the work item — offering the task
120
+ identifier if there is one. Confirm before opening the task on the second side; it is opened
121
+ when the work reaches it, not in advance.
122
+
123
+ When the discipline covers only one side, say plainly which part of the request this account
124
+ can carry and leave the rest for whoever holds the other discipline.
125
+
126
+ ## Documenting for the Linked Project
127
+
128
+ When a task changed anything an `api_endpoint` record covers, the delivery question gains an
129
+ option to document the change for the linked project. Take it when the other side has to do
130
+ something differently: a changed request or response shape, a new error code, a route that
131
+ moved. Say what changed and what the other side must do, not what was refactored internally.
132
+
133
+ A note can also be asked for at any time, in a chat of its own, for work that was already
134
+ finished.
135
+
116
136
  ## Delivery
117
137
 
118
138
  After `task.close`, and every time, ask the user what to do with the finished work. Nothing has been committed at this point and nothing may be until they answer. Offer exactly these:
@@ -1,6 +1,6 @@
1
1
  # Greenfield Scaffolding
2
2
 
3
- Architecture templates are organization-scoped source modules stored as approved engineering memory. A greenfield project reproduces the team architecture from those modules instead of re-deriving it from prose rules. The backend stores, versions and authorizes them; it never generates code. The agent applies the rename map and writes every file itself.
3
+ Architecture templates are source modules stored as approved engineering memory. The product ships a starter for each stack it supports and an organization may hold its own, which takes precedence; a template never belongs to a single project. A greenfield project reproduces the team architecture from those modules instead of re-deriving it from prose rules. The backend stores, versions and authorizes them; it never generates code. The agent applies the rename map and writes every file itself.
4
4
 
5
5
  ## Organization first
6
6
 
@@ -15,7 +15,7 @@ Call `organization.list` and ask the user which organization the project belongs
15
15
  3. Present the optional modules through the native questionnaire. A package-shaped project usually skips the application modules; an application usually takes them.
16
16
  4. Confirm the naming decisions in one questionnaire: package name and the concrete class name behind every rename placeholder. Never invent a name the user did not choose.
17
17
  5. For each module in `applyOrder`, call `architecture.module`, apply the rename map and string replacements, write the files, then call `architecture.record_application` with the template path and the written path of every file. Respect `dependsOn`; do not reorder modules.
18
- 6. Merge every module's `pubspecDependencies` into the manifest file. Keep the existing constraint when a dependency already exists and report the conflict.
18
+ 6. Merge every module's `packageDependencies` into the project's dependency manifest — `pubspec.yaml`, `package.json`, whatever the stack uses. Keep the existing constraint when a dependency already exists and report the conflict.
19
19
  7. Satisfy the `assetContract`. Ask the user for each required asset role. Never invent an asset, never ship a placeholder binary, and never copy a licensed font from another project.
20
20
  8. Resolve every `tenantSpecific` point through the questionnaire: base URLs, backend header contracts, storage key prefixes, supported locales, bundle identifiers. These are deliberately absent from the template. Do not guess them.
21
21
  9. Run code generation and localization generation, then static analysis and tests. Record the validation checkpoints as usual.
@@ -27,7 +27,7 @@ Verification exempts a scaffolded file only while its working-tree hash still eq
27
27
 
28
28
  Nothing else is relaxed. A file you edit after scaffolding no longer matches its recorded hash and falls back to the normal screen and component memory rules. A file the template never declared is never exempt. A scaffold task that recorded no applied module fails verification, so scaffold mode cannot be used to skip memory obligations on ordinary work.
29
29
 
30
- Write the first real screen as a separate `write` task. Do not extend the scaffold task to cover feature work.
30
+ Write the first real unit of work — a screen, a resource — as a separate `write` task. Do not extend the scaffold task to cover feature work.
31
31
 
32
32
  ## Keeping templates current
33
33