engineering-memory 1.2.0 → 1.3.1

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.2.0",
3
+ "version": "1.3.1",
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',
@@ -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',
@@ -371,6 +372,14 @@ export function registerEngineeringMemoryTools(server, service) {
371
372
  taskId: z.string().min(1),
372
373
  }),
373
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)));
374
383
  server.registerTool('organization.create', {
375
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.',
376
385
  inputSchema: z.object({
@@ -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();
@@ -162,7 +162,25 @@ export class BridgeService {
162
162
  if (authentication) {
163
163
  return asJsonValue({ authentication, repository: publicRepository(repository) });
164
164
  }
165
- 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
+ }
166
184
  const projectId = input.projectId ?? repository.projectId ?? pointer?.projectId;
167
185
  const taskSlug = input.taskSlug ?? pointer?.taskSlug;
168
186
  const sessionId = input.sessionId ?? pointer?.sessionId;
@@ -939,6 +957,7 @@ export class BridgeService {
939
957
  }
940
958
  let taskChangedPaths = [];
941
959
  let taskChanges = [];
960
+ let foreignChangedPaths = [];
942
961
  const snapshotResponse = await this.dependencies.client.request(endpoints.sessionResume, {
943
962
  method: 'POST',
944
963
  body: {
@@ -976,7 +995,13 @@ export class BridgeService {
976
995
  if (!baseline || activeLease?.baselineDiffHash !== baseline.diffHash) {
977
996
  throw new Error('Write task verification requires its locally pinned change baseline');
978
997
  }
979
- taskChanges = manifestDelta(baseline.changedPaths, repository.git.changedPaths);
998
+ const wholeTreeDelta = manifestDelta(baseline.changedPaths, repository.git.changedPaths);
999
+ const leased = new Set(normalizeChangedPaths([...baseline.leasePaths, ...leaseChangedPaths]));
1000
+ taskChanges = wholeTreeDelta.filter((entry) => leased.has(normalizeChangedPaths([entry.path])[0]));
1001
+ foreignChangedPaths = wholeTreeDelta
1002
+ .filter((entry) => !leased.has(normalizeChangedPaths([entry.path])[0]))
1003
+ .map((entry) => entry.path)
1004
+ .sort();
980
1005
  taskChangedPaths = taskChanges.map((entry) => entry.path).sort();
981
1006
  if (input.changedPaths &&
982
1007
  stableStringify(normalizeChangedPaths(input.changedPaths)) !==
@@ -1030,7 +1055,7 @@ export class BridgeService {
1030
1055
  }
1031
1056
  catch (error) {
1032
1057
  if (!isBackendUnavailableError(error)) {
1033
- await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint);
1058
+ await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, input.taskId);
1034
1059
  }
1035
1060
  throw error;
1036
1061
  }
@@ -1046,14 +1071,17 @@ export class BridgeService {
1046
1071
  taskVersion,
1047
1072
  taskChanges,
1048
1073
  });
1049
- await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint);
1074
+ await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, input.taskId);
1050
1075
  }
1051
1076
  else {
1052
- await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint);
1077
+ await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, input.taskId);
1053
1078
  }
1054
1079
  return asJsonValue({
1055
1080
  ...objectOrEmpty(response.data),
1056
1081
  mode: taskMode,
1082
+ ...(foreignChangedPaths.length > 0
1083
+ ? { changedPathsOutsideThisTask: foreignChangedPaths }
1084
+ : {}),
1057
1085
  repository: publicRepository(repository),
1058
1086
  });
1059
1087
  });
@@ -1097,6 +1125,29 @@ export class BridgeService {
1097
1125
  });
1098
1126
  });
1099
1127
  }
1128
+ async taskAbandon(input) {
1129
+ return await this.execute(async () => {
1130
+ return await this.taskExclusive(input.taskId, async () => {
1131
+ const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1132
+ const response = await this.dependencies.client.request(endpoints.taskAbandon, {
1133
+ method: 'POST',
1134
+ body: { taskId: input.taskId, reason: input.reason },
1135
+ });
1136
+ const abandoned = objectValue(response.data);
1137
+ if (!abandoned || abandoned.abandoned !== true) {
1138
+ throw new Error('The backend did not abandon the task');
1139
+ }
1140
+ const forgotten = await this.dependencies.activeContexts.forget(repository.repoFingerprint, input.taskId);
1141
+ await this.dependencies.gate.invalidateTask(input.taskId);
1142
+ return asJsonValue({
1143
+ ...abandoned,
1144
+ localPointerRemoved: forgotten,
1145
+ remainingTasks: (await this.dependencies.activeContexts.list(repository.repoFingerprint)).map(describePointer),
1146
+ repository: publicRepository(repository),
1147
+ });
1148
+ });
1149
+ });
1150
+ }
1100
1151
  async projectSetup(input) {
1101
1152
  return await this.execute(async () => {
1102
1153
  const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
@@ -1731,7 +1782,7 @@ export class BridgeService {
1731
1782
  return refreshed;
1732
1783
  }
1733
1784
  async requireActivePointer(repoFingerprint, taskId, allowOfflineWarnings = false, allowedIntent) {
1734
- const pointer = await this.dependencies.activeContexts.load(repoFingerprint);
1785
+ const pointer = await this.dependencies.activeContexts.loadForTask(repoFingerprint, taskId);
1735
1786
  const blockingConflicts = pointer?.resumeConflicts.filter((conflict) => !(allowOfflineWarnings && isOfflineDevelopmentWarning(conflict)));
1736
1787
  const lifecycleIntentBlocked = (pointer?.verificationIntent && allowedIntent !== 'verify') ||
1737
1788
  (pointer?.closeIntent && allowedIntent !== 'close');
@@ -1832,13 +1883,13 @@ export class BridgeService {
1832
1883
  }
1833
1884
  catch (error) {
1834
1885
  if (!isBackendUnavailableError(error)) {
1835
- await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint);
1886
+ await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, pointer.taskId);
1836
1887
  }
1837
1888
  throw error;
1838
1889
  }
1839
1890
  const responseData = objectValue(response.data);
1840
1891
  if (responseData?.verified !== true || responseData.diffHash !== body.diffHash) {
1841
- await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint);
1892
+ await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, pointer.taskId);
1842
1893
  throw new Error('Verification retry did not confirm the durable verification intent');
1843
1894
  }
1844
1895
  const taskVersion = numericTaskVersion(responseData.taskVersion);
@@ -1851,7 +1902,7 @@ export class BridgeService {
1851
1902
  taskVersion,
1852
1903
  taskChanges: intent.taskChanges,
1853
1904
  });
1854
- await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint);
1905
+ await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, pointer.taskId);
1855
1906
  await this.dependencies.activeContexts.updateTaskSnapshot(pointer.taskId, taskVersion, pointer.lastSequence);
1856
1907
  return { ...responseData, recoveredAfterResponseLoss: true };
1857
1908
  }
@@ -1885,13 +1936,13 @@ export class BridgeService {
1885
1936
  }
1886
1937
  catch (error) {
1887
1938
  if (!isBackendUnavailableError(error)) {
1888
- await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint);
1939
+ await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
1889
1940
  }
1890
1941
  throw error;
1891
1942
  }
1892
1943
  const closedTask = objectValue(response.data);
1893
1944
  if (closedTask?.closed !== true) {
1894
- await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint);
1945
+ await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
1895
1946
  throw new Error('Task close response does not confirm task closure');
1896
1947
  }
1897
1948
  const closedTaskVersion = numericTaskVersion(closedTask.taskVersion);
@@ -1905,7 +1956,7 @@ export class BridgeService {
1905
1956
  taskVersion: closedTaskVersion,
1906
1957
  taskChanges,
1907
1958
  });
1908
- await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint);
1959
+ await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
1909
1960
  return asJsonValue({
1910
1961
  ...closedTask,
1911
1962
  repository: publicRepository(repository),
@@ -2122,6 +2173,17 @@ async function buildPathChangeManifest(repoRoot, changes) {
2122
2173
  });
2123
2174
  }));
2124
2175
  }
2176
+ function describePointer(pointer) {
2177
+ return asJsonValue({
2178
+ taskSlug: pointer.taskSlug,
2179
+ taskId: pointer.taskId,
2180
+ projectId: pointer.projectId,
2181
+ sessionId: pointer.sessionId,
2182
+ updatedAt: pointer.updatedAt,
2183
+ resumeConflicts: pointer.resumeConflicts,
2184
+ unsettledIntent: pointer.verificationIntent ? 'verify' : pointer.closeIntent ? 'close' : null,
2185
+ });
2186
+ }
2125
2187
  function publicRepository(repository) {
2126
2188
  return asJsonValue({
2127
2189
  repoRoot: repository.repoRoot,
@@ -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