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 +1 -1
- package/runtime/dist/src/config.js +1 -0
- package/runtime/dist/src/mcp/tool-definitions.js +9 -0
- package/runtime/dist/src/runtime/active-context-store.js +172 -85
- package/runtime/dist/src/runtime/bridge-service.js +74 -12
- package/skill/references/lifecycle.md +4 -0
- package/skill/references/questionnaires.md +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "engineering-memory",
|
|
3
|
-
"version": "1.
|
|
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
|
-
|
|
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
|
|
25
|
-
if (!
|
|
26
|
-
throw new Error('
|
|
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
|
-
...(
|
|
74
|
+
...(sameSession && existing?.verificationIntent
|
|
32
75
|
? { verificationIntent: existing.verificationIntent }
|
|
33
76
|
: {}),
|
|
34
|
-
...(
|
|
77
|
+
...(sameSession && existing?.closeIntent ? { closeIntent: existing.closeIntent } : {}),
|
|
35
78
|
});
|
|
79
|
+
await this.writeCurrentMarker(input.repoFingerprint, input.taskId);
|
|
36
80
|
});
|
|
37
81
|
}
|
|
38
|
-
async
|
|
39
|
-
await
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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 =
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
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 =
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
|
195
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
315
|
+
if (entry.isFile() && entry.name.endsWith('.json')) {
|
|
316
|
+
names.push(entry.name);
|
|
317
|
+
}
|
|
241
318
|
}
|
|
319
|
+
return names;
|
|
242
320
|
}
|
|
243
|
-
|
|
321
|
+
directoryFor(repoFingerprint) {
|
|
244
322
|
assertFingerprint(repoFingerprint);
|
|
245
|
-
return join(this.root,
|
|
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
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|