engineering-memory 1.10.2 → 1.10.4
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/git/source-snapshot.js +450 -0
- package/runtime/dist/src/index.js +4 -0
- package/runtime/dist/src/mcp/onboarding-tools.js +663 -0
- package/runtime/dist/src/mcp/questionnaire-tools.js +149 -0
- package/runtime/dist/src/mcp/server.js +4 -1
- package/runtime/dist/src/mcp/tool-definitions.js +71 -0
- package/runtime/dist/src/project/project-intake.js +406 -0
- package/runtime/dist/src/runtime/api-client.js +3 -0
- package/runtime/dist/src/runtime/bridge-service.js +393 -8
- package/runtime/dist/src/runtime/onboarding-store.js +47 -0
- package/runtime/dist/src/runtime/questionnaire-store.js +259 -0
- package/runtime/dist/src/utilities/files.js +8 -1
- package/skill/SKILL.md +2 -2
- package/skill/references/lifecycle.md +47 -20
- package/skill/references/project-onboarding.md +146 -0
- package/skill/references/questionnaires.md +85 -7
- package/skill/references/scaffolding.md +13 -4
|
@@ -20,7 +20,10 @@ export const backendRecoveryOperationNames = [
|
|
|
20
20
|
'session.resume',
|
|
21
21
|
'context.refresh',
|
|
22
22
|
'context.prepare_change',
|
|
23
|
+
'memory.sync_status',
|
|
24
|
+
'project.onboarding_status',
|
|
23
25
|
'memory.query',
|
|
26
|
+
'memory.catalog',
|
|
24
27
|
'memory.history',
|
|
25
28
|
'memory.propose_revision',
|
|
26
29
|
'memory.list_proposals',
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import { join } from 'node:path';
|
|
2
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { SourceSnapshot } from '../git/source-snapshot.js';
|
|
5
|
+
import { ProjectIntake } from '../project/project-intake.js';
|
|
6
|
+
import { canonicalPath } from '../utilities/files.js';
|
|
4
7
|
import { minimatch } from 'minimatch';
|
|
5
8
|
import { endpoints } from '../config.js';
|
|
6
9
|
import { sha256, stableStringify } from '../utilities/hash.js';
|
|
7
10
|
import { ApiResponseError, BackendUnavailableError, } from './api-client.js';
|
|
8
11
|
import { assertSafeToPersist, normalizeRepositoryPaths } from './offline-outbox.js';
|
|
12
|
+
import { principalFingerprint } from './principal-state.js';
|
|
9
13
|
import { BridgeRecoveryError } from './recovery-error.js';
|
|
10
14
|
export const validationIds = [
|
|
11
15
|
'format',
|
|
@@ -32,6 +36,91 @@ export class BridgeService {
|
|
|
32
36
|
this.dependencies = dependencies;
|
|
33
37
|
this.deferDeliveries = dependencies.deferDeliveries ?? true;
|
|
34
38
|
}
|
|
39
|
+
async questionnaireAsk(input) {
|
|
40
|
+
const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
|
|
41
|
+
const { repoRoot: _repoRoot, preparation: _preparation, ...definition } = input;
|
|
42
|
+
return await this.dependencies.questionnaires.ask(scope, definition);
|
|
43
|
+
}
|
|
44
|
+
async questionnaireResume(input) {
|
|
45
|
+
const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
|
|
46
|
+
const record = await this.dependencies.questionnaires.get(scope, input.questionnaireId);
|
|
47
|
+
if (!record)
|
|
48
|
+
throw refuse('No questionnaire exists for this account and repository binding.', 'session.entry');
|
|
49
|
+
return record;
|
|
50
|
+
}
|
|
51
|
+
async questionnaireWithdraw(input) {
|
|
52
|
+
const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
|
|
53
|
+
return this.dependencies.questionnaires.withdraw(scope, input.questionnaireId);
|
|
54
|
+
}
|
|
55
|
+
async questionnaireAccept(input) {
|
|
56
|
+
const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
|
|
57
|
+
if (stableStringify(scope) !== stableStringify(input.scope)) {
|
|
58
|
+
throw refuse('The account or repository binding changed while the questionnaire was open.', 'session.entry');
|
|
59
|
+
}
|
|
60
|
+
return await this.dependencies.questionnaires.accept(scope, input.questionnaireId, input.requestKey, input.answer);
|
|
61
|
+
}
|
|
62
|
+
async questionnaireScope(repoRoot, preparation = false) {
|
|
63
|
+
await this.dependencies.principalState?.ensure();
|
|
64
|
+
const accessToken = await this.dependencies.credentials.get('access-token');
|
|
65
|
+
const target = await canonicalPath(repoRoot ?? process.cwd());
|
|
66
|
+
let lookup = target;
|
|
67
|
+
if (preparation) {
|
|
68
|
+
for (;;) {
|
|
69
|
+
try {
|
|
70
|
+
await lstat(lookup);
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error.code !== 'ENOENT' || dirname(lookup) === lookup)
|
|
75
|
+
throw error;
|
|
76
|
+
lookup = dirname(lookup);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
let repository;
|
|
81
|
+
try {
|
|
82
|
+
repository = await this.dependencies.repositories.resolve(lookup);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (!preparation ||
|
|
86
|
+
!(error instanceof Error) ||
|
|
87
|
+
!error.message.startsWith('Not a Git repository:'))
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
const decision = repository
|
|
91
|
+
? await this.dependencies.repositoryDecisions.load(repository.repoFingerprint)
|
|
92
|
+
: null;
|
|
93
|
+
if (decision?.state === 'disabled')
|
|
94
|
+
throw refuse('Engineering Memory is disabled in this repository.', 'session.entry');
|
|
95
|
+
if (preparation && repository?.projectId)
|
|
96
|
+
throw refuse('This target is already inside a bound project. Use its normal questionnaire scope.', 'session.entry');
|
|
97
|
+
const principalHash = accessToken ? principalFingerprint(accessToken) : sha256('anonymous');
|
|
98
|
+
const currentCredential = await this.dependencies.credentials.get('access-token');
|
|
99
|
+
if (principalHash !==
|
|
100
|
+
(currentCredential ? principalFingerprint(currentCredential) : sha256('anonymous'))) {
|
|
101
|
+
throw refuse('The account changed while the questionnaire scope was being resolved.', 'session.entry');
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
principalHash,
|
|
105
|
+
repoFingerprint: preparation
|
|
106
|
+
? sha256('preparation:' + (process.platform === 'win32' ? target.toLowerCase() : target))
|
|
107
|
+
: repository.repoFingerprint,
|
|
108
|
+
projectId: preparation ? null : repository.projectId,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
async pendingQuestionnaires(repoRoot) {
|
|
112
|
+
const scope = await this.questionnaireScope(repoRoot);
|
|
113
|
+
return (await this.dependencies.questionnaires.pending(scope)).map((record) => ({
|
|
114
|
+
questionnaireId: record.questionnaireId,
|
|
115
|
+
message: record.message,
|
|
116
|
+
options: record.options,
|
|
117
|
+
allowFreeText: record.allowFreeText,
|
|
118
|
+
...(record.textField ? { textField: record.textField } : {}),
|
|
119
|
+
createdAt: record.createdAt,
|
|
120
|
+
status: record.status,
|
|
121
|
+
nextAction: 'Call questionnaire.resume with this questionnaireId. No answer, elapsed time, dismissal or interruption is consent. If the user explicitly cancels this decision, call questionnaire.withdraw. Unrelated pending decisions never block independently authorized work.',
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
35
124
|
deliverInBackground() {
|
|
36
125
|
if (!this.deferDeliveries)
|
|
37
126
|
return;
|
|
@@ -185,6 +274,23 @@ export class BridgeService {
|
|
|
185
274
|
async sessionResume(input) {
|
|
186
275
|
return await this.execute(async () => {
|
|
187
276
|
const repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd(), input.projectId);
|
|
277
|
+
const decision = await this.dependencies.repositoryDecisions.load(repository.repoFingerprint);
|
|
278
|
+
if (decision?.state === 'disabled') {
|
|
279
|
+
return asJsonValue({
|
|
280
|
+
decision: 'disabled',
|
|
281
|
+
nextAction: entryNextAction(false, 'disabled'),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
const pendingQuestionnaires = await this.pendingQuestionnaires(repository.repoRoot);
|
|
285
|
+
const signedIn = (await this.dependencies.browserAuth.status()).authenticated;
|
|
286
|
+
if (!signedIn && pendingQuestionnaires.length > 0) {
|
|
287
|
+
return asJsonValue({
|
|
288
|
+
authenticated: false,
|
|
289
|
+
pendingQuestionnaires,
|
|
290
|
+
repository: publicRepository(repository),
|
|
291
|
+
nextAction: 'Resume only the pending sign-in questionnaire for this work. Signing in is still required before accessing project memory.',
|
|
292
|
+
});
|
|
293
|
+
}
|
|
188
294
|
const authentication = await this.dependencies.browserAuth.ensureAuthenticated();
|
|
189
295
|
if (authentication) {
|
|
190
296
|
return asJsonValue({ authentication, repository: publicRepository(repository) });
|
|
@@ -204,6 +310,7 @@ export class BridgeService {
|
|
|
204
310
|
return asJsonValue({
|
|
205
311
|
taskChoiceRequired: true,
|
|
206
312
|
requestedTaskSlug: input.taskSlug,
|
|
313
|
+
pendingQuestionnaires,
|
|
207
314
|
liveTasks: live.map(describePointer),
|
|
208
315
|
repository: publicRepository(repository),
|
|
209
316
|
});
|
|
@@ -212,6 +319,7 @@ export class BridgeService {
|
|
|
212
319
|
return asJsonValue({
|
|
213
320
|
taskChoiceRequired: true,
|
|
214
321
|
requestedSessionId: input.sessionId,
|
|
322
|
+
pendingQuestionnaires,
|
|
215
323
|
liveTasks: live.map(describePointer),
|
|
216
324
|
repository: publicRepository(repository),
|
|
217
325
|
});
|
|
@@ -219,6 +327,7 @@ export class BridgeService {
|
|
|
219
327
|
if (!pointer && live.length > 1) {
|
|
220
328
|
return asJsonValue({
|
|
221
329
|
taskChoiceRequired: true,
|
|
330
|
+
pendingQuestionnaires,
|
|
222
331
|
liveTasks: live.map(describePointer),
|
|
223
332
|
repository: publicRepository(repository),
|
|
224
333
|
});
|
|
@@ -227,6 +336,13 @@ export class BridgeService {
|
|
|
227
336
|
const taskSlug = input.taskSlug ?? pointer?.taskSlug;
|
|
228
337
|
const sessionId = input.sessionId ?? pointer?.sessionId;
|
|
229
338
|
if (!projectId || !taskSlug || !sessionId) {
|
|
339
|
+
if (pendingQuestionnaires.length > 0) {
|
|
340
|
+
return asJsonValue({
|
|
341
|
+
pendingQuestionnaires,
|
|
342
|
+
repository: publicRepository(repository),
|
|
343
|
+
nextAction: 'Resume only the pending questionnaire for this work. Unrelated pending questions never block a separate task; continue independently authorized work.',
|
|
344
|
+
});
|
|
345
|
+
}
|
|
230
346
|
throw refuse('This repository has no live task to resume.', 'session.bootstrap');
|
|
231
347
|
}
|
|
232
348
|
if ((pointer &&
|
|
@@ -240,6 +356,7 @@ export class BridgeService {
|
|
|
240
356
|
const recoveredClose = await this.retryCloseIntent(repository, pointer);
|
|
241
357
|
return asJsonValue({
|
|
242
358
|
backend: { close: recoveredClose },
|
|
359
|
+
pendingQuestionnaires,
|
|
243
360
|
repository: publicRepository(repository),
|
|
244
361
|
localJournal: await this.dependencies.journal.load(projectId, taskSlug),
|
|
245
362
|
synchronization: {
|
|
@@ -411,6 +528,7 @@ export class BridgeService {
|
|
|
411
528
|
backend,
|
|
412
529
|
repository: publicRepository(repository),
|
|
413
530
|
localJournal,
|
|
531
|
+
pendingQuestionnaires,
|
|
414
532
|
synchronization: {
|
|
415
533
|
backendSource: responseSource,
|
|
416
534
|
backendFresh: responseSource !== 'stale_cache',
|
|
@@ -739,6 +857,264 @@ export class BridgeService {
|
|
|
739
857
|
return await this.mutateWithOutbox('memory.propose_revision', endpoints.memoryProposeRevision, body);
|
|
740
858
|
});
|
|
741
859
|
}
|
|
860
|
+
async projectInspect(input) {
|
|
861
|
+
return this.execute(async () => asJsonValue(await new ProjectIntake().inspect(input.repoRoot)));
|
|
862
|
+
}
|
|
863
|
+
async onboardingChoice(input) {
|
|
864
|
+
return this.execute(async () => {
|
|
865
|
+
const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
|
|
866
|
+
if (!this.dependencies.onboarding)
|
|
867
|
+
throw refuse('Update the client to persist onboarding choices.', 'session.entry');
|
|
868
|
+
if (input.choice) {
|
|
869
|
+
if (input.choice.requestKey !== input.requestKey)
|
|
870
|
+
throw refuse('Keep the onboarding request identity unchanged.', 'session.entry');
|
|
871
|
+
const question = input.questionnaireId
|
|
872
|
+
? await this.dependencies.questionnaires.get(scope, input.questionnaireId)
|
|
873
|
+
: null;
|
|
874
|
+
const digest = sha256(stableStringify(input.choice));
|
|
875
|
+
if (question?.answer?.choice !== 'approve' || !question.message.includes(digest))
|
|
876
|
+
throw refuse('Approve the exact mode and profile through project.onboard before continuing.', 'session.entry');
|
|
877
|
+
return asJsonValue(await this.dependencies.onboarding.save(scope, input.choice));
|
|
878
|
+
}
|
|
879
|
+
return asJsonValue(await this.dependencies.onboarding.get(scope, input.requestKey));
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
async projectInitialize(input) {
|
|
883
|
+
if (input.questionnaireId !==
|
|
884
|
+
'onboard-profile-' + sha256(stableStringify(input.choice)) + '-initialize')
|
|
885
|
+
return this.execute(async () => {
|
|
886
|
+
throw refuse('Confirm Git preparation through project.initialize; a saved profile does not authorize initialization.', 'session.entry');
|
|
887
|
+
});
|
|
888
|
+
const stored = await this.onboardingChoice(input);
|
|
889
|
+
if (!stored.ok)
|
|
890
|
+
return stored;
|
|
891
|
+
return this.execute(async () => {
|
|
892
|
+
if (input.choice.mode !== 'greenfield')
|
|
893
|
+
throw refuse('Only the greenfield choice initializes Git. Use project.onboard for adoption or rework.', 'session.entry');
|
|
894
|
+
return asJsonValue({
|
|
895
|
+
...(await new ProjectIntake().initialize(input.repoRoot)),
|
|
896
|
+
onboarding: stored.data,
|
|
897
|
+
});
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
async memorySource(input) {
|
|
901
|
+
return this.execute(async () => {
|
|
902
|
+
const repository = await this.dependencies.repositories.resolve(input.repoRoot);
|
|
903
|
+
const snapshot = new SourceSnapshot();
|
|
904
|
+
if (input.previousCommit) {
|
|
905
|
+
const history = await snapshot.history(repository.repoRoot, input.previousCommit, input.commit ?? input.ref ?? 'HEAD');
|
|
906
|
+
const paths = history.changes.changedPaths.filter((path) => !input.afterPath || Buffer.compare(Buffer.from(path), Buffer.from(input.afterPath)) > 0);
|
|
907
|
+
const limit = input.limit ?? 100;
|
|
908
|
+
const page = new Set(paths.slice(0, limit));
|
|
909
|
+
return asJsonValue({
|
|
910
|
+
previousCommit: history.previousCommit,
|
|
911
|
+
currentCommit: history.currentCommit,
|
|
912
|
+
fallback: history.fallback ?? null,
|
|
913
|
+
recentRelevantCommitIds: history.recentRelevantCommitIds,
|
|
914
|
+
historyTruncated: history.historyTruncated,
|
|
915
|
+
changedPaths: [...page],
|
|
916
|
+
renamed: history.changes.renamed.filter((change) => page.has(change.from) || page.has(change.to)),
|
|
917
|
+
added: history.changes.added.filter((path) => page.has(path)),
|
|
918
|
+
deleted: history.changes.deleted.filter((path) => page.has(path)),
|
|
919
|
+
nextCursor: paths.length > limit ? paths[limit - 1] : null,
|
|
920
|
+
...(history.fallback
|
|
921
|
+
? {
|
|
922
|
+
nextAction: 'Use memory.sync_delta against the persisted previous inventory for content comparison. Missing history cannot prove no change.',
|
|
923
|
+
}
|
|
924
|
+
: {}),
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
if (input.path) {
|
|
928
|
+
if (!input.commit)
|
|
929
|
+
throw refuse('Read source only at the inspection commit.', 'memory.sync_status');
|
|
930
|
+
const content = await snapshot.read(repository.repoRoot, input.commit, input.path, input.maxBytes ?? 1048576);
|
|
931
|
+
const offset = input.offset ?? 0, size = input.maxCharacters ?? 20000;
|
|
932
|
+
return asJsonValue({
|
|
933
|
+
commit: input.commit,
|
|
934
|
+
path: input.path,
|
|
935
|
+
content: content.slice(offset, offset + size),
|
|
936
|
+
offset,
|
|
937
|
+
totalCharacters: content.length,
|
|
938
|
+
nextOffset: offset + size < content.length ? offset + size : null,
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
const result = await snapshot.snapshot(repository.repoRoot, input.ref ?? 'HEAD');
|
|
942
|
+
const files = result.files.filter((file) => !input.afterPath ||
|
|
943
|
+
Buffer.compare(Buffer.from(file.path), Buffer.from(input.afterPath)) > 0);
|
|
944
|
+
const limit = input.limit ?? 100;
|
|
945
|
+
return asJsonValue({
|
|
946
|
+
...result,
|
|
947
|
+
files: files.slice(0, limit),
|
|
948
|
+
totalFiles: result.files.length,
|
|
949
|
+
nextCursor: files.length > limit ? files[limit - 1].path : null,
|
|
950
|
+
});
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
async memorySyncStart(input) {
|
|
954
|
+
return this.execute(async () => {
|
|
955
|
+
const repository = await this.dependencies.repositories.resolve(input.repoRoot, input.projectId);
|
|
956
|
+
const snapshot = await new SourceSnapshot().snapshot(repository.repoRoot, input.ref ?? 'HEAD');
|
|
957
|
+
const { questionnaireId, ...confirmedInput } = input;
|
|
958
|
+
const digest = sha256(stableStringify(confirmedInput));
|
|
959
|
+
const scope = await this.questionnaireScope(input.repoRoot);
|
|
960
|
+
const question = await this.dependencies.questionnaires.get(scope, questionnaireId);
|
|
961
|
+
if (questionnaireId !== 'sync-start-' + digest ||
|
|
962
|
+
question?.answer?.choice !== 'approve' ||
|
|
963
|
+
input.ref !== snapshot.commit)
|
|
964
|
+
throw refuse('Confirm this exact mode and committed source through memory.sync_start before starting the inspection.', 'memory.sync_status');
|
|
965
|
+
const selected = await this.dependencies.client.request('/memory/onboarding/choose', {
|
|
966
|
+
method: 'POST',
|
|
967
|
+
body: cleanJson({
|
|
968
|
+
projectId: input.projectId,
|
|
969
|
+
requestKey: input.requestKey,
|
|
970
|
+
mode: input.mode,
|
|
971
|
+
profile: {},
|
|
972
|
+
sourceCommit: snapshot.commit,
|
|
973
|
+
reworkBehavior: input.reworkBehavior,
|
|
974
|
+
}),
|
|
975
|
+
});
|
|
976
|
+
const body = cleanJson({
|
|
977
|
+
projectId: input.projectId,
|
|
978
|
+
requestKey: input.requestKey,
|
|
979
|
+
mode: input.mode,
|
|
980
|
+
previousRunId: input.previousRunId,
|
|
981
|
+
reworkBehavior: input.reworkBehavior,
|
|
982
|
+
sourceCommit: snapshot.commit,
|
|
983
|
+
treeHash: snapshot.treeHash,
|
|
984
|
+
manifestHash: snapshot.manifestHash,
|
|
985
|
+
expectedFiles: snapshot.files.length,
|
|
986
|
+
});
|
|
987
|
+
assertSafeToPersist(body);
|
|
988
|
+
const response = await this.dependencies.client.request('/memory/sync/start', {
|
|
989
|
+
method: 'POST',
|
|
990
|
+
body,
|
|
991
|
+
});
|
|
992
|
+
const run = objectValue(response.data);
|
|
993
|
+
if (!run || typeof run.id !== 'string')
|
|
994
|
+
throw refuse('The inspection response has no run identity.', 'memory.sync_status');
|
|
995
|
+
if (run.state === 'inventory' || run.state === 'planning') {
|
|
996
|
+
for (let offset = 0; offset < snapshot.files.length; offset += 1000) {
|
|
997
|
+
const page = cleanJson({
|
|
998
|
+
projectId: input.projectId,
|
|
999
|
+
runId: run.id,
|
|
1000
|
+
files: snapshot.files.slice(offset, offset + 1000),
|
|
1001
|
+
});
|
|
1002
|
+
assertSafeToPersist(page);
|
|
1003
|
+
await this.dependencies.client.request('/memory/sync/inventory', {
|
|
1004
|
+
method: 'POST',
|
|
1005
|
+
body: page,
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
await this.dependencies.client.request('/memory/sync/inventory', {
|
|
1009
|
+
method: 'POST',
|
|
1010
|
+
body: cleanJson({ projectId: input.projectId, runId: run.id, files: [], seal: true }),
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
return asJsonValue({
|
|
1014
|
+
runId: run.id,
|
|
1015
|
+
onboarding: selected.data,
|
|
1016
|
+
sourceCommit: snapshot.commit,
|
|
1017
|
+
sourceRef: input.ref ?? 'HEAD',
|
|
1018
|
+
checkedOutBranch: await this.dependencies.repositories.git.currentBranch(repository.repoRoot),
|
|
1019
|
+
manifestHash: snapshot.manifestHash,
|
|
1020
|
+
files: snapshot.files.length,
|
|
1021
|
+
workingTreeExcluded: true,
|
|
1022
|
+
nextAction: 'Read memory.source at this commit, plan semantic units across architecture, state, services, network, data, screens and flows; then seal the coverage plan.',
|
|
1023
|
+
});
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
async memorySyncStatus(input) {
|
|
1027
|
+
const { repoRoot, ...request } = input;
|
|
1028
|
+
const result = await this.onboardingRequest('status', cleanJson(request));
|
|
1029
|
+
if (!result.ok || !repoRoot || !input.runId)
|
|
1030
|
+
return result;
|
|
1031
|
+
return this.execute(async () => {
|
|
1032
|
+
const repository = await this.dependencies.repositories.resolve(repoRoot, input.projectId);
|
|
1033
|
+
const data = objectValue(result.data), run = objectValue(data?.run);
|
|
1034
|
+
return asJsonValue({
|
|
1035
|
+
...data,
|
|
1036
|
+
checkedOutHead: repository.git.head,
|
|
1037
|
+
sourceMatchesCheckout: run?.sourceCommit === repository.git.head,
|
|
1038
|
+
...(run?.sourceCommit !== repository.git.head
|
|
1039
|
+
? {
|
|
1040
|
+
sourceNotice: 'The checkout differs from the pinned inspection. Keep analyzing the pinned source; record a follow-up refresh for newer desired changes, and never report this source as the current checkout.',
|
|
1041
|
+
}
|
|
1042
|
+
: {}),
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
async memorySyncDelta(input) {
|
|
1047
|
+
const { repoRoot, ...request } = input;
|
|
1048
|
+
const delta = await this.onboardingRequest('delta', cleanJson(request));
|
|
1049
|
+
if (!delta.ok || !repoRoot || input.afterId)
|
|
1050
|
+
return delta;
|
|
1051
|
+
const current = await this.onboardingRequest('status', cleanJson({ projectId: input.projectId, runId: input.runId, limit: 1 }));
|
|
1052
|
+
if (!current.ok)
|
|
1053
|
+
return current;
|
|
1054
|
+
const run = objectValue(objectValue(current.data)?.run);
|
|
1055
|
+
if (typeof run?.previousRunId !== 'string' || typeof run.sourceCommit !== 'string')
|
|
1056
|
+
return delta;
|
|
1057
|
+
const previous = await this.onboardingRequest('status', cleanJson({ projectId: input.projectId, runId: run.previousRunId, limit: 1 }));
|
|
1058
|
+
if (!previous.ok)
|
|
1059
|
+
return previous;
|
|
1060
|
+
const prior = objectValue(objectValue(previous.data)?.run);
|
|
1061
|
+
if (typeof prior?.sourceCommit !== 'string')
|
|
1062
|
+
return delta;
|
|
1063
|
+
const history = await this.memorySource({
|
|
1064
|
+
repoRoot,
|
|
1065
|
+
commit: run.sourceCommit,
|
|
1066
|
+
previousCommit: prior.sourceCommit,
|
|
1067
|
+
limit: 100,
|
|
1068
|
+
});
|
|
1069
|
+
return {
|
|
1070
|
+
ok: true,
|
|
1071
|
+
data: asJsonValue({
|
|
1072
|
+
...objectValue(delta.data),
|
|
1073
|
+
sourceHistory: history.ok
|
|
1074
|
+
? history.data
|
|
1075
|
+
: { unavailable: true, contentComparisonAuthoritative: true },
|
|
1076
|
+
}),
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
async onboardingRequest(operation, body) {
|
|
1080
|
+
return this.execute(async () => {
|
|
1081
|
+
assertSafeToPersist(body);
|
|
1082
|
+
const routes = {
|
|
1083
|
+
status: '/memory/sync/status',
|
|
1084
|
+
inventory: '/memory/sync/inventory',
|
|
1085
|
+
plan: '/memory/sync/plan',
|
|
1086
|
+
next: '/memory/sync/next',
|
|
1087
|
+
submit: '/memory/sync/submit',
|
|
1088
|
+
verify: '/memory/sync/verify',
|
|
1089
|
+
reopen: '/memory/sync/reopen',
|
|
1090
|
+
delta: '/memory/sync/delta',
|
|
1091
|
+
catalog: '/memory/catalog',
|
|
1092
|
+
read_revisions: '/memory/revisions/read',
|
|
1093
|
+
review_batch: '/memory/proposals/review-batch',
|
|
1094
|
+
onboarding_choose: '/memory/onboarding/choose',
|
|
1095
|
+
onboarding_status: '/memory/onboarding/status',
|
|
1096
|
+
rework: '/memory/onboarding/rework',
|
|
1097
|
+
};
|
|
1098
|
+
const response = await this.dependencies.client.request(routes[operation], {
|
|
1099
|
+
method: 'POST',
|
|
1100
|
+
body,
|
|
1101
|
+
});
|
|
1102
|
+
if (operation === 'review_batch' && Array.isArray(response.data)) {
|
|
1103
|
+
const invalidatedTaskIds = [
|
|
1104
|
+
...new Set(response.data.flatMap((item) => {
|
|
1105
|
+
const value = objectValue(item)?.invalidatedTaskIds;
|
|
1106
|
+
return Array.isArray(value)
|
|
1107
|
+
? value.filter((id) => typeof id === 'string')
|
|
1108
|
+
: [];
|
|
1109
|
+
})),
|
|
1110
|
+
];
|
|
1111
|
+
for (const id of invalidatedTaskIds)
|
|
1112
|
+
await this.dependencies.gate.invalidateTask(id);
|
|
1113
|
+
await this.dependencies.activeContexts.markTasksConflict(invalidatedTaskIds, 'approved_memory_revision_requires_refresh');
|
|
1114
|
+
}
|
|
1115
|
+
return response.data;
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
742
1118
|
async memoryListProposals(input) {
|
|
743
1119
|
return await this.execute(async () => {
|
|
744
1120
|
const response = await this.dependencies.client.request(endpoints.memoryListProposals, { method: 'POST', body: cleanJson(input) });
|
|
@@ -1745,6 +2121,10 @@ export class BridgeService {
|
|
|
1745
2121
|
repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
|
|
1746
2122
|
}
|
|
1747
2123
|
catch (error) {
|
|
2124
|
+
const missingTarget = error.code === 'ENOENT';
|
|
2125
|
+
const outsideGit = error instanceof Error && error.message.startsWith('Not a Git repository:');
|
|
2126
|
+
if (!missingTarget && !outsideGit)
|
|
2127
|
+
throw error;
|
|
1748
2128
|
const authenticated = authentication.authenticated === true;
|
|
1749
2129
|
const clonable = authenticated ? await this.clonableProjects() : [];
|
|
1750
2130
|
return asJsonValue({
|
|
@@ -1752,9 +2132,10 @@ export class BridgeService {
|
|
|
1752
2132
|
repository: null,
|
|
1753
2133
|
decision: 'none',
|
|
1754
2134
|
clonableProjects: clonable,
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
2135
|
+
preparationAvailable: true,
|
|
2136
|
+
nextAction: !authenticated
|
|
2137
|
+
? 'Sign in through auth.signin_browser before binding a project. Native questionnaires may use preparation:true for this target before Git exists.'
|
|
2138
|
+
: 'No Git repository is bound here. For requested project creation, inspect the selected target with project.inspect, ask greenfield/adopt/rework through questionnaire.ask with preparation:true, then project.initialize if needed and organization/project selection. Existing projects may be cloned from clonableProjects. Never create a folder, choose a mode or bind a project silently.',
|
|
1758
2139
|
reason: error instanceof Error ? error.message : String(error),
|
|
1759
2140
|
});
|
|
1760
2141
|
}
|
|
@@ -1772,6 +2153,7 @@ export class BridgeService {
|
|
|
1772
2153
|
? await this.actionableWorkItems(projectId)
|
|
1773
2154
|
: [];
|
|
1774
2155
|
const taskSelectionRequired = liveTasks.length > 0 || workItems.length > 0;
|
|
2156
|
+
const pendingQuestionnaires = state !== 'disabled' ? await this.pendingQuestionnaires(repository.repoRoot) : [];
|
|
1775
2157
|
return asJsonValue({
|
|
1776
2158
|
authenticated,
|
|
1777
2159
|
repository: publicRepository(repository),
|
|
@@ -1780,15 +2162,18 @@ export class BridgeService {
|
|
|
1780
2162
|
liveTasks,
|
|
1781
2163
|
workItems,
|
|
1782
2164
|
taskSelectionRequired,
|
|
2165
|
+
...(state !== 'disabled' ? { pendingQuestionnaires } : {}),
|
|
1783
2166
|
client,
|
|
1784
2167
|
...(incomplete && state === 'bound'
|
|
1785
2168
|
? {
|
|
1786
2169
|
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.',
|
|
1787
2170
|
}
|
|
1788
2171
|
: {}),
|
|
1789
|
-
nextAction:
|
|
1790
|
-
? '
|
|
1791
|
-
:
|
|
2172
|
+
nextAction: pendingQuestionnaires.length > 0
|
|
2173
|
+
? 'Resume only the pending questionnaire for this work with questionnaire.resume before taking action that depends on its answer. Unrelated pending questions never block opening a separate task. Never infer consent from a timeout, dismissal, interrupted turn or missing answer.'
|
|
2174
|
+
: taskSelectionRequired
|
|
2175
|
+
? '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.'
|
|
2176
|
+
: entryNextAction(authenticated, state),
|
|
1792
2177
|
});
|
|
1793
2178
|
});
|
|
1794
2179
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { link } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { assertManagedPath, readJson, removeFile, writeJson } from '../utilities/files.js';
|
|
5
|
+
import { sha256, stableStringify } from '../utilities/hash.js';
|
|
6
|
+
import { assertSafeToPersist } from './offline-outbox.js';
|
|
7
|
+
export class OnboardingStore {
|
|
8
|
+
root;
|
|
9
|
+
constructor(stateRoot) {
|
|
10
|
+
this.root = join(stateRoot, 'onboarding-choices');
|
|
11
|
+
}
|
|
12
|
+
async get(scope, requestKey) {
|
|
13
|
+
const value = await readJson(this.path(scope, requestKey), this.root);
|
|
14
|
+
if (value && value.requestKey !== requestKey)
|
|
15
|
+
throw new Error('Invalid onboarding choice identity');
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
async save(scope, choice) {
|
|
19
|
+
assertSafeToPersist(choice);
|
|
20
|
+
const path = this.path(scope, choice.requestKey);
|
|
21
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
22
|
+
await writeJson(temporary, choice, this.root);
|
|
23
|
+
try {
|
|
24
|
+
const target = await assertManagedPath(this.root, path, true);
|
|
25
|
+
try {
|
|
26
|
+
await link(temporary, target);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error.code !== 'EEXIST')
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
await removeFile(temporary, this.root);
|
|
35
|
+
}
|
|
36
|
+
const saved = await this.get(scope, choice.requestKey);
|
|
37
|
+
if (!saved || stableStringify(saved) !== stableStringify(choice))
|
|
38
|
+
throw new Error('This request already has a different onboarding choice. Use a new request key.');
|
|
39
|
+
return saved;
|
|
40
|
+
}
|
|
41
|
+
path(scope, requestKey) {
|
|
42
|
+
if (!/^[A-Za-z0-9_-]{1,120}$/.test(requestKey))
|
|
43
|
+
throw new Error('Invalid onboarding request key');
|
|
44
|
+
return join(this.root, sha256(stableStringify(scope)), sha256(requestKey) + '.json');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=onboarding-store.js.map
|