engineering-memory 1.6.3 → 1.7.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/bin/engineering-memory.mjs +3 -0
- package/bin/entry-point.test.mjs +29 -0
- package/package.json +1 -1
- package/runtime/dist/src/git/git-inspector.js +12 -2
- package/runtime/dist/src/mcp/tool-definitions.js +11 -2
- package/runtime/dist/src/runtime/api-client.js +6 -2
- package/runtime/dist/src/runtime/bridge-service.js +58 -54
- package/skill/references/lifecycle.md +2 -0
- package/skill/references/questionnaires.md +1 -1
|
@@ -69,6 +69,9 @@ async function main(argumentList) {
|
|
|
69
69
|
skillSource,
|
|
70
70
|
'--bridge-runtime-source',
|
|
71
71
|
staged,
|
|
72
|
+
...(rest.includes('--client-version')
|
|
73
|
+
? []
|
|
74
|
+
: ['--client-version', await publishedVersion()]),
|
|
72
75
|
]);
|
|
73
76
|
} finally {
|
|
74
77
|
await rm(staged, { recursive: true, force: true });
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
|
|
5
|
+
const entryPoint = new URL('./engineering-memory.mjs', import.meta.url);
|
|
6
|
+
const manifest = new URL('../package.json', import.meta.url);
|
|
7
|
+
|
|
8
|
+
test('the published entry point tells the installer which version it is', async () => {
|
|
9
|
+
const source = await readFile(entryPoint, 'utf8');
|
|
10
|
+
|
|
11
|
+
assert.match(
|
|
12
|
+
source,
|
|
13
|
+
/'--client-version',\s*await publishedVersion\(\)/,
|
|
14
|
+
'The installer is never told the version, so every install reports itself as unknown and no update is ever offered',
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('the published entry point tells the installer which backend it was built for', async () => {
|
|
19
|
+
const source = await readFile(entryPoint, 'utf8');
|
|
20
|
+
|
|
21
|
+
assert.match(source, /'--api-url',\s*await publishedApiUrl\(\)/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('the manifest carries a version the entry point can stamp', async () => {
|
|
25
|
+
const declared = JSON.parse(await readFile(manifest, 'utf8'));
|
|
26
|
+
|
|
27
|
+
assert.match(String(declared.version), /^\d+\.\d+\.\d+$/);
|
|
28
|
+
assert.equal(declared.name, 'engineering-memory');
|
|
29
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "engineering-memory",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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",
|
|
@@ -24,12 +24,22 @@ export class GitInspector {
|
|
|
24
24
|
this.gitValue(repoRoot, ['config', '--get', 'remote.origin.url']),
|
|
25
25
|
this.gitValue(repoRoot, ['rev-list', '--max-parents=0', 'HEAD']),
|
|
26
26
|
]);
|
|
27
|
-
const canonicalRoot = await canonicalPath(repoRoot);
|
|
28
27
|
const identity = remote
|
|
29
28
|
? `${canonicalRemoteIdentity(remote)}\n${firstCommit ?? ''}`
|
|
30
|
-
: `${
|
|
29
|
+
: `${(await this.mainWorktree(repoRoot)).toLowerCase()}\n${firstCommit ?? ''}`;
|
|
31
30
|
return sha256(identity);
|
|
32
31
|
}
|
|
32
|
+
async mainWorktree(repoRoot) {
|
|
33
|
+
const commonDirectory = await this.gitValue(repoRoot, [
|
|
34
|
+
'rev-parse',
|
|
35
|
+
'--path-format=absolute',
|
|
36
|
+
'--git-common-dir',
|
|
37
|
+
]);
|
|
38
|
+
if (!commonDirectory) {
|
|
39
|
+
return await canonicalPath(repoRoot);
|
|
40
|
+
}
|
|
41
|
+
return await canonicalPath(resolve(commonDirectory, '..'));
|
|
42
|
+
}
|
|
33
43
|
async manifest(repoRoot) {
|
|
34
44
|
const root = await this.findRoot(repoRoot);
|
|
35
45
|
const head = await this.gitValue(root, ['rev-parse', 'HEAD']);
|
|
@@ -91,7 +91,7 @@ const discoverableKindNames = [
|
|
|
91
91
|
];
|
|
92
92
|
const reconciliationEntry = z.object({
|
|
93
93
|
resourceId: z.string().min(1),
|
|
94
|
-
type: z.enum(['approved_revision', 'no_semantic_memory_change']),
|
|
94
|
+
type: z.enum(['approved_revision', 'no_semantic_memory_change', 'scaffold_applied']),
|
|
95
95
|
proposalId: z.string().optional(),
|
|
96
96
|
revisionId: z.string().optional(),
|
|
97
97
|
reason: z.string().optional(),
|
|
@@ -130,6 +130,7 @@ export function registerEngineeringMemoryTools(server, service) {
|
|
|
130
130
|
externalTaskId: z.string().min(2),
|
|
131
131
|
objective: z.string().min(2),
|
|
132
132
|
taskKind: z.string().min(2),
|
|
133
|
+
workItemKey: z.string().min(1).optional(),
|
|
133
134
|
mode: z.enum(['write', 'read_only', 'scaffold']).optional(),
|
|
134
135
|
knownRevisions: z.record(z.string(), z.number().int().min(0)).optional(),
|
|
135
136
|
}),
|
|
@@ -287,7 +288,9 @@ export function registerEngineeringMemoryTools(server, service) {
|
|
|
287
288
|
taskId: z.string().min(1),
|
|
288
289
|
entries: z.array(reconciliationEntry).min(1).max(50).optional(),
|
|
289
290
|
resourceId: z.string().min(1).optional(),
|
|
290
|
-
type: z
|
|
291
|
+
type: z
|
|
292
|
+
.enum(['approved_revision', 'no_semantic_memory_change', 'scaffold_applied'])
|
|
293
|
+
.optional(),
|
|
291
294
|
proposalId: z.string().optional(),
|
|
292
295
|
revisionId: z.string().optional(),
|
|
293
296
|
reason: z.string().optional(),
|
|
@@ -326,6 +329,12 @@ export function registerEngineeringMemoryTools(server, service) {
|
|
|
326
329
|
message: 'No-semantic-change reconciliation requires a reason of at least 8 characters',
|
|
327
330
|
});
|
|
328
331
|
}
|
|
332
|
+
if (entry.type === 'scaffold_applied' && (!entry.reason || entry.reason.length < 8)) {
|
|
333
|
+
context.addIssue({
|
|
334
|
+
code: 'custom',
|
|
335
|
+
message: 'Scaffold-applied reconciliation requires a reason of at least 8 characters',
|
|
336
|
+
});
|
|
337
|
+
}
|
|
329
338
|
}
|
|
330
339
|
}),
|
|
331
340
|
}, async (input) => toolResult(await service.taskReconcile(input)));
|
|
@@ -13,12 +13,14 @@ export class ApiResponseError extends Error {
|
|
|
13
13
|
code;
|
|
14
14
|
retryable;
|
|
15
15
|
envelope;
|
|
16
|
-
|
|
16
|
+
recovery;
|
|
17
|
+
constructor(message, httpStatus, code, retryable, envelope, recovery = null) {
|
|
17
18
|
super(message);
|
|
18
19
|
this.httpStatus = httpStatus;
|
|
19
20
|
this.code = code;
|
|
20
21
|
this.retryable = retryable;
|
|
21
22
|
this.envelope = envelope;
|
|
23
|
+
this.recovery = recovery;
|
|
22
24
|
this.name = 'ApiResponseError';
|
|
23
25
|
}
|
|
24
26
|
}
|
|
@@ -106,7 +108,9 @@ export class ApiClient {
|
|
|
106
108
|
this.responseSources.set(cached.value, 'stale_cache');
|
|
107
109
|
return cached.value;
|
|
108
110
|
}
|
|
109
|
-
|
|
111
|
+
const recovery = envelope.errorModel?.recovery ?? null;
|
|
112
|
+
const refusal = envelope.errorModel?.text ?? envelope.message ?? `Backend returned ${response.status}`;
|
|
113
|
+
throw new ApiResponseError(recovery ? `${refusal} Recovery: call ${recovery}.` : refusal, response.status, envelope.errorModel?.code ?? null, response.status === 408 || response.status === 429 || response.status >= 500, envelope, recovery);
|
|
110
114
|
}
|
|
111
115
|
if (request.cacheKey) {
|
|
112
116
|
await this.options.cache.set(cacheKey, response.headers.get('etag'), envelope);
|
|
@@ -53,6 +53,7 @@ export class BridgeService {
|
|
|
53
53
|
externalTaskId: input.externalTaskId,
|
|
54
54
|
objective: input.objective,
|
|
55
55
|
taskKind: input.taskKind,
|
|
56
|
+
workItemKey: input.workItemKey,
|
|
56
57
|
mode: input.mode,
|
|
57
58
|
knownRevisions: input.knownRevisions,
|
|
58
59
|
}, repository.repoRoot);
|
|
@@ -81,7 +82,7 @@ export class BridgeService {
|
|
|
81
82
|
});
|
|
82
83
|
}
|
|
83
84
|
if (!projectId) {
|
|
84
|
-
throw
|
|
85
|
+
throw refuse('This repository has a marker that names no project, so there is nothing to bootstrap against.', 'project.resolve');
|
|
85
86
|
}
|
|
86
87
|
const checkpointId = deterministicUuid('session.bootstrap', projectId, repository.repoFingerprint, input.externalTaskId);
|
|
87
88
|
const response = await this.dependencies.client.request(endpoints.sessionBootstrap, {
|
|
@@ -92,6 +93,7 @@ export class BridgeService {
|
|
|
92
93
|
externalTaskId: persistedBootstrap.externalTaskId,
|
|
93
94
|
objective: persistedBootstrap.objective,
|
|
94
95
|
taskKind: persistedBootstrap.taskKind,
|
|
96
|
+
workItemKey: persistedBootstrap.workItemKey,
|
|
95
97
|
mode: persistedBootstrap.mode ?? 'write',
|
|
96
98
|
repoFingerprint: repository.repoFingerprint,
|
|
97
99
|
...(input.mode === 'read_only'
|
|
@@ -116,7 +118,7 @@ export class BridgeService {
|
|
|
116
118
|
checkpointEvent.idempotencyKey !== checkpointId ||
|
|
117
119
|
typeof checkpointPayload?.createdAt !== 'string' ||
|
|
118
120
|
!isIsoTimestamp(checkpointPayload.createdAt)) {
|
|
119
|
-
throw
|
|
121
|
+
throw refuse('The backend opened the session without the checkpoint the task is anchored to.', 'session.bootstrap');
|
|
120
122
|
}
|
|
121
123
|
const pointer = {
|
|
122
124
|
repoFingerprint: repository.repoFingerprint,
|
|
@@ -200,14 +202,14 @@ export class BridgeService {
|
|
|
200
202
|
const taskSlug = input.taskSlug ?? pointer?.taskSlug;
|
|
201
203
|
const sessionId = input.sessionId ?? pointer?.sessionId;
|
|
202
204
|
if (!projectId || !taskSlug || !sessionId) {
|
|
203
|
-
throw
|
|
205
|
+
throw refuse('This repository has no live task to resume.', 'session.bootstrap');
|
|
204
206
|
}
|
|
205
207
|
if ((pointer &&
|
|
206
208
|
(pointer.projectId !== projectId ||
|
|
207
209
|
pointer.taskSlug !== taskSlug ||
|
|
208
210
|
pointer.sessionId !== sessionId)) ||
|
|
209
211
|
(repository.projectId && repository.projectId !== projectId)) {
|
|
210
|
-
throw
|
|
212
|
+
throw refuse('The live task belongs to a different project than the one this repository is bound to.', 'project.resolve');
|
|
211
213
|
}
|
|
212
214
|
if (pointer?.closeIntent) {
|
|
213
215
|
const recoveredClose = await this.retryCloseIntent(repository, pointer);
|
|
@@ -245,7 +247,7 @@ export class BridgeService {
|
|
|
245
247
|
typeof backendTask.id !== 'string' ||
|
|
246
248
|
typeof backendSession.id !== 'string' ||
|
|
247
249
|
backendSession.id !== sessionId) {
|
|
248
|
-
throw
|
|
250
|
+
throw refuse('The backend resumed the session without the task it belongs to.', 'session.resume');
|
|
249
251
|
}
|
|
250
252
|
let localJournal = await this.dependencies.journal.load(projectId, taskSlug);
|
|
251
253
|
let pendingOutbox = await this.dependencies.outbox.list();
|
|
@@ -545,11 +547,11 @@ export class BridgeService {
|
|
|
545
547
|
.map((entry) => objectValue(entry))
|
|
546
548
|
.find((entry) => entry?.projectId === input.projectId);
|
|
547
549
|
if (!project) {
|
|
548
|
-
throw
|
|
550
|
+
throw refuse('That project has no repository address recorded, or this account is not a member of it. An organization owner records the address.', 'project.list');
|
|
549
551
|
}
|
|
550
552
|
const result = await this.dependencies.repositories.git.clone(String(project.repositoryUrl), input.targetPath);
|
|
551
553
|
if (!result.cloned) {
|
|
552
|
-
throw
|
|
554
|
+
throw refuse(`The repository could not be cloned: ${result.reason}. Engineering Memory never handles Git credentials, so this is between the developer's own Git configuration and the host.`, 'project.clone');
|
|
553
555
|
}
|
|
554
556
|
return asJsonValue({
|
|
555
557
|
cloned: true,
|
|
@@ -834,7 +836,7 @@ export class BridgeService {
|
|
|
834
836
|
return await this.execute(async () => {
|
|
835
837
|
const entry = await this.dependencies.outbox.get(input.outboxId);
|
|
836
838
|
if (!entry) {
|
|
837
|
-
throw
|
|
839
|
+
throw refuse('There is no pending delivery under that identifier.', 'session.resume');
|
|
838
840
|
}
|
|
839
841
|
const metadata = {
|
|
840
842
|
id: entry.id,
|
|
@@ -866,7 +868,7 @@ export class BridgeService {
|
|
|
866
868
|
return asJsonValue({ discarded: true, taskId, pendingDelivery: metadata });
|
|
867
869
|
}
|
|
868
870
|
if (!entry.lastError?.startsWith('api_409_')) {
|
|
869
|
-
throw
|
|
871
|
+
throw refuse('This delivery did not fail on a version conflict, so rebasing it is not the repair.', 'session.resume');
|
|
870
872
|
}
|
|
871
873
|
if (entry.journalRef) {
|
|
872
874
|
const taskSnapshot = await this.refreshTaskPointer(taskId, entry.journalRef.projectId);
|
|
@@ -883,7 +885,7 @@ export class BridgeService {
|
|
|
883
885
|
});
|
|
884
886
|
}
|
|
885
887
|
if (entry.operation !== 'memory.propose_revision' || !taskId) {
|
|
886
|
-
throw
|
|
888
|
+
throw refuse('This delivery cannot be rebased safely; discard it or retry it as it stands.', 'task.resolve_pending_delivery');
|
|
887
889
|
}
|
|
888
890
|
if (!input.proposalRebaseApproved || input.confirmedBaseRevision === undefined) {
|
|
889
891
|
return asJsonValue({
|
|
@@ -897,7 +899,7 @@ export class BridgeService {
|
|
|
897
899
|
const projectId = typeof proposal?.projectId === 'string' ? proposal.projectId : null;
|
|
898
900
|
const queuedBaseRevision = proposal?.baseRevision;
|
|
899
901
|
if (!projectId || typeof queuedBaseRevision !== 'number') {
|
|
900
|
-
throw
|
|
902
|
+
throw refuse('The queued proposal does not carry the revision it was written against.', 'memory.propose_revision');
|
|
901
903
|
}
|
|
902
904
|
if (queuedBaseRevision !== input.confirmedBaseRevision) {
|
|
903
905
|
return asJsonValue({
|
|
@@ -918,7 +920,7 @@ export class BridgeService {
|
|
|
918
920
|
const refreshData = objectValue(refreshed.data);
|
|
919
921
|
const refreshedTask = objectValue(refreshData?.task);
|
|
920
922
|
if (!refreshedTask || refreshedTask.id !== taskId) {
|
|
921
|
-
throw
|
|
923
|
+
throw refuse('The refresh did not return the task this queued proposal belongs to.', 'session.resume');
|
|
922
924
|
}
|
|
923
925
|
const expectedTaskVersion = numericTaskVersion(refreshedTask.lockVersion);
|
|
924
926
|
const rebasedBody = cleanJson({ ...proposal, expectedTaskVersion });
|
|
@@ -1189,7 +1191,7 @@ export class BridgeService {
|
|
|
1189
1191
|
initialProjectProfile: input.initialProjectProfile,
|
|
1190
1192
|
});
|
|
1191
1193
|
if ('resourceDiscovery' in input.initialProjectProfile.metadata) {
|
|
1192
|
-
throw
|
|
1194
|
+
throw refuse('Project setup metadata cannot override canonical resource discovery', 'project.setup');
|
|
1193
1195
|
}
|
|
1194
1196
|
const persistentBody = normalizeRepositoryPaths(body, repository.repoRoot);
|
|
1195
1197
|
assertSafeToPersist(persistentBody);
|
|
@@ -1212,7 +1214,7 @@ export class BridgeService {
|
|
|
1212
1214
|
!marker ||
|
|
1213
1215
|
marker.projectId !== project.id ||
|
|
1214
1216
|
readDiscoveryUnits(policy).length === 0) {
|
|
1215
|
-
throw
|
|
1217
|
+
throw refuse('Project setup response is not policy-ready', 'project.setup');
|
|
1216
1218
|
}
|
|
1217
1219
|
const markerPath = await this.dependencies.repositories.writeMarker(repository.repoRoot, project.id);
|
|
1218
1220
|
return asJsonValue({ ...data, markerPath });
|
|
@@ -1272,7 +1274,7 @@ export class BridgeService {
|
|
|
1272
1274
|
const files = await Promise.all(input.files.map(async (file) => {
|
|
1273
1275
|
const hash = await hashWorkingTreeFile(repository.repoRoot, file.path);
|
|
1274
1276
|
if (!hash) {
|
|
1275
|
-
throw
|
|
1277
|
+
throw refuse(`Applied template file is not readable: ${file.path}`, 'architecture.record_application');
|
|
1276
1278
|
}
|
|
1277
1279
|
return { templatePath: file.templatePath, path: file.path, sha256: hash };
|
|
1278
1280
|
}));
|
|
@@ -1332,7 +1334,7 @@ export class BridgeService {
|
|
|
1332
1334
|
});
|
|
1333
1335
|
const project = objectValue(response.data);
|
|
1334
1336
|
if (!project || project.id !== input.projectId) {
|
|
1335
|
-
throw
|
|
1337
|
+
throw refuse('Project bind response does not match the selected project', 'project.resolve');
|
|
1336
1338
|
}
|
|
1337
1339
|
const markerPath = await this.dependencies.repositories.writeMarker(repository.repoRoot, input.projectId);
|
|
1338
1340
|
return asJsonValue({
|
|
@@ -1589,7 +1591,7 @@ export class BridgeService {
|
|
|
1589
1591
|
(entry.operation === 'task.checkpoint' || entry.operation === 'task.record_correction'));
|
|
1590
1592
|
const blockedEntry = taskEntries.find((entry) => entry.lastError && entry.lastError !== 'backend_unavailable');
|
|
1591
1593
|
if (blockedEntry && blockedEntry.idempotencyKey !== idempotencyKey) {
|
|
1592
|
-
throw
|
|
1594
|
+
throw refuse('A blocked task delivery must be explicitly resolved before checkpointing', 'task.resolve_pending_delivery');
|
|
1593
1595
|
}
|
|
1594
1596
|
const existingExpected = pendingJournal
|
|
1595
1597
|
? expectedTaskVersionFromBody(pendingJournal.body)
|
|
@@ -1605,7 +1607,7 @@ export class BridgeService {
|
|
|
1605
1607
|
correction,
|
|
1606
1608
|
});
|
|
1607
1609
|
if (!staged.delivery) {
|
|
1608
|
-
throw
|
|
1610
|
+
throw refuse('Task journal delivery was not materialized', 'task.resolve_pending_delivery');
|
|
1609
1611
|
}
|
|
1610
1612
|
const queued = await this.dependencies.outbox.enqueue({
|
|
1611
1613
|
operation: staged.delivery.operation,
|
|
@@ -1760,10 +1762,10 @@ export class BridgeService {
|
|
|
1760
1762
|
const pointer = await this.requireActivePointer(repository.repoFingerprint, taskId);
|
|
1761
1763
|
if ((projectId && pointer.projectId !== projectId) ||
|
|
1762
1764
|
(repository.projectId && pointer.projectId !== repository.projectId)) {
|
|
1763
|
-
throw
|
|
1765
|
+
throw refuse('Active task snapshot does not match the repository project', 'session.resume');
|
|
1764
1766
|
}
|
|
1765
1767
|
if ((await this.dependencies.outbox.list()).some((entry) => taskIdFromDeliverySafe(entry) === taskId)) {
|
|
1766
|
-
throw
|
|
1768
|
+
throw refuse('Pending task deliveries must be resolved before using the task snapshot', 'task.resolve_pending_delivery');
|
|
1767
1769
|
}
|
|
1768
1770
|
const response = await this.dependencies.client.request(endpoints.sessionResume, {
|
|
1769
1771
|
method: 'POST',
|
|
@@ -1776,7 +1778,7 @@ export class BridgeService {
|
|
|
1776
1778
|
const backend = objectValue(response.data);
|
|
1777
1779
|
const task = objectValue(backend?.task);
|
|
1778
1780
|
if (!task || task.id !== taskId || backend?.requiresContextRefresh === true) {
|
|
1779
|
-
throw
|
|
1781
|
+
throw refuse('Backend task snapshot is stale or does not match the active task', 'session.resume');
|
|
1780
1782
|
}
|
|
1781
1783
|
const refreshed = {
|
|
1782
1784
|
...pointer,
|
|
@@ -1799,7 +1801,7 @@ export class BridgeService {
|
|
|
1799
1801
|
const backend = objectValue(response.data);
|
|
1800
1802
|
const task = objectValue(backend?.task);
|
|
1801
1803
|
if (!task || task.id !== taskId || backend?.requiresContextRefresh === true) {
|
|
1802
|
-
throw
|
|
1804
|
+
throw refuse('Backend task snapshot is stale or does not match the pending delivery', 'session.resume');
|
|
1803
1805
|
}
|
|
1804
1806
|
const refreshed = {
|
|
1805
1807
|
...pointer,
|
|
@@ -1818,7 +1820,7 @@ export class BridgeService {
|
|
|
1818
1820
|
pointer.taskId !== taskId ||
|
|
1819
1821
|
(blockingConflicts?.length ?? 0) > 0 ||
|
|
1820
1822
|
lifecycleIntentBlocked) {
|
|
1821
|
-
throw
|
|
1823
|
+
throw refuse('A clean active task snapshot is required for this operation', 'session.resume');
|
|
1822
1824
|
}
|
|
1823
1825
|
return pointer;
|
|
1824
1826
|
}
|
|
@@ -1847,7 +1849,7 @@ export class BridgeService {
|
|
|
1847
1849
|
backendTask.projectId !== pointer.projectId ||
|
|
1848
1850
|
backendSession?.id !== pointer.sessionId ||
|
|
1849
1851
|
body.pendingOutboxCount !== 0) {
|
|
1850
|
-
throw
|
|
1852
|
+
throw refuse('Verification recovery does not match the durable verification intent', 'session.resume');
|
|
1851
1853
|
}
|
|
1852
1854
|
if (recovery) {
|
|
1853
1855
|
const recoveryTaskVersion = numericTaskVersion(recovery.taskVersion);
|
|
@@ -1855,12 +1857,12 @@ export class BridgeService {
|
|
|
1855
1857
|
recovery.mode !== intent.mode ||
|
|
1856
1858
|
(recoveryTaskVersion !== expectedTaskVersion &&
|
|
1857
1859
|
recoveryTaskVersion !== expectedTaskVersion + 1)) {
|
|
1858
|
-
throw
|
|
1860
|
+
throw refuse('Verification recovery does not match the verified backend task', 'session.resume');
|
|
1859
1861
|
}
|
|
1860
1862
|
}
|
|
1861
1863
|
else if (numericTaskVersion(backendTask.lockVersion) !== expectedTaskVersion ||
|
|
1862
1864
|
normalizeTaskMode(backendTask.mode) !== intent.mode) {
|
|
1863
|
-
throw
|
|
1865
|
+
throw refuse('Open backend task does not match the durable verification intent', 'session.resume');
|
|
1864
1866
|
}
|
|
1865
1867
|
const currentTaskChanges = intent.mode !== 'read_only'
|
|
1866
1868
|
? pointer.changeBaseline
|
|
@@ -1868,7 +1870,7 @@ export class BridgeService {
|
|
|
1868
1870
|
: null
|
|
1869
1871
|
: [];
|
|
1870
1872
|
if (!currentTaskChanges) {
|
|
1871
|
-
throw
|
|
1873
|
+
throw refuse('Verification recovery is missing its locally pinned baseline', 'session.resume');
|
|
1872
1874
|
}
|
|
1873
1875
|
const currentChangedPaths = currentTaskChanges.map((entry) => entry.path).sort();
|
|
1874
1876
|
const currentPathChanges = intent.mode === 'read_only'
|
|
@@ -1878,7 +1880,7 @@ export class BridgeService {
|
|
|
1878
1880
|
stableStringify(body.pathChanges ?? null) !== stableStringify(currentPathChanges) ||
|
|
1879
1881
|
stableStringify(intent.taskChanges.map(changedPathIdentity)) !==
|
|
1880
1882
|
stableStringify(currentTaskChanges.map(changedPathIdentity))) {
|
|
1881
|
-
throw
|
|
1883
|
+
throw refuse('Current Git state does not match the durable verification intent', 'session.resume');
|
|
1882
1884
|
}
|
|
1883
1885
|
if (intent.mode !== 'read_only') {
|
|
1884
1886
|
const retryLease = recovery
|
|
@@ -1894,13 +1896,13 @@ export class BridgeService {
|
|
|
1894
1896
|
retryLease.baselineDiffHash !== body.baselineDiffHash ||
|
|
1895
1897
|
body.baselineDiffHash !== pointer.changeBaseline?.diffHash ||
|
|
1896
1898
|
!pathsContainAll(retryLeasePaths, currentChangedPaths)) {
|
|
1897
|
-
throw
|
|
1899
|
+
throw refuse('Backend lease does not match the durable verification intent', 'context.prepare_change');
|
|
1898
1900
|
}
|
|
1899
1901
|
}
|
|
1900
1902
|
else if (currentChangedPaths.length !== 0 ||
|
|
1901
1903
|
body.leaseId !== undefined ||
|
|
1902
1904
|
body.baselineDiffHash !== undefined) {
|
|
1903
|
-
throw
|
|
1905
|
+
throw refuse('Read-only verification recovery contains a write lease', 'task.verify');
|
|
1904
1906
|
}
|
|
1905
1907
|
let response;
|
|
1906
1908
|
try {
|
|
@@ -1918,7 +1920,7 @@ export class BridgeService {
|
|
|
1918
1920
|
const responseData = objectValue(response.data);
|
|
1919
1921
|
if (responseData?.verified !== true || responseData.diffHash !== body.diffHash) {
|
|
1920
1922
|
await this.dependencies.activeContexts.clearVerificationIntent(repository.repoFingerprint, pointer.taskId);
|
|
1921
|
-
throw
|
|
1923
|
+
throw refuse('Verification retry did not confirm the durable verification intent', 'session.resume');
|
|
1922
1924
|
}
|
|
1923
1925
|
const taskVersion = numericTaskVersion(responseData.taskVersion);
|
|
1924
1926
|
await this.dependencies.gate.record({
|
|
@@ -1937,7 +1939,7 @@ export class BridgeService {
|
|
|
1937
1939
|
async retryCloseIntent(repository, pointer) {
|
|
1938
1940
|
const intent = pointer.closeIntent;
|
|
1939
1941
|
if (!intent) {
|
|
1940
|
-
throw
|
|
1942
|
+
throw refuse('Durable close intent is unavailable', 'session.resume');
|
|
1941
1943
|
}
|
|
1942
1944
|
const body = intent.body;
|
|
1943
1945
|
const currentTaskChanges = pointer.changeBaseline
|
|
@@ -1949,7 +1951,7 @@ export class BridgeService {
|
|
|
1949
1951
|
pointer.sessionId !== body.sessionId ||
|
|
1950
1952
|
stableStringify(intent.taskChanges.map(changedPathIdentity)) !==
|
|
1951
1953
|
stableStringify(currentTaskChanges.map(changedPathIdentity))) {
|
|
1952
|
-
throw
|
|
1954
|
+
throw refuse('Current state does not match the durable close intent', 'session.resume');
|
|
1953
1955
|
}
|
|
1954
1956
|
return await this.deliverCloseIntent(repository, body, intent.taskChanges, true);
|
|
1955
1957
|
}
|
|
@@ -1971,7 +1973,7 @@ export class BridgeService {
|
|
|
1971
1973
|
const closedTask = objectValue(response.data);
|
|
1972
1974
|
if (closedTask?.closed !== true) {
|
|
1973
1975
|
await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
|
|
1974
|
-
throw
|
|
1976
|
+
throw refuse('Task close response does not confirm task closure', 'session.resume');
|
|
1975
1977
|
}
|
|
1976
1978
|
const closedTaskVersion = numericTaskVersion(closedTask.taskVersion);
|
|
1977
1979
|
await this.dependencies.gate.record({
|
|
@@ -2040,13 +2042,13 @@ export class BridgeService {
|
|
|
2040
2042
|
})()
|
|
2041
2043
|
: await this.dependencies.activeContexts.findByTaskId(input.taskId, input.projectId, false);
|
|
2042
2044
|
if (pointer.resumeConflicts.some((conflict) => !isOfflineDevelopmentWarning(conflict))) {
|
|
2043
|
-
throw
|
|
2045
|
+
throw refuse('A clean active task snapshot is required for checkpointing', 'session.resume');
|
|
2044
2046
|
}
|
|
2045
2047
|
if (pointer.verificationIntent || pointer.closeIntent) {
|
|
2046
|
-
throw
|
|
2048
|
+
throw refuse('A clean active task snapshot is required for checkpointing', 'session.resume');
|
|
2047
2049
|
}
|
|
2048
2050
|
if (pointer.taskSlug.toLowerCase() !== input.taskSlug.toLowerCase()) {
|
|
2049
|
-
throw
|
|
2051
|
+
throw refuse(`Checkpoint task slug ${input.taskSlug} does not match the active task ${pointer.taskSlug}`, 'session.resume');
|
|
2050
2052
|
}
|
|
2051
2053
|
return pointer;
|
|
2052
2054
|
}
|
|
@@ -2095,6 +2097,7 @@ export class BridgeService {
|
|
|
2095
2097
|
message: error.message,
|
|
2096
2098
|
code: error.code ?? error.httpStatus,
|
|
2097
2099
|
retryable: error.retryable,
|
|
2100
|
+
...(error.recovery ? { recovery: error.recovery } : {}),
|
|
2098
2101
|
},
|
|
2099
2102
|
};
|
|
2100
2103
|
}
|
|
@@ -2117,7 +2120,7 @@ function normalizeChangedPaths(paths) {
|
|
|
2117
2120
|
path.startsWith('/') ||
|
|
2118
2121
|
/^[A-Za-z]:\//.test(path) ||
|
|
2119
2122
|
path.split('/').includes('..')) {
|
|
2120
|
-
throw
|
|
2123
|
+
throw refuse(`Changed path must be repository-relative: ${path}`, 'context.prepare_change');
|
|
2121
2124
|
}
|
|
2122
2125
|
}
|
|
2123
2126
|
return [...new Set(normalized)].sort();
|
|
@@ -2172,7 +2175,7 @@ function semanticGitStatus(status) {
|
|
|
2172
2175
|
function semanticPathStatus(status) {
|
|
2173
2176
|
const semantic = semanticGitStatus(status);
|
|
2174
2177
|
if (!['A', 'R', 'C', 'M', 'D', 'T', 'U'].includes(semantic)) {
|
|
2175
|
-
throw
|
|
2178
|
+
throw refuse(`Unsupported semantic Git status: ${status}`, 'task.verify');
|
|
2176
2179
|
}
|
|
2177
2180
|
return semantic;
|
|
2178
2181
|
}
|
|
@@ -2342,6 +2345,7 @@ function publicError(error) {
|
|
|
2342
2345
|
httpStatus: error.httpStatus,
|
|
2343
2346
|
code: error.code,
|
|
2344
2347
|
retryable: error.retryable,
|
|
2348
|
+
...(error.recovery ? { recovery: error.recovery } : {}),
|
|
2345
2349
|
});
|
|
2346
2350
|
}
|
|
2347
2351
|
return asJsonValue({
|
|
@@ -2356,7 +2360,7 @@ function isBackendUnavailableError(error) {
|
|
|
2356
2360
|
function numericSequence(value) {
|
|
2357
2361
|
const sequence = typeof value === 'number' ? value : Number(value ?? 0);
|
|
2358
2362
|
if (!Number.isSafeInteger(sequence) || sequence < 0) {
|
|
2359
|
-
throw
|
|
2363
|
+
throw refuse('Backend task sequence is invalid', 'session.resume');
|
|
2360
2364
|
}
|
|
2361
2365
|
return sequence;
|
|
2362
2366
|
}
|
|
@@ -2367,7 +2371,7 @@ function isIsoTimestamp(value) {
|
|
|
2367
2371
|
function numericTaskVersion(value) {
|
|
2368
2372
|
const version = typeof value === 'number' ? value : Number(value);
|
|
2369
2373
|
if (!Number.isSafeInteger(version) || version < 1) {
|
|
2370
|
-
throw
|
|
2374
|
+
throw refuse('Backend task version is invalid', 'session.resume');
|
|
2371
2375
|
}
|
|
2372
2376
|
return version;
|
|
2373
2377
|
}
|
|
@@ -2385,7 +2389,7 @@ function validateOfflineLease(value, sessionId, changedPaths, baselineDiffHash)
|
|
|
2385
2389
|
stableStringify(normalizeChangedPaths(leasePaths)) !== stableStringify(changedPaths) ||
|
|
2386
2390
|
!Number.isFinite(expiresAt) ||
|
|
2387
2391
|
expiresAt <= Date.now()) {
|
|
2388
|
-
throw
|
|
2392
|
+
throw refuse('Cached change lease is missing, expired or does not match the current Git state', 'context.prepare_change');
|
|
2389
2393
|
}
|
|
2390
2394
|
}
|
|
2391
2395
|
function isOfflineLeaseUsable(lease, sessionId, changedPaths, baselineDiffHash) {
|
|
@@ -2450,7 +2454,7 @@ function isOfflineDevelopmentWarning(value) {
|
|
|
2450
2454
|
function taskIdFromDelivery(value) {
|
|
2451
2455
|
const body = objectValue(value);
|
|
2452
2456
|
if (!body || typeof body.taskId !== 'string') {
|
|
2453
|
-
throw
|
|
2457
|
+
throw refuse('Pending task delivery does not contain a task identifier', 'task.resolve_pending_delivery');
|
|
2454
2458
|
}
|
|
2455
2459
|
return body.taskId;
|
|
2456
2460
|
}
|
|
@@ -2511,7 +2515,7 @@ export function newMemoryResourceCandidate(entry, policy) {
|
|
|
2511
2515
|
if (policy) {
|
|
2512
2516
|
const matched = policy.units.filter((unit) => unit.patterns.some((pattern) => globMatches(entry.path, pattern)));
|
|
2513
2517
|
if (matched.length > 1) {
|
|
2514
|
-
throw
|
|
2518
|
+
throw refuse(`Resource discovery policy is ambiguous for path: ${entry.path}`, 'memory.propose_revision');
|
|
2515
2519
|
}
|
|
2516
2520
|
return matched[0] ? [{ path: entry.path, kind: matched[0].kind }] : [];
|
|
2517
2521
|
}
|
|
@@ -2565,7 +2569,7 @@ function readResourceDiscoveryPolicy(snapshot, activeLease) {
|
|
|
2565
2569
|
}
|
|
2566
2570
|
const units = readDiscoveryUnits(discovery);
|
|
2567
2571
|
if (units.length === 0) {
|
|
2568
|
-
throw
|
|
2572
|
+
throw refuse('Pinned resource discovery policy is incomplete', 'context.refresh');
|
|
2569
2573
|
}
|
|
2570
2574
|
return { units };
|
|
2571
2575
|
}
|
|
@@ -2602,7 +2606,7 @@ function globMatches(path, pattern) {
|
|
|
2602
2606
|
normalizedPattern.startsWith('/') ||
|
|
2603
2607
|
/^[A-Za-z]:\//.test(normalizedPattern) ||
|
|
2604
2608
|
normalizedPattern.split('/').includes('..')) {
|
|
2605
|
-
throw
|
|
2609
|
+
throw refuse(`Resource discovery pattern is unsafe: ${pattern}`, 'memory.propose_revision');
|
|
2606
2610
|
}
|
|
2607
2611
|
return minimatch(path.replace(/\\/g, '/'), normalizedPattern, { dot: true });
|
|
2608
2612
|
}
|
|
@@ -2612,10 +2616,10 @@ function assertNewResourceEvidence(candidates, evidence) {
|
|
|
2612
2616
|
path: normalizeChangedPaths([entry.path])[0],
|
|
2613
2617
|
}));
|
|
2614
2618
|
if (normalizedEvidence.some((entry) => entry.resourceKey.trim().length < 2)) {
|
|
2615
|
-
throw
|
|
2619
|
+
throw refuse('New resource evidence requires a memory resource key', 'memory.propose_revision');
|
|
2616
2620
|
}
|
|
2617
2621
|
if (new Set(normalizedEvidence.map((entry) => entry.path)).size !== normalizedEvidence.length) {
|
|
2618
|
-
throw
|
|
2622
|
+
throw refuse('New resource evidence contains duplicate paths', 'task.verify');
|
|
2619
2623
|
}
|
|
2620
2624
|
const expected = [...candidates].sort((left, right) => left.path.localeCompare(right.path));
|
|
2621
2625
|
const actual = normalizedEvidence
|
|
@@ -2634,27 +2638,27 @@ function assertNewResourceEvidence(candidates, evidence) {
|
|
|
2634
2638
|
missing.length ? `missing evidence for ${missing.join(', ')}` : '',
|
|
2635
2639
|
extra.length ? `evidence for paths the policy does not classify: ${extra.join(', ')}` : '',
|
|
2636
2640
|
].filter(Boolean);
|
|
2637
|
-
throw
|
|
2641
|
+
throw refuse(`New screen or shared component paths require exact memory reconciliation. ${details.length ? details.join('; ') : 'The kinds do not match the project discovery policy.'}`, 'task.verify');
|
|
2638
2642
|
}
|
|
2639
2643
|
}
|
|
2640
2644
|
function assertValidationEvidence(validations) {
|
|
2641
2645
|
if (validations.length === 0) {
|
|
2642
|
-
throw
|
|
2646
|
+
throw refuse('Validation evidence is required', 'task.verify');
|
|
2643
2647
|
}
|
|
2644
2648
|
const allowed = new Set(validationIds);
|
|
2645
2649
|
const seen = new Set();
|
|
2646
2650
|
for (const validation of validations) {
|
|
2647
2651
|
if (!allowed.has(validation.validationId)) {
|
|
2648
|
-
throw
|
|
2652
|
+
throw refuse(`Unknown validation evidence: ${validation.validationId}`, 'task.verify');
|
|
2649
2653
|
}
|
|
2650
2654
|
if (seen.has(validation.validationId)) {
|
|
2651
|
-
throw
|
|
2655
|
+
throw refuse(`Duplicate validation evidence: ${validation.validationId}`, 'task.verify');
|
|
2652
2656
|
}
|
|
2653
2657
|
if (!validation.command.trim()) {
|
|
2654
|
-
throw
|
|
2658
|
+
throw refuse(`Validation command is required: ${validation.validationId}`, 'task.verify');
|
|
2655
2659
|
}
|
|
2656
2660
|
if (!/^[0-9a-f]{64}$/.test(validation.outputHash)) {
|
|
2657
|
-
throw
|
|
2661
|
+
throw refuse(`Validation output hash is invalid: ${validation.validationId}`, 'task.verify');
|
|
2658
2662
|
}
|
|
2659
2663
|
seen.add(validation.validationId);
|
|
2660
2664
|
}
|
|
@@ -10,6 +10,8 @@ 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
|
+
Two tasks in one repository need two branches, and one working tree can only have one checked out. A second checkout is what `git worktree` is for: `git worktree add ../<repo>-<task> -b <branch>` gives the task its own directory and its own branch against the same repository, and the chat for that task runs there. Engineering Memory treats them as one project — the fingerprint comes from the repository, not the directory — while each task measures only what changed in its own tree. Offer this when a task starts in a repository that already has a live task, and never ask somebody to switch branches in a tree another task is using.
|
|
14
|
+
|
|
13
15
|
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
16
|
|
|
15
17
|
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.
|
|
@@ -60,7 +60,7 @@ Ask for the registered email and intended role. Show owner, maintainer, member,
|
|
|
60
60
|
|
|
61
61
|
## Branch
|
|
62
62
|
|
|
63
|
-
At the start of a write task, before the first edit, ask whether to open a branch for it and confirm the name. Offer the convention the returned engineering rules state, the current branch as the alternative, and let the user name something else. Never create a branch during read-only analysis, and never create one without asking.
|
|
63
|
+
At the start of a write task, before the first edit, ask whether to open a branch for it and confirm the name. Offer the convention the returned engineering rules state, the current branch as the alternative, and let the user name something else. Never create a branch during read-only analysis, and never create one without asking. When the repository already has another live task, add a third option — a separate worktree for this one — and say what it means: its own directory, its own branch, the same project, and the other task's tree left alone.
|
|
64
64
|
|
|
65
65
|
## Flow Entry and Exit
|
|
66
66
|
|