engineering-memory 1.0.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engineering-memory",
3
- "version": "1.0.1",
3
+ "version": "1.3.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",
@@ -51,6 +51,7 @@ export const endpoints = {
51
51
  organizationCreate: '/organizations',
52
52
  taskVerify: '/runtime/task/verify',
53
53
  taskClose: '/runtime/task/close',
54
+ taskAbandon: '/runtime/task/abandon',
54
55
  taskCommitGate: '/runtime/task/commit-gate',
55
56
  projectSetup: '/projects/setup',
56
57
  projectList: '/projects',
@@ -48,6 +48,7 @@ export class GitInspector {
48
48
  repoRoot: root,
49
49
  head,
50
50
  changedPaths,
51
+ worktreeHash: sha256(`${stableStringify(normalizedManifest)}\n`),
51
52
  diffHash: sha256(`${head ?? '<unborn>'}\n${stableStringify(normalizedManifest)}\n`),
52
53
  };
53
54
  }
@@ -64,6 +64,7 @@ export const engineeringMemoryToolNames = [
64
64
  'task.resolve_pending_delivery',
65
65
  'task.verify',
66
66
  'task.close',
67
+ 'task.abandon',
67
68
  'architecture.plan',
68
69
  'architecture.module',
69
70
  'architecture.record_application',
@@ -81,6 +82,13 @@ export const engineeringMemoryToolNames = [
81
82
  'auth.signin_browser',
82
83
  'auth.logout',
83
84
  ];
85
+ const discoverableKindNames = [
86
+ 'screen_logic',
87
+ 'component_mapping',
88
+ 'module_logic',
89
+ 'data_model',
90
+ 'api_endpoint',
91
+ ];
84
92
  const reconciliationEntry = z.object({
85
93
  resourceId: z.string().min(1),
86
94
  type: z.enum(['approved_revision', 'no_semantic_memory_change']),
@@ -193,6 +201,9 @@ export function registerEngineeringMemoryTools(server, service) {
193
201
  'figma_mapping',
194
202
  'figma_reference',
195
203
  'flow_logic',
204
+ 'module_logic',
205
+ 'data_model',
206
+ 'api_endpoint',
196
207
  'current_deviation',
197
208
  'quality_gate',
198
209
  'task_history',
@@ -348,7 +359,7 @@ export function registerEngineeringMemoryTools(server, service) {
348
359
  newResources: z
349
360
  .array(z.object({
350
361
  path: z.string().min(1),
351
- kind: z.enum(['screen_logic', 'component_mapping']),
362
+ kind: z.enum(discoverableKindNames),
352
363
  resourceKey: z.string().min(2),
353
364
  }))
354
365
  .optional(),
@@ -361,6 +372,14 @@ export function registerEngineeringMemoryTools(server, service) {
361
372
  taskId: z.string().min(1),
362
373
  }),
363
374
  }, async (input) => toolResult(await service.taskClose(input)));
375
+ server.registerTool('task.abandon', {
376
+ description: "Abandon a task the user has said they are giving up on, so it stops holding its proposals and its local pointer. It has no effect on any other task: nobody else's work waits on this, and nothing already verified or closed is undone. Ask the user first; never abandon a task on your own judgement.",
377
+ inputSchema: z.object({
378
+ repoRoot: optionalRepoRoot,
379
+ taskId: z.string().min(1),
380
+ reason: z.string().min(1).max(2000),
381
+ }),
382
+ }, async (input) => toolResult(await service.taskAbandon(input)));
364
383
  server.registerTool('organization.create', {
365
384
  description: 'Create an organization the user named, with the short identifier they typed. Offer this underneath the organizations they already belong to; never invent the identifier from the name.',
366
385
  inputSchema: z.object({
@@ -382,13 +401,7 @@ export function registerEngineeringMemoryTools(server, service) {
382
401
  content: z.string().min(2),
383
402
  discoveryUnits: z
384
403
  .array(z.object({
385
- kind: z.enum([
386
- 'screen_logic',
387
- 'component_mapping',
388
- 'module_logic',
389
- 'data_model',
390
- 'api_endpoint',
391
- ]),
404
+ kind: z.enum(discoverableKindNames),
392
405
  patterns: z.array(z.string().min(1)).min(1),
393
406
  }))
394
407
  .min(1)
@@ -1,8 +1,9 @@
1
1
  import { join } from 'node:path';
2
2
  import { readdir } from 'node:fs/promises';
3
- import { ensureManagedDirectory, readJson, removeFile, writeJson } from '../utilities/files.js';
3
+ import { ensureManagedDirectory, pathExists, readJson, removeFile, writeJson, } from '../utilities/files.js';
4
4
  import { sha256, stableStringify } from '../utilities/hash.js';
5
5
  import { assertSafeToPersist } from './offline-outbox.js';
6
+ const currentMarkerName = 'current.json';
6
7
  export class ActiveContextStore {
7
8
  root;
8
9
  queues = new Map();
@@ -11,42 +12,95 @@ export class ActiveContextStore {
11
12
  }
12
13
  async load(repoFingerprint) {
13
14
  assertFingerprint(repoFingerprint);
14
- const pointer = await readJson(this.pathFor(repoFingerprint), this.root);
15
+ await this.adoptLegacyPointer(repoFingerprint);
16
+ const marker = await readJson(this.currentPathFor(repoFingerprint), this.root);
17
+ if (marker && isUuid(marker.taskId)) {
18
+ const pointer = await this.loadForTask(repoFingerprint, marker.taskId);
19
+ if (pointer) {
20
+ return pointer;
21
+ }
22
+ }
23
+ const pointers = await this.list(repoFingerprint);
24
+ if (pointers.length !== 1) {
25
+ return null;
26
+ }
27
+ await this.writeCurrentMarker(repoFingerprint, pointers[0].taskId);
28
+ return pointers[0];
29
+ }
30
+ async loadForTask(repoFingerprint, taskId) {
31
+ assertFingerprint(repoFingerprint);
32
+ if (!isUuid(taskId)) {
33
+ throw new Error('Engineering task identifier is invalid');
34
+ }
35
+ const pointer = await readJson(this.pathFor(repoFingerprint, taskId), this.root);
15
36
  if (!pointer) {
16
37
  return null;
17
38
  }
18
39
  this.validate(pointer, repoFingerprint);
19
40
  return pointer;
20
41
  }
42
+ async loadForSlug(repoFingerprint, taskSlug) {
43
+ const pointers = await this.list(repoFingerprint);
44
+ return pointers.find((pointer) => pointer.taskSlug === taskSlug) ?? null;
45
+ }
46
+ async list(repoFingerprint) {
47
+ assertFingerprint(repoFingerprint);
48
+ await this.adoptLegacyPointer(repoFingerprint);
49
+ const directory = this.directoryFor(repoFingerprint);
50
+ if (!(await pathExists(directory, this.root))) {
51
+ return [];
52
+ }
53
+ await ensureManagedDirectory(this.root, directory);
54
+ const pointers = [];
55
+ for (const name of await this.pointerFileNames(directory)) {
56
+ const pointer = await readJson(join(directory, name), this.root);
57
+ if (pointer) {
58
+ this.validate(pointer, repoFingerprint);
59
+ pointers.push(pointer);
60
+ }
61
+ }
62
+ return pointers.sort((left, right) => left.updatedAt.localeCompare(right.updatedAt));
63
+ }
21
64
  async save(input) {
22
65
  await this.exclusive(input.repoFingerprint, async () => {
23
- const existing = await readJson(this.pathFor(input.repoFingerprint), this.root);
24
- const sameTask = existing?.taskId === input.taskId && existing.sessionId === input.sessionId;
25
- if (!sameTask && (existing?.verificationIntent || existing?.closeIntent)) {
26
- throw new Error('Pending lifecycle intent prevents replacing the active task pointer');
66
+ const existing = await readJson(this.pathFor(input.repoFingerprint, input.taskId), this.root);
67
+ const sameSession = existing?.sessionId === input.sessionId;
68
+ if (!sameSession && (existing?.verificationIntent || existing?.closeIntent)) {
69
+ throw new Error('This task has an unsettled verification or close intent; resume it before starting a new session for the same task');
27
70
  }
28
71
  const { verificationIntent: _verificationIntent, closeIntent: _closeIntent, ...pointerInput } = input;
29
72
  await this.writePointer({
30
73
  ...pointerInput,
31
- ...(sameTask && existing?.verificationIntent
74
+ ...(sameSession && existing?.verificationIntent
32
75
  ? { verificationIntent: existing.verificationIntent }
33
76
  : {}),
34
- ...(sameTask && existing?.closeIntent ? { closeIntent: existing.closeIntent } : {}),
77
+ ...(sameSession && existing?.closeIntent ? { closeIntent: existing.closeIntent } : {}),
35
78
  });
79
+ await this.writeCurrentMarker(input.repoFingerprint, input.taskId);
36
80
  });
37
81
  }
38
- async updateTaskSnapshot(taskId, taskVersion, lastSequence) {
39
- await ensureManagedDirectory(this.root, this.root);
40
- const entries = await readdir(this.root, { withFileTypes: true });
41
- for (const entry of entries) {
42
- if (entry.name.endsWith('.json') && (!entry.isFile() || entry.isSymbolicLink())) {
43
- throw new Error(`Unsafe active context entry: ${entry.name}`);
82
+ async forget(repoFingerprint, taskId) {
83
+ return await this.exclusive(repoFingerprint, async () => {
84
+ const path = this.pathFor(repoFingerprint, taskId);
85
+ if (!(await pathExists(path, this.root))) {
86
+ return false;
44
87
  }
45
- if (!entry.isFile() || !entry.name.endsWith('.json')) {
46
- continue;
88
+ await removeFile(path, this.root);
89
+ const remaining = await this.list(repoFingerprint);
90
+ if (remaining.length === 0) {
91
+ await removeFile(this.currentPathFor(repoFingerprint), this.root);
92
+ return true;
93
+ }
94
+ const marker = await readJson(this.currentPathFor(repoFingerprint), this.root);
95
+ if (!marker || marker.taskId === taskId) {
96
+ await this.writeCurrentMarker(repoFingerprint, remaining.at(-1).taskId);
47
97
  }
48
- const pointer = await readJson(join(this.root, entry.name), this.root);
49
- if (pointer?.taskId === taskId) {
98
+ return true;
99
+ });
100
+ }
101
+ async updateTaskSnapshot(taskId, taskVersion, lastSequence) {
102
+ for (const pointer of await this.allPointers()) {
103
+ if (pointer.taskId === taskId) {
50
104
  await this.save({
51
105
  ...pointer,
52
106
  taskVersion,
@@ -56,10 +110,10 @@ export class ActiveContextStore {
56
110
  }
57
111
  }
58
112
  async setChangeBaseline(repoFingerprint, sessionId, manifest, leasePaths, taskSnapshot) {
59
- const pointer = await this.load(repoFingerprint);
60
- if (!pointer ||
61
- pointer.sessionId !== sessionId ||
62
- (taskSnapshot && pointer.taskId !== taskSnapshot.taskId)) {
113
+ const pointer = taskSnapshot
114
+ ? await this.loadForTask(repoFingerprint, taskSnapshot.taskId)
115
+ : await this.load(repoFingerprint);
116
+ if (!pointer || pointer.sessionId !== sessionId) {
63
117
  throw new Error('Change baseline does not match the active repository session');
64
118
  }
65
119
  if (pointer.changeBaseline?.leasePaths.some((path) => !leasePaths.includes(path))) {
@@ -84,12 +138,9 @@ export class ActiveContextStore {
84
138
  }
85
139
  async setVerificationIntent(repoFingerprint, input) {
86
140
  await this.exclusive(repoFingerprint, async () => {
87
- const pointer = await this.readPointer(repoFingerprint);
141
+ const pointer = await this.readIntentPointer(repoFingerprint, input.body);
88
142
  const bodyHash = sha256(stableStringify(input.body));
89
- if (!pointer ||
90
- pointer.taskId !== input.body.taskId ||
91
- pointer.sessionId !== input.body.sessionId ||
92
- pointer.closeIntent) {
143
+ if (!pointer || pointer.closeIntent) {
93
144
  throw new Error('Verification intent does not match the active task');
94
145
  }
95
146
  if (pointer.verificationIntent) {
@@ -110,9 +161,11 @@ export class ActiveContextStore {
110
161
  });
111
162
  });
112
163
  }
113
- async clearVerificationIntent(repoFingerprint) {
164
+ async clearVerificationIntent(repoFingerprint, taskId) {
114
165
  await this.exclusive(repoFingerprint, async () => {
115
- const pointer = await this.readPointer(repoFingerprint);
166
+ const pointer = taskId
167
+ ? await this.loadForTask(repoFingerprint, taskId)
168
+ : await this.load(repoFingerprint);
116
169
  if (!pointer?.verificationIntent) {
117
170
  return;
118
171
  }
@@ -122,12 +175,9 @@ export class ActiveContextStore {
122
175
  }
123
176
  async setCloseIntent(repoFingerprint, input) {
124
177
  await this.exclusive(repoFingerprint, async () => {
125
- const pointer = await this.readPointer(repoFingerprint);
178
+ const pointer = await this.readIntentPointer(repoFingerprint, input.body);
126
179
  const bodyHash = sha256(stableStringify(input.body));
127
- if (!pointer ||
128
- pointer.taskId !== input.body.taskId ||
129
- pointer.sessionId !== input.body.sessionId ||
130
- pointer.verificationIntent) {
180
+ if (!pointer || pointer.verificationIntent) {
131
181
  throw new Error('Close intent does not match the active task');
132
182
  }
133
183
  if (pointer.closeIntent) {
@@ -147,9 +197,11 @@ export class ActiveContextStore {
147
197
  });
148
198
  });
149
199
  }
150
- async clearCloseIntent(repoFingerprint) {
200
+ async clearCloseIntent(repoFingerprint, taskId) {
151
201
  await this.exclusive(repoFingerprint, async () => {
152
- const pointer = await this.readPointer(repoFingerprint);
202
+ const pointer = taskId
203
+ ? await this.loadForTask(repoFingerprint, taskId)
204
+ : await this.load(repoFingerprint);
153
205
  if (!pointer?.closeIntent) {
154
206
  return;
155
207
  }
@@ -158,24 +210,7 @@ export class ActiveContextStore {
158
210
  });
159
211
  }
160
212
  async findByTaskId(taskId, projectId, requireClean = true) {
161
- await ensureManagedDirectory(this.root, this.root);
162
- const entries = await readdir(this.root, { withFileTypes: true });
163
- const matches = [];
164
- for (const entry of entries) {
165
- if (entry.name.endsWith('.json') && (!entry.isFile() || entry.isSymbolicLink())) {
166
- throw new Error(`Unsafe active context entry: ${entry.name}`);
167
- }
168
- if (!entry.isFile() || !entry.name.endsWith('.json')) {
169
- continue;
170
- }
171
- const pointer = await readJson(join(this.root, entry.name), this.root);
172
- if (pointer) {
173
- this.validate(pointer, pointer.repoFingerprint);
174
- if (pointer.taskId === taskId && pointer.projectId === projectId) {
175
- matches.push(pointer);
176
- }
177
- }
178
- }
213
+ const matches = (await this.allPointers()).filter((pointer) => pointer.taskId === taskId && pointer.projectId === projectId);
179
214
  if (matches.length !== 1 ||
180
215
  (requireClean &&
181
216
  (matches[0].resumeConflicts.length > 0 ||
@@ -189,18 +224,9 @@ export class ActiveContextStore {
189
224
  if (taskIds.length === 0) {
190
225
  return;
191
226
  }
192
- await ensureManagedDirectory(this.root, this.root);
193
227
  const targetIds = new Set(taskIds);
194
- const entries = await readdir(this.root, { withFileTypes: true });
195
- for (const entry of entries) {
196
- if (entry.name.endsWith('.json') && (!entry.isFile() || entry.isSymbolicLink())) {
197
- throw new Error(`Unsafe active context entry: ${entry.name}`);
198
- }
199
- if (!entry.isFile() || !entry.name.endsWith('.json')) {
200
- continue;
201
- }
202
- const pointer = await readJson(join(this.root, entry.name), this.root);
203
- if (pointer && targetIds.has(pointer.taskId)) {
228
+ for (const pointer of await this.allPointers()) {
229
+ if (targetIds.has(pointer.taskId)) {
204
230
  await this.save({
205
231
  ...pointer,
206
232
  resumeConflicts: [...new Set([...pointer.resumeConflicts, conflict])],
@@ -212,17 +238,8 @@ export class ActiveContextStore {
212
238
  if (!isUuid(sessionId)) {
213
239
  throw new Error('Context session identifier is invalid');
214
240
  }
215
- await ensureManagedDirectory(this.root, this.root);
216
- const entries = await readdir(this.root, { withFileTypes: true });
217
- for (const entry of entries) {
218
- if (entry.name.endsWith('.json') && (!entry.isFile() || entry.isSymbolicLink())) {
219
- throw new Error(`Unsafe active context entry: ${entry.name}`);
220
- }
221
- if (!entry.isFile() || !entry.name.endsWith('.json')) {
222
- continue;
223
- }
224
- const pointer = await readJson(join(this.root, entry.name), this.root);
225
- if (pointer?.sessionId === sessionId) {
241
+ for (const pointer of await this.allPointers()) {
242
+ if (pointer.sessionId === sessionId) {
226
243
  await this.save({
227
244
  ...pointer,
228
245
  resumeConflicts: pointer.resumeConflicts.filter((value) => value !== conflict),
@@ -232,20 +249,90 @@ export class ActiveContextStore {
232
249
  }
233
250
  async clear() {
234
251
  await ensureManagedDirectory(this.root, this.root);
235
- const entries = await readdir(this.root, { withFileTypes: true });
236
- for (const entry of entries) {
237
- if (!entry.isFile() || entry.isSymbolicLink() || !entry.name.endsWith('.json')) {
252
+ for (const entry of await readdir(this.root, { withFileTypes: true })) {
253
+ if (entry.isSymbolicLink()) {
254
+ throw new Error(`Unsafe active context entry: ${entry.name}`);
255
+ }
256
+ if (entry.isFile() && entry.name.endsWith('.json')) {
257
+ await removeFile(join(this.root, entry.name), this.root);
258
+ continue;
259
+ }
260
+ if (!entry.isDirectory() || !/^[0-9a-f]{64}$/.test(entry.name)) {
261
+ throw new Error(`Unsafe active context entry: ${entry.name}`);
262
+ }
263
+ const directory = join(this.root, entry.name);
264
+ for (const name of await this.jsonFileNames(directory)) {
265
+ await removeFile(join(directory, name), this.root);
266
+ }
267
+ }
268
+ }
269
+ async allPointers() {
270
+ await ensureManagedDirectory(this.root, this.root);
271
+ const pointers = [];
272
+ for (const entry of await readdir(this.root, { withFileTypes: true })) {
273
+ if (entry.isSymbolicLink()) {
274
+ throw new Error(`Unsafe active context entry: ${entry.name}`);
275
+ }
276
+ if (!entry.isDirectory() || !/^[0-9a-f]{64}$/.test(entry.name)) {
277
+ continue;
278
+ }
279
+ pointers.push(...(await this.list(entry.name)));
280
+ }
281
+ return pointers;
282
+ }
283
+ async adoptLegacyPointer(repoFingerprint) {
284
+ const legacyPath = join(this.root, `${repoFingerprint}.json`);
285
+ if (!(await pathExists(legacyPath, this.root))) {
286
+ return;
287
+ }
288
+ const pointer = await readJson(legacyPath, this.root);
289
+ if (pointer) {
290
+ this.validate(pointer, repoFingerprint);
291
+ const target = this.pathFor(repoFingerprint, pointer.taskId);
292
+ if (!(await pathExists(target, this.root))) {
293
+ await writeJson(target, pointer, this.root);
294
+ await this.writeCurrentMarker(repoFingerprint, pointer.taskId);
295
+ }
296
+ }
297
+ await removeFile(legacyPath, this.root);
298
+ }
299
+ async readIntentPointer(repoFingerprint, body) {
300
+ if (typeof body.taskId !== 'string' || !isUuid(body.taskId)) {
301
+ return null;
302
+ }
303
+ const pointer = await this.loadForTask(repoFingerprint, body.taskId);
304
+ return pointer && pointer.sessionId === body.sessionId ? pointer : null;
305
+ }
306
+ async pointerFileNames(directory) {
307
+ return (await this.jsonFileNames(directory)).filter((name) => name !== currentMarkerName);
308
+ }
309
+ async jsonFileNames(directory) {
310
+ const names = [];
311
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
312
+ if (entry.name.endsWith('.json') && (!entry.isFile() || entry.isSymbolicLink())) {
238
313
  throw new Error(`Unsafe active context entry: ${entry.name}`);
239
314
  }
240
- await removeFile(join(this.root, entry.name), this.root);
315
+ if (entry.isFile() && entry.name.endsWith('.json')) {
316
+ names.push(entry.name);
317
+ }
241
318
  }
319
+ return names;
242
320
  }
243
- pathFor(repoFingerprint) {
321
+ directoryFor(repoFingerprint) {
244
322
  assertFingerprint(repoFingerprint);
245
- return join(this.root, `${repoFingerprint}.json`);
323
+ return join(this.root, repoFingerprint);
324
+ }
325
+ pathFor(repoFingerprint, taskId) {
326
+ if (!isUuid(taskId)) {
327
+ throw new Error('Engineering task identifier is invalid');
328
+ }
329
+ return join(this.directoryFor(repoFingerprint), `${taskId}.json`);
330
+ }
331
+ currentPathFor(repoFingerprint) {
332
+ return join(this.directoryFor(repoFingerprint), currentMarkerName);
246
333
  }
247
- async readPointer(repoFingerprint) {
248
- return await readJson(this.pathFor(repoFingerprint), this.root);
334
+ async writeCurrentMarker(repoFingerprint, taskId) {
335
+ await writeJson(this.currentPathFor(repoFingerprint), { schemaVersion: 1, taskId }, this.root);
249
336
  }
250
337
  async writePointer(input) {
251
338
  const pointer = {
@@ -255,7 +342,7 @@ export class ActiveContextStore {
255
342
  };
256
343
  this.validate(pointer, input.repoFingerprint);
257
344
  assertSafeToPersist(JSON.parse(JSON.stringify(pointer)));
258
- await writeJson(this.pathFor(input.repoFingerprint), pointer, this.root);
345
+ await writeJson(this.pathFor(input.repoFingerprint, input.taskId), pointer, this.root);
259
346
  }
260
347
  async exclusive(repoFingerprint, action) {
261
348
  const previous = this.queues.get(repoFingerprint) ?? Promise.resolve();
@@ -94,7 +94,9 @@ export class BridgeService {
94
94
  taskKind: persistedBootstrap.taskKind,
95
95
  mode: persistedBootstrap.mode ?? 'write',
96
96
  repoFingerprint: repository.repoFingerprint,
97
- ...(input.mode === 'read_only' ? { baselineDiffHash: repository.git.diffHash } : {}),
97
+ ...(input.mode === 'read_only'
98
+ ? { baselineDiffHash: repository.git.worktreeHash }
99
+ : {}),
98
100
  checkpointIdempotencyKey: checkpointId,
99
101
  knownRevisions: persistedBootstrap.knownRevisions,
100
102
  }),
@@ -160,7 +162,25 @@ export class BridgeService {
160
162
  if (authentication) {
161
163
  return asJsonValue({ authentication, repository: publicRepository(repository) });
162
164
  }
163
- let pointer = await this.dependencies.activeContexts.load(repository.repoFingerprint);
165
+ const live = await this.dependencies.activeContexts.list(repository.repoFingerprint);
166
+ let pointer = input.taskSlug
167
+ ? (live.find((entry) => entry.taskSlug === input.taskSlug) ?? null)
168
+ : await this.dependencies.activeContexts.load(repository.repoFingerprint);
169
+ if (!pointer && input.taskSlug) {
170
+ return asJsonValue({
171
+ taskChoiceRequired: true,
172
+ requestedTaskSlug: input.taskSlug,
173
+ liveTasks: live.map(describePointer),
174
+ repository: publicRepository(repository),
175
+ });
176
+ }
177
+ if (!pointer && !input.sessionId && live.length > 1) {
178
+ return asJsonValue({
179
+ taskChoiceRequired: true,
180
+ liveTasks: live.map(describePointer),
181
+ repository: publicRepository(repository),
182
+ });
183
+ }
164
184
  const projectId = input.projectId ?? repository.projectId ?? pointer?.projectId;
165
185
  const taskSlug = input.taskSlug ?? pointer?.taskSlug;
166
186
  const sessionId = input.sessionId ?? pointer?.sessionId;
@@ -1009,6 +1029,7 @@ export class BridgeService {
1009
1029
  changedPaths: taskMode === 'read_only' ? [] : taskChangedPaths,
1010
1030
  pathChanges: pathChangeManifest,
1011
1031
  diffHash: repository.git.diffHash,
1032
+ ...(taskMode === 'read_only' ? { worktreeHash: repository.git.worktreeHash } : {}),
1012
1033
  validations,
1013
1034
  pendingOutboxCount,
1014
1035
  newResources: input.newResources,
@@ -1027,7 +1048,7 @@ export class BridgeService {
1027
1048
  }
1028
1049
  catch (error) {
1029
1050
  if (!isBackendUnavailableError(error)) {
1030
- await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint);
1051
+ await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, input.taskId);
1031
1052
  }
1032
1053
  throw error;
1033
1054
  }
@@ -1043,10 +1064,10 @@ export class BridgeService {
1043
1064
  taskVersion,
1044
1065
  taskChanges,
1045
1066
  });
1046
- await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint);
1067
+ await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, input.taskId);
1047
1068
  }
1048
1069
  else {
1049
- await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint);
1070
+ await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, input.taskId);
1050
1071
  }
1051
1072
  return asJsonValue({
1052
1073
  ...objectOrEmpty(response.data),
@@ -1094,6 +1115,29 @@ export class BridgeService {
1094
1115
  });
1095
1116
  });
1096
1117
  }
1118
+ async taskAbandon(input) {
1119
+ return await this.execute(async () => {
1120
+ return await this.taskExclusive(input.taskId, async () => {
1121
+ const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1122
+ const response = await this.dependencies.client.request(endpoints.taskAbandon, {
1123
+ method: 'POST',
1124
+ body: { taskId: input.taskId, reason: input.reason },
1125
+ });
1126
+ const abandoned = objectValue(response.data);
1127
+ if (!abandoned || abandoned.abandoned !== true) {
1128
+ throw new Error('The backend did not abandon the task');
1129
+ }
1130
+ const forgotten = await this.dependencies.activeContexts.forget(repository.repoFingerprint, input.taskId);
1131
+ await this.dependencies.gate.invalidateTask(input.taskId);
1132
+ return asJsonValue({
1133
+ ...abandoned,
1134
+ localPointerRemoved: forgotten,
1135
+ remainingTasks: (await this.dependencies.activeContexts.list(repository.repoFingerprint)).map(describePointer),
1136
+ repository: publicRepository(repository),
1137
+ });
1138
+ });
1139
+ });
1140
+ }
1097
1141
  async projectSetup(input) {
1098
1142
  return await this.execute(async () => {
1099
1143
  const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
@@ -1305,12 +1349,19 @@ export class BridgeService {
1305
1349
  const authenticated = authentication.authenticated === true;
1306
1350
  const state = decision?.state ?? (repository.projectId ? 'bound' : 'none');
1307
1351
  const client = await this.clientUpdate(authenticated);
1352
+ const shipped = objectValue(objectValue(client)?.shippedKnowledge);
1353
+ const incomplete = shipped && (shipped.failure !== null || Number(shipped.live) < Number(shipped.expected));
1308
1354
  return asJsonValue({
1309
1355
  authenticated,
1310
1356
  repository: publicRepository(repository),
1311
1357
  decision: state,
1312
1358
  projectId: repository.projectId ?? decision?.projectId ?? null,
1313
1359
  client,
1360
+ ...(incomplete
1361
+ ? {
1362
+ 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.',
1363
+ }
1364
+ : {}),
1314
1365
  nextAction: entryNextAction(authenticated, state),
1315
1366
  });
1316
1367
  });
@@ -1721,7 +1772,7 @@ export class BridgeService {
1721
1772
  return refreshed;
1722
1773
  }
1723
1774
  async requireActivePointer(repoFingerprint, taskId, allowOfflineWarnings = false, allowedIntent) {
1724
- const pointer = await this.dependencies.activeContexts.load(repoFingerprint);
1775
+ const pointer = await this.dependencies.activeContexts.loadForTask(repoFingerprint, taskId);
1725
1776
  const blockingConflicts = pointer?.resumeConflicts.filter((conflict) => !(allowOfflineWarnings && isOfflineDevelopmentWarning(conflict)));
1726
1777
  const lifecycleIntentBlocked = (pointer?.verificationIntent && allowedIntent !== 'verify') ||
1727
1778
  (pointer?.closeIntent && allowedIntent !== 'close');
@@ -1822,13 +1873,13 @@ export class BridgeService {
1822
1873
  }
1823
1874
  catch (error) {
1824
1875
  if (!isBackendUnavailableError(error)) {
1825
- await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint);
1876
+ await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, pointer.taskId);
1826
1877
  }
1827
1878
  throw error;
1828
1879
  }
1829
1880
  const responseData = objectValue(response.data);
1830
1881
  if (responseData?.verified !== true || responseData.diffHash !== body.diffHash) {
1831
- await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint);
1882
+ await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, pointer.taskId);
1832
1883
  throw new Error('Verification retry did not confirm the durable verification intent');
1833
1884
  }
1834
1885
  const taskVersion = numericTaskVersion(responseData.taskVersion);
@@ -1841,7 +1892,7 @@ export class BridgeService {
1841
1892
  taskVersion,
1842
1893
  taskChanges: intent.taskChanges,
1843
1894
  });
1844
- await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint);
1895
+ await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, pointer.taskId);
1845
1896
  await this.dependencies.activeContexts.updateTaskSnapshot(pointer.taskId, taskVersion, pointer.lastSequence);
1846
1897
  return { ...responseData, recoveredAfterResponseLoss: true };
1847
1898
  }
@@ -1875,13 +1926,13 @@ export class BridgeService {
1875
1926
  }
1876
1927
  catch (error) {
1877
1928
  if (!isBackendUnavailableError(error)) {
1878
- await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint);
1929
+ await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
1879
1930
  }
1880
1931
  throw error;
1881
1932
  }
1882
1933
  const closedTask = objectValue(response.data);
1883
1934
  if (closedTask?.closed !== true) {
1884
- await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint);
1935
+ await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
1885
1936
  throw new Error('Task close response does not confirm task closure');
1886
1937
  }
1887
1938
  const closedTaskVersion = numericTaskVersion(closedTask.taskVersion);
@@ -1895,7 +1946,7 @@ export class BridgeService {
1895
1946
  taskVersion: closedTaskVersion,
1896
1947
  taskChanges,
1897
1948
  });
1898
- await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint);
1949
+ await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
1899
1950
  return asJsonValue({
1900
1951
  ...closedTask,
1901
1952
  repository: publicRepository(repository),
@@ -2112,6 +2163,17 @@ async function buildPathChangeManifest(repoRoot, changes) {
2112
2163
  });
2113
2164
  }));
2114
2165
  }
2166
+ function describePointer(pointer) {
2167
+ return asJsonValue({
2168
+ taskSlug: pointer.taskSlug,
2169
+ taskId: pointer.taskId,
2170
+ projectId: pointer.projectId,
2171
+ sessionId: pointer.sessionId,
2172
+ updatedAt: pointer.updatedAt,
2173
+ resumeConflicts: pointer.resumeConflicts,
2174
+ unsettledIntent: pointer.verificationIntent ? 'verify' : pointer.closeIntent ? 'close' : null,
2175
+ });
2176
+ }
2115
2177
  function publicRepository(repository) {
2116
2178
  return asJsonValue({
2117
2179
  repoRoot: repository.repoRoot,
@@ -2393,6 +2455,16 @@ export function newMemoryResourceCandidate(entry, policy) {
2393
2455
  ]).has(extension)) {
2394
2456
  return [];
2395
2457
  }
2458
+ const name = entry.path.toLowerCase().split('/').slice(-1)[0] ?? '';
2459
+ if (name.endsWith('.entity.ts')) {
2460
+ return [{ path: entry.path, kind: 'data_model' }];
2461
+ }
2462
+ if (name.endsWith('.controller.ts')) {
2463
+ return [{ path: entry.path, kind: 'api_endpoint' }];
2464
+ }
2465
+ if (name.endsWith('.module.ts') || name.endsWith('.service.ts')) {
2466
+ return [{ path: entry.path, kind: 'module_logic' }];
2467
+ }
2396
2468
  const segments = entry.path.toLowerCase().split('/').slice(0, -1);
2397
2469
  const screenIndex = segments.findIndex((segment) => ['screen', 'screens', 'view', 'views'].includes(segment));
2398
2470
  const componentIndex = segments.findIndex((segment) => ['widget', 'widgets', 'component', 'components'].includes(segment));
@@ -10,6 +10,10 @@ For a bound repository, call `session.bootstrap` before producing a plan or chan
10
10
 
11
11
  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
12
 
13
+ 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
+
15
+ 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.
16
+
13
17
  If an existing task is identified or execution resumes after compaction, call `session.resume`. Reconcile backend sequence, local outbox, Markdown projections, current Git diff, pinned revisions, and the active lease before any further action.
14
18
 
15
19
  ## Discovery
@@ -40,7 +40,7 @@ Switching off records the decision and ends the subject. Switching back on clear
40
40
 
41
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.
42
42
 
43
- A task that is still open blocks the switch, and says so plainly. Its lease, its journal and its checkpoints all belong to the project it was opened against, and carrying them into another one records work under a project it did not happen in. Close the open task first, or say explicitly that it is being abandoned, and only then switch.
43
+ A task that is still open blocks the switch, and says so plainly. Its lease, its journal and its checkpoints all belong to the project it was opened against, and carrying them into another one records work under a project it did not happen in. Close the open task first, or abandon it with `task.abandon` once the user has said that is what they want, and only then switch. This is about this session's own task and nothing else: another person's unfinished task in the same repository never blocks anything.
44
44
 
45
45
  ## Unbound Repository
46
46