engineering-memory 1.7.0 → 1.9.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 +2 -0
- package/install/commands.mjs +39 -10
- package/package.json +1 -1
- package/runtime/dist/src/config.js +9 -1
- package/runtime/dist/src/git/git-inspector.js +140 -17
- package/runtime/dist/src/git/pre-commit.js +16 -0
- package/runtime/dist/src/git/verification-gate.js +14 -1
- package/runtime/dist/src/mcp/tool-definitions.js +101 -5
- package/runtime/dist/src/project/repository.js +1 -1
- package/runtime/dist/src/runtime/active-context-store.js +13 -7
- package/runtime/dist/src/runtime/api-client.js +62 -12
- package/runtime/dist/src/runtime/bridge-service.js +357 -21
- package/runtime/dist/src/runtime/principal-state.js +3 -2
- package/runtime/dist/src/runtime/recovery-error.js +9 -0
- package/runtime/dist/src/runtime/task-branch-store.js +111 -0
- package/skill/SKILL.md +2 -0
- package/skill/references/lifecycle.md +15 -9
- package/skill/references/memory-updates.md +3 -1
- package/skill/references/questionnaires.md +6 -4
- package/skill/references/scaffolding.md +9 -8
- package/bin/entry-point.test.mjs +0 -29
|
@@ -3,9 +3,11 @@ import { readFile } from 'node:fs/promises';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { minimatch } from 'minimatch';
|
|
5
5
|
import { endpoints } from '../config.js';
|
|
6
|
+
import { RepositoryResolver } from '../project/repository.js';
|
|
6
7
|
import { sha256, stableStringify } from '../utilities/hash.js';
|
|
7
8
|
import { ApiResponseError, BackendUnavailableError, } from './api-client.js';
|
|
8
9
|
import { assertSafeToPersist, normalizeRepositoryPaths } from './offline-outbox.js';
|
|
10
|
+
import { BridgeRecoveryError } from './recovery-error.js';
|
|
9
11
|
export const validationIds = [
|
|
10
12
|
'format',
|
|
11
13
|
'static_analysis',
|
|
@@ -54,6 +56,7 @@ export class BridgeService {
|
|
|
54
56
|
objective: input.objective,
|
|
55
57
|
taskKind: input.taskKind,
|
|
56
58
|
workItemKey: input.workItemKey,
|
|
59
|
+
workItemId: input.workItemId,
|
|
57
60
|
mode: input.mode,
|
|
58
61
|
knownRevisions: input.knownRevisions,
|
|
59
62
|
}, repository.repoRoot);
|
|
@@ -71,19 +74,28 @@ export class BridgeService {
|
|
|
71
74
|
body: { repoFingerprint: repository.repoFingerprint },
|
|
72
75
|
});
|
|
73
76
|
const project = objectValue(resolved.data);
|
|
74
|
-
const
|
|
77
|
+
const restoreRequired = Boolean(project?.archivedAt);
|
|
78
|
+
const restoreGuidance = archivedProjectGuidance(project);
|
|
79
|
+
const projects = await this.dependencies.client.request(restoreRequired ? `${endpoints.projectList}?includeArchived=true` : endpoints.projectList);
|
|
75
80
|
return asJsonValue({
|
|
76
|
-
projectBindingRequired:
|
|
81
|
+
projectBindingRequired: !restoreRequired,
|
|
82
|
+
restoreRequired,
|
|
77
83
|
candidate: project,
|
|
78
84
|
requestedProjectId: projectId,
|
|
79
85
|
repository: publicRepository(repository),
|
|
80
86
|
projects: projects.data,
|
|
81
87
|
outbox: { recoveredDeliveries, ...objectOrEmpty(outbox) },
|
|
88
|
+
...(restoreRequired ? restoreGuidance : {}),
|
|
82
89
|
});
|
|
83
90
|
}
|
|
84
91
|
if (!projectId) {
|
|
85
92
|
throw refuse('This repository has a marker that names no project, so there is nothing to bootstrap against.', 'project.resolve');
|
|
86
93
|
}
|
|
94
|
+
const bootstrapMode = persistedBootstrap.mode ?? 'write';
|
|
95
|
+
const decision = bootstrapMode === 'read_only'
|
|
96
|
+
? null
|
|
97
|
+
: await this.requireBranchDecision(repository, input.externalTaskId);
|
|
98
|
+
const branch = decision?.branch;
|
|
87
99
|
const checkpointId = deterministicUuid('session.bootstrap', projectId, repository.repoFingerprint, input.externalTaskId);
|
|
88
100
|
const response = await this.dependencies.client.request(endpoints.sessionBootstrap, {
|
|
89
101
|
method: 'POST',
|
|
@@ -94,11 +106,13 @@ export class BridgeService {
|
|
|
94
106
|
objective: persistedBootstrap.objective,
|
|
95
107
|
taskKind: persistedBootstrap.taskKind,
|
|
96
108
|
workItemKey: persistedBootstrap.workItemKey,
|
|
109
|
+
workItemId: persistedBootstrap.workItemId,
|
|
97
110
|
mode: persistedBootstrap.mode ?? 'write',
|
|
98
111
|
repoFingerprint: repository.repoFingerprint,
|
|
99
112
|
...(input.mode === 'read_only'
|
|
100
113
|
? { baselineDiffHash: repository.git.worktreeHash }
|
|
101
114
|
: {}),
|
|
115
|
+
...(decision ? { branch } : {}),
|
|
102
116
|
checkpointIdempotencyKey: checkpointId,
|
|
103
117
|
knownRevisions: persistedBootstrap.knownRevisions,
|
|
104
118
|
}),
|
|
@@ -120,11 +134,20 @@ export class BridgeService {
|
|
|
120
134
|
!isIsoTimestamp(checkpointPayload.createdAt)) {
|
|
121
135
|
throw refuse('The backend opened the session without the checkpoint the task is anchored to.', 'session.bootstrap');
|
|
122
136
|
}
|
|
137
|
+
if (decision)
|
|
138
|
+
await this.dependencies.repositories.git
|
|
139
|
+
.branchStore(repository.repoRoot)
|
|
140
|
+
.bind(projectId, input.externalTaskId, task.id);
|
|
123
141
|
const pointer = {
|
|
124
142
|
repoFingerprint: repository.repoFingerprint,
|
|
125
143
|
projectId,
|
|
126
144
|
taskId: task.id,
|
|
127
145
|
taskSlug: input.externalTaskId,
|
|
146
|
+
worktreeId: sha256(repository.repoRoot),
|
|
147
|
+
...(decision
|
|
148
|
+
? { branch: typeof task.branch === 'string' ? task.branch : decision.branch }
|
|
149
|
+
: {}),
|
|
150
|
+
mode: normalizeTaskMode(task.mode),
|
|
128
151
|
sessionId: session.id,
|
|
129
152
|
lastSequence: numericSequence(task.lastSequence),
|
|
130
153
|
taskVersion: numericTaskVersion(task.lockVersion),
|
|
@@ -249,6 +272,12 @@ export class BridgeService {
|
|
|
249
272
|
backendSession.id !== sessionId) {
|
|
250
273
|
throw refuse('The backend resumed the session without the task it belongs to.', 'session.resume');
|
|
251
274
|
}
|
|
275
|
+
await this.assertTaskWorktree(repository, backendTask, pointer);
|
|
276
|
+
if (backendTask.status === 'closed' || backendTask.status === 'abandoned') {
|
|
277
|
+
await this.dependencies.repositories.git
|
|
278
|
+
.branchStore(repository.repoRoot)
|
|
279
|
+
.release(backendTask.id);
|
|
280
|
+
}
|
|
252
281
|
let localJournal = await this.dependencies.journal.load(projectId, taskSlug);
|
|
253
282
|
let pendingOutbox = await this.dependencies.outbox.list();
|
|
254
283
|
const responseSource = this.dependencies.client.getResponseSource(response);
|
|
@@ -256,7 +285,7 @@ export class BridgeService {
|
|
|
256
285
|
? await this.retryVerificationRecovery(repository, pointer, backend)
|
|
257
286
|
: null;
|
|
258
287
|
if (recoveredVerification) {
|
|
259
|
-
pointer = await this.dependencies.activeContexts.
|
|
288
|
+
pointer = await this.dependencies.activeContexts.loadForTask(repository.repoFingerprint, pointer.taskId);
|
|
260
289
|
}
|
|
261
290
|
let hydratedFromBackend = false;
|
|
262
291
|
let mergedFromBackend = false;
|
|
@@ -358,6 +387,13 @@ export class BridgeService {
|
|
|
358
387
|
projectId,
|
|
359
388
|
taskId: backendTask.id,
|
|
360
389
|
taskSlug,
|
|
390
|
+
worktreeId: pointer?.worktreeId ?? sha256(repository.repoRoot),
|
|
391
|
+
...(typeof backendTask.branch === 'string'
|
|
392
|
+
? { branch: backendTask.branch }
|
|
393
|
+
: pointer?.branch !== undefined
|
|
394
|
+
? { branch: pointer.branch }
|
|
395
|
+
: {}),
|
|
396
|
+
mode: normalizeTaskMode(backendTask.mode),
|
|
361
397
|
sessionId,
|
|
362
398
|
lastSequence: resumedLastSequence,
|
|
363
399
|
taskVersion: resumedTaskVersion,
|
|
@@ -405,7 +441,7 @@ export class BridgeService {
|
|
|
405
441
|
await this.recoverJournalOutbox();
|
|
406
442
|
const outbox = await this.flushOutbox();
|
|
407
443
|
const pendingOutbox = await this.dependencies.outbox.list();
|
|
408
|
-
const pointer = await this.dependencies.activeContexts.
|
|
444
|
+
const pointer = await this.dependencies.activeContexts.loadForSession(repository.repoFingerprint, input.sessionId);
|
|
409
445
|
if (pointer?.verificationIntent || pointer?.closeIntent) {
|
|
410
446
|
throw refuse('A verification or close intent from an earlier session is still unsettled, so a new lease would be taken against the wrong state.', 'session.resume');
|
|
411
447
|
}
|
|
@@ -436,6 +472,10 @@ export class BridgeService {
|
|
|
436
472
|
...input.changedPaths,
|
|
437
473
|
]);
|
|
438
474
|
const baselineDiffHash = activeBaseline?.diffHash ?? repository.git.diffHash;
|
|
475
|
+
const decision = pointer &&
|
|
476
|
+
(input.transitionToWrite || (pointer.branch !== undefined && pointer.mode !== 'read_only'))
|
|
477
|
+
? await this.requireBranchDecision(repository, pointer.taskSlug)
|
|
478
|
+
: null;
|
|
439
479
|
let transitionTaskVersion;
|
|
440
480
|
if (input.transitionToWrite) {
|
|
441
481
|
if (!pointer || pointer.sessionId !== input.sessionId) {
|
|
@@ -448,6 +488,7 @@ export class BridgeService {
|
|
|
448
488
|
sessionId: input.sessionId,
|
|
449
489
|
changedPaths,
|
|
450
490
|
baselineDiffHash,
|
|
491
|
+
...(decision ? { branch: decision.branch } : {}),
|
|
451
492
|
...(input.transitionToWrite
|
|
452
493
|
? { transitionToWrite: true, expectedTaskVersion: transitionTaskVersion }
|
|
453
494
|
: {}),
|
|
@@ -492,8 +533,19 @@ export class BridgeService {
|
|
|
492
533
|
lastSequence: numericSequence(preparedTask.lastSequence),
|
|
493
534
|
}
|
|
494
535
|
: undefined);
|
|
495
|
-
const preparedPointer = await this.dependencies.activeContexts.
|
|
536
|
+
const preparedPointer = await this.dependencies.activeContexts.loadForSession(repository.repoFingerprint, input.sessionId);
|
|
496
537
|
if (preparedPointer) {
|
|
538
|
+
if (decision) {
|
|
539
|
+
await this.dependencies.repositories.git
|
|
540
|
+
.branchStore(repository.repoRoot)
|
|
541
|
+
.bind(preparedPointer.projectId, preparedPointer.taskSlug, preparedPointer.taskId);
|
|
542
|
+
await this.dependencies.activeContexts.save({
|
|
543
|
+
...preparedPointer,
|
|
544
|
+
worktreeId: sha256(repository.repoRoot),
|
|
545
|
+
branch: typeof preparedTask?.branch === 'string' ? preparedTask.branch : decision.branch,
|
|
546
|
+
mode: 'write',
|
|
547
|
+
});
|
|
548
|
+
}
|
|
497
549
|
const preparedData = objectValue(response.data);
|
|
498
550
|
await this.seedResumeSnapshot(preparedPointer, {
|
|
499
551
|
...objectOrEmpty(response.data),
|
|
@@ -1003,6 +1055,7 @@ export class BridgeService {
|
|
|
1003
1055
|
});
|
|
1004
1056
|
}
|
|
1005
1057
|
const taskMode = normalizeTaskMode(snapshotTask.mode);
|
|
1058
|
+
await this.assertTaskWorktree(repository, snapshotTask, pointer);
|
|
1006
1059
|
const resourceDiscoveryPolicy = readResourceDiscoveryPolicy(snapshot, activeLease);
|
|
1007
1060
|
const validations = normalizePersistentInput(input.validations, repository.repoRoot);
|
|
1008
1061
|
if (taskMode === 'read_only') {
|
|
@@ -1065,6 +1118,11 @@ export class BridgeService {
|
|
|
1065
1118
|
changedPaths: taskMode === 'read_only' ? [] : taskChangedPaths,
|
|
1066
1119
|
pathChanges: pathChangeManifest,
|
|
1067
1120
|
diffHash: repository.git.diffHash,
|
|
1121
|
+
...(taskMode !== 'read_only'
|
|
1122
|
+
? {
|
|
1123
|
+
branch: await this.dependencies.repositories.git.currentBranch(repository.repoRoot),
|
|
1124
|
+
}
|
|
1125
|
+
: {}),
|
|
1068
1126
|
...(taskMode === 'read_only' ? { worktreeHash: repository.git.worktreeHash } : {}),
|
|
1069
1127
|
validations,
|
|
1070
1128
|
pendingOutboxCount,
|
|
@@ -1098,6 +1156,9 @@ export class BridgeService {
|
|
|
1098
1156
|
repoFingerprint: repository.repoFingerprint,
|
|
1099
1157
|
repoRoot: repository.repoRoot,
|
|
1100
1158
|
diffHash: repository.git.diffHash,
|
|
1159
|
+
branch: typeof snapshotTask.branch === 'string'
|
|
1160
|
+
? snapshotTask.branch
|
|
1161
|
+
: (pointer.branch ?? null),
|
|
1101
1162
|
taskVersion,
|
|
1102
1163
|
taskChanges,
|
|
1103
1164
|
});
|
|
@@ -1141,11 +1202,17 @@ export class BridgeService {
|
|
|
1141
1202
|
const taskChanges = pointer.changeBaseline
|
|
1142
1203
|
? manifestDelta(pointer.changeBaseline.changedPaths, repository.git.changedPaths)
|
|
1143
1204
|
: [];
|
|
1205
|
+
await this.assertTaskWorktree(repository, task, pointer);
|
|
1144
1206
|
const closeBody = {
|
|
1145
1207
|
taskId: input.taskId,
|
|
1146
1208
|
sessionId: pointer.sessionId,
|
|
1147
1209
|
expectedTaskVersion: numericTaskVersion(task.lockVersion),
|
|
1148
1210
|
diffHash: repository.git.diffHash,
|
|
1211
|
+
...(normalizeTaskMode(task.mode) !== 'read_only'
|
|
1212
|
+
? {
|
|
1213
|
+
branch: await this.dependencies.repositories.git.currentBranch(repository.repoRoot),
|
|
1214
|
+
}
|
|
1215
|
+
: {}),
|
|
1149
1216
|
};
|
|
1150
1217
|
await this.dependencies.activeContexts.setCloseIntent(repository.repoFingerprint, {
|
|
1151
1218
|
body: closeBody,
|
|
@@ -1155,6 +1222,111 @@ export class BridgeService {
|
|
|
1155
1222
|
});
|
|
1156
1223
|
});
|
|
1157
1224
|
}
|
|
1225
|
+
async taskBranch(input) {
|
|
1226
|
+
return await this.execute(async () => {
|
|
1227
|
+
const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
|
|
1228
|
+
const authentication = await this.dependencies.browserAuth.ensureAuthenticated();
|
|
1229
|
+
if (authentication)
|
|
1230
|
+
return asJsonValue({ authentication });
|
|
1231
|
+
if (!repository.projectId)
|
|
1232
|
+
throw refuse('Resolve the project before choosing its task branch.', 'project.resolve');
|
|
1233
|
+
if (!input.externalTaskId ||
|
|
1234
|
+
input.externalTaskId.length > 160 ||
|
|
1235
|
+
/[\r\n]/.test(input.externalTaskId))
|
|
1236
|
+
throw refuse('Pass the exact externalTaskId of the task whose branch question was answered.', 'task.branch');
|
|
1237
|
+
if (Boolean(input.name) === (input.keepCurrent === true) ||
|
|
1238
|
+
(input.worktreePath && !input.name))
|
|
1239
|
+
throw refuse('Choose exactly one branch name or keepCurrent. A separate worktree also needs a branch name.', 'task.branch');
|
|
1240
|
+
assertSafeToPersist(cleanJson({ externalTaskId: input.externalTaskId, name: input.name }));
|
|
1241
|
+
const git = this.dependencies.repositories.git;
|
|
1242
|
+
let target = repository;
|
|
1243
|
+
try {
|
|
1244
|
+
if (input.name)
|
|
1245
|
+
await git.validateBranch(repository.repoRoot, input.name);
|
|
1246
|
+
if (input.worktreePath && input.name) {
|
|
1247
|
+
const targetRoot = await git.createWorktree(repository.repoRoot, input.name, input.worktreePath);
|
|
1248
|
+
target = await this.dependencies.repositories.resolve(targetRoot);
|
|
1249
|
+
if (target.projectId && target.projectId !== repository.projectId)
|
|
1250
|
+
throw refuse('The target worktree belongs to a different Engineering Memory project. Choose another worktreePath.', 'task.branch');
|
|
1251
|
+
if (!target.markerPath) {
|
|
1252
|
+
await new RepositoryResolver(git, repository.schemaVersion ?? 2).writeMarker(targetRoot, repository.projectId);
|
|
1253
|
+
target = await this.dependencies.repositories.resolve(targetRoot);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
const branch = input.name ?? (await git.currentBranch(target.repoRoot));
|
|
1257
|
+
const store = git.branchStore(target.repoRoot);
|
|
1258
|
+
await store.reserve({
|
|
1259
|
+
projectId: repository.projectId,
|
|
1260
|
+
externalTaskId: input.externalTaskId,
|
|
1261
|
+
branch,
|
|
1262
|
+
});
|
|
1263
|
+
if (input.name && (await git.currentBranch(target.repoRoot)) !== input.name) {
|
|
1264
|
+
try {
|
|
1265
|
+
if (await git.branchExists(target.repoRoot, input.name))
|
|
1266
|
+
await git.switchBranch(target.repoRoot, input.name);
|
|
1267
|
+
else
|
|
1268
|
+
await git.createBranch(target.repoRoot, input.name);
|
|
1269
|
+
}
|
|
1270
|
+
catch (error) {
|
|
1271
|
+
if ((await git.currentBranch(target.repoRoot)) !== input.name) {
|
|
1272
|
+
await store.cancelUnopened(repository.projectId, input.externalTaskId);
|
|
1273
|
+
throw error;
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
return asJsonValue({
|
|
1278
|
+
branch,
|
|
1279
|
+
kept: input.keepCurrent === true,
|
|
1280
|
+
repoRoot: target.repoRoot,
|
|
1281
|
+
externalTaskId: input.externalTaskId,
|
|
1282
|
+
nextAction: 'Use this repoRoot for session.bootstrap and every subsequent command for this task. The decision survives retries and restarts.',
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
catch (error) {
|
|
1286
|
+
if (error instanceof BridgeRecoveryError)
|
|
1287
|
+
throw error;
|
|
1288
|
+
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');
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
async requireBranchDecision(repository, externalTaskId) {
|
|
1293
|
+
const git = this.dependencies.repositories.git;
|
|
1294
|
+
const store = git.branchStore(repository.repoRoot);
|
|
1295
|
+
let record = await store.read();
|
|
1296
|
+
if (!record) {
|
|
1297
|
+
const pointer = await this.dependencies.activeContexts.loadForSlug(repository.repoFingerprint, externalTaskId);
|
|
1298
|
+
if (pointer?.worktreeId === sha256(repository.repoRoot) &&
|
|
1299
|
+
pointer.mode !== 'read_only' &&
|
|
1300
|
+
pointer.branch !== undefined) {
|
|
1301
|
+
await store.reserve({
|
|
1302
|
+
projectId: pointer.projectId,
|
|
1303
|
+
externalTaskId,
|
|
1304
|
+
branch: pointer.branch,
|
|
1305
|
+
taskId: pointer.taskId,
|
|
1306
|
+
});
|
|
1307
|
+
record = await store.read();
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
if (!record)
|
|
1311
|
+
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');
|
|
1312
|
+
if (record.decision.projectId !== repository.projectId ||
|
|
1313
|
+
record.decision.externalTaskId !== externalTaskId)
|
|
1314
|
+
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');
|
|
1315
|
+
const branch = await git.currentBranch(repository.repoRoot);
|
|
1316
|
+
if (branch !== record.decision.branch)
|
|
1317
|
+
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');
|
|
1318
|
+
return record.decision;
|
|
1319
|
+
}
|
|
1320
|
+
async assertTaskWorktree(repository, task, pointer) {
|
|
1321
|
+
if (normalizeTaskMode(task.mode) === 'read_only')
|
|
1322
|
+
return;
|
|
1323
|
+
if (pointer?.worktreeId && pointer.worktreeId !== sha256(repository.repoRoot))
|
|
1324
|
+
throw refuse('This task belongs to a different worktree. Resume and run its commands in the worktree selected for this task.', 'session.resume');
|
|
1325
|
+
const recorded = typeof task.branch === 'string' ? task.branch : pointer?.branch;
|
|
1326
|
+
const current = await this.dependencies.repositories.git.currentBranch(repository.repoRoot);
|
|
1327
|
+
if (recorded && current !== recorded)
|
|
1328
|
+
throw refuse(`This task selected ${recorded}. Call task.branch with externalTaskId ${pointer?.taskSlug ?? String(task.externalTaskId)} and name ${recorded} before continuing.`, 'task.branch');
|
|
1329
|
+
}
|
|
1158
1330
|
async taskAbandon(input) {
|
|
1159
1331
|
return await this.execute(async () => {
|
|
1160
1332
|
return await this.taskExclusive(input.taskId, async () => {
|
|
@@ -1167,6 +1339,9 @@ export class BridgeService {
|
|
|
1167
1339
|
if (!abandoned || abandoned.abandoned !== true) {
|
|
1168
1340
|
throw refuse('The backend did not record the abandonment, so the task is still open.', 'session.resume');
|
|
1169
1341
|
}
|
|
1342
|
+
await this.dependencies.repositories.git
|
|
1343
|
+
.branchStore(repository.repoRoot)
|
|
1344
|
+
.release(input.taskId);
|
|
1170
1345
|
const forgotten = await this.dependencies.activeContexts.forget(repository.repoFingerprint, input.taskId);
|
|
1171
1346
|
await this.dependencies.gate.invalidateTask(input.taskId);
|
|
1172
1347
|
return asJsonValue({
|
|
@@ -1224,11 +1399,59 @@ export class BridgeService {
|
|
|
1224
1399
|
return await this.execute(async () => {
|
|
1225
1400
|
const response = await this.dependencies.client.request(endpoints.projectMemberAdd(input.projectId), {
|
|
1226
1401
|
method: 'POST',
|
|
1227
|
-
body: {
|
|
1402
|
+
body: cleanJson({
|
|
1403
|
+
email: input.email,
|
|
1404
|
+
role: input.role,
|
|
1405
|
+
...(input.discipline !== undefined ? { discipline: input.discipline } : {}),
|
|
1406
|
+
}),
|
|
1228
1407
|
});
|
|
1229
1408
|
return asJsonValue({ membership: response.data });
|
|
1230
1409
|
});
|
|
1231
1410
|
}
|
|
1411
|
+
async projectMemberList(input) {
|
|
1412
|
+
return await this.execute(async () => {
|
|
1413
|
+
const response = await this.dependencies.client.request(endpoints.projectMemberList(input.projectId));
|
|
1414
|
+
return asJsonValue({ members: response.data });
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
async auditList(input) {
|
|
1418
|
+
return await this.execute(async () => {
|
|
1419
|
+
const query = new URLSearchParams();
|
|
1420
|
+
if (input.organizationId) {
|
|
1421
|
+
query.set('organizationId', input.organizationId);
|
|
1422
|
+
}
|
|
1423
|
+
if (input.projectId)
|
|
1424
|
+
query.set('projectId', input.projectId);
|
|
1425
|
+
if (input.beforeCreatedAt) {
|
|
1426
|
+
query.set('beforeCreatedAt', input.beforeCreatedAt);
|
|
1427
|
+
}
|
|
1428
|
+
if (input.beforeId)
|
|
1429
|
+
query.set('beforeId', input.beforeId);
|
|
1430
|
+
if (input.limit !== undefined)
|
|
1431
|
+
query.set('limit', String(input.limit));
|
|
1432
|
+
const response = await this.dependencies.client.request(`${endpoints.auditList}?${query.toString()}`);
|
|
1433
|
+
return asJsonValue({ page: response.data });
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
async organizationMemberUpsert(input) {
|
|
1437
|
+
return await this.execute(async () => {
|
|
1438
|
+
const response = await this.dependencies.client.request(endpoints.organizationMemberUpsert(input.organizationId), {
|
|
1439
|
+
method: 'PUT',
|
|
1440
|
+
body: cleanJson({
|
|
1441
|
+
email: input.email,
|
|
1442
|
+
role: input.role,
|
|
1443
|
+
discipline: input.discipline,
|
|
1444
|
+
}),
|
|
1445
|
+
});
|
|
1446
|
+
return asJsonValue({ membership: response.data });
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
async organizationMemberList(input) {
|
|
1450
|
+
return await this.execute(async () => {
|
|
1451
|
+
const response = await this.dependencies.client.request(endpoints.organizationMemberList(input.organizationId));
|
|
1452
|
+
return asJsonValue({ members: response.data });
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1232
1455
|
async organizationList() {
|
|
1233
1456
|
return await this.execute(async () => {
|
|
1234
1457
|
const authentication = await this.dependencies.browserAuth.ensureAuthenticated();
|
|
@@ -1243,6 +1466,33 @@ export class BridgeService {
|
|
|
1243
1466
|
});
|
|
1244
1467
|
});
|
|
1245
1468
|
}
|
|
1469
|
+
async workItemList(input) {
|
|
1470
|
+
return await this.execute(async () => {
|
|
1471
|
+
const query = new URLSearchParams();
|
|
1472
|
+
if (input.status)
|
|
1473
|
+
query.set('status', input.status);
|
|
1474
|
+
if (input.priority)
|
|
1475
|
+
query.set('priority', input.priority);
|
|
1476
|
+
if (input.assigneeUserId) {
|
|
1477
|
+
query.set('assigneeUserId', input.assigneeUserId);
|
|
1478
|
+
}
|
|
1479
|
+
if (input.includeArchived)
|
|
1480
|
+
query.set('includeArchived', 'true');
|
|
1481
|
+
const suffix = query.size > 0 ? `?${query.toString()}` : '';
|
|
1482
|
+
const response = await this.dependencies.client.request(`${endpoints.workItemList(input.projectId)}${suffix}`);
|
|
1483
|
+
return asJsonValue({
|
|
1484
|
+
selectionRequired: true,
|
|
1485
|
+
questionnaire: 'Select the project work item to run in this chat',
|
|
1486
|
+
...objectOrEmpty(response.data),
|
|
1487
|
+
});
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
async workItemGet(input) {
|
|
1491
|
+
return await this.execute(async () => {
|
|
1492
|
+
const response = await this.dependencies.client.request(endpoints.workItemGet(input.projectId, input.workItemId));
|
|
1493
|
+
return asJsonValue({ workItem: response.data });
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1246
1496
|
async architecturePlan(input) {
|
|
1247
1497
|
return await this.execute(async () => {
|
|
1248
1498
|
const body = cleanJson(input);
|
|
@@ -1297,20 +1547,48 @@ export class BridgeService {
|
|
|
1297
1547
|
return asJsonValue({ organization: response.data });
|
|
1298
1548
|
});
|
|
1299
1549
|
}
|
|
1300
|
-
async projectList() {
|
|
1550
|
+
async projectList(input = {}) {
|
|
1301
1551
|
return await this.execute(async () => {
|
|
1302
1552
|
const authentication = await this.dependencies.browserAuth.ensureAuthenticated();
|
|
1303
1553
|
if (authentication) {
|
|
1304
1554
|
return asJsonValue({ authentication });
|
|
1305
1555
|
}
|
|
1306
|
-
const response = await this.dependencies.client.request(
|
|
1556
|
+
const response = await this.dependencies.client.request(input.includeArchived
|
|
1557
|
+
? `${endpoints.projectList}?includeArchived=true`
|
|
1558
|
+
: endpoints.projectList);
|
|
1307
1559
|
return asJsonValue({
|
|
1308
1560
|
selectionRequired: true,
|
|
1309
1561
|
questionnaire: 'Select an existing project or choose new project setup',
|
|
1310
1562
|
projects: response.data,
|
|
1563
|
+
...(input.includeArchived
|
|
1564
|
+
? {
|
|
1565
|
+
archivedProjectActions: (Array.isArray(response.data) ? response.data : [])
|
|
1566
|
+
.map((entry) => objectValue(entry))
|
|
1567
|
+
.filter((project) => Boolean(project?.archivedAt))
|
|
1568
|
+
.map((project) => archivedProjectGuidance(project)),
|
|
1569
|
+
}
|
|
1570
|
+
: {}),
|
|
1311
1571
|
});
|
|
1312
1572
|
});
|
|
1313
1573
|
}
|
|
1574
|
+
async projectArchive(input) {
|
|
1575
|
+
return await this.execute(async () => {
|
|
1576
|
+
const response = await this.dependencies.client.request(endpoints.projectArchive(input.projectId), {
|
|
1577
|
+
method: 'DELETE',
|
|
1578
|
+
body: { expectedVersion: input.expectedVersion },
|
|
1579
|
+
});
|
|
1580
|
+
return asJsonValue({ project: response.data, archived: true });
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1583
|
+
async projectRestore(input) {
|
|
1584
|
+
return await this.execute(async () => {
|
|
1585
|
+
const response = await this.dependencies.client.request(endpoints.projectRestore(input.projectId), {
|
|
1586
|
+
method: 'POST',
|
|
1587
|
+
body: { expectedVersion: input.expectedVersion },
|
|
1588
|
+
});
|
|
1589
|
+
return asJsonValue({ project: response.data, restored: true });
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1314
1592
|
async projectResolve(input) {
|
|
1315
1593
|
return await this.execute(async () => {
|
|
1316
1594
|
const authentication = await this.dependencies.browserAuth.ensureAuthenticated();
|
|
@@ -1355,12 +1633,16 @@ export class BridgeService {
|
|
|
1355
1633
|
body: { repoFingerprint: repository.repoFingerprint },
|
|
1356
1634
|
});
|
|
1357
1635
|
const project = objectValue(response.data);
|
|
1636
|
+
const restoreRequired = Boolean(project?.archivedAt);
|
|
1637
|
+
const restoreGuidance = archivedProjectGuidance(project);
|
|
1358
1638
|
return asJsonValue({
|
|
1359
|
-
resolved: project !== null,
|
|
1639
|
+
resolved: project !== null && !restoreRequired,
|
|
1360
1640
|
project,
|
|
1361
1641
|
markerPath: null,
|
|
1362
1642
|
repository: publicRepository(repository),
|
|
1363
1643
|
selectionRequired: project === null,
|
|
1644
|
+
restoreRequired,
|
|
1645
|
+
...(restoreRequired ? restoreGuidance : {}),
|
|
1364
1646
|
});
|
|
1365
1647
|
});
|
|
1366
1648
|
}
|
|
@@ -1391,21 +1673,50 @@ export class BridgeService {
|
|
|
1391
1673
|
const client = await this.clientUpdate(authenticated);
|
|
1392
1674
|
const shipped = objectValue(objectValue(client)?.shippedKnowledge);
|
|
1393
1675
|
const incomplete = shipped && (shipped.failure !== null || Number(shipped.live) < Number(shipped.expected));
|
|
1676
|
+
const projectId = repository.projectId ?? decision?.projectId ?? null;
|
|
1677
|
+
const liveTasks = authenticated && state === 'bound'
|
|
1678
|
+
? (await this.dependencies.activeContexts.list(repository.repoFingerprint)).map(describePointer)
|
|
1679
|
+
: [];
|
|
1680
|
+
const workItems = authenticated && state === 'bound' && projectId
|
|
1681
|
+
? await this.actionableWorkItems(projectId)
|
|
1682
|
+
: [];
|
|
1683
|
+
const taskSelectionRequired = liveTasks.length > 0 || workItems.length > 0;
|
|
1394
1684
|
return asJsonValue({
|
|
1395
1685
|
authenticated,
|
|
1396
1686
|
repository: publicRepository(repository),
|
|
1397
1687
|
decision: state,
|
|
1398
|
-
projectId
|
|
1688
|
+
projectId,
|
|
1689
|
+
liveTasks,
|
|
1690
|
+
workItems,
|
|
1691
|
+
taskSelectionRequired,
|
|
1399
1692
|
client,
|
|
1400
|
-
...(incomplete
|
|
1693
|
+
...(incomplete && state === 'bound'
|
|
1401
1694
|
? {
|
|
1402
1695
|
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.',
|
|
1403
1696
|
}
|
|
1404
1697
|
: {}),
|
|
1405
|
-
nextAction:
|
|
1698
|
+
nextAction: taskSelectionRequired
|
|
1699
|
+
? 'Present the live runs and actionable project work items, then ask which one this chat should handle. Resume only the run they choose, or bootstrap a separate run with the chosen workItemId. Another live task never blocks opening this one.'
|
|
1700
|
+
: entryNextAction(authenticated, state),
|
|
1406
1701
|
});
|
|
1407
1702
|
});
|
|
1408
1703
|
}
|
|
1704
|
+
async actionableWorkItems(projectId) {
|
|
1705
|
+
try {
|
|
1706
|
+
const response = await this.dependencies.client.request(`${endpoints.workItemList(projectId)}?limit=100`);
|
|
1707
|
+
const items = objectValue(response.data)?.items;
|
|
1708
|
+
if (!Array.isArray(items))
|
|
1709
|
+
return [];
|
|
1710
|
+
const actionable = new Set(['backlog', 'ready', 'in_progress', 'in_review']);
|
|
1711
|
+
return items.filter((entry) => {
|
|
1712
|
+
const item = objectValue(entry);
|
|
1713
|
+
return item ? actionable.has(String(item.status)) : false;
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
catch {
|
|
1717
|
+
return [];
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1409
1720
|
async clientUpdate(authenticated) {
|
|
1410
1721
|
const installed = this.dependencies.clientVersion;
|
|
1411
1722
|
if (!authenticated) {
|
|
@@ -1929,6 +2240,7 @@ export class BridgeService {
|
|
|
1929
2240
|
repoFingerprint: pointer.repoFingerprint,
|
|
1930
2241
|
repoRoot: repository.repoRoot,
|
|
1931
2242
|
diffHash: repository.git.diffHash,
|
|
2243
|
+
branch: typeof backendTask?.branch === 'string' ? backendTask.branch : (pointer.branch ?? null),
|
|
1932
2244
|
taskVersion,
|
|
1933
2245
|
taskChanges: intent.taskChanges,
|
|
1934
2246
|
});
|
|
@@ -1957,6 +2269,9 @@ export class BridgeService {
|
|
|
1957
2269
|
}
|
|
1958
2270
|
async deliverCloseIntent(repository, body, taskChanges, recoveredAfterResponseLoss) {
|
|
1959
2271
|
const touchedContract = taskChanges.some((change) => /\.controller\.[tj]s$/.test(change.path));
|
|
2272
|
+
const currentBranch = await this.dependencies.repositories.git.currentBranch(repository.repoRoot);
|
|
2273
|
+
if (typeof body.branch === 'string' && body.branch !== currentBranch)
|
|
2274
|
+
throw refuse(`Return to the branch ${body.branch} recorded in this close request before retrying.`, 'task.branch');
|
|
1960
2275
|
let response;
|
|
1961
2276
|
try {
|
|
1962
2277
|
response = await this.dependencies.client.request(endpoints.taskClose, {
|
|
@@ -1982,11 +2297,19 @@ export class BridgeService {
|
|
|
1982
2297
|
repoFingerprint: repository.repoFingerprint,
|
|
1983
2298
|
repoRoot: repository.repoRoot,
|
|
1984
2299
|
diffHash: String(body.diffHash),
|
|
2300
|
+
branch: typeof closedTask.branch === 'string'
|
|
2301
|
+
? closedTask.branch
|
|
2302
|
+
: typeof body.branch === 'string'
|
|
2303
|
+
? body.branch
|
|
2304
|
+
: null,
|
|
1985
2305
|
taskClosed: true,
|
|
1986
2306
|
taskVersion: closedTaskVersion,
|
|
1987
2307
|
taskChanges,
|
|
1988
2308
|
});
|
|
1989
2309
|
await this.dependencies.activeContexts.clearCloseIntent(repository.repoFingerprint, String(body.taskId));
|
|
2310
|
+
await this.dependencies.repositories.git
|
|
2311
|
+
.branchStore(repository.repoRoot)
|
|
2312
|
+
.release(String(body.taskId));
|
|
1990
2313
|
return asJsonValue({
|
|
1991
2314
|
...closedTask,
|
|
1992
2315
|
repository: publicRepository(repository),
|
|
@@ -2107,7 +2430,7 @@ export class BridgeService {
|
|
|
2107
2430
|
kind: 'bridge_error',
|
|
2108
2431
|
message: error instanceof Error ? error.message : 'Bridge operation failed',
|
|
2109
2432
|
retryable: false,
|
|
2110
|
-
...(error instanceof
|
|
2433
|
+
...(error instanceof BridgeRecoveryError ? { recovery: error.recovery } : {}),
|
|
2111
2434
|
},
|
|
2112
2435
|
};
|
|
2113
2436
|
}
|
|
@@ -2300,6 +2623,26 @@ function objectValue(value) {
|
|
|
2300
2623
|
function objectOrEmpty(value) {
|
|
2301
2624
|
return objectValue(value ?? undefined) ?? {};
|
|
2302
2625
|
}
|
|
2626
|
+
function archivedProjectGuidance(project) {
|
|
2627
|
+
const projectId = typeof project?.id === 'string' ? project.id : null;
|
|
2628
|
+
const expectedVersion = typeof project?.lockVersion === 'number' ? project.lockVersion : null;
|
|
2629
|
+
if (project?.role === 'owner') {
|
|
2630
|
+
return {
|
|
2631
|
+
projectId,
|
|
2632
|
+
expectedVersion,
|
|
2633
|
+
requiredRole: 'owner',
|
|
2634
|
+
recovery: 'project.restore',
|
|
2635
|
+
nextAction: 'Call project.restore with this project id and lockVersion before continuing.',
|
|
2636
|
+
};
|
|
2637
|
+
}
|
|
2638
|
+
return {
|
|
2639
|
+
projectId,
|
|
2640
|
+
expectedVersion,
|
|
2641
|
+
requiredRole: 'owner',
|
|
2642
|
+
recovery: 'project.member_list',
|
|
2643
|
+
nextAction: 'Call project.member_list with this project id, identify an owner, and ask that owner to restore the archived project.',
|
|
2644
|
+
};
|
|
2645
|
+
}
|
|
2303
2646
|
function slugify(value) {
|
|
2304
2647
|
return value
|
|
2305
2648
|
.trim()
|
|
@@ -2404,15 +2747,8 @@ function isOfflineLeaseUsable(lease, sessionId, changedPaths, baselineDiffHash)
|
|
|
2404
2747
|
return false;
|
|
2405
2748
|
}
|
|
2406
2749
|
}
|
|
2407
|
-
class RefusalError extends Error {
|
|
2408
|
-
recovery;
|
|
2409
|
-
constructor(message, recovery) {
|
|
2410
|
-
super(message);
|
|
2411
|
-
this.recovery = recovery;
|
|
2412
|
-
}
|
|
2413
|
-
}
|
|
2414
2750
|
function refuse(message, recovery) {
|
|
2415
|
-
return new
|
|
2751
|
+
return new BridgeRecoveryError(message, recovery);
|
|
2416
2752
|
}
|
|
2417
2753
|
function short(value) {
|
|
2418
2754
|
return typeof value === 'string' && value.length > 0 ? value.slice(0, 12) : 'none';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { readJson, removeFile, writeJson } from '../utilities/files.js';
|
|
3
3
|
import { sha256 } from '../utilities/hash.js';
|
|
4
|
+
import { BridgeRecoveryError } from './recovery-error.js';
|
|
4
5
|
export class PrincipalStateGuard {
|
|
5
6
|
stateRoot;
|
|
6
7
|
credentials;
|
|
@@ -31,7 +32,7 @@ export class PrincipalStateGuard {
|
|
|
31
32
|
return;
|
|
32
33
|
}
|
|
33
34
|
if ((await this.outbox.list()).length > 0) {
|
|
34
|
-
throw new
|
|
35
|
+
throw new BridgeRecoveryError('Pending offline work belongs to a different authenticated principal.', 'task.resolve_pending_delivery');
|
|
35
36
|
}
|
|
36
37
|
await this.clearPrincipalState();
|
|
37
38
|
await writeJson(this.ownerPath, {
|
|
@@ -44,7 +45,7 @@ export class PrincipalStateGuard {
|
|
|
44
45
|
async clearAfterLogout() {
|
|
45
46
|
await this.exclusive(async () => {
|
|
46
47
|
if ((await this.outbox.list()).length > 0) {
|
|
47
|
-
throw new
|
|
48
|
+
throw new BridgeRecoveryError('Pending offline work must be resolved before logout.', 'task.resolve_pending_delivery');
|
|
48
49
|
}
|
|
49
50
|
await this.clearPrincipalState();
|
|
50
51
|
await this.credentials.clear();
|