engineering-memory 0.1.0 → 0.2.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,14 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'node:module';
3
- import { cp, mkdir, mkdtemp, rm } from 'node:fs/promises';
4
- import { tmpdir } from 'node:os';
3
+ import { rm } from 'node:fs/promises';
5
4
  import { dirname, join, resolve } from 'node:path';
6
5
  import { fileURLToPath } from 'node:url';
7
- import {
8
- exists,
9
- readJson,
10
- resolveProductionPackagePaths,
11
- } from '../install/files.mjs';
6
+ import { exists, readJson } from '../install/files.mjs';
7
+ import { stageBridgeRuntime } from '../lib/stage-runtime.mjs';
12
8
  import { runInstaller } from '../install/cli.mjs';
13
9
 
14
10
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -33,42 +29,6 @@ Choose the hook mode through the Codex or Claude native questionnaire before
33
29
  passing a repository. The installer never opens a questionnaire or an
34
30
  authentication web page.`;
35
31
 
36
- // The published package cannot carry node_modules, because npm excludes it from
37
- // every tarball. The bridge runtime is therefore assembled here from the
38
- // dependencies npm installed for this package, into the exact layout the
39
- // installer verifies against the bridge lockfile.
40
- async function stageBridgeRuntime() {
41
- const source = join(packageRoot, 'runtime');
42
- const packageJson = await readJson(join(source, 'package.json'));
43
- const packageLock = await readJson(join(source, 'package-lock.json'));
44
- if (!packageJson || !packageLock) {
45
- throw new Error(`Bridge runtime metadata is missing in ${source}`);
46
- }
47
- const staged = await mkdtemp(join(tmpdir(), 'engineering-memory-runtime-'));
48
- await cp(join(source, 'dist'), join(staged, 'dist'), { recursive: true });
49
- await cp(join(source, 'package.json'), join(staged, 'package.json'));
50
- await cp(
51
- join(source, 'package-lock.json'),
52
- join(staged, 'package-lock.json'),
53
- );
54
- for (const entry of resolveProductionPackagePaths(packageJson, packageLock)) {
55
- const name = entry.path.slice('node_modules/'.length);
56
- let installed;
57
- try {
58
- installed = dirname(require.resolve(`${name}/package.json`));
59
- } catch {
60
- if (entry.optional) continue;
61
- throw new Error(
62
- `Bridge dependency ${name} is not installed. Reinstall this package so npm resolves its dependencies.`,
63
- );
64
- }
65
- const destination = join(staged, ...entry.path.split('/'));
66
- await mkdir(dirname(destination), { recursive: true });
67
- await cp(installed, destination, { recursive: true });
68
- }
69
- return staged;
70
- }
71
-
72
32
  // The backend this package was published for. An installation only names one
73
33
  // when it points at a different, self-hosted backend.
74
34
  async function publishedApiUrl() {
@@ -82,6 +42,11 @@ async function publishedApiUrl() {
82
42
  return apiUrl;
83
43
  }
84
44
 
45
+ async function publishedVersion() {
46
+ const manifest = await readJson(join(packageRoot, 'package.json'));
47
+ return String(manifest?.version ?? '0.0.0');
48
+ }
49
+
85
50
  async function main(argumentList) {
86
51
  const [command, ...rest] = argumentList;
87
52
  if (!command || command === '--help' || command === 'help') {
@@ -95,7 +60,7 @@ async function main(argumentList) {
95
60
  if (!(await exists(join(skillSource, 'SKILL.md')))) {
96
61
  throw new Error(`Skill source is missing in ${skillSource}`);
97
62
  }
98
- const staged = await stageBridgeRuntime();
63
+ const staged = await stageBridgeRuntime(packageRoot, require);
99
64
  try {
100
65
  await runInstaller([
101
66
  ...rest,
@@ -1,8 +1,23 @@
1
1
  const shared = `This section is managed by the Engineering Memory installer.
2
2
 
3
- For every task, search from the working directory toward the repository root for \`.engineering-memory/project.json\` before planning or editing.
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.
4
4
 
5
- When the marker exists, load the personal \`engineering-memory\` skill and use the \`engineering-memory\` MCP server. Call \`session.bootstrap\` before planning or editing. After compaction, a new chat, interruption, or handoff, call \`session.resume\` before continuing.
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
+
7
+ - Not signed in: ask them to sign in and start the browser flow when they agree. Nothing about the project can be answered until that resolves.
8
+ - No decision recorded: ask which organization and then which project, whatever the message was. The other answer is switching Engineering Memory off in this repository, and it is remembered with \`session.set_decision\`.
9
+ - Switched off: say nothing about Engineering Memory, offer nothing, hint at nothing, however useful it would be for what was asked. They turned it off; raising it again is what this prevents. Record it back on only when they ask.
10
+ - Bound: continue into the lifecycle.
11
+
12
+ \`session.entry\` also reports this client's version. When it says an update is available, offer once to run \`npx engineering-memory@latest install\` and tell the user the client restarts afterwards. If they decline, record it with \`session.decline_update\` and never raise that version again. When it says an update is required, there is no choice to offer: say that this client is older than the backend supports, give the command, and do not carry on as though the lifecycle works.
13
+
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
+
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.
17
+
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
+
20
+ After compaction, a new chat, interruption, or handoff, call \`session.resume\` before continuing.
6
21
 
7
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.
8
23
 
package/install/cli.mjs CHANGED
@@ -8,6 +8,7 @@ const usage = `Usage:
8
8
  Options:
9
9
  --clients codex,claude
10
10
  --api-url <https-url>
11
+ --client-version <version>
11
12
  --development
12
13
  --repo <repository-root>
13
14
  --hook-mode chain|verify-only|cancel
@@ -39,6 +40,8 @@ export function parseInstallerArguments(arguments_) {
39
40
  .filter(Boolean);
40
41
  } else if (argument === '--api-url') {
41
42
  result.apiUrl = value;
43
+ } else if (argument === '--client-version') {
44
+ result.clientVersion = value;
42
45
  } else if (argument === '--repo') {
43
46
  result.repoRoot = resolve(value);
44
47
  } else if (argument === '--hook-mode') {
@@ -70,6 +70,7 @@ export async function installEngineeringMemory(options = {}) {
70
70
  const nodePath = resolve(options.nodePath ?? process.execPath);
71
71
  const development = options.development === true;
72
72
  const apiUrl = normalizeApiUrl(options.apiUrl, { development });
73
+ const clientVersion = options.clientVersion ?? null;
73
74
  const selectedClients = normalizeClients(
74
75
  options.selectedClients ?? ['codex', 'claude'],
75
76
  );
@@ -124,6 +125,7 @@ export async function installEngineeringMemory(options = {}) {
124
125
  bridgeEntry,
125
126
  nodePath,
126
127
  apiUrl,
128
+ clientVersion,
127
129
  state: existingState,
128
130
  commandRunner,
129
131
  });
@@ -176,6 +178,7 @@ export async function installEngineeringMemory(options = {}) {
176
178
  bridgeEntry,
177
179
  nodePath,
178
180
  apiUrl,
181
+ clientVersion,
179
182
  development,
180
183
  mcp,
181
184
  hook,
@@ -188,6 +191,7 @@ export async function installEngineeringMemory(options = {}) {
188
191
  dispatcherPaths: clientPlans.map((plan) => plan.dispatcherPath),
189
192
  selectedClients,
190
193
  apiUrl,
194
+ clientVersion,
191
195
  runtimePath,
192
196
  bridgeEntry,
193
197
  gateEntry,
@@ -3,6 +3,7 @@ import { commandFailure } from './commands.mjs';
3
3
 
4
4
  const SERVER_NAME = 'engineering-memory';
5
5
  const API_URL_ENV = 'ENGINEERING_MEMORY_API_URL';
6
+ const CLIENT_VERSION_ENV = 'ENGINEERING_MEMORY_CLIENT_VERSION';
6
7
  const missingServerPattern =
7
8
  /not found|does not exist|unknown (?:mcp )?server|no (?:mcp )?server|is not configured/i;
8
9
 
@@ -44,6 +45,7 @@ export async function planMcpRegistrations({
44
45
  bridgeEntry,
45
46
  nodePath,
46
47
  apiUrl,
48
+ clientVersion,
47
49
  state,
48
50
  commandRunner,
49
51
  }) {
@@ -63,6 +65,7 @@ export async function planMcpRegistrations({
63
65
  nodePath,
64
66
  bridgeEntry,
65
67
  apiUrl,
68
+ clientVersion,
66
69
  });
67
70
  if (existing.code === 0) {
68
71
  if (!isOwnedRegistration(clientName, managed)) {
@@ -177,9 +180,14 @@ async function removeRegistration(plan, commandRunner) {
177
180
  }
178
181
 
179
182
  function environmentArgs(registration) {
180
- return registration.apiUrl
181
- ? ['--env', `${API_URL_ENV}=${registration.apiUrl}`]
182
- : [];
183
+ const args = [];
184
+ if (registration.apiUrl) {
185
+ args.push('--env', `${API_URL_ENV}=${registration.apiUrl}`);
186
+ }
187
+ if (registration.clientVersion) {
188
+ args.push('--env', `${CLIENT_VERSION_ENV}=${registration.clientVersion}`);
189
+ }
190
+ return args;
183
191
  }
184
192
 
185
193
  function createRegistration(clientName, values) {
@@ -197,6 +205,7 @@ function registrationFingerprint(clientName, registration) {
197
205
  nodePath: registration.nodePath,
198
206
  bridgeEntry: registration.bridgeEntry,
199
207
  apiUrl: registration.apiUrl ?? null,
208
+ clientVersion: registration.clientVersion ?? null,
200
209
  }),
201
210
  )
202
211
  .digest('hex');
@@ -207,6 +216,7 @@ function sameRegistration(left, right) {
207
216
  left?.nodePath === right.nodePath &&
208
217
  left?.bridgeEntry === right.bridgeEntry &&
209
218
  left?.apiUrl === right.apiUrl &&
219
+ left?.clientVersion === right.clientVersion &&
210
220
  left?.fingerprint === right.fingerprint
211
221
  );
212
222
  }
@@ -355,10 +365,14 @@ function canonicalRegistration(registration) {
355
365
  return canonicalRegistrationFromValues({
356
366
  command: registration.nodePath,
357
367
  args: [registration.bridgeEntry],
358
- env:
359
- registration.apiUrl === undefined
368
+ env: {
369
+ ...(registration.apiUrl === undefined
360
370
  ? {}
361
- : { [API_URL_ENV]: registration.apiUrl },
371
+ : { [API_URL_ENV]: registration.apiUrl }),
372
+ ...(registration.clientVersion
373
+ ? { [CLIENT_VERSION_ENV]: registration.clientVersion }
374
+ : {}),
375
+ },
362
376
  });
363
377
  }
364
378
 
@@ -0,0 +1,53 @@
1
+ import { cp, mkdir, mkdtemp, stat } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { readJson, resolveProductionPackagePaths } from '../install/files.mjs';
5
+
6
+ // The published package cannot carry node_modules, because npm excludes it from
7
+ // every tarball. The bridge runtime is assembled here from the dependencies npm
8
+ // installed for this package, into the exact layout the installer verifies
9
+ // against the bridge lockfile.
10
+ export async function stageBridgeRuntime(packageRoot, resolver) {
11
+ const source = join(packageRoot, 'runtime');
12
+ const packageJson = await readJson(join(source, 'package.json'));
13
+ const packageLock = await readJson(join(source, 'package-lock.json'));
14
+ if (!packageJson || !packageLock) {
15
+ throw new Error(`Bridge runtime metadata is missing in ${source}`);
16
+ }
17
+ const staged = await mkdtemp(join(tmpdir(), 'engineering-memory-runtime-'));
18
+ await cp(join(source, 'dist'), join(staged, 'dist'), { recursive: true });
19
+ await cp(join(source, 'package.json'), join(staged, 'package.json'));
20
+ await cp(
21
+ join(source, 'package-lock.json'),
22
+ join(staged, 'package-lock.json'),
23
+ );
24
+ for (const entry of resolveProductionPackagePaths(packageJson, packageLock)) {
25
+ const name = entry.path.slice('node_modules/'.length);
26
+ const installed = await locate(name, resolver);
27
+ if (!installed) {
28
+ if (entry.optional) continue;
29
+ throw new Error(
30
+ `Bridge dependency ${name} is not installed. Reinstall this package so npm resolves its dependencies.`,
31
+ );
32
+ }
33
+ const destination = join(staged, ...entry.path.split('/'));
34
+ await mkdir(dirname(destination), { recursive: true });
35
+ await cp(installed, destination, { recursive: true });
36
+ }
37
+ return staged;
38
+ }
39
+
40
+ // A package is free to keep package.json out of its exports map, and several do,
41
+ // so the directory is found on disk rather than through module resolution.
42
+ async function locate(name, resolver) {
43
+ for (const directory of resolver.resolve.paths(name) ?? []) {
44
+ const candidate = join(directory, ...name.split('/'));
45
+ try {
46
+ await stat(join(candidate, 'package.json'));
47
+ return candidate;
48
+ } catch {
49
+ continue;
50
+ }
51
+ }
52
+ return null;
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engineering-memory",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
@@ -13,6 +13,7 @@
13
13
  "files": [
14
14
  "bin",
15
15
  "dispatcher",
16
+ "lib",
16
17
  "install",
17
18
  "skill",
18
19
  "runtime"
@@ -9,6 +9,7 @@ export function loadBridgeConfig(env = process.env) {
9
9
  return {
10
10
  apiBaseUrl: (env.ENGINEERING_MEMORY_API_URL ?? 'http://127.0.0.1:3000').replace(/\/$/, ''),
11
11
  stateRoot: resolve(env.ENGINEERING_MEMORY_STATE_DIR ?? join(homedir(), '.engineering-memory')),
12
+ clientVersion: env.ENGINEERING_MEMORY_CLIENT_VERSION ?? null,
12
13
  requestTimeoutMs: positiveInteger(env.ENGINEERING_MEMORY_TIMEOUT_MS, 15_000),
13
14
  cacheMaxEntries: positiveInteger(env.ENGINEERING_MEMORY_CACHE_MAX_ENTRIES, 64),
14
15
  cacheMaxBytes: positiveInteger(env.ENGINEERING_MEMORY_CACHE_MAX_BYTES, 5 * 1024 * 1024),
@@ -35,6 +36,7 @@ export const endpoints = {
35
36
  contextRefresh: '/runtime/context/refresh',
36
37
  memoryQuery: '/memory/query',
37
38
  memoryHistory: '/memory/history',
39
+ clientVersion: '/runtime/client-version',
38
40
  memoryScaffoldPlan: '/memory/scaffold-plan',
39
41
  memoryArchitectureModule: '/memory/architecture-module',
40
42
  memoryProposeRevision: '/memory/proposals',
@@ -13,6 +13,8 @@ import { ApiClient } from './runtime/api-client.js';
13
13
  import { BridgeService } from './runtime/bridge-service.js';
14
14
  import { OfflineOutbox } from './runtime/offline-outbox.js';
15
15
  import { ActiveContextStore } from './runtime/active-context-store.js';
16
+ import { RepositoryDecisionStore } from './runtime/repository-decision-store.js';
17
+ import { UpdateChoiceStore } from './runtime/update-choice-store.js';
16
18
  import { PrincipalStateGuard } from './runtime/principal-state.js';
17
19
  export function createBridgeService() {
18
20
  const config = loadBridgeConfig();
@@ -39,6 +41,8 @@ export function createBridgeService() {
39
41
  const git = new GitInspector();
40
42
  const outbox = new OfflineOutbox(stateRoot);
41
43
  const activeContexts = new ActiveContextStore(stateRoot);
44
+ const repositoryDecisions = new RepositoryDecisionStore(stateRoot);
45
+ const updateChoices = new UpdateChoiceStore(stateRoot);
42
46
  const gate = new VerificationGate(stateRoot, git, outbox);
43
47
  const principalState = new PrincipalStateGuard(stateRoot, credentials, cache, outbox, activeContexts, gate);
44
48
  return new BridgeService({
@@ -50,6 +54,9 @@ export function createBridgeService() {
50
54
  outbox,
51
55
  gate,
52
56
  activeContexts,
57
+ repositoryDecisions,
58
+ updateChoices,
59
+ clientVersion: config.clientVersion,
53
60
  principalState,
54
61
  });
55
62
  }
@@ -44,6 +44,9 @@ const checkpointBase = {
44
44
  idempotencyKey: z.string().uuid().optional(),
45
45
  };
46
46
  export const engineeringMemoryToolNames = [
47
+ 'session.entry',
48
+ 'session.set_decision',
49
+ 'session.decline_update',
47
50
  'session.bootstrap',
48
51
  'session.resume',
49
52
  'context.prepare_change',
@@ -74,6 +77,21 @@ export const engineeringMemoryToolNames = [
74
77
  'auth.logout',
75
78
  ];
76
79
  export function registerEngineeringMemoryTools(server, service) {
80
+ server.registerTool('session.entry', {
81
+ description: 'The first call of every session in a repository, before answering anything about the project. Reports whether the user is signed in, whether this repository is bound, what this user decided about it last time, and the one thing to do now. A repository the user switched Engineering Memory off in reports that, and is left alone.',
82
+ inputSchema: z.object({ repoRoot: optionalRepoRoot }),
83
+ }, async (input) => toolResult(await service.sessionEntry(input)));
84
+ server.registerTool('session.set_decision', {
85
+ description: 'Record what the user decided about Engineering Memory in this repository: bound once they have chosen a project, disabled when they ask for it off, or none to forget the decision when they ask for it back. Call it only from something the user actually said; it is the only thing that changes the entry state.',
86
+ inputSchema: z.object({
87
+ repoRoot: optionalRepoRoot,
88
+ decision: z.enum(['bound', 'disabled', 'none']),
89
+ }),
90
+ }, async (input) => toolResult(await service.sessionSetDecision(input)));
91
+ server.registerTool('session.decline_update', {
92
+ description: 'Record that the user turned down the available client update. Call it only when they say no. The same version is never offered again; a newer one is a new question. A required update is not declinable and must not be recorded here.',
93
+ inputSchema: z.object({}),
94
+ }, async () => toolResult(await service.sessionDeclineUpdate()));
77
95
  server.registerTool('session.bootstrap', {
78
96
  description: 'Authenticate, resolve the repository project, open or resume a write or read-only task, and load mandatory engineering context before planning.',
79
97
  inputSchema: z.object({
@@ -1130,6 +1130,92 @@ export class BridgeService {
1130
1130
  });
1131
1131
  });
1132
1132
  }
1133
+ async sessionEntry(input = {}) {
1134
+ return await this.execute(async () => {
1135
+ const authentication = await this.dependencies.browserAuth.status();
1136
+ let repository;
1137
+ try {
1138
+ repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1139
+ }
1140
+ catch (error) {
1141
+ return asJsonValue({
1142
+ authenticated: authentication.authenticated === true,
1143
+ repository: null,
1144
+ decision: 'none',
1145
+ nextAction: 'This directory is not a Git repository, so Engineering Memory has nothing to bind. Work normally and say nothing about it.',
1146
+ reason: error instanceof Error ? error.message : String(error),
1147
+ });
1148
+ }
1149
+ const decision = await this.dependencies.repositoryDecisions.load(repository.repoFingerprint);
1150
+ const authenticated = authentication.authenticated === true;
1151
+ const state = decision?.state ?? (repository.projectId ? 'bound' : 'none');
1152
+ const client = await this.clientUpdate(authenticated);
1153
+ return asJsonValue({
1154
+ authenticated,
1155
+ repository: publicRepository(repository),
1156
+ decision: state,
1157
+ projectId: repository.projectId ?? decision?.projectId ?? null,
1158
+ client,
1159
+ nextAction: entryNextAction(authenticated, state),
1160
+ });
1161
+ });
1162
+ }
1163
+ // What the backend expects of this client, and whether the user has already
1164
+ // been asked about it. A required update leaves no choice; an optional one is
1165
+ // offered once per version and never raised again after a refusal.
1166
+ async clientUpdate(authenticated) {
1167
+ const installed = this.dependencies.clientVersion;
1168
+ if (!authenticated) {
1169
+ return asJsonValue({ installed, status: 'unknown' });
1170
+ }
1171
+ let report;
1172
+ try {
1173
+ const response = await this.dependencies.client.request(`${endpoints.clientVersion}?installed=${encodeURIComponent(installed ?? '')}`);
1174
+ report = objectValue(response.data);
1175
+ }
1176
+ catch {
1177
+ return asJsonValue({ installed, status: 'unknown' });
1178
+ }
1179
+ if (!report)
1180
+ return asJsonValue({ installed, status: 'unknown' });
1181
+ const status = String(report.status);
1182
+ if (status === 'update_available') {
1183
+ const declined = await this.dependencies.updateChoices.declinedVersion();
1184
+ if (declined === String(report.latest)) {
1185
+ return asJsonValue({ ...report, status: 'declined' });
1186
+ }
1187
+ }
1188
+ return asJsonValue({ ...report, command: 'npx engineering-memory@latest install' });
1189
+ }
1190
+ async sessionDeclineUpdate() {
1191
+ return await this.execute(async () => {
1192
+ const response = await this.dependencies.client.request(`${endpoints.clientVersion}?installed=${encodeURIComponent(this.dependencies.clientVersion ?? '')}`);
1193
+ const report = objectValue(response.data);
1194
+ const latest = report ? String(report.latest) : 'unknown';
1195
+ await this.dependencies.updateChoices.decline(latest);
1196
+ return asJsonValue({
1197
+ declinedVersion: latest,
1198
+ nextAction: 'Do not offer this update again. Say nothing more about it unless the user asks, or until a newer version than this one is published.',
1199
+ });
1200
+ });
1201
+ }
1202
+ async sessionSetDecision(input) {
1203
+ return await this.execute(async () => {
1204
+ const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1205
+ if (input.decision === 'none') {
1206
+ await this.dependencies.repositoryDecisions.clear(repository.repoFingerprint);
1207
+ return asJsonValue({
1208
+ decision: 'none',
1209
+ nextAction: 'Ask which organization and then which project, listing what the user already has with the option to create a new one last.',
1210
+ });
1211
+ }
1212
+ const decision = await this.dependencies.repositoryDecisions.save(repository.repoFingerprint, input.decision, repository.projectId ?? undefined);
1213
+ return asJsonValue({
1214
+ decision: decision.state,
1215
+ nextAction: entryNextAction(true, decision.state),
1216
+ });
1217
+ });
1218
+ }
1133
1219
  async authStatus() {
1134
1220
  return await this.execute(async () => {
1135
1221
  return asJsonValue(await this.dependencies.browserAuth.status());
@@ -1895,6 +1981,18 @@ export function parseCorrectionScopeLine(summary) {
1895
1981
  const match = /^Correction scope ([a-z_]+): (.+)$/.exec(summary);
1896
1982
  return match ? { scope: match[1], reference: match[2] } : null;
1897
1983
  }
1984
+ function entryNextAction(authenticated, decision) {
1985
+ if (decision === 'disabled') {
1986
+ return 'This user switched Engineering Memory off in this repository. Say nothing about it, offer nothing, and work as an ordinary agent until they ask for it back.';
1987
+ }
1988
+ if (!authenticated) {
1989
+ return 'Ask the user to sign in before answering anything about this project, and start the browser flow when they agree.';
1990
+ }
1991
+ if (decision === 'bound') {
1992
+ return 'Continue into the lifecycle with session.bootstrap or session.resume.';
1993
+ }
1994
+ 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.';
1995
+ }
1898
1996
  function deliveryQuestion() {
1899
1997
  return asJsonValue({
1900
1998
  required: true,
@@ -0,0 +1,67 @@
1
+ import { join } from 'node:path';
2
+ import { readJson, removeFile, writeJson } from '../utilities/files.js';
3
+ // What a user decided about Engineering Memory in one repository, on one
4
+ // machine. It never lives in the repository: a developer switching it off must
5
+ // not switch it off for everyone who clones after them.
6
+ export class RepositoryDecisionStore {
7
+ root;
8
+ queues = new Map();
9
+ constructor(stateRoot) {
10
+ this.root = join(stateRoot, 'repository-decisions');
11
+ }
12
+ async load(repoFingerprint) {
13
+ assertFingerprint(repoFingerprint);
14
+ const decision = await readJson(this.pathFor(repoFingerprint), this.root);
15
+ if (!decision)
16
+ return null;
17
+ if (decision.repoFingerprint !== repoFingerprint ||
18
+ (decision.state !== 'bound' && decision.state !== 'disabled')) {
19
+ throw new Error('Repository decision record is invalid');
20
+ }
21
+ return decision;
22
+ }
23
+ async save(repoFingerprint, state, projectId) {
24
+ assertFingerprint(repoFingerprint);
25
+ return await this.exclusive(repoFingerprint, async () => {
26
+ const decision = {
27
+ schemaVersion: 1,
28
+ repoFingerprint,
29
+ state,
30
+ ...(projectId ? { projectId } : {}),
31
+ updatedAt: new Date().toISOString(),
32
+ };
33
+ await writeJson(this.pathFor(repoFingerprint), decision, this.root);
34
+ return decision;
35
+ });
36
+ }
37
+ async clear(repoFingerprint) {
38
+ assertFingerprint(repoFingerprint);
39
+ await this.exclusive(repoFingerprint, async () => {
40
+ await removeFile(this.pathFor(repoFingerprint), this.root);
41
+ });
42
+ }
43
+ pathFor(repoFingerprint) {
44
+ return join(this.root, `${repoFingerprint}.json`);
45
+ }
46
+ async exclusive(repoFingerprint, action) {
47
+ const previous = this.queues.get(repoFingerprint) ?? Promise.resolve();
48
+ let release = () => undefined;
49
+ const current = new Promise((resolvePromise) => {
50
+ release = resolvePromise;
51
+ });
52
+ this.queues.set(repoFingerprint, previous.then(() => current));
53
+ await previous;
54
+ try {
55
+ return await action();
56
+ }
57
+ finally {
58
+ release();
59
+ }
60
+ }
61
+ }
62
+ function assertFingerprint(value) {
63
+ if (!/^[0-9a-f]{64}$/.test(value)) {
64
+ throw new Error('Repository fingerprint is invalid');
65
+ }
66
+ }
67
+ //# sourceMappingURL=repository-decision-store.js.map
@@ -0,0 +1,26 @@
1
+ import { join } from 'node:path';
2
+ import { readJson, writeJson } from '../utilities/files.js';
3
+ // Which client version the user was offered and turned down. Asking again for
4
+ // the same version is nagging; asking when a newer one arrives is news.
5
+ export class UpdateChoiceStore {
6
+ path;
7
+ root;
8
+ constructor(stateRoot) {
9
+ this.root = join(stateRoot, 'client');
10
+ this.path = join(this.root, 'update-choice.json');
11
+ }
12
+ async declinedVersion() {
13
+ const choice = await readJson(this.path, this.root);
14
+ return choice?.declinedVersion ?? null;
15
+ }
16
+ async decline(version) {
17
+ const choice = {
18
+ schemaVersion: 1,
19
+ declinedVersion: version,
20
+ updatedAt: new Date().toISOString(),
21
+ };
22
+ await writeJson(this.path, choice, this.root);
23
+ return choice;
24
+ }
25
+ }
26
+ //# sourceMappingURL=update-choice-store.js.map
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: engineering-memory
3
- description: Enforces the Engineering Memory lifecycle for Codex and Claude in repositories bound by .engineering-memory/project.json. Use for every planning, implementation, review, diagnosis, rework, Figma, component, screen, service, model, navigation, localization, storage, validation, or Git task in a bound repository.
3
+ description: Enforces the Engineering Memory lifecycle for Codex and Claude in repositories bound by .engineering-memory/project.json. Use before answering anything about a bound repository, including a question about what it has or where its design lives, and for every planning, implementation, review, diagnosis, rework, Figma, component, screen, service, model, navigation, localization, storage, validation, or Git task in one.
4
4
  ---
5
5
 
6
6
  # Engineering Memory
@@ -11,18 +11,19 @@ Read [lifecycle.md](references/lifecycle.md) before acting in a bound repository
11
11
 
12
12
  Mandatory behavior:
13
13
 
14
- 1. Locate `.engineering-memory/project.json` from the working directory toward the repository root.
15
- 2. Before planning or editing, call `session.bootstrap`. After compaction, a new chat, interruption, or handoff, call `session.resume` first.
16
- 3. Open the task in `read_only` mode for review, diagnosis, planning, or reporting that does not authorize writes; use `scaffold` mode only to apply organization architecture templates to a new project; otherwise use `write` mode. Do read-only discovery, then record the discovery checkpoint.
17
- 4. For a write task, call `context.prepare_change` with the intended paths and record the pre-edit checkpoint before the first edit. Do not edit paths outside the active lease. A read-only task must not acquire a change lease unless the user expands the task to writing and the bridge performs the explicit mode transition.
18
- 5. Use the returned context pack as the engineering authority for the task. Use `memory.query` only for targeted missing context, and `memory.history` to read why the records you are changing became what they are before you design against them.
19
- 6. Record phase, correction, validation, and handoff checkpoints at the required moments. Declare the task's discretionary decisions on the validation-before checkpoint, and when a task repeats a shape, have the first unit reviewed before writing the rest.
20
- 7. For a write task, reconcile every changed screen and component. Propose revisions when semantics changed; otherwise record an explicit no-semantic-memory-change reconciliation.
21
- 8. Before validation, read the changed code back against the rules that govern it and record `task.self_review`. Verification refuses without a review of the current diff, and any later edit requires reviewing again.
22
- 9. Run `task.verify` before claiming completion. Write tasks verify the exact Git diff, active lease, and structured command-bound validation evidence. Read-only tasks verify that the current Git diff hash still equals the baseline captured at bootstrap, plus the required discovery, validation, handoff, pinned-context, and synchronization evidence. Run `task.close` only after verification succeeds. The pre-commit gate must confirm the closed task online; a local verification receipt is insufficient.
23
- 10. After closing a task, ask the delivery question every time commit, commit and push, or either of those with a draft or ready pull request onto a base branch the user names and do only what they choose. Once a pull request exists, check whether it merges cleanly and ask before resolving a conflict.
24
- 11. Never commit, push, publish, deploy, approve a permanent memory revision, or overwrite an existing Git hook without explicit user authorization.
25
- 12. Never store tokens, passwords, client secrets, raw headers, raw payloads, customer data, or PII in tool inputs, journals, memory, logs, or generated documentation.
14
+ 1. Locate `.engineering-memory/project.json` from the working directory toward the repository root. When it exists, the project's knowledge is in the backend and not in the working tree, so answer nothing about the project before bootstrapping — a file search that finds no design link, no screen record and no rule is reporting what the repository lacks, not what the project knows.
15
+ 2. Call `session.entry` before answering anything in a repository, and act on what it reports before the message itself: sign in when it says so, ask for organization and project when nothing has been decided, and stay completely silent about Engineering Memory in a repository where the user switched it off. Record every one of those answers with `session.set_decision`, and only ever from something the user actually said.
16
+ 3. Before planning or editing, call `session.bootstrap`. After compaction, a new chat, interruption, or handoff, call `session.resume` first.
17
+ 4. Open the task in `read_only` mode for review, diagnosis, planning, or reporting that does not authorize writes; use `scaffold` mode only to apply organization architecture templates to a new project; otherwise use `write` mode. Do read-only discovery, then record the discovery checkpoint.
18
+ 5. For a write task, call `context.prepare_change` with the intended paths and record the pre-edit checkpoint before the first edit. Do not edit paths outside the active lease. A read-only task must not acquire a change lease unless the user expands the task to writing and the bridge performs the explicit mode transition.
19
+ 6. Use the returned context pack as the engineering authority for the task. Use `memory.query` only for targeted missing context, and `memory.history` to read why the records you are changing became what they are before you design against them.
20
+ 7. Record phase, correction, validation, and handoff checkpoints at the required moments. Declare the task's discretionary decisions on the validation-before checkpoint, and when a task repeats a shape, have the first unit reviewed before writing the rest.
21
+ 8. For a write task, reconcile every changed screen and component. Propose revisions when semantics changed; otherwise record an explicit no-semantic-memory-change reconciliation.
22
+ 9. Before validation, read the changed code back against the rules that govern it and record `task.self_review`. Verification refuses without a review of the current diff, and any later edit requires reviewing again.
23
+ 10. Run `task.verify` before claiming completion. Write tasks verify the exact Git diff, active lease, and structured command-bound validation evidence. Read-only tasks verify that the current Git diff hash still equals the baseline captured at bootstrap, plus the required discovery, validation, handoff, pinned-context, and synchronization evidence. Run `task.close` only after verification succeeds. The pre-commit gate must confirm the closed task online; a local verification receipt is insufficient.
24
+ 11. After closing a task, ask the delivery question every time — commit, commit and push, or either of those with a draft or ready pull request onto a base branch the user names — and do only what they choose. Once a pull request exists, check whether it merges cleanly and ask before resolving a conflict.
25
+ 12. Never commit, push, publish, deploy, approve a permanent memory revision, or overwrite an existing Git hook without explicit user authorization.
26
+ 13. Never store tokens, passwords, client secrets, raw headers, raw payloads, customer data, or PII in tool inputs, journals, memory, logs, or generated documentation.
26
27
 
27
28
  If the repository is unbound, do not silently create or attach a project. Use the native questionnaire workflow. If the user selects task-only skip, do not create a marker or memory records.
28
29
 
@@ -6,6 +6,18 @@ Use Codex or Claude native question controls. Do not open a custom survey web pa
6
6
 
7
7
  When no Engineering Memory session exists, ask whether to sign in, create an account, or skip Engineering Memory for this task. Explain that skip is permitted only for an unbound repository. After sign in or sign up is selected, let the bridge open the browser authentication flow. Never ask for a password in the native questionnaire or chat. If a browser link is expired, rejected, already used, or otherwise unusable, call `auth.signin_browser` with `restart: true` and present only the newly returned URL.
8
8
 
9
+ ## Entering a Repository
10
+
11
+ `session.entry` decides what is asked, and it is asked whatever the user's first message is. A question about the project is not a reason to skip it; it is the reason it exists.
12
+
13
+ When nothing has been decided about this repository, the choice put to the user is: work under an organization and project, or switch Engineering Memory off here. Both answers are recorded with `session.set_decision`, and the second one is final until they ask for it back — not asked again next session, not raised when a task would obviously benefit, not hinted at. A person who said no should never have to say it twice.
14
+
15
+ ## Client Updates
16
+
17
+ `session.entry` reports whether this installation is behind the published client. An available update is offered once: say what it is, offer to run `npx engineering-memory@latest install`, and say the client has to be restarted afterwards for the new tools to load. A refusal is recorded with `session.decline_update` and that version is never mentioned again; a later, newer version is a new question rather than the same one repeated.
18
+
19
+ A required update is not a question. The client is older than the backend supports, so say that, give the command, and do not offer to carry on without it — the tools the lifecycle depends on are not there.
20
+
9
21
  ## Organization
10
22
 
11
23
  Call `organization.list` and ask which organization to work under. List the ones the user already belongs to first, in the order the call returns them, and put **create a new organization** last. Never pick one on their behalf, not even when there is only one — the answer decides where everything the task produces is written.
@@ -22,7 +34,9 @@ Ask both questions again whenever a chat starts in a repository whose binding yo
22
34
 
23
35
  ## Switching Organization or Project
24
36
 
25
- The user may say mid-chat that they want to change organization or project, in those words or any others that mean it. Take it as an instruction rather than a remark, and act on it.
37
+ The user may say mid-chat that they want to change organization or project, switch Engineering Memory off, or switch it back on. They will say it in their own words, in whatever language they are writing in, and there is no list of phrases to watch for — recognise what they mean and act on it. Take it as an instruction rather than a remark.
38
+
39
+ Switching off records the decision and ends the subject. Switching back on clears it and runs the organization and project questions again.
26
40
 
27
41
  Changing the organization always means asking for the project again afterwards, because a project belongs to exactly one organization and the old answer cannot survive the change. Run the organization questionnaire, then the project one, then bind the repository to the chosen project with `project.resolve` so the marker matches what the user just said.
28
42