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 +1 -1
- package/runtime/dist/src/config.js +1 -0
- package/runtime/dist/src/git/git-inspector.js +1 -0
- package/runtime/dist/src/mcp/tool-definitions.js +21 -8
- package/runtime/dist/src/runtime/active-context-store.js +172 -85
- package/runtime/dist/src/runtime/bridge-service.js +84 -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.0
|
|
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',
|
|
@@ -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(
|
|
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
|
-
|
|
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();
|
|
@@ -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'
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|