engineering-memory 1.6.4 → 1.8.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.
@@ -6,16 +6,19 @@ import { delimiter, join } from 'node:path';
6
6
  const metaCharacters = /([()\][%!^"`<>&|;, *?])/g;
7
7
  const defaultPathExtensions = '.COM;.EXE;.BAT;.CMD';
8
8
  const shimExtension = /\.(?:cmd|bat)$/i;
9
+ const npmShimPath = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i;
9
10
  const pathSeparators = /[/\\]/;
10
11
 
11
12
  export async function runCommand(command, args, options = {}) {
12
13
  const environment = options.env ?? process.env;
13
14
  const invocation = await planInvocation(command, args, environment, options);
15
+ const captureOutput = options.captureOutput !== false;
14
16
  return await new Promise((resolve) => {
15
17
  const child = spawn(invocation.command, invocation.args, {
16
18
  cwd: options.cwd,
17
19
  env: environment,
18
20
  shell: false,
21
+ ...(captureOutput ? {} : { stdio: 'ignore' }),
19
22
  windowsHide: true,
20
23
  windowsVerbatimArguments: invocation.verbatim,
21
24
  });
@@ -38,6 +41,24 @@ export async function runCommand(command, args, options = {}) {
38
41
  });
39
42
  }
40
43
 
44
+ export function prependEnvironmentPath(environment, directory) {
45
+ const pathKeys = Object.keys(environment).filter(
46
+ (key) => key.toLowerCase() === 'path',
47
+ );
48
+ const pathKey =
49
+ pathKeys.find((key) => Boolean(environment[key])) ?? pathKeys[0] ?? 'PATH';
50
+ const current = pathKeys
51
+ .map((key) => environment[key])
52
+ .filter(Boolean)
53
+ .join(delimiter);
54
+ environment[pathKey] = current
55
+ ? `${directory}${delimiter}${current}`
56
+ : directory;
57
+ for (const duplicate of pathKeys) {
58
+ if (duplicate !== pathKey) delete environment[duplicate];
59
+ }
60
+ }
61
+
41
62
  export async function planInvocation(
42
63
  command,
43
64
  args,
@@ -58,9 +79,13 @@ export async function planInvocation(
58
79
  return { command: executable, args, verbatim: false };
59
80
  }
60
81
  const comSpec = environment.ComSpec ?? environment.COMSPEC ?? 'cmd.exe';
61
- const line = [escapeCommand(executable), ...args.map(escapeArgument)].join(
62
- ' ',
63
- );
82
+ const doubleEscapeMetaCharacters = npmShimPath.test(executable);
83
+ const line = [
84
+ escapeCommand(executable),
85
+ ...args.map((argument) =>
86
+ escapeArgument(argument, doubleEscapeMetaCharacters),
87
+ ),
88
+ ].join(' ');
64
89
  return {
65
90
  command: comSpec,
66
91
  args: ['/d', '/s', '/c', `"${line}"`],
@@ -80,7 +105,10 @@ export async function resolveWindowsExecutable(command, environment) {
80
105
  if (pathSeparators.test(command)) {
81
106
  return await firstExistingFile(command, extensions, carriesExtension);
82
107
  }
83
- const searchPath = environment.Path ?? environment.PATH ?? '';
108
+ const searchPath = Object.entries(environment)
109
+ .filter(([key, value]) => key.toLowerCase() === 'path' && Boolean(value))
110
+ .map(([, value]) => value)
111
+ .join(delimiter);
84
112
  for (const directory of searchPath.split(delimiter).filter(Boolean)) {
85
113
  const candidate = await firstExistingFile(
86
114
  join(unquote(directory), command),
@@ -122,13 +150,14 @@ function escapeCommand(value) {
122
150
  return String(value).replace(metaCharacters, '^$1');
123
151
  }
124
152
 
125
- function escapeArgument(value) {
153
+ function escapeArgument(value, doubleEscapeMetaCharacters) {
126
154
  const escaped = String(value)
127
- .replace(/(\\*)"/g, '$1$1\\"')
128
- .replace(/(\\*)$/, '$1$1');
129
- return `"${escaped}"`
130
- .replace(metaCharacters, '^$1')
131
- .replace(metaCharacters, '^$1');
155
+ .replace(/(?=(\\+?)?)\1"/g, '$1$1\\"')
156
+ .replace(/(?=(\\+?)?)\1$/, '$1$1');
157
+ const quoted = `"${escaped}"`.replace(metaCharacters, '^$1');
158
+ return doubleEscapeMetaCharacters
159
+ ? quoted.replace(metaCharacters, '^$1')
160
+ : quoted;
132
161
  }
133
162
 
134
163
  export function commandFailure(command, result) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engineering-memory",
3
- "version": "1.6.4",
3
+ "version": "1.8.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",
@@ -14,7 +14,7 @@ export function loadBridgeConfig(env = process.env) {
14
14
  cacheMaxEntries: positiveInteger(env.ENGINEERING_MEMORY_CACHE_MAX_ENTRIES, 64),
15
15
  cacheMaxBytes: positiveInteger(env.ENGINEERING_MEMORY_CACHE_MAX_BYTES, 5 * 1024 * 1024),
16
16
  cacheMaxAgeMs: positiveInteger(env.ENGINEERING_MEMORY_CACHE_MAX_AGE_MS, 24 * 60 * 60 * 1000),
17
- markerSchemaVersion: positiveInteger(env.ENGINEERING_MEMORY_MARKER_SCHEMA_VERSION, 1),
17
+ markerSchemaVersion: positiveInteger(env.ENGINEERING_MEMORY_MARKER_SCHEMA_VERSION, 2),
18
18
  credentialService: env.ENGINEERING_MEMORY_CREDENTIAL_SERVICE ?? 'engineering-memory',
19
19
  credentialAccount: env.ENGINEERING_MEMORY_CREDENTIAL_ACCOUNT ?? 'default',
20
20
  };
@@ -26,6 +26,7 @@ export function apiStateRoot(config) {
26
26
  return join(config.stateRoot, 'origins', apiNamespaceKey(config.apiBaseUrl));
27
27
  }
28
28
  export const endpoints = {
29
+ auditList: '/audit',
29
30
  authRefresh: '/auth/refresh',
30
31
  authLogout: '/auth/logout',
31
32
  authBrowserStart: '/auth/browser/start',
@@ -49,6 +50,8 @@ export const endpoints = {
49
50
  taskScaffoldApplication: '/tasks/scaffold-application',
50
51
  organizationList: '/organizations',
51
52
  organizationCreate: '/organizations',
53
+ organizationMemberList: (organizationId) => `/organizations/${organizationId}/members`,
54
+ organizationMemberUpsert: (organizationId) => `/organizations/${organizationId}/members`,
52
55
  taskVerify: '/runtime/task/verify',
53
56
  taskClose: '/runtime/task/close',
54
57
  taskAbandon: '/runtime/task/abandon',
@@ -57,7 +60,12 @@ export const endpoints = {
57
60
  projectList: '/projects',
58
61
  projectResolve: '/projects/resolve',
59
62
  projectBind: (projectId) => `/projects/${projectId}/bind`,
63
+ projectArchive: (projectId) => `/projects/${projectId}`,
64
+ projectRestore: (projectId) => `/projects/${projectId}/restore`,
60
65
  projectMemberAdd: (projectId) => `/projects/${projectId}/members`,
66
+ projectMemberList: (projectId) => `/projects/${projectId}/members`,
67
+ workItemList: (projectId) => `/projects/${projectId}/work-items`,
68
+ workItemGet: (projectId, workItemId) => `/projects/${projectId}/work-items/${workItemId}`,
61
69
  projectLink: '/projects/link',
62
70
  projectUnlink: '/projects/unlink',
63
71
  projectLinks: (projectId) => `/projects/${projectId}/links`,
@@ -19,37 +19,44 @@ export class GitInspector {
19
19
  }
20
20
  return await canonicalPath(result.stdout.trim());
21
21
  }
22
- async fingerprint(repoRoot) {
23
- const [remote, firstCommit] = await Promise.all([
24
- this.gitValue(repoRoot, ['config', '--get', 'remote.origin.url']),
25
- this.gitValue(repoRoot, ['rev-list', '--max-parents=0', 'HEAD']),
26
- ]);
27
- const canonicalRoot = await canonicalPath(repoRoot);
22
+ async fingerprint(repoRoot, identityVersion = 2) {
23
+ if (!Number.isSafeInteger(identityVersion) || identityVersion < 1) {
24
+ throw new Error('Repository identity version must be a positive integer');
25
+ }
26
+ const remote = await this.gitValue(repoRoot, ['config', '--get', 'remote.origin.url']);
28
27
  const identity = remote
29
- ? `${canonicalRemoteIdentity(remote)}\n${firstCommit ?? ''}`
30
- : `${canonicalRoot.toLowerCase()}\n${firstCommit ?? ''}`;
28
+ ? canonicalRemoteIdentity(remote)
29
+ : (await this.mainWorktree(repoRoot)).toLowerCase();
30
+ if (identityVersion === 1) {
31
+ const firstCommit = await this.gitValue(repoRoot, ['rev-list', '--max-parents=0', 'HEAD']);
32
+ return sha256(`${identity}\n${firstCommit ?? ''}`);
33
+ }
31
34
  return sha256(identity);
32
35
  }
36
+ async mainWorktree(repoRoot) {
37
+ const commonDirectory = await this.gitValue(repoRoot, [
38
+ 'rev-parse',
39
+ '--path-format=absolute',
40
+ '--git-common-dir',
41
+ ]);
42
+ if (!commonDirectory) {
43
+ return await canonicalPath(repoRoot);
44
+ }
45
+ return await canonicalPath(resolve(commonDirectory, '..'));
46
+ }
33
47
  async manifest(repoRoot) {
34
48
  const root = await this.findRoot(repoRoot);
35
49
  const head = await this.gitValue(root, ['rev-parse', 'HEAD']);
36
50
  const changedPaths = head
37
51
  ? await this.changedPathsAgainstHead(root)
38
52
  : await this.unbornChangedPaths(root);
39
- const normalizedManifest = changedPaths.map(({ path, originalPath, status, contentHash, size, mode }) => ({
40
- path,
41
- originalPath: originalPath ?? null,
42
- status: semanticStatus(status),
43
- contentHash,
44
- size,
45
- mode: mode ?? null,
46
- }));
53
+ const hashManifest = canonicalHashManifest(changedPaths);
47
54
  return {
48
55
  repoRoot: root,
49
56
  head,
50
57
  changedPaths,
51
- worktreeHash: sha256(`${stableStringify(normalizedManifest)}\n`),
52
- diffHash: sha256(`${head ?? '<unborn>'}\n${stableStringify(normalizedManifest)}\n`),
58
+ worktreeHash: sha256(`${stableStringify(hashManifest)}\n`),
59
+ diffHash: sha256(`${head ?? '<unborn>'}\n${stableStringify(hashManifest)}\n`),
53
60
  };
54
61
  }
55
62
  async stagedManifest(repoRoot) {
@@ -354,6 +361,41 @@ function semanticStatus(status) {
354
361
  }
355
362
  return status.trim() || status;
356
363
  }
364
+ function canonicalHashManifest(entries) {
365
+ const byPath = new Map();
366
+ const renamedSources = new Set();
367
+ for (const entry of entries) {
368
+ const status = semanticStatus(entry.status);
369
+ const deleted = status === 'D';
370
+ if (status === 'R') {
371
+ if (!entry.originalPath) {
372
+ throw new Error(`Git rename is missing its original path: ${entry.path}`);
373
+ }
374
+ renamedSources.add(entry.originalPath);
375
+ }
376
+ byPath.set(entry.path, {
377
+ path: entry.path,
378
+ originalPath: null,
379
+ status: status === 'C' || status === 'R' ? 'A' : status,
380
+ contentHash: deleted ? null : entry.contentHash,
381
+ size: deleted ? null : entry.size,
382
+ mode: deleted ? null : (entry.mode ?? null),
383
+ });
384
+ }
385
+ for (const path of renamedSources) {
386
+ if (!byPath.has(path)) {
387
+ byPath.set(path, {
388
+ path,
389
+ originalPath: null,
390
+ status: 'D',
391
+ contentHash: null,
392
+ size: null,
393
+ mode: null,
394
+ });
395
+ }
396
+ }
397
+ return [...byPath.values()].sort((left, right) => left.path.localeCompare(right.path));
398
+ }
357
399
  function deduplicateChangedPaths(entries) {
358
400
  const byPath = new Map();
359
401
  for (const entry of entries) {
@@ -44,6 +44,7 @@ const checkpointBase = {
44
44
  idempotencyKey: z.string().uuid().optional(),
45
45
  };
46
46
  export const engineeringMemoryToolNames = [
47
+ 'audit.list',
47
48
  'session.entry',
48
49
  'session.set_decision',
49
50
  'session.decline_update',
@@ -70,10 +71,17 @@ export const engineeringMemoryToolNames = [
70
71
  'architecture.record_application',
71
72
  'organization.list',
72
73
  'organization.create',
74
+ 'organization.member_list',
75
+ 'organization.member_upsert',
73
76
  'project.setup',
74
77
  'project.list',
75
78
  'project.resolve',
79
+ 'project.archive',
80
+ 'project.restore',
76
81
  'project.member_add',
82
+ 'project.member_list',
83
+ 'work_item.list',
84
+ 'work_item.get',
77
85
  'project.clone',
78
86
  'project.link',
79
87
  'project.unlink',
@@ -97,6 +105,31 @@ const reconciliationEntry = z.object({
97
105
  reason: z.string().optional(),
98
106
  });
99
107
  export function registerEngineeringMemoryTools(server, service) {
108
+ server.registerTool('audit.list', {
109
+ description: 'List one authorized organization or project audit trail with an exact cursor. Choose exactly one scope and send both cursor fields together.',
110
+ inputSchema: z
111
+ .object({
112
+ organizationId: z.string().uuid().optional(),
113
+ projectId: z.string().uuid().optional(),
114
+ beforeCreatedAt: z.string().datetime({ offset: true }).optional(),
115
+ beforeId: z.string().uuid().optional(),
116
+ limit: z.number().int().min(1).max(100).optional(),
117
+ })
118
+ .superRefine((value, context) => {
119
+ if (Boolean(value.organizationId) === Boolean(value.projectId)) {
120
+ context.addIssue({
121
+ code: 'custom',
122
+ message: 'Choose exactly one organizationId or projectId.',
123
+ });
124
+ }
125
+ if (Boolean(value.beforeCreatedAt) !== Boolean(value.beforeId)) {
126
+ context.addIssue({
127
+ code: 'custom',
128
+ message: 'Send beforeCreatedAt and beforeId together.',
129
+ });
130
+ }
131
+ }),
132
+ }, async (input) => toolResult(await service.auditList(input)));
100
133
  server.registerTool('session.entry', {
101
134
  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.',
102
135
  inputSchema: z.object({ repoRoot: optionalRepoRoot }),
@@ -131,6 +164,7 @@ export function registerEngineeringMemoryTools(server, service) {
131
164
  objective: z.string().min(2),
132
165
  taskKind: z.string().min(2),
133
166
  workItemKey: z.string().min(1).optional(),
167
+ workItemId: z.string().uuid().optional(),
134
168
  mode: z.enum(['write', 'read_only', 'scaffold']).optional(),
135
169
  knownRevisions: z.record(z.string(), z.number().int().min(0)).optional(),
136
170
  }),
@@ -401,6 +435,19 @@ export function registerEngineeringMemoryTools(server, service) {
401
435
  slug: z.string().min(2).max(120),
402
436
  }),
403
437
  }, async (input) => toolResult(await service.organizationCreate(input)));
438
+ server.registerTool('organization.member_list', {
439
+ description: "List an organization's members and roles so a member who cannot perform an owner-only recovery can coordinate with an owner.",
440
+ inputSchema: z.object({ organizationId: z.string().uuid() }),
441
+ }, async (input) => toolResult(await service.organizationMemberList(input)));
442
+ server.registerTool('organization.member_upsert', {
443
+ description: 'Add or update an existing Engineering Memory account in an organization. Only an organization owner may call this; use it before adding that account to a child project.',
444
+ inputSchema: z.object({
445
+ organizationId: z.string().uuid(),
446
+ email: z.string().email(),
447
+ role: z.enum(['owner', 'member']),
448
+ discipline: z.enum(['backend', 'frontend', 'fullstack']),
449
+ }),
450
+ }, async (input) => toolResult(await service.organizationMemberUpsert(input)));
404
451
  server.registerTool('project.setup', {
405
452
  description: 'Create a policy-ready backend project from the native questionnaire and local repository audit, then write its secret-free marker.',
406
453
  inputSchema: z.object({
@@ -429,7 +476,7 @@ export function registerEngineeringMemoryTools(server, service) {
429
476
  }),
430
477
  }, async (input) => toolResult(await service.projectSetup(input)));
431
478
  server.registerTool('architecture.plan', {
432
- description: 'List the organization architecture modules in apply order with their manifests, rename map, asset contract and tenant-specific points. Returns no file bodies.',
479
+ description: 'List only architecture modules compatible with the approved project runtime contract, in apply order with their manifests, rename map, asset contract and tenant-specific points. Returns no file bodies; an empty plan means continue without scaffolding.',
433
480
  inputSchema: z.object({
434
481
  projectId: z.string().uuid(),
435
482
  sessionId: z.string().uuid(),
@@ -465,9 +512,9 @@ export function registerEngineeringMemoryTools(server, service) {
465
512
  inputSchema: z.object({}),
466
513
  }, async () => toolResult(await service.organizationList()));
467
514
  server.registerTool('project.list', {
468
- description: 'List projects available to the authenticated user so the agent can present them through the native questionnaire UI.',
469
- inputSchema: z.object({}),
470
- }, async () => toolResult(await service.projectList()));
515
+ description: 'List projects available to the authenticated user, optionally including archived projects that need restoration.',
516
+ inputSchema: z.object({ includeArchived: z.boolean().optional() }),
517
+ }, async (input) => toolResult(await service.projectList(input)));
471
518
  server.registerTool('project.resolve', {
472
519
  description: 'Resolve the current repository fingerprint or bind an explicitly selected project, then write the secret-free marker only after backend authorization succeeds.',
473
520
  inputSchema: z.object({
@@ -476,14 +523,52 @@ export function registerEngineeringMemoryTools(server, service) {
476
523
  projectId: z.string().uuid().optional(),
477
524
  }),
478
525
  }, async (input) => toolResult(await service.projectResolve(input)));
526
+ server.registerTool('project.archive', {
527
+ description: 'Archive a project at an explicit version while preserving its repository identity and history.',
528
+ inputSchema: z.object({
529
+ projectId: z.string().uuid(),
530
+ expectedVersion: z.number().int().min(1),
531
+ }),
532
+ }, async (input) => toolResult(await service.projectArchive(input)));
533
+ server.registerTool('project.restore', {
534
+ description: 'Restore an archived project at the version returned by project.list with includeArchived enabled.',
535
+ inputSchema: z.object({
536
+ projectId: z.string().uuid(),
537
+ expectedVersion: z.number().int().min(1),
538
+ }),
539
+ }, async (input) => toolResult(await service.projectRestore(input)));
479
540
  server.registerTool('project.member_add', {
480
- description: 'Add an existing Engineering Memory account to a project with an explicit role.',
541
+ description: 'Add or update an existing Engineering Memory account with an explicit project role and optional discipline override.',
481
542
  inputSchema: z.object({
482
543
  projectId: z.string().min(1),
483
544
  email: z.string().email(),
484
545
  role: z.enum(['owner', 'maintainer', 'member', 'reader']),
546
+ discipline: z.enum(['backend', 'frontend', 'fullstack']).nullable().optional(),
485
547
  }),
486
548
  }, async (input) => toolResult(await service.projectMemberAdd(input)));
549
+ server.registerTool('project.member_list', {
550
+ 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
+ inputSchema: z.object({ projectId: z.string().uuid() }),
552
+ }, async (input) => toolResult(await service.projectMemberList(input)));
553
+ server.registerTool('work_item.list', {
554
+ description: 'List selectable work items for a project before opening an engineering run in this chat.',
555
+ inputSchema: z.object({
556
+ projectId: z.string().uuid(),
557
+ status: z
558
+ .enum(['backlog', 'ready', 'in_progress', 'in_review', 'done', 'cancelled'])
559
+ .optional(),
560
+ priority: z.enum(['lowest', 'low', 'medium', 'high', 'highest']).optional(),
561
+ assigneeUserId: z.string().uuid().optional(),
562
+ includeArchived: z.boolean().optional(),
563
+ }),
564
+ }, async (input) => toolResult(await service.workItemList(input)));
565
+ server.registerTool('work_item.get', {
566
+ description: 'Load one project work item, including acceptance criteria and dependencies, before starting or resuming its engineering run.',
567
+ inputSchema: z.object({
568
+ projectId: z.string().uuid(),
569
+ workItemId: z.string().uuid(),
570
+ }),
571
+ }, async (input) => toolResult(await service.workItemGet(input)));
487
572
  server.registerTool('auth.logout', {
488
573
  description: 'Revoke the refresh session and delete local credentials; transient revoke failures preserve the session unless the user explicitly confirms local-only logout.',
489
574
  inputSchema: z.object({
@@ -18,7 +18,7 @@ export class RepositoryResolver {
18
18
  throw new Error('Explicit project does not match the repository marker');
19
19
  }
20
20
  const [repoFingerprint, git] = await Promise.all([
21
- this.git.fingerprint(repoRoot),
21
+ this.git.fingerprint(repoRoot, marker?.schemaVersion ?? this.markerSchemaVersion),
22
22
  this.git.manifest(repoRoot),
23
23
  ]);
24
24
  return {
@@ -43,6 +43,16 @@ export class ActiveContextStore {
43
43
  const pointers = await this.list(repoFingerprint);
44
44
  return pointers.find((pointer) => pointer.taskSlug === taskSlug) ?? null;
45
45
  }
46
+ async loadForSession(repoFingerprint, sessionId) {
47
+ if (!isUuid(sessionId)) {
48
+ throw new Error('Context session identifier is invalid');
49
+ }
50
+ const matches = (await this.list(repoFingerprint)).filter((pointer) => pointer.sessionId === sessionId);
51
+ if (matches.length > 1) {
52
+ throw new Error('Context session has more than one active task pointer');
53
+ }
54
+ return matches[0] ?? null;
55
+ }
46
56
  async list(repoFingerprint) {
47
57
  assertFingerprint(repoFingerprint);
48
58
  await this.adoptLegacyPointer(repoFingerprint);
@@ -112,7 +122,7 @@ export class ActiveContextStore {
112
122
  async setChangeBaseline(repoFingerprint, sessionId, manifest, leasePaths, taskSnapshot) {
113
123
  const pointer = taskSnapshot
114
124
  ? await this.loadForTask(repoFingerprint, taskSnapshot.taskId)
115
- : await this.load(repoFingerprint);
125
+ : await this.loadForSession(repoFingerprint, sessionId);
116
126
  if (!pointer || pointer.sessionId !== sessionId) {
117
127
  throw new Error('Change baseline does not match the active repository session');
118
128
  }
@@ -163,9 +173,7 @@ export class ActiveContextStore {
163
173
  }
164
174
  async clearVerificationIntent(repoFingerprint, taskId) {
165
175
  await this.exclusive(repoFingerprint, async () => {
166
- const pointer = taskId
167
- ? await this.loadForTask(repoFingerprint, taskId)
168
- : await this.load(repoFingerprint);
176
+ const pointer = await this.loadForTask(repoFingerprint, taskId);
169
177
  if (!pointer?.verificationIntent) {
170
178
  return;
171
179
  }
@@ -199,9 +207,7 @@ export class ActiveContextStore {
199
207
  }
200
208
  async clearCloseIntent(repoFingerprint, taskId) {
201
209
  await this.exclusive(repoFingerprint, async () => {
202
- const pointer = taskId
203
- ? await this.loadForTask(repoFingerprint, taskId)
204
- : await this.load(repoFingerprint);
210
+ const pointer = await this.loadForTask(repoFingerprint, taskId);
205
211
  if (!pointer?.closeIntent) {
206
212
  return;
207
213
  }
@@ -1,4 +1,46 @@
1
1
  import { sha256, stableStringify } from '../utilities/hash.js';
2
+ export const backendRecoveryOperationNames = [
3
+ 'auth.signin_browser',
4
+ 'auth.web_csrf',
5
+ 'auth.web_signin',
6
+ 'audit.list',
7
+ 'organization.member_list',
8
+ 'organization.member_upsert',
9
+ 'project.setup',
10
+ 'project.resolve',
11
+ 'project.list',
12
+ 'project.restore',
13
+ 'project.member_list',
14
+ 'organization.list',
15
+ 'project.member_add',
16
+ 'work_item.list',
17
+ 'work_item.get',
18
+ 'session.bootstrap',
19
+ 'session.resume',
20
+ 'context.refresh',
21
+ 'context.prepare_change',
22
+ 'memory.query',
23
+ 'memory.history',
24
+ 'memory.propose_revision',
25
+ 'memory.list_proposals',
26
+ 'memory.review_proposal',
27
+ 'architecture.plan',
28
+ 'architecture.record_application',
29
+ 'task.checkpoint',
30
+ 'task.reconcile',
31
+ 'task.self_review',
32
+ 'task.verify',
33
+ 'task.close',
34
+ 'task.abandon',
35
+ 'task.resolve_pending_delivery',
36
+ ];
37
+ const backendRecoveryOperations = new Set(backendRecoveryOperationNames);
38
+ const browserSigninRecovery = 'auth.signin_browser';
39
+ export function normalizeBackendRecovery(value) {
40
+ return typeof value === 'string' && backendRecoveryOperations.has(value)
41
+ ? value
42
+ : null;
43
+ }
2
44
  export class BackendUnavailableError extends Error {
3
45
  causeValue;
4
46
  retryable = true;
@@ -14,14 +56,17 @@ export class ApiResponseError extends Error {
14
56
  retryable;
15
57
  envelope;
16
58
  recovery;
17
- constructor(message, httpStatus, code, retryable, envelope, recovery = null) {
18
- super(message);
59
+ constructor(message, httpStatus, code, retryable, envelope, recovery) {
60
+ const normalizedRecovery = normalizeBackendRecovery(recovery) ?? (httpStatus === 401 ? browserSigninRecovery : null);
61
+ super(normalizedRecovery && !message.includes('Recovery: call ')
62
+ ? `${message} Recovery: call ${normalizedRecovery}.`
63
+ : message);
19
64
  this.httpStatus = httpStatus;
20
65
  this.code = code;
21
66
  this.retryable = retryable;
22
67
  this.envelope = envelope;
23
- this.recovery = recovery;
24
68
  this.name = 'ApiResponseError';
69
+ this.recovery = normalizedRecovery;
25
70
  }
26
71
  }
27
72
  export class ApiClient {
@@ -53,7 +98,7 @@ export class ApiClient {
53
98
  if (request.authenticated !== false) {
54
99
  const accessToken = await this.options.credentials.get('access-token');
55
100
  if (!accessToken) {
56
- throw new ApiResponseError('Authentication is required', 401, null, false, null);
101
+ throw new ApiResponseError('Authentication is required. Recovery: call auth.signin_browser.', 401, null, false, null, browserSigninRecovery);
57
102
  }
58
103
  headers.Authorization = `Bearer ${accessToken}`;
59
104
  }
@@ -108,9 +153,9 @@ export class ApiClient {
108
153
  this.responseSources.set(cached.value, 'stale_cache');
109
154
  return cached.value;
110
155
  }
111
- const recovery = envelope.errorModel?.recovery ?? null;
156
+ const recovery = envelope.errorModel?.recovery ?? (response.status === 401 ? browserSigninRecovery : null);
112
157
  const refusal = envelope.errorModel?.text ?? envelope.message ?? `Backend returned ${response.status}`;
113
- throw new ApiResponseError(recovery ? `${refusal} Recovery: call ${recovery}.` : refusal, response.status, envelope.errorModel?.code ?? null, response.status === 408 || response.status === 429 || response.status >= 500, envelope, recovery);
158
+ throw new ApiResponseError(refusal, response.status, envelope.errorModel?.code ?? null, retryableResponse(response.status, envelope.errorModel?.retryable), envelope, recovery);
114
159
  }
115
160
  if (request.cacheKey) {
116
161
  await this.options.cache.set(cacheKey, response.headers.get('etag'), envelope);
@@ -146,7 +191,7 @@ export class ApiClient {
146
191
  const refreshToken = await this.options.credentials.get('refresh-token');
147
192
  if (!refreshToken) {
148
193
  await this.options.credentials.clear();
149
- throw new ApiResponseError('Authentication has expired', 401, null, false, null);
194
+ throw new ApiResponseError('Authentication has expired. Recovery: call auth.signin_browser.', 401, null, false, null, browserSigninRecovery);
150
195
  }
151
196
  const controller = new AbortController();
152
197
  const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs);
@@ -172,18 +217,19 @@ export class ApiClient {
172
217
  catch (error) {
173
218
  if (isDefinitiveRefreshRejection(response.status)) {
174
219
  await this.options.credentials.clear();
220
+ throw new ApiResponseError('Authentication has expired. Recovery: call auth.signin_browser.', response.status, null, false, null, browserSigninRecovery);
175
221
  }
176
222
  throw error;
177
223
  }
178
224
  if (!response.ok || envelope.status === 'error') {
179
225
  if (isDefinitiveRefreshRejection(response.status)) {
180
226
  await this.options.credentials.clear();
181
- throw new ApiResponseError('Authentication has expired', response.status, null, false, envelope);
227
+ throw new ApiResponseError('Authentication has expired. Recovery: call auth.signin_browser.', response.status, null, false, envelope, browserSigninRecovery);
182
228
  }
183
- throw new ApiResponseError(envelope.errorModel?.text ?? envelope.message ?? 'Token refresh failed', response.status, envelope.errorModel?.code ?? null, response.status === 408 || response.status === 429 || response.status >= 500, envelope);
229
+ throw new ApiResponseError(envelope.errorModel?.text ?? envelope.message ?? 'Token refresh failed', response.status, envelope.errorModel?.code ?? null, retryableResponse(response.status, envelope.errorModel?.retryable), envelope, null);
184
230
  }
185
231
  if (!isRefreshTokens(envelope.data)) {
186
- throw new ApiResponseError('Token refresh response is missing credentials', response.status, null, false, envelope);
232
+ throw new ApiResponseError('Token refresh response is missing credentials. Recovery: call auth.signin_browser.', response.status, null, false, envelope, browserSigninRecovery);
187
233
  }
188
234
  await this.options.credentials.set('access-token', envelope.data.accessToken);
189
235
  if (envelope.data.refreshToken) {
@@ -196,10 +242,10 @@ export class ApiClient {
196
242
  value = await response.json();
197
243
  }
198
244
  catch {
199
- throw new ApiResponseError(`Backend returned a non-JSON response with status ${response.status}`, response.status, null, response.status >= 500, null);
245
+ throw new ApiResponseError(`Backend returned a non-JSON response with status ${response.status}`, response.status, null, response.status >= 500, null, null);
200
246
  }
201
247
  if (!isEnvelope(value)) {
202
- throw new ApiResponseError('Backend response does not match the Engineering Memory envelope', response.status, null, false, null);
248
+ throw new ApiResponseError('Backend response does not match the Engineering Memory envelope', response.status, null, false, null, null);
203
249
  }
204
250
  return value;
205
251
  }
@@ -223,6 +269,9 @@ function isEnvelope(value) {
223
269
  'message' in candidate &&
224
270
  'errorModel' in candidate);
225
271
  }
272
+ function retryableResponse(status, declared) {
273
+ return declared ?? (status === 408 || status === 429 || status >= 500);
274
+ }
226
275
  function isRefreshTokens(value) {
227
276
  return (value !== null &&
228
277
  !Array.isArray(value) &&
@@ -6,6 +6,7 @@ import { endpoints } from '../config.js';
6
6
  import { sha256, stableStringify } from '../utilities/hash.js';
7
7
  import { ApiResponseError, BackendUnavailableError, } from './api-client.js';
8
8
  import { assertSafeToPersist, normalizeRepositoryPaths } from './offline-outbox.js';
9
+ import { BridgeRecoveryError } from './recovery-error.js';
9
10
  export const validationIds = [
10
11
  'format',
11
12
  'static_analysis',
@@ -54,6 +55,7 @@ export class BridgeService {
54
55
  objective: input.objective,
55
56
  taskKind: input.taskKind,
56
57
  workItemKey: input.workItemKey,
58
+ workItemId: input.workItemId,
57
59
  mode: input.mode,
58
60
  knownRevisions: input.knownRevisions,
59
61
  }, repository.repoRoot);
@@ -71,14 +73,18 @@ export class BridgeService {
71
73
  body: { repoFingerprint: repository.repoFingerprint },
72
74
  });
73
75
  const project = objectValue(resolved.data);
74
- const projects = await this.dependencies.client.request(endpoints.projectList);
76
+ const restoreRequired = Boolean(project?.archivedAt);
77
+ const restoreGuidance = archivedProjectGuidance(project);
78
+ const projects = await this.dependencies.client.request(restoreRequired ? `${endpoints.projectList}?includeArchived=true` : endpoints.projectList);
75
79
  return asJsonValue({
76
- projectBindingRequired: true,
80
+ projectBindingRequired: !restoreRequired,
81
+ restoreRequired,
77
82
  candidate: project,
78
83
  requestedProjectId: projectId,
79
84
  repository: publicRepository(repository),
80
85
  projects: projects.data,
81
86
  outbox: { recoveredDeliveries, ...objectOrEmpty(outbox) },
87
+ ...(restoreRequired ? restoreGuidance : {}),
82
88
  });
83
89
  }
84
90
  if (!projectId) {
@@ -94,6 +100,7 @@ export class BridgeService {
94
100
  objective: persistedBootstrap.objective,
95
101
  taskKind: persistedBootstrap.taskKind,
96
102
  workItemKey: persistedBootstrap.workItemKey,
103
+ workItemId: persistedBootstrap.workItemId,
97
104
  mode: persistedBootstrap.mode ?? 'write',
98
105
  repoFingerprint: repository.repoFingerprint,
99
106
  ...(input.mode === 'read_only'
@@ -256,7 +263,7 @@ export class BridgeService {
256
263
  ? await this.retryVerificationRecovery(repository, pointer, backend)
257
264
  : null;
258
265
  if (recoveredVerification) {
259
- pointer = await this.dependencies.activeContexts.load(repository.repoFingerprint);
266
+ pointer = await this.dependencies.activeContexts.loadForTask(repository.repoFingerprint, pointer.taskId);
260
267
  }
261
268
  let hydratedFromBackend = false;
262
269
  let mergedFromBackend = false;
@@ -405,7 +412,7 @@ export class BridgeService {
405
412
  await this.recoverJournalOutbox();
406
413
  const outbox = await this.flushOutbox();
407
414
  const pendingOutbox = await this.dependencies.outbox.list();
408
- const pointer = await this.dependencies.activeContexts.load(repository.repoFingerprint);
415
+ const pointer = await this.dependencies.activeContexts.loadForSession(repository.repoFingerprint, input.sessionId);
409
416
  if (pointer?.verificationIntent || pointer?.closeIntent) {
410
417
  throw refuse('A verification or close intent from an earlier session is still unsettled, so a new lease would be taken against the wrong state.', 'session.resume');
411
418
  }
@@ -492,7 +499,7 @@ export class BridgeService {
492
499
  lastSequence: numericSequence(preparedTask.lastSequence),
493
500
  }
494
501
  : undefined);
495
- const preparedPointer = await this.dependencies.activeContexts.load(repository.repoFingerprint);
502
+ const preparedPointer = await this.dependencies.activeContexts.loadForSession(repository.repoFingerprint, input.sessionId);
496
503
  if (preparedPointer) {
497
504
  const preparedData = objectValue(response.data);
498
505
  await this.seedResumeSnapshot(preparedPointer, {
@@ -1224,11 +1231,59 @@ export class BridgeService {
1224
1231
  return await this.execute(async () => {
1225
1232
  const response = await this.dependencies.client.request(endpoints.projectMemberAdd(input.projectId), {
1226
1233
  method: 'POST',
1227
- body: { email: input.email, role: input.role },
1234
+ body: cleanJson({
1235
+ email: input.email,
1236
+ role: input.role,
1237
+ ...(input.discipline !== undefined ? { discipline: input.discipline } : {}),
1238
+ }),
1239
+ });
1240
+ return asJsonValue({ membership: response.data });
1241
+ });
1242
+ }
1243
+ async projectMemberList(input) {
1244
+ return await this.execute(async () => {
1245
+ const response = await this.dependencies.client.request(endpoints.projectMemberList(input.projectId));
1246
+ return asJsonValue({ members: response.data });
1247
+ });
1248
+ }
1249
+ async auditList(input) {
1250
+ return await this.execute(async () => {
1251
+ const query = new URLSearchParams();
1252
+ if (input.organizationId) {
1253
+ query.set('organizationId', input.organizationId);
1254
+ }
1255
+ if (input.projectId)
1256
+ query.set('projectId', input.projectId);
1257
+ if (input.beforeCreatedAt) {
1258
+ query.set('beforeCreatedAt', input.beforeCreatedAt);
1259
+ }
1260
+ if (input.beforeId)
1261
+ query.set('beforeId', input.beforeId);
1262
+ if (input.limit !== undefined)
1263
+ query.set('limit', String(input.limit));
1264
+ const response = await this.dependencies.client.request(`${endpoints.auditList}?${query.toString()}`);
1265
+ return asJsonValue({ page: response.data });
1266
+ });
1267
+ }
1268
+ async organizationMemberUpsert(input) {
1269
+ return await this.execute(async () => {
1270
+ const response = await this.dependencies.client.request(endpoints.organizationMemberUpsert(input.organizationId), {
1271
+ method: 'PUT',
1272
+ body: cleanJson({
1273
+ email: input.email,
1274
+ role: input.role,
1275
+ discipline: input.discipline,
1276
+ }),
1228
1277
  });
1229
1278
  return asJsonValue({ membership: response.data });
1230
1279
  });
1231
1280
  }
1281
+ async organizationMemberList(input) {
1282
+ return await this.execute(async () => {
1283
+ const response = await this.dependencies.client.request(endpoints.organizationMemberList(input.organizationId));
1284
+ return asJsonValue({ members: response.data });
1285
+ });
1286
+ }
1232
1287
  async organizationList() {
1233
1288
  return await this.execute(async () => {
1234
1289
  const authentication = await this.dependencies.browserAuth.ensureAuthenticated();
@@ -1243,6 +1298,33 @@ export class BridgeService {
1243
1298
  });
1244
1299
  });
1245
1300
  }
1301
+ async workItemList(input) {
1302
+ return await this.execute(async () => {
1303
+ const query = new URLSearchParams();
1304
+ if (input.status)
1305
+ query.set('status', input.status);
1306
+ if (input.priority)
1307
+ query.set('priority', input.priority);
1308
+ if (input.assigneeUserId) {
1309
+ query.set('assigneeUserId', input.assigneeUserId);
1310
+ }
1311
+ if (input.includeArchived)
1312
+ query.set('includeArchived', 'true');
1313
+ const suffix = query.size > 0 ? `?${query.toString()}` : '';
1314
+ const response = await this.dependencies.client.request(`${endpoints.workItemList(input.projectId)}${suffix}`);
1315
+ return asJsonValue({
1316
+ selectionRequired: true,
1317
+ questionnaire: 'Select the project work item to run in this chat',
1318
+ ...objectOrEmpty(response.data),
1319
+ });
1320
+ });
1321
+ }
1322
+ async workItemGet(input) {
1323
+ return await this.execute(async () => {
1324
+ const response = await this.dependencies.client.request(endpoints.workItemGet(input.projectId, input.workItemId));
1325
+ return asJsonValue({ workItem: response.data });
1326
+ });
1327
+ }
1246
1328
  async architecturePlan(input) {
1247
1329
  return await this.execute(async () => {
1248
1330
  const body = cleanJson(input);
@@ -1297,18 +1379,46 @@ export class BridgeService {
1297
1379
  return asJsonValue({ organization: response.data });
1298
1380
  });
1299
1381
  }
1300
- async projectList() {
1382
+ async projectList(input = {}) {
1301
1383
  return await this.execute(async () => {
1302
1384
  const authentication = await this.dependencies.browserAuth.ensureAuthenticated();
1303
1385
  if (authentication) {
1304
1386
  return asJsonValue({ authentication });
1305
1387
  }
1306
- const response = await this.dependencies.client.request(endpoints.projectList);
1388
+ const response = await this.dependencies.client.request(input.includeArchived
1389
+ ? `${endpoints.projectList}?includeArchived=true`
1390
+ : endpoints.projectList);
1307
1391
  return asJsonValue({
1308
1392
  selectionRequired: true,
1309
1393
  questionnaire: 'Select an existing project or choose new project setup',
1310
1394
  projects: response.data,
1395
+ ...(input.includeArchived
1396
+ ? {
1397
+ archivedProjectActions: (Array.isArray(response.data) ? response.data : [])
1398
+ .map((entry) => objectValue(entry))
1399
+ .filter((project) => Boolean(project?.archivedAt))
1400
+ .map((project) => archivedProjectGuidance(project)),
1401
+ }
1402
+ : {}),
1403
+ });
1404
+ });
1405
+ }
1406
+ async projectArchive(input) {
1407
+ return await this.execute(async () => {
1408
+ const response = await this.dependencies.client.request(endpoints.projectArchive(input.projectId), {
1409
+ method: 'DELETE',
1410
+ body: { expectedVersion: input.expectedVersion },
1311
1411
  });
1412
+ return asJsonValue({ project: response.data, archived: true });
1413
+ });
1414
+ }
1415
+ async projectRestore(input) {
1416
+ return await this.execute(async () => {
1417
+ const response = await this.dependencies.client.request(endpoints.projectRestore(input.projectId), {
1418
+ method: 'POST',
1419
+ body: { expectedVersion: input.expectedVersion },
1420
+ });
1421
+ return asJsonValue({ project: response.data, restored: true });
1312
1422
  });
1313
1423
  }
1314
1424
  async projectResolve(input) {
@@ -1355,12 +1465,16 @@ export class BridgeService {
1355
1465
  body: { repoFingerprint: repository.repoFingerprint },
1356
1466
  });
1357
1467
  const project = objectValue(response.data);
1468
+ const restoreRequired = Boolean(project?.archivedAt);
1469
+ const restoreGuidance = archivedProjectGuidance(project);
1358
1470
  return asJsonValue({
1359
- resolved: project !== null,
1471
+ resolved: project !== null && !restoreRequired,
1360
1472
  project,
1361
1473
  markerPath: null,
1362
1474
  repository: publicRepository(repository),
1363
1475
  selectionRequired: project === null,
1476
+ restoreRequired,
1477
+ ...(restoreRequired ? restoreGuidance : {}),
1364
1478
  });
1365
1479
  });
1366
1480
  }
@@ -1391,21 +1505,50 @@ export class BridgeService {
1391
1505
  const client = await this.clientUpdate(authenticated);
1392
1506
  const shipped = objectValue(objectValue(client)?.shippedKnowledge);
1393
1507
  const incomplete = shipped && (shipped.failure !== null || Number(shipped.live) < Number(shipped.expected));
1508
+ const projectId = repository.projectId ?? decision?.projectId ?? null;
1509
+ const liveTasks = authenticated && state === 'bound'
1510
+ ? (await this.dependencies.activeContexts.list(repository.repoFingerprint)).map(describePointer)
1511
+ : [];
1512
+ const workItems = authenticated && state === 'bound' && projectId
1513
+ ? await this.actionableWorkItems(projectId)
1514
+ : [];
1515
+ const taskSelectionRequired = liveTasks.length > 0 || workItems.length > 0;
1394
1516
  return asJsonValue({
1395
1517
  authenticated,
1396
1518
  repository: publicRepository(repository),
1397
1519
  decision: state,
1398
- projectId: repository.projectId ?? decision?.projectId ?? null,
1520
+ projectId,
1521
+ liveTasks,
1522
+ workItems,
1523
+ taskSelectionRequired,
1399
1524
  client,
1400
- ...(incomplete
1525
+ ...(incomplete && state === 'bound'
1401
1526
  ? {
1402
1527
  shippedKnowledgeWarning: 'The backend is not serving all of the knowledge this release ships. Tell the user before relying on the rules: some are missing from the running server, so a review against them is incomplete. The counts and the reason are in client.shippedKnowledge.',
1403
1528
  }
1404
1529
  : {}),
1405
- nextAction: entryNextAction(authenticated, state),
1530
+ nextAction: taskSelectionRequired
1531
+ ? 'Present the live runs and actionable project work items, then ask which one this chat should handle. Resume only the run they choose, or bootstrap a separate run with the chosen workItemId. Another live task never blocks opening this one.'
1532
+ : entryNextAction(authenticated, state),
1406
1533
  });
1407
1534
  });
1408
1535
  }
1536
+ async actionableWorkItems(projectId) {
1537
+ try {
1538
+ const response = await this.dependencies.client.request(`${endpoints.workItemList(projectId)}?limit=100`);
1539
+ const items = objectValue(response.data)?.items;
1540
+ if (!Array.isArray(items))
1541
+ return [];
1542
+ const actionable = new Set(['backlog', 'ready', 'in_progress', 'in_review']);
1543
+ return items.filter((entry) => {
1544
+ const item = objectValue(entry);
1545
+ return item ? actionable.has(String(item.status)) : false;
1546
+ });
1547
+ }
1548
+ catch {
1549
+ return [];
1550
+ }
1551
+ }
1409
1552
  async clientUpdate(authenticated) {
1410
1553
  const installed = this.dependencies.clientVersion;
1411
1554
  if (!authenticated) {
@@ -2107,7 +2250,7 @@ export class BridgeService {
2107
2250
  kind: 'bridge_error',
2108
2251
  message: error instanceof Error ? error.message : 'Bridge operation failed',
2109
2252
  retryable: false,
2110
- ...(error instanceof RefusalError ? { recovery: error.recovery } : {}),
2253
+ ...(error instanceof BridgeRecoveryError ? { recovery: error.recovery } : {}),
2111
2254
  },
2112
2255
  };
2113
2256
  }
@@ -2300,6 +2443,26 @@ function objectValue(value) {
2300
2443
  function objectOrEmpty(value) {
2301
2444
  return objectValue(value ?? undefined) ?? {};
2302
2445
  }
2446
+ function archivedProjectGuidance(project) {
2447
+ const projectId = typeof project?.id === 'string' ? project.id : null;
2448
+ const expectedVersion = typeof project?.lockVersion === 'number' ? project.lockVersion : null;
2449
+ if (project?.role === 'owner') {
2450
+ return {
2451
+ projectId,
2452
+ expectedVersion,
2453
+ requiredRole: 'owner',
2454
+ recovery: 'project.restore',
2455
+ nextAction: 'Call project.restore with this project id and lockVersion before continuing.',
2456
+ };
2457
+ }
2458
+ return {
2459
+ projectId,
2460
+ expectedVersion,
2461
+ requiredRole: 'owner',
2462
+ recovery: 'project.member_list',
2463
+ nextAction: 'Call project.member_list with this project id, identify an owner, and ask that owner to restore the archived project.',
2464
+ };
2465
+ }
2303
2466
  function slugify(value) {
2304
2467
  return value
2305
2468
  .trim()
@@ -2404,15 +2567,8 @@ function isOfflineLeaseUsable(lease, sessionId, changedPaths, baselineDiffHash)
2404
2567
  return false;
2405
2568
  }
2406
2569
  }
2407
- class RefusalError extends Error {
2408
- recovery;
2409
- constructor(message, recovery) {
2410
- super(message);
2411
- this.recovery = recovery;
2412
- }
2413
- }
2414
2570
  function refuse(message, recovery) {
2415
- return new RefusalError(`${message} Recovery: call ${recovery}.`, recovery);
2571
+ return new BridgeRecoveryError(message, recovery);
2416
2572
  }
2417
2573
  function short(value) {
2418
2574
  return typeof value === 'string' && value.length > 0 ? value.slice(0, 12) : 'none';
@@ -1,6 +1,7 @@
1
1
  import { join } from 'node:path';
2
2
  import { readJson, removeFile, writeJson } from '../utilities/files.js';
3
3
  import { sha256 } from '../utilities/hash.js';
4
+ import { BridgeRecoveryError } from './recovery-error.js';
4
5
  export class PrincipalStateGuard {
5
6
  stateRoot;
6
7
  credentials;
@@ -31,7 +32,7 @@ export class PrincipalStateGuard {
31
32
  return;
32
33
  }
33
34
  if ((await this.outbox.list()).length > 0) {
34
- throw new Error('Pending offline work belongs to a different authenticated principal');
35
+ throw new BridgeRecoveryError('Pending offline work belongs to a different authenticated principal.', 'task.resolve_pending_delivery');
35
36
  }
36
37
  await this.clearPrincipalState();
37
38
  await writeJson(this.ownerPath, {
@@ -44,7 +45,7 @@ export class PrincipalStateGuard {
44
45
  async clearAfterLogout() {
45
46
  await this.exclusive(async () => {
46
47
  if ((await this.outbox.list()).length > 0) {
47
- throw new Error('Pending offline work must be resolved before logout');
48
+ throw new BridgeRecoveryError('Pending offline work must be resolved before logout.', 'task.resolve_pending_delivery');
48
49
  }
49
50
  await this.clearPrincipalState();
50
51
  await this.credentials.clear();
@@ -0,0 +1,9 @@
1
+ export class BridgeRecoveryError extends Error {
2
+ recovery;
3
+ constructor(message, recovery) {
4
+ super(`${message} Recovery: call ${recovery}.`);
5
+ this.recovery = recovery;
6
+ this.name = 'BridgeRecoveryError';
7
+ }
8
+ }
9
+ //# sourceMappingURL=recovery-error.js.map
@@ -4,12 +4,16 @@
4
4
 
5
5
  Sign-in decides nothing beyond who the user is. The organization and the project are chosen after it, through the questionnaires in `questionnaires.md`, and both listings end with an option to create a new one. Ask for both whenever this session has not already confirmed them, and ask again the moment the user says they want to change either — changing the organization always means choosing the project again.
6
6
 
7
- Treat `.engineering-memory/project.json` as the binding authority. The marker contains only `projectId` and `schemaVersion`. Never infer a binding from a directory name or Git remote when a marker exists.
7
+ Treat `.engineering-memory/project.json` as the binding authority. The marker contains only `projectId` and `schemaVersion`. Never infer a binding from a directory name or Git remote when a marker exists. Schema 1 markers retain the legacy commit-aware repository identity so existing bindings keep working. Schema 2 markers use the canonical remote or main-worktree identity, which stays unchanged when an unborn repository receives its first commit. Never rewrite or upgrade a marker by hand.
8
+
9
+ An archived project is recoverable state, not a missing or conflicting binding. When an owner receives `project.restore`, use the `projectId` and `expectedVersion` in its data and call that operation before retrying. A non-owner receives `project.member_list` instead: list the members, identify a project owner and explain that the owner must restore the exact project before this session can retry. Use `project.list` with `includeArchived: true` when an owner must first select the archived project. Never create or bind a replacement project to escape archive state.
8
10
 
9
11
  For a bound repository, call `session.bootstrap` before producing a plan or changing files. Supply the current repository root, project ID, task ID or stable local task slug, objective, task kind, task mode, and current Git diff hash. Use `read_only` for review, diagnosis, planning, or reporting without write authority; use `scaffold` when the task applies organization architecture templates to a new project; use `write` when the request authorizes repository changes. The bridge records the read-only Git diff hash as the immutable task baseline, including a pre-existing dirty worktree. Use the returned task ID, task version, context session ID, pinned revisions, project profile, engineering rules, prior task documents, quality gates, and current deviations.
10
12
 
11
13
  Before the first edit of a write task, settle the branch. Ask the user through the native questionnaire whether to open a branch for this task and which name to use, offering the convention the returned rules carry. Do this once, at the start, not at commit time — the commit gate runs long after the work is written, and by then the wrong branch has already cost something. A read-only task never creates a branch.
12
14
 
15
+ Two tasks in one repository need two branches, and one working tree can only have one checked out. A second checkout is what `git worktree` is for: `git worktree add ../<repo>-<task> -b <branch>` gives the task its own directory and its own branch against the same repository, and the chat for that task runs there. Engineering Memory treats them as one project — the fingerprint comes from the repository, not the directory — while each task measures only what changed in its own tree. Offer this when a task starts in a repository that already has a live task, and never ask somebody to switch branches in a tree another task is using.
16
+
13
17
  A repository holds as many tasks as the people working in it. Never treat somebody else's unfinished task as a reason this one cannot proceed: no task waits on another task's review, reconciliation, verification or close, and nothing that is already verified or closed is undone by what happens elsewhere. When `session.resume` reports more than one live task for this repository, it lists them and the right move is to ask the user which one this is, never to guess and never to adopt the one that happens to be most recent.
14
18
 
15
19
  A task nobody is going to finish is abandoned rather than inherited. Ask the user first, then call `task.abandon` with the task ID and their reason: it records the task as abandoned, withdraws the proposals it left waiting for review, releases its lease and session, and removes its local pointer. It changes nothing about any other task. Never abandon a task on your own judgement, and never abandon one to get past an error in your own.
@@ -43,12 +47,14 @@ When the request covers both halves of a linked product — a flow that needs en
43
47
  as screens — and the user's discipline covers both, it is one work item and two tasks: one in
44
48
  each repository. Verification and the commit gate stay per repository because a commit is.
45
49
 
46
- Ask which side to start on, propose the work item key, and open the task in the repository
47
- being worked in. When the work moves to the other side, open its task with the same
48
- `workItemKey` and that repository's root — every tool takes `repoRoot`, and both stay open at
49
- once. `context.prepare_change` then returns `siblingTasks`: the other task's objective, status
50
- and recent checkpoints, so a decision taken on one side is visible on the other without anyone
51
- repeating it.
50
+ Ask which side to start on and select the work item before opening the first task. When the work
51
+ moves to the other side, open its task with the same `workItemId` and that repository's root —
52
+ every tool takes `repoRoot`, and both stay open at once. The backend accepts that id only from the
53
+ current project or a directly linked project the account can work in, and derives the display key
54
+ from the work item. `context.prepare_change` then returns `siblingTasks`: the other task's
55
+ objective, status and recent checkpoints, so a decision taken on one side is visible on the other
56
+ without anyone repeating it. A legacy key is never an authorization boundary; key-only tasks can
57
+ see siblings only inside directly linked projects where the account is already a member.
52
58
 
53
59
  Do not open the second task speculatively. Open it when the work actually reaches that side.
54
60
 
@@ -4,12 +4,14 @@ Backend resources are immutable revisions. The local agent drafts structured con
4
4
 
5
5
  Use `memory.propose_revision` for project profiles, engineering rules, service contracts, localization contracts, navigation contracts, state contracts, screen logic, component mappings, Figma mappings, current deviations, quality gates, task history, and architecture templates.
6
6
 
7
- Knowledge is layered. `scope: product` proposes a change to the shared engineering core that every organization reads, `scope: organization` proposes one that only this organization reads and which hides the product text for that key, and `scope: project` proposes a record for this project alone. The nearest layer wins when context is delivered. A product-scope proposal is the way a developer improves the product itself from inside their own project, and it requires nothing but the skill.
7
+ Knowledge is layered. `scope: product` proposes a change to the shared engineering core that every organization reads, `scope: organization` proposes one that only this organization reads and which hides the product text for that key, and `scope: project` proposes a record for this project alone. The nearest layer wins when context is delivered. A product-scope proposal is the way a developer improves the product itself from inside their own project, and it requires nothing but the skill. It remains inactive for the configured product release principal to review; waiting in that platform queue does not prevent the contributor's task from verifying or closing.
8
8
 
9
9
  An architecture template is organization-scoped source, not prose. When a task establishes or changes a shared architecture structure that the templates carry, the template is stale and needs its own revision. Its manifest and content must stay in step: one `files[]` entry per `## file:` block, same path, byte count, SHA-256 and order. See `scaffolding.md`.
10
10
 
11
11
  Every proposal must include the current `baseRevision`. A conflict means the resource changed after context pinning. Do not overwrite it. Refresh context, compare revisions, and ask the user when the merge changes intent.
12
12
 
13
+ When a revision changes which stacks receive the resource, send the complete selector as `metadata.appliesToStacks`. An absent selector preserves the resource's current stack scope; `[]` deliberately makes it universal. A concrete source template keeps the family selector, such as `["spring-boot"]`, and declares its exact machine-readable `metadata.compatibility`: language and supported language versions, framework and supported framework release lines, build tools and namespaces. The backend compares that contract with the approved project profile `runtimeContract` before returning source. Version-neutral Spring engineering rules remain `["spring-boot"]` and carry no source compatibility gate. Never broaden or delete a compatibility contract merely so an older project can use Engineering Memory; deliver the compatible rules and leave incompatible scaffolding unselected.
14
+
13
15
  Permanent proposals remain inactive until an authorized user explicitly approves them. Call `memory.review_proposal` only after receiving that decision through the native questionnaire.
14
16
 
15
17
  A proposal created in an earlier session, or created by a bundle import, is found with `memory.list_proposals`. It reports every proposal still waiting for review that this project or its organization can reach, with the reason, the current and base revision numbers, and whether the proposal now needs a rebase. Pass a single `proposalId` to read that proposal's proposed text before putting the decision to the user. Never present a decision the user cannot see the content of, and never approve on the strength of the summary alone. Approval makes the current context lease stale. Call `context.refresh`, reread the changed rule, prepare a new change lease, and rerun affected validation.
@@ -30,6 +30,8 @@ A new organization is not empty. It reads the product engineering core immediate
30
30
 
31
31
  Once the organization is chosen, call `project.list` and offer that organization's projects, with **create a new project** last. Ignore projects belonging to other organizations; a listing that mixes them is how work lands in the wrong place. Creating one follows the unbound-repository flow below.
32
32
 
33
+ The normal list omits archived projects. If repository entry, setup, binding or task open reports `project.restore`, use the project id and expected version returned with that refusal and restore it before continuing. If it reports `project.member_list`, the current member cannot restore the project: call that operation, identify the project owners in its result and tell the user which owner must perform the versioned restore. If an owner does not yet have the project id and expected version, call `project.list` with `includeArchived: true` and let them select the archived project. Archive state never authorizes creating a duplicate project.
34
+
33
35
  Ask both questions again whenever a chat starts in a repository whose binding you have not confirmed in this session.
34
36
 
35
37
  ## Switching Organization or Project
@@ -50,17 +52,17 @@ Ask whether the current repository belongs to an existing accessible project, sh
50
52
 
51
53
  For a repository the backend has not seen before, ask whether this is an existing codebase import or a greenfield project. Before a marker or backend task exists, an existing-codebase import may perform one bounded local read-only structural and Figma discovery pass. It must not edit code. Use the findings in a native questionnaire to confirm the initial project profile and the repository-relative patterns that identify new memory resources. Send them as `discoveryUnits`, one unit per kind the project actually has: a client project declares `screen_logic` and `component_mapping`, and a server-side project declares `module_logic`, `data_model` and `api_endpoint` against its own layout. Never ask a server-side project for screen patterns, and never invent a unit for a kind the repository does not contain. Then call `project.setup`; the backend creates the project, owner membership, active profile revision, and pinned discovery policy atomically. Write the returned marker only after that succeeds, then start the normal `session.bootstrap` lifecycle. Later discoveries are reviewable memory proposals.
52
54
 
53
- Greenfield setup asks for project name, framework, the response envelope, the exception model, authentication needs, localization languages, storage policy, and the initial discovery patterns. A client project is also asked for the Figma library and screen links if available, the design token sources, the page architecture and the navigation pattern; a server-side project is asked instead for the data layer, the migration tool and how configuration and secrets arrive. Ask what the project is before deciding which list applies. Never invent missing answers. Do not write a marker unless the setup response contains the active initial profile and discovery policy.
55
+ Greenfield setup asks for project name, framework, the response envelope, the exception model, authentication needs, localization languages, storage policy, and the initial discovery patterns. A client project is also asked for the Figma library and screen links if available, the design token sources, the page architecture and the navigation pattern; a server-side project is asked instead for the data layer, the migration tool and how configuration and secrets arrive. A Spring project must also name Maven or Gradle, its Java release, its exact Spring Boot version and its `javax` or `jakarta` namespace before any architecture template is offered; `Spring Boot` alone is not enough information to select compatible source. Record those confirmed values in `initialProjectProfile.metadata.runtimeContract` as `language: "java"`, `languageVersion`, `framework: "spring-boot"`, `frameworkVersion`, `buildTool` and `namespace`. Never infer or invent this object. The backend compares it with each source module and returns no incompatible module; an absent contract means broad Spring rules still apply but no constrained source template is offered. Ask what the project is before deciding which list applies. Do not write a marker unless the setup response contains the active initial profile and discovery policy.
54
56
 
55
57
  After setup, ask whether to build the project from the organization architecture templates. If the user accepts, follow `scaffolding.md`: confirm the optional modules, then confirm the package name and the concrete class name behind every rename placeholder in one questionnaire, then ask for each required asset role and each tenant-specific value the templates deliberately leave open.
56
58
 
57
59
  ## Project Membership
58
60
 
59
- Ask for the registered email and intended role. Show owner, maintainer, member, and reader effects. Confirm before calling `project.member_add`.
61
+ Ask for the registered email and intended role. Show owner, maintainer, member, and reader effects. Confirm before calling `project.member_add`. A project grant is valid only while the same account belongs to the parent organization. If the call reports `organization.member_upsert`, do not retry the project write: an organization owner must first confirm the organization role and discipline and call that recovery. When the current account is not an organization owner, explain that coordination requirement instead of pretending the project grant succeeded. Retry `project.member_add` only after the parent grant exists.
60
62
 
61
63
  ## Branch
62
64
 
63
- At the start of a write task, before the first edit, ask whether to open a branch for it and confirm the name. Offer the convention the returned engineering rules state, the current branch as the alternative, and let the user name something else. Never create a branch during read-only analysis, and never create one without asking.
65
+ At the start of a write task, before the first edit, ask whether to open a branch for it and confirm the name. Offer the convention the returned engineering rules state, the current branch as the alternative, and let the user name something else. Never create a branch during read-only analysis, and never create one without asking. When the repository already has another live task, add a third option — a separate worktree for this one — and say what it means: its own directory, its own branch, the same project, and the other task's tree left alone.
64
66
 
65
67
  ## Flow Entry and Exit
66
68
 
@@ -1,6 +1,6 @@
1
1
  # Greenfield Scaffolding
2
2
 
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.
3
+ Architecture templates are source modules stored as approved engineering memory. The product may ship one or more explicitly compatible starter generations for a stack and an organization may hold its own, which takes precedence; a template never belongs to a single project. General support for a stack does not imply that every starter generation is available. A greenfield project reproduces the team architecture from compatible 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
 
@@ -13,13 +13,14 @@ Call `organization.list` and ask the user which organization the project belongs
13
13
  1. Open the task in `scaffold` mode and complete the normal bootstrap, discovery and `context.prepare_change` steps. Prepare the full set of intended paths before the first write.
14
14
  2. Call `architecture.plan`. It returns each module's manifest in apply order with its dependencies, rename map, string replacements, pubspec dependencies, asset contract and tenant-specific points. It carries no file bodies.
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
- 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
- 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 `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
- 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
- 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
- 9. Run code generation and localization generation, then static analysis and tests. Record the validation checkpoints as usual.
22
- 10. Reconcile each applied template resource with a `scaffold_applied` reconciliation carrying a short reason, then run `task.verify`.
16
+ 4. Treat the modules returned by the backend as the compatibility boundary: it compares each manifest's `compatibility` object with the approved project profile `runtimeContract` before returning it. Read both contracts back and confirm them in the questionnaire; never add a module the plan withheld or substitute a conversational guess. For Spring, the comparison covers Java release, exact Spring Boot release line, build tool and `javax` or `jakarta` namespace. If the plan is empty, say that no compatible starter exists and continue without scaffolding; never upgrade or downgrade the project to make a template fit.
17
+ 5. 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.
18
+ 6. 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.
19
+ 7. 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.
20
+ 8. 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.
21
+ 9. 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.
22
+ 10. Run the stack's generator, compiler or type checker against every emitted source file before calling the scaffold complete. If the required local compiler is unavailable or below the selected template's declared release, stop with the exact tool and version needed rather than claiming the source is valid. This refusal applies only to applying that template, not to using Engineering Memory with an existing project. Then run code generation, localization generation, static analysis and tests that the project requires.
23
+ 11. Reconcile each applied template resource with a `scaffold_applied` reconciliation carrying a short reason, then run `task.verify`.
23
24
 
24
25
  ## What scaffold mode does and does not relax
25
26