engineering-memory 1.8.0 → 1.10.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/dispatcher/sections.mjs +4 -2
- package/package.json +1 -1
- package/runtime/dist/src/config.js +6 -0
- package/runtime/dist/src/git/git-inspector.js +91 -0
- package/runtime/dist/src/git/pre-commit.js +16 -0
- package/runtime/dist/src/git/verification-gate.js +20 -4
- package/runtime/dist/src/index.js +1 -1
- package/runtime/dist/src/mcp/tool-definitions.js +163 -4
- package/runtime/dist/src/project/repository.js +69 -32
- package/runtime/dist/src/runtime/api-client.js +1 -0
- package/runtime/dist/src/runtime/bridge-service.js +268 -13
- package/runtime/dist/src/runtime/task-branch-store.js +111 -0
- package/skill/SKILL.md +5 -3
- package/skill/references/lifecycle.md +17 -16
- package/skill/references/questionnaires.md +6 -6
|
@@ -67,7 +67,7 @@ export class BridgeService {
|
|
|
67
67
|
const recoveredDeliveries = await this.recoverJournalOutbox();
|
|
68
68
|
const outbox = await this.flushOutbox();
|
|
69
69
|
let projectId = repository.projectId;
|
|
70
|
-
if (!repository.
|
|
70
|
+
if (!repository.bindingPath) {
|
|
71
71
|
const resolved = await this.dependencies.client.request(endpoints.projectResolve, {
|
|
72
72
|
method: 'POST',
|
|
73
73
|
body: { repoFingerprint: repository.repoFingerprint },
|
|
@@ -88,8 +88,13 @@ export class BridgeService {
|
|
|
88
88
|
});
|
|
89
89
|
}
|
|
90
90
|
if (!projectId) {
|
|
91
|
-
throw refuse('This repository has
|
|
91
|
+
throw refuse('This repository has no selected project. Select its project before bootstrapping.', 'project.resolve');
|
|
92
92
|
}
|
|
93
|
+
const bootstrapMode = persistedBootstrap.mode ?? 'write';
|
|
94
|
+
const decision = bootstrapMode === 'read_only'
|
|
95
|
+
? null
|
|
96
|
+
: await this.requireBranchDecision(repository, input.externalTaskId);
|
|
97
|
+
const branch = decision?.branch;
|
|
93
98
|
const checkpointId = deterministicUuid('session.bootstrap', projectId, repository.repoFingerprint, input.externalTaskId);
|
|
94
99
|
const response = await this.dependencies.client.request(endpoints.sessionBootstrap, {
|
|
95
100
|
method: 'POST',
|
|
@@ -106,6 +111,7 @@ export class BridgeService {
|
|
|
106
111
|
...(input.mode === 'read_only'
|
|
107
112
|
? { baselineDiffHash: repository.git.worktreeHash }
|
|
108
113
|
: {}),
|
|
114
|
+
...(decision ? { branch } : {}),
|
|
109
115
|
checkpointIdempotencyKey: checkpointId,
|
|
110
116
|
knownRevisions: persistedBootstrap.knownRevisions,
|
|
111
117
|
}),
|
|
@@ -127,11 +133,20 @@ export class BridgeService {
|
|
|
127
133
|
!isIsoTimestamp(checkpointPayload.createdAt)) {
|
|
128
134
|
throw refuse('The backend opened the session without the checkpoint the task is anchored to.', 'session.bootstrap');
|
|
129
135
|
}
|
|
136
|
+
if (decision)
|
|
137
|
+
await this.dependencies.repositories.git
|
|
138
|
+
.branchStore(repository.repoRoot)
|
|
139
|
+
.bind(projectId, input.externalTaskId, task.id);
|
|
130
140
|
const pointer = {
|
|
131
141
|
repoFingerprint: repository.repoFingerprint,
|
|
132
142
|
projectId,
|
|
133
143
|
taskId: task.id,
|
|
134
144
|
taskSlug: input.externalTaskId,
|
|
145
|
+
worktreeId: sha256(repository.repoRoot),
|
|
146
|
+
...(decision
|
|
147
|
+
? { branch: typeof task.branch === 'string' ? task.branch : decision.branch }
|
|
148
|
+
: {}),
|
|
149
|
+
mode: normalizeTaskMode(task.mode),
|
|
135
150
|
sessionId: session.id,
|
|
136
151
|
lastSequence: numericSequence(task.lastSequence),
|
|
137
152
|
taskVersion: numericTaskVersion(task.lockVersion),
|
|
@@ -256,6 +271,12 @@ export class BridgeService {
|
|
|
256
271
|
backendSession.id !== sessionId) {
|
|
257
272
|
throw refuse('The backend resumed the session without the task it belongs to.', 'session.resume');
|
|
258
273
|
}
|
|
274
|
+
await this.assertTaskWorktree(repository, backendTask, pointer);
|
|
275
|
+
if (backendTask.status === 'closed' || backendTask.status === 'abandoned') {
|
|
276
|
+
await this.dependencies.repositories.git
|
|
277
|
+
.branchStore(repository.repoRoot)
|
|
278
|
+
.release(backendTask.id);
|
|
279
|
+
}
|
|
259
280
|
let localJournal = await this.dependencies.journal.load(projectId, taskSlug);
|
|
260
281
|
let pendingOutbox = await this.dependencies.outbox.list();
|
|
261
282
|
const responseSource = this.dependencies.client.getResponseSource(response);
|
|
@@ -365,6 +386,13 @@ export class BridgeService {
|
|
|
365
386
|
projectId,
|
|
366
387
|
taskId: backendTask.id,
|
|
367
388
|
taskSlug,
|
|
389
|
+
worktreeId: pointer?.worktreeId ?? sha256(repository.repoRoot),
|
|
390
|
+
...(typeof backendTask.branch === 'string'
|
|
391
|
+
? { branch: backendTask.branch }
|
|
392
|
+
: pointer?.branch !== undefined
|
|
393
|
+
? { branch: pointer.branch }
|
|
394
|
+
: {}),
|
|
395
|
+
mode: normalizeTaskMode(backendTask.mode),
|
|
368
396
|
sessionId,
|
|
369
397
|
lastSequence: resumedLastSequence,
|
|
370
398
|
taskVersion: resumedTaskVersion,
|
|
@@ -443,6 +471,10 @@ export class BridgeService {
|
|
|
443
471
|
...input.changedPaths,
|
|
444
472
|
]);
|
|
445
473
|
const baselineDiffHash = activeBaseline?.diffHash ?? repository.git.diffHash;
|
|
474
|
+
const decision = pointer &&
|
|
475
|
+
(input.transitionToWrite || (pointer.branch !== undefined && pointer.mode !== 'read_only'))
|
|
476
|
+
? await this.requireBranchDecision(repository, pointer.taskSlug)
|
|
477
|
+
: null;
|
|
446
478
|
let transitionTaskVersion;
|
|
447
479
|
if (input.transitionToWrite) {
|
|
448
480
|
if (!pointer || pointer.sessionId !== input.sessionId) {
|
|
@@ -455,6 +487,7 @@ export class BridgeService {
|
|
|
455
487
|
sessionId: input.sessionId,
|
|
456
488
|
changedPaths,
|
|
457
489
|
baselineDiffHash,
|
|
490
|
+
...(decision ? { branch: decision.branch } : {}),
|
|
458
491
|
...(input.transitionToWrite
|
|
459
492
|
? { transitionToWrite: true, expectedTaskVersion: transitionTaskVersion }
|
|
460
493
|
: {}),
|
|
@@ -501,6 +534,17 @@ export class BridgeService {
|
|
|
501
534
|
: undefined);
|
|
502
535
|
const preparedPointer = await this.dependencies.activeContexts.loadForSession(repository.repoFingerprint, input.sessionId);
|
|
503
536
|
if (preparedPointer) {
|
|
537
|
+
if (decision) {
|
|
538
|
+
await this.dependencies.repositories.git
|
|
539
|
+
.branchStore(repository.repoRoot)
|
|
540
|
+
.bind(preparedPointer.projectId, preparedPointer.taskSlug, preparedPointer.taskId);
|
|
541
|
+
await this.dependencies.activeContexts.save({
|
|
542
|
+
...preparedPointer,
|
|
543
|
+
worktreeId: sha256(repository.repoRoot),
|
|
544
|
+
branch: typeof preparedTask?.branch === 'string' ? preparedTask.branch : decision.branch,
|
|
545
|
+
mode: 'write',
|
|
546
|
+
});
|
|
547
|
+
}
|
|
504
548
|
const preparedData = objectValue(response.data);
|
|
505
549
|
await this.seedResumeSnapshot(preparedPointer, {
|
|
506
550
|
...objectOrEmpty(response.data),
|
|
@@ -1010,6 +1054,7 @@ export class BridgeService {
|
|
|
1010
1054
|
});
|
|
1011
1055
|
}
|
|
1012
1056
|
const taskMode = normalizeTaskMode(snapshotTask.mode);
|
|
1057
|
+
await this.assertTaskWorktree(repository, snapshotTask, pointer);
|
|
1013
1058
|
const resourceDiscoveryPolicy = readResourceDiscoveryPolicy(snapshot, activeLease);
|
|
1014
1059
|
const validations = normalizePersistentInput(input.validations, repository.repoRoot);
|
|
1015
1060
|
if (taskMode === 'read_only') {
|
|
@@ -1072,6 +1117,11 @@ export class BridgeService {
|
|
|
1072
1117
|
changedPaths: taskMode === 'read_only' ? [] : taskChangedPaths,
|
|
1073
1118
|
pathChanges: pathChangeManifest,
|
|
1074
1119
|
diffHash: repository.git.diffHash,
|
|
1120
|
+
...(taskMode !== 'read_only'
|
|
1121
|
+
? {
|
|
1122
|
+
branch: await this.dependencies.repositories.git.currentBranch(repository.repoRoot),
|
|
1123
|
+
}
|
|
1124
|
+
: {}),
|
|
1075
1125
|
...(taskMode === 'read_only' ? { worktreeHash: repository.git.worktreeHash } : {}),
|
|
1076
1126
|
validations,
|
|
1077
1127
|
pendingOutboxCount,
|
|
@@ -1105,6 +1155,9 @@ export class BridgeService {
|
|
|
1105
1155
|
repoFingerprint: repository.repoFingerprint,
|
|
1106
1156
|
repoRoot: repository.repoRoot,
|
|
1107
1157
|
diffHash: repository.git.diffHash,
|
|
1158
|
+
branch: typeof snapshotTask.branch === 'string'
|
|
1159
|
+
? snapshotTask.branch
|
|
1160
|
+
: (pointer.branch ?? null),
|
|
1108
1161
|
taskVersion,
|
|
1109
1162
|
taskChanges,
|
|
1110
1163
|
});
|
|
@@ -1148,11 +1201,17 @@ export class BridgeService {
|
|
|
1148
1201
|
const taskChanges = pointer.changeBaseline
|
|
1149
1202
|
? manifestDelta(pointer.changeBaseline.changedPaths, repository.git.changedPaths)
|
|
1150
1203
|
: [];
|
|
1204
|
+
await this.assertTaskWorktree(repository, task, pointer);
|
|
1151
1205
|
const closeBody = {
|
|
1152
1206
|
taskId: input.taskId,
|
|
1153
1207
|
sessionId: pointer.sessionId,
|
|
1154
1208
|
expectedTaskVersion: numericTaskVersion(task.lockVersion),
|
|
1155
1209
|
diffHash: repository.git.diffHash,
|
|
1210
|
+
...(normalizeTaskMode(task.mode) !== 'read_only'
|
|
1211
|
+
? {
|
|
1212
|
+
branch: await this.dependencies.repositories.git.currentBranch(repository.repoRoot),
|
|
1213
|
+
}
|
|
1214
|
+
: {}),
|
|
1156
1215
|
};
|
|
1157
1216
|
await this.dependencies.activeContexts.setCloseIntent(repository.repoFingerprint, {
|
|
1158
1217
|
body: closeBody,
|
|
@@ -1162,6 +1221,109 @@ export class BridgeService {
|
|
|
1162
1221
|
});
|
|
1163
1222
|
});
|
|
1164
1223
|
}
|
|
1224
|
+
async taskBranch(input) {
|
|
1225
|
+
return await this.execute(async () => {
|
|
1226
|
+
const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
|
|
1227
|
+
const authentication = await this.dependencies.browserAuth.ensureAuthenticated();
|
|
1228
|
+
if (authentication)
|
|
1229
|
+
return asJsonValue({ authentication });
|
|
1230
|
+
if (!repository.projectId)
|
|
1231
|
+
throw refuse('Resolve the project before choosing its task branch.', 'project.resolve');
|
|
1232
|
+
if (!input.externalTaskId ||
|
|
1233
|
+
input.externalTaskId.length > 160 ||
|
|
1234
|
+
/[\r\n]/.test(input.externalTaskId))
|
|
1235
|
+
throw refuse('Pass the exact externalTaskId of the task whose branch question was answered.', 'task.branch');
|
|
1236
|
+
if (Boolean(input.name) === (input.keepCurrent === true) ||
|
|
1237
|
+
(input.worktreePath && !input.name))
|
|
1238
|
+
throw refuse('Choose exactly one branch name or keepCurrent. A separate worktree also needs a branch name.', 'task.branch');
|
|
1239
|
+
assertSafeToPersist(cleanJson({ externalTaskId: input.externalTaskId, name: input.name }));
|
|
1240
|
+
const git = this.dependencies.repositories.git;
|
|
1241
|
+
let target = repository;
|
|
1242
|
+
try {
|
|
1243
|
+
if (input.name)
|
|
1244
|
+
await git.validateBranch(repository.repoRoot, input.name);
|
|
1245
|
+
if (input.worktreePath && input.name) {
|
|
1246
|
+
const targetRoot = await git.createWorktree(repository.repoRoot, input.name, input.worktreePath);
|
|
1247
|
+
target = await this.dependencies.repositories.resolve(targetRoot);
|
|
1248
|
+
if (target.projectId && target.projectId !== repository.projectId)
|
|
1249
|
+
throw refuse('The target worktree belongs to a different Engineering Memory project. Choose another worktreePath.', 'task.branch');
|
|
1250
|
+
if (!target.bindingPath || target.repoFingerprint !== repository.repoFingerprint)
|
|
1251
|
+
throw refuse('The worktree did not inherit the selected local project binding.', 'project.resolve');
|
|
1252
|
+
}
|
|
1253
|
+
const branch = input.name ?? (await git.currentBranch(target.repoRoot));
|
|
1254
|
+
const store = git.branchStore(target.repoRoot);
|
|
1255
|
+
await store.reserve({
|
|
1256
|
+
projectId: repository.projectId,
|
|
1257
|
+
externalTaskId: input.externalTaskId,
|
|
1258
|
+
branch,
|
|
1259
|
+
});
|
|
1260
|
+
if (input.name && (await git.currentBranch(target.repoRoot)) !== input.name) {
|
|
1261
|
+
try {
|
|
1262
|
+
if (await git.branchExists(target.repoRoot, input.name))
|
|
1263
|
+
await git.switchBranch(target.repoRoot, input.name);
|
|
1264
|
+
else
|
|
1265
|
+
await git.createBranch(target.repoRoot, input.name);
|
|
1266
|
+
}
|
|
1267
|
+
catch (error) {
|
|
1268
|
+
if ((await git.currentBranch(target.repoRoot)) !== input.name) {
|
|
1269
|
+
await store.cancelUnopened(repository.projectId, input.externalTaskId);
|
|
1270
|
+
throw error;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
return asJsonValue({
|
|
1275
|
+
branch,
|
|
1276
|
+
kept: input.keepCurrent === true,
|
|
1277
|
+
repoRoot: target.repoRoot,
|
|
1278
|
+
externalTaskId: input.externalTaskId,
|
|
1279
|
+
nextAction: 'Use this repoRoot for session.bootstrap and every subsequent command for this task. The decision survives retries and restarts.',
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
catch (error) {
|
|
1283
|
+
if (error instanceof BridgeRecoveryError)
|
|
1284
|
+
throw error;
|
|
1285
|
+
throw refuse(`${error instanceof Error ? error.message : String(error)} Retry task.branch for the selected task; use worktreePath to leave a busy or dirty checkout intact.`, 'task.branch');
|
|
1286
|
+
}
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
async requireBranchDecision(repository, externalTaskId) {
|
|
1290
|
+
const git = this.dependencies.repositories.git;
|
|
1291
|
+
const store = git.branchStore(repository.repoRoot);
|
|
1292
|
+
let record = await store.read();
|
|
1293
|
+
if (!record) {
|
|
1294
|
+
const pointer = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, externalTaskId);
|
|
1295
|
+
if (pointer?.worktreeId === sha256(repository.repoRoot) &&
|
|
1296
|
+
pointer.mode !== 'read_only' &&
|
|
1297
|
+
pointer.branch !== undefined) {
|
|
1298
|
+
await store.reserve({
|
|
1299
|
+
projectId: pointer.projectId,
|
|
1300
|
+
externalTaskId,
|
|
1301
|
+
branch: pointer.branch,
|
|
1302
|
+
taskId: pointer.taskId,
|
|
1303
|
+
});
|
|
1304
|
+
record = await store.read();
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
if (!record)
|
|
1308
|
+
throw refuse(`Ask the branch question through the native questionnaire for task ${externalTaskId}, then call task.branch with that externalTaskId and a name or keepCurrent. For parallel work choose a separate worktreePath.`, 'task.branch');
|
|
1309
|
+
if (record.decision.projectId !== repository.projectId ||
|
|
1310
|
+
record.decision.externalTaskId !== externalTaskId)
|
|
1311
|
+
throw refuse(`This worktree belongs to task ${record.decision.externalTaskId}. Use task.branch with worktreePath for ${externalTaskId}, then bootstrap in the returned directory.`, 'task.branch');
|
|
1312
|
+
const branch = await git.currentBranch(repository.repoRoot);
|
|
1313
|
+
if (branch !== record.decision.branch)
|
|
1314
|
+
throw refuse(`Task ${externalTaskId} selected ${record.decision.branch ?? 'detached HEAD'}, but this worktree is on ${branch ?? 'detached HEAD'}. Return to the selected branch with task.branch.`, 'task.branch');
|
|
1315
|
+
return record.decision;
|
|
1316
|
+
}
|
|
1317
|
+
async assertTaskWorktree(repository, task, pointer) {
|
|
1318
|
+
if (normalizeTaskMode(task.mode) === 'read_only')
|
|
1319
|
+
return;
|
|
1320
|
+
if (pointer?.worktreeId && pointer.worktreeId !== sha256(repository.repoRoot))
|
|
1321
|
+
throw refuse('This task belongs to a different worktree. Resume and run its commands in the worktree selected for this task.', 'session.resume');
|
|
1322
|
+
const recorded = typeof task.branch === 'string' ? task.branch : pointer?.branch;
|
|
1323
|
+
const current = await this.dependencies.repositories.git.currentBranch(repository.repoRoot);
|
|
1324
|
+
if (recorded && current !== recorded)
|
|
1325
|
+
throw refuse(`This task selected ${recorded}. Call task.branch with externalTaskId ${pointer?.taskSlug ?? String(task.externalTaskId)} and name ${recorded} before continuing.`, 'task.branch');
|
|
1326
|
+
}
|
|
1165
1327
|
async taskAbandon(input) {
|
|
1166
1328
|
return await this.execute(async () => {
|
|
1167
1329
|
return await this.taskExclusive(input.taskId, async () => {
|
|
@@ -1174,6 +1336,9 @@ export class BridgeService {
|
|
|
1174
1336
|
if (!abandoned || abandoned.abandoned !== true) {
|
|
1175
1337
|
throw refuse('The backend did not record the abandonment, so the task is still open.', 'session.resume');
|
|
1176
1338
|
}
|
|
1339
|
+
await this.dependencies.repositories.git
|
|
1340
|
+
.branchStore(repository.repoRoot)
|
|
1341
|
+
.release(input.taskId);
|
|
1177
1342
|
const forgotten = await this.dependencies.activeContexts.forget(repository.repoFingerprint, input.taskId);
|
|
1178
1343
|
await this.dependencies.gate.invalidateTask(input.taskId);
|
|
1179
1344
|
return asJsonValue({
|
|
@@ -1223,8 +1388,8 @@ export class BridgeService {
|
|
|
1223
1388
|
readDiscoveryUnits(policy).length === 0) {
|
|
1224
1389
|
throw refuse('Project setup response is not policy-ready', 'project.setup');
|
|
1225
1390
|
}
|
|
1226
|
-
const
|
|
1227
|
-
return asJsonValue({ ...data, markerPath });
|
|
1391
|
+
const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, project.id);
|
|
1392
|
+
return asJsonValue({ ...data, marker: null, markerPath: null, bindingPath });
|
|
1228
1393
|
});
|
|
1229
1394
|
}
|
|
1230
1395
|
async projectMemberAdd(input) {
|
|
@@ -1325,6 +1490,77 @@ export class BridgeService {
|
|
|
1325
1490
|
return asJsonValue({ workItem: response.data });
|
|
1326
1491
|
});
|
|
1327
1492
|
}
|
|
1493
|
+
async workItemCreate(input) {
|
|
1494
|
+
return this.execute(async () => {
|
|
1495
|
+
const response = await this.dependencies.client.request(endpoints.workItemList(input.projectId), { method: 'POST', body: cleanJson(input.data) });
|
|
1496
|
+
return asJsonValue(response.data);
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
async workItemUpdate(input) {
|
|
1500
|
+
return this.execute(async () => {
|
|
1501
|
+
const response = await this.dependencies.client.request(endpoints.workItemGet(input.projectId, input.workItemId), { method: 'PATCH', body: cleanJson(input.data) });
|
|
1502
|
+
return asJsonValue(response.data);
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
async workItemArchive(input) {
|
|
1506
|
+
return this.execute(async () => {
|
|
1507
|
+
const response = await this.dependencies.client.request(endpoints.workItemGet(input.projectId, input.workItemId), { method: 'DELETE', body: cleanJson({ expectedVersion: input.expectedVersion }) });
|
|
1508
|
+
return asJsonValue(response.data);
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
async workItemRestore(input) {
|
|
1512
|
+
return this.execute(async () => {
|
|
1513
|
+
const response = await this.dependencies.client.request(endpoints.workItemRestore(input.projectId, input.workItemId), { method: 'POST', body: cleanJson({ expectedVersion: input.expectedVersion }) });
|
|
1514
|
+
return asJsonValue(response.data);
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
async workItemRecordTest(input) {
|
|
1518
|
+
return this.execute(async () => {
|
|
1519
|
+
const response = await this.dependencies.client.request(endpoints.workItemTests(input.projectId, input.workItemId), { method: 'POST', body: cleanJson(input.data) });
|
|
1520
|
+
return asJsonValue(response.data);
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
async workItemConfirmPlan(input) {
|
|
1524
|
+
return this.execute(async () => {
|
|
1525
|
+
const response = await this.dependencies.client.request(endpoints.workItemPlan(input.projectId, input.workItemId), { method: 'PUT', body: cleanJson(input.data) });
|
|
1526
|
+
return asJsonValue(response.data);
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
async projectUpdate(input) {
|
|
1530
|
+
return this.execute(async () => {
|
|
1531
|
+
const response = await this.dependencies.client.request(endpoints.projectUpdate(input.projectId), { method: 'PATCH', body: cleanJson(input.data) });
|
|
1532
|
+
return asJsonValue(response.data);
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
async workItemRuns(input) {
|
|
1536
|
+
return this.execute(async () => {
|
|
1537
|
+
const query = new URLSearchParams({
|
|
1538
|
+
offset: String(input.offset ?? 0),
|
|
1539
|
+
limit: String(input.limit ?? 50),
|
|
1540
|
+
});
|
|
1541
|
+
const response = await this.dependencies.client.request(`${endpoints.workItemRuns(input.projectId, input.workItemId)}?${query}`);
|
|
1542
|
+
return asJsonValue(response.data);
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
async workItemTestResults(input) {
|
|
1546
|
+
return this.execute(async () => {
|
|
1547
|
+
const query = new URLSearchParams({
|
|
1548
|
+
offset: String(input.offset ?? 0),
|
|
1549
|
+
limit: String(input.limit ?? 50),
|
|
1550
|
+
});
|
|
1551
|
+
const response = await this.dependencies.client.request(`${endpoints.workItemTests(input.projectId, input.workItemId)}?${query}`);
|
|
1552
|
+
return asJsonValue(response.data);
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
async workItemPlan(input) {
|
|
1556
|
+
return this.execute(async () => {
|
|
1557
|
+
const response = await this.dependencies.client.request(endpoints.workItemPlan(input.projectId, input.workItemId));
|
|
1558
|
+
return asJsonValue({
|
|
1559
|
+
...objectOrEmpty(response.data),
|
|
1560
|
+
nextAction: 'Read the current and authorized linked project contracts. Decide whether web, mobile, backend or multiple projects need changes. If additional scope is needed, explain why and obtain the native questionnaire answer before work_item.confirm_plan. Reuse an already confirmed scope and shared branch; each repository has its own worktree and task lifecycle.',
|
|
1561
|
+
});
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1328
1564
|
async architecturePlan(input) {
|
|
1329
1565
|
return await this.execute(async () => {
|
|
1330
1566
|
const body = cleanJson(input);
|
|
@@ -1373,6 +1609,15 @@ export class BridgeService {
|
|
|
1373
1609
|
return asJsonValue({ application: response.data });
|
|
1374
1610
|
});
|
|
1375
1611
|
}
|
|
1612
|
+
async organizationUpdate(input) {
|
|
1613
|
+
return this.execute(async () => {
|
|
1614
|
+
const response = await this.dependencies.client.request(endpoints.organizationUpdate(input.organizationId), {
|
|
1615
|
+
method: 'PATCH',
|
|
1616
|
+
body: cleanJson({ name: input.name, expectedName: input.expectedName }),
|
|
1617
|
+
});
|
|
1618
|
+
return asJsonValue(response.data);
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1376
1621
|
async organizationCreate(input) {
|
|
1377
1622
|
return await this.execute(async () => {
|
|
1378
1623
|
const response = await this.dependencies.client.request(endpoints.organizationCreate, { method: 'POST', body: cleanJson(input) });
|
|
@@ -1446,17 +1691,14 @@ export class BridgeService {
|
|
|
1446
1691
|
if (!project || project.id !== input.projectId) {
|
|
1447
1692
|
throw refuse('Project bind response does not match the selected project', 'project.resolve');
|
|
1448
1693
|
}
|
|
1449
|
-
const
|
|
1694
|
+
const bindingPath = await this.dependencies.repositories.writeBinding(repository.repoRoot, input.projectId);
|
|
1450
1695
|
return asJsonValue({
|
|
1451
1696
|
resolved: true,
|
|
1452
1697
|
bound: true,
|
|
1453
1698
|
project,
|
|
1454
|
-
markerPath,
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
markerPath,
|
|
1458
|
-
projectId: input.projectId,
|
|
1459
|
-
}),
|
|
1699
|
+
markerPath: null,
|
|
1700
|
+
bindingPath,
|
|
1701
|
+
repository: publicRepository(await this.dependencies.repositories.resolve(repository.repoRoot)),
|
|
1460
1702
|
selectionRequired: false,
|
|
1461
1703
|
});
|
|
1462
1704
|
}
|
|
@@ -1501,11 +1743,11 @@ export class BridgeService {
|
|
|
1501
1743
|
}
|
|
1502
1744
|
const decision = await this.dependencies.repositoryDecisions.load(repository.repoFingerprint);
|
|
1503
1745
|
const authenticated = authentication.authenticated === true;
|
|
1504
|
-
const state = decision?.state
|
|
1746
|
+
const state = decision?.state === 'disabled' ? 'disabled' : repository.bindingPath ? 'bound' : 'none';
|
|
1505
1747
|
const client = await this.clientUpdate(authenticated);
|
|
1506
1748
|
const shipped = objectValue(objectValue(client)?.shippedKnowledge);
|
|
1507
1749
|
const incomplete = shipped && (shipped.failure !== null || Number(shipped.live) < Number(shipped.expected));
|
|
1508
|
-
const projectId = repository.projectId
|
|
1750
|
+
const projectId = repository.projectId;
|
|
1509
1751
|
const liveTasks = authenticated && state === 'bound'
|
|
1510
1752
|
? (await this.dependencies.activeContexts.list(repository.repoFingerprint)).map(describePointer)
|
|
1511
1753
|
: [];
|
|
@@ -2072,6 +2314,7 @@ export class BridgeService {
|
|
|
2072
2314
|
repoFingerprint: pointer.repoFingerprint,
|
|
2073
2315
|
repoRoot: repository.repoRoot,
|
|
2074
2316
|
diffHash: repository.git.diffHash,
|
|
2317
|
+
branch: typeof backendTask?.branch === 'string' ? backendTask.branch : (pointer.branch ?? null),
|
|
2075
2318
|
taskVersion,
|
|
2076
2319
|
taskChanges: intent.taskChanges,
|
|
2077
2320
|
});
|
|
@@ -2100,6 +2343,9 @@ export class BridgeService {
|
|
|
2100
2343
|
}
|
|
2101
2344
|
async deliverCloseIntent(repository, body, taskChanges, recoveredAfterResponseLoss) {
|
|
2102
2345
|
const touchedContract = taskChanges.some((change) => /\.controller\.[tj]s$/.test(change.path));
|
|
2346
|
+
const currentBranch = await this.dependencies.repositories.git.currentBranch(repository.repoRoot);
|
|
2347
|
+
if (typeof body.branch === 'string' && body.branch !== currentBranch)
|
|
2348
|
+
throw refuse(`Return to the branch ${body.branch} recorded in this close request before retrying.`, 'task.branch');
|
|
2103
2349
|
let response;
|
|
2104
2350
|
try {
|
|
2105
2351
|
response = await this.dependencies.client.request(endpoints.taskClose, {
|
|
@@ -2125,11 +2371,19 @@ export class BridgeService {
|
|
|
2125
2371
|
repoFingerprint: repository.repoFingerprint,
|
|
2126
2372
|
repoRoot: repository.repoRoot,
|
|
2127
2373
|
diffHash: String(body.diffHash),
|
|
2374
|
+
branch: typeof closedTask.branch === 'string'
|
|
2375
|
+
? closedTask.branch
|
|
2376
|
+
: typeof body.branch === 'string'
|
|
2377
|
+
? body.branch
|
|
2378
|
+
: null,
|
|
2128
2379
|
taskClosed: true,
|
|
2129
2380
|
taskVersion: closedTaskVersion,
|
|
2130
2381
|
taskChanges,
|
|
2131
2382
|
});
|
|
2132
2383
|
await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
|
|
2384
|
+
await this.dependencies.repositories.git
|
|
2385
|
+
.branchStore(repository.repoRoot)
|
|
2386
|
+
.release(String(body.taskId));
|
|
2133
2387
|
return asJsonValue({
|
|
2134
2388
|
...closedTask,
|
|
2135
2389
|
repository: publicRepository(repository),
|
|
@@ -2363,6 +2617,7 @@ function publicRepository(repository) {
|
|
|
2363
2617
|
return asJsonValue({
|
|
2364
2618
|
repoRoot: repository.repoRoot,
|
|
2365
2619
|
markerPath: repository.markerPath,
|
|
2620
|
+
bindingPath: repository.bindingPath,
|
|
2366
2621
|
projectId: repository.projectId,
|
|
2367
2622
|
schemaVersion: repository.schemaVersion,
|
|
2368
2623
|
repoFingerprint: repository.repoFingerprint,
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { NativeCommandRunner } from '../utilities/process.js';
|
|
2
|
+
import { assertSafeToPersist } from './offline-outbox.js';
|
|
3
|
+
import { BridgeRecoveryError } from './recovery-error.js';
|
|
4
|
+
const decisionRef = 'refs/worktree/engineering-memory-task';
|
|
5
|
+
export class TaskBranchStore {
|
|
6
|
+
repoRoot;
|
|
7
|
+
runner;
|
|
8
|
+
constructor(repoRoot, runner = new NativeCommandRunner()) {
|
|
9
|
+
this.repoRoot = repoRoot;
|
|
10
|
+
this.runner = runner;
|
|
11
|
+
}
|
|
12
|
+
async read() {
|
|
13
|
+
const reference = await this.runner.run('git', ['rev-parse', '--verify', '--quiet', decisionRef], { cwd: this.repoRoot });
|
|
14
|
+
if (reference.exitCode === 1)
|
|
15
|
+
return null;
|
|
16
|
+
if (reference.exitCode !== 0)
|
|
17
|
+
throw new BridgeRecoveryError('Git could not read the worktree task reservation. Retry the branch operation.', 'task.branch');
|
|
18
|
+
const oid = reference.stdout.trim();
|
|
19
|
+
const blob = await this.runner.run('git', ['cat-file', 'blob', oid], { cwd: this.repoRoot });
|
|
20
|
+
if (blob.exitCode !== 0)
|
|
21
|
+
throw new BridgeRecoveryError('Git could not read the recorded branch decision. Retry the branch operation.', 'task.branch');
|
|
22
|
+
let decision;
|
|
23
|
+
try {
|
|
24
|
+
decision = JSON.parse(blob.stdout);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new BridgeRecoveryError('The worktree branch record is invalid. Use a separate worktree to continue this task.', 'task.branch');
|
|
28
|
+
}
|
|
29
|
+
if (!decision)
|
|
30
|
+
throw new BridgeRecoveryError('The worktree branch record is invalid. Use a separate worktree to continue this task.', 'task.branch');
|
|
31
|
+
if (typeof decision.projectId !== 'string' ||
|
|
32
|
+
typeof decision.externalTaskId !== 'string' ||
|
|
33
|
+
!(decision.branch === null || typeof decision.branch === 'string')) {
|
|
34
|
+
throw new BridgeRecoveryError('The worktree branch record is invalid. Use a separate worktree to continue this task.', 'task.branch');
|
|
35
|
+
}
|
|
36
|
+
return { oid, decision };
|
|
37
|
+
}
|
|
38
|
+
async reserve(decision) {
|
|
39
|
+
assertSafeToPersist({ ...decision });
|
|
40
|
+
const existing = await this.read();
|
|
41
|
+
if (existing) {
|
|
42
|
+
this.assertSame(existing.decision, decision);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const oid = await this.hash(decision);
|
|
46
|
+
const written = await this.runner.run('git', ['update-ref', '--no-deref', decisionRef, oid, '0'.repeat(oid.length)], { cwd: this.repoRoot });
|
|
47
|
+
if (written.exitCode !== 0) {
|
|
48
|
+
const winner = await this.read();
|
|
49
|
+
if (winner) {
|
|
50
|
+
this.assertSame(winner.decision, decision);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
throw new BridgeRecoveryError('Another branch operation is in progress. Retry the same task.branch operation.', 'task.branch');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async bind(projectId, externalTaskId, taskId) {
|
|
57
|
+
const current = await this.read();
|
|
58
|
+
if (!current ||
|
|
59
|
+
current.decision.projectId !== projectId ||
|
|
60
|
+
current.decision.externalTaskId !== externalTaskId)
|
|
61
|
+
throw new BridgeRecoveryError('The worktree reservation changed while opening the task. Resume it before editing.', 'session.resume');
|
|
62
|
+
if (current.decision.taskId === taskId)
|
|
63
|
+
return;
|
|
64
|
+
const oid = await this.hash({ ...current.decision, taskId });
|
|
65
|
+
const result = await this.runner.run('git', ['update-ref', '--no-deref', decisionRef, oid, current.oid], { cwd: this.repoRoot });
|
|
66
|
+
if (result.exitCode !== 0) {
|
|
67
|
+
const latest = await this.read();
|
|
68
|
+
if (latest?.decision.taskId !== taskId)
|
|
69
|
+
throw new BridgeRecoveryError('The worktree reservation changed while opening the task. Resume it before editing.', 'session.resume');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async cancelUnopened(projectId, externalTaskId) {
|
|
73
|
+
const current = await this.read();
|
|
74
|
+
if (!current ||
|
|
75
|
+
current.decision.taskId ||
|
|
76
|
+
current.decision.projectId !== projectId ||
|
|
77
|
+
current.decision.externalTaskId !== externalTaskId)
|
|
78
|
+
return;
|
|
79
|
+
const result = await this.runner.run('git', ['update-ref', '--no-deref', '-d', decisionRef, current.oid], { cwd: this.repoRoot });
|
|
80
|
+
if (result.exitCode !== 0 && (await this.read())?.oid === current.oid)
|
|
81
|
+
throw new BridgeRecoveryError('The failed branch operation could not release its reservation. Retry task.branch with the same task identifier.', 'task.branch');
|
|
82
|
+
}
|
|
83
|
+
async release(taskId) {
|
|
84
|
+
const current = await this.read();
|
|
85
|
+
if (!current || current.decision.taskId !== taskId)
|
|
86
|
+
return;
|
|
87
|
+
const result = await this.runner.run('git', ['update-ref', '--no-deref', '-d', decisionRef, current.oid], { cwd: this.repoRoot });
|
|
88
|
+
if (result.exitCode !== 0 && (await this.read())?.decision.taskId === taskId) {
|
|
89
|
+
throw new BridgeRecoveryError('The task has finished but its worktree reservation could not be released. Resume the task to retry cleanup.', 'session.resume');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
assertSame(current, requested) {
|
|
93
|
+
if (current.projectId !== requested.projectId ||
|
|
94
|
+
current.externalTaskId !== requested.externalTaskId) {
|
|
95
|
+
throw new BridgeRecoveryError(`This worktree belongs to task ${current.externalTaskId}. Resume that task, or call task.branch with worktreePath and a branch name for the new task.`, 'task.branch');
|
|
96
|
+
}
|
|
97
|
+
if (current.branch !== requested.branch) {
|
|
98
|
+
throw new BridgeRecoveryError(`This task selected ${current.branch ?? 'detached HEAD'}. Return to that branch before continuing.`, 'task.branch');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async hash(decision) {
|
|
102
|
+
const result = await this.runner.run('git', ['hash-object', '-w', '--stdin'], {
|
|
103
|
+
cwd: this.repoRoot,
|
|
104
|
+
input: JSON.stringify(decision),
|
|
105
|
+
});
|
|
106
|
+
if (result.exitCode !== 0)
|
|
107
|
+
throw new BridgeRecoveryError('Git could not save the branch decision. Retry the same branch operation.', 'task.branch');
|
|
108
|
+
return result.stdout.trim();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=task-branch-store.js.map
|
package/skill/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: engineering-memory
|
|
3
|
-
description: Enforces the Engineering Memory lifecycle for Codex and Claude in repositories bound
|
|
3
|
+
description: Enforces the Engineering Memory lifecycle for Codex and Claude in repositories bound in user-local Engineering Memory state. Use before answering anything about a bound repository, including a question about what it has or where its design lives, and for every planning, implementation, review, diagnosis, rework, Figma, component, screen, service, model, navigation, localization, storage, validation, or Git task in one.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Engineering Memory
|
|
@@ -11,7 +11,9 @@ Read [lifecycle.md](references/lifecycle.md) before acting in a bound repository
|
|
|
11
11
|
|
|
12
12
|
Mandatory behavior:
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Use the host's native questionnaire for every question to the user, including implementation choices, names, clarification, branch/worktree decisions and delivery. In Codex use request_user_input when available; in Claude use AskUserQuestion. Never replace the questionnaire with a chat instruction such as 'type this', 'reply yes', or 'write X if you want Y'. Do not open a survey web page. If the required native control is unavailable or prohibited for that kind of question, follow the host's tool restrictions, explain the limitation, and continue only work already authorized; do not fabricate a survey or silently choose an answer. Existing answers remain valid through retries and handoffs.
|
|
15
|
+
|
|
16
|
+
1. Discover the repository binding through `session.entry`. Bindings live in the user-level Engineering Memory state directory, outside the repository and installed runtime. No project settings file is required. For a bound repository, the project's knowledge is in the backend, so answer nothing about it before bootstrapping; the absence of local design files or records says nothing about its stored knowledge.
|
|
15
17
|
2. Call `session.entry` before answering anything in a repository, and act on what it reports before the message itself: sign in when it says so, ask for organization and project when nothing has been decided, and stay completely silent about Engineering Memory in a repository where the user switched it off. Record every one of those answers with `session.set_decision`, and only ever from something the user actually said.
|
|
16
18
|
3. Before planning or editing, call `session.bootstrap`. After compaction, a new chat, interruption, or handoff, call `session.resume` first.
|
|
17
19
|
4. Open the task in `read_only` mode for review, diagnosis, planning, or reporting that does not authorize writes; use `scaffold` mode only to apply organization architecture templates to a new project; otherwise use `write` mode. Do read-only discovery, then record the discovery checkpoint.
|
|
@@ -25,6 +27,6 @@ Mandatory behavior:
|
|
|
25
27
|
12. Never commit, push, publish, deploy, approve a permanent memory revision, or overwrite an existing Git hook without explicit user authorization.
|
|
26
28
|
13. Never store tokens, passwords, client secrets, raw headers, raw payloads, customer data, or PII in tool inputs, journals, memory, logs, or generated documentation.
|
|
27
29
|
|
|
28
|
-
If the repository is unbound, do not silently create or attach a project. Use the native questionnaire workflow. If the user selects task-only skip, do not create a
|
|
30
|
+
If the repository is unbound, do not silently create or attach a project. Use the native questionnaire workflow. If the user selects task-only skip, do not create a binding or memory records.
|
|
29
31
|
|
|
30
32
|
If the backend is unavailable, use only an unexpired bridge-provided cached context. Checkpoints may enter the local outbox, but verification, task close, and commit remain blocked until synchronization succeeds.
|