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.
@@ -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
- nextAction: clonable.length > 0
1756
- ? 'This directory is not a Git repository. Offer the listed projects and clone the one the user chooses with project.clone. Do not mention Engineering Memory otherwise.'
1757
- : 'This directory is not a Git repository, so Engineering Memory has nothing to bind. Work normally and say nothing about it.',
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: taskSelectionRequired
1790
- ? '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.'
1791
- : entryNextAction(authenticated, state),
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