engineering-memory 1.10.3 → 1.10.5

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.
@@ -1,6 +1,9 @@
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';
@@ -33,32 +36,64 @@ export class BridgeService {
33
36
  this.dependencies = dependencies;
34
37
  this.deferDeliveries = dependencies.deferDeliveries ?? true;
35
38
  }
36
- async questionnaireAsk(input) {
37
- const scope = await this.questionnaireScope(input.repoRoot);
38
- const { repoRoot: _repoRoot, ...definition } = input;
39
- return await this.dependencies.questionnaires.ask(scope, definition);
39
+ async questionnaireAsk(input, previousDefinitions = []) {
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, previousDefinitions);
40
43
  }
41
44
  async questionnaireResume(input) {
42
- const scope = await this.questionnaireScope(input.repoRoot);
45
+ const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
43
46
  const record = await this.dependencies.questionnaires.get(scope, input.questionnaireId);
44
47
  if (!record)
45
48
  throw refuse('No questionnaire exists for this account and repository binding.', 'session.entry');
46
49
  return record;
47
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
+ }
48
55
  async questionnaireAccept(input) {
49
- const scope = await this.questionnaireScope(input.repoRoot);
56
+ const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
50
57
  if (stableStringify(scope) !== stableStringify(input.scope)) {
51
58
  throw refuse('The account or repository binding changed while the questionnaire was open.', 'session.entry');
52
59
  }
53
60
  return await this.dependencies.questionnaires.accept(scope, input.questionnaireId, input.requestKey, input.answer);
54
61
  }
55
- async questionnaireScope(repoRoot) {
62
+ async questionnaireScope(repoRoot, preparation = false) {
56
63
  await this.dependencies.principalState?.ensure();
57
64
  const accessToken = await this.dependencies.credentials.get('access-token');
58
- const repository = await this.dependencies.repositories.resolve(repoRoot ?? process.cwd());
59
- const decision = await this.dependencies.repositoryDecisions.load(repository.repoFingerprint);
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;
60
93
  if (decision?.state === 'disabled')
61
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');
62
97
  const principalHash = accessToken ? principalFingerprint(accessToken) : sha256('anonymous');
63
98
  const currentCredential = await this.dependencies.credentials.get('access-token');
64
99
  if (principalHash !==
@@ -67,8 +102,10 @@ export class BridgeService {
67
102
  }
68
103
  return {
69
104
  principalHash,
70
- repoFingerprint: repository.repoFingerprint,
71
- projectId: repository.projectId,
105
+ repoFingerprint: preparation
106
+ ? sha256('preparation:' + (process.platform === 'win32' ? target.toLowerCase() : target))
107
+ : repository.repoFingerprint,
108
+ projectId: preparation ? null : repository.projectId,
72
109
  };
73
110
  }
74
111
  async pendingQuestionnaires(repoRoot) {
@@ -78,9 +115,10 @@ export class BridgeService {
78
115
  message: record.message,
79
116
  options: record.options,
80
117
  allowFreeText: record.allowFreeText,
118
+ ...(record.textField ? { textField: record.textField } : {}),
81
119
  createdAt: record.createdAt,
82
120
  status: record.status,
83
- nextAction: 'Call questionnaire.resume with this questionnaireId. No answer, elapsed time, dismissal or interruption is consent. Continue only work that is independent of the pending decision.',
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.',
84
122
  }));
85
123
  }
86
124
  deliverInBackground() {
@@ -819,6 +857,264 @@ export class BridgeService {
819
857
  return await this.mutateWithOutbox('memory.propose_revision', endpoints.memoryProposeRevision, body);
820
858
  });
821
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
+ }
822
1118
  async memoryListProposals(input) {
823
1119
  return await this.execute(async () => {
824
1120
  const response = await this.dependencies.client.request(endpoints.memoryListProposals, { method: 'POST', body: cleanJson(input) });
@@ -1825,6 +2121,10 @@ export class BridgeService {
1825
2121
  repository = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1826
2122
  }
1827
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;
1828
2128
  const authenticated = authentication.authenticated === true;
1829
2129
  const clonable = authenticated ? await this.clonableProjects() : [];
1830
2130
  return asJsonValue({
@@ -1832,9 +2132,10 @@ export class BridgeService {
1832
2132
  repository: null,
1833
2133
  decision: 'none',
1834
2134
  clonableProjects: clonable,
1835
- nextAction: clonable.length > 0
1836
- ? '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.'
1837
- : '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.',
1838
2139
  reason: error instanceof Error ? error.message : String(error),
1839
2140
  });
1840
2141
  }
@@ -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
@@ -9,6 +9,10 @@ const identifier = z.string().regex(/^[A-Za-z0-9_-]{1,100}$/);
9
9
  export const questionnaireDefinitionSchema = z.strictObject({
10
10
  questionnaireId: identifier,
11
11
  message: z.string().trim().min(1).max(2000),
12
+ language: z
13
+ .enum(['tr', 'en'])
14
+ .optional()
15
+ .describe('Language of the current conversation: tr for Turkish, en for English. Translate the message and option labels into that language too.'),
12
16
  options: z
13
17
  .array(z.strictObject({
14
18
  id: z
@@ -21,6 +25,17 @@ export const questionnaireDefinitionSchema = z.strictObject({
21
25
  .max(12)
22
26
  .refine((values) => new Set(values.map((value) => value.id)).size === values.length),
23
27
  allowFreeText: z.boolean().default(false),
28
+ textField: z
29
+ .strictObject({
30
+ title: z.string().trim().min(1).max(120),
31
+ maxLength: z.number().int().min(1).max(2000),
32
+ requiredForChoice: z
33
+ .string()
34
+ .regex(/^[A-Za-z0-9_-]{1,80}$/)
35
+ .optional(),
36
+ valueType: z.enum(['text', 'uuid_list']).optional(),
37
+ })
38
+ .optional(),
24
39
  });
25
40
  const scopeSchema = z.strictObject({
26
41
  principalHash: z.string().regex(/^[0-9a-f]{64}$/),
@@ -38,26 +53,62 @@ const storedAnswerSchema = z.strictObject({
38
53
  requestKey: z.string().regex(/^questionnaire_[0-9a-f]{64}$/),
39
54
  answeredAt: z.string().datetime(),
40
55
  answerAvailable: z.boolean(),
41
- answer: z.strictObject({ choice: z.string() }).optional(),
56
+ withdrawn: z.literal(true).optional(),
57
+ answer: z.strictObject({ choice: z.string(), text: z.string().optional() }).optional(),
42
58
  });
43
59
  export function questionnaireAnswerSchema(record) {
60
+ const textSchema = record.textField
61
+ ? record.textField.valueType === 'uuid_list'
62
+ ? z
63
+ .string()
64
+ .trim()
65
+ .min(2)
66
+ .max(record.textField.maxLength)
67
+ .refine((value) => {
68
+ try {
69
+ const parsed = JSON.parse(value);
70
+ if (!Array.isArray(parsed) || new Set(parsed).size !== parsed.length)
71
+ return false;
72
+ return z.array(z.string().uuid()).max(10).safeParse(parsed).success;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }, 'Enter a JSON array containing valid project IDs.')
78
+ .optional()
79
+ : z.string().trim().min(1).max(record.textField.maxLength).optional()
80
+ : record.allowFreeText
81
+ ? z.string().trim().min(1).max(2000).optional()
82
+ : z.never().optional();
44
83
  return z
45
84
  .strictObject({
46
85
  choice: z.enum([
47
86
  ...record.options.map((option) => option.id),
48
87
  ...(record.allowFreeText ? ['__other__'] : []),
49
88
  ]),
50
- text: z.preprocess((value) => (typeof value === 'string' && value.trim() === '' ? undefined : value), record.allowFreeText ? z.string().trim().min(1).max(2000).optional() : z.never().optional()),
89
+ text: z.preprocess((value) => (typeof value === 'string' && value.trim() === '' ? undefined : value), textSchema),
51
90
  })
52
- .refine((answer) => answer.choice === '__other__' ? typeof answer.text === 'string' : answer.text === undefined);
91
+ .refine((answer) => {
92
+ if (record.textField?.requiredForChoice === answer.choice)
93
+ return typeof answer.text === 'string';
94
+ if (answer.choice === '__other__')
95
+ return typeof answer.text === 'string';
96
+ return answer.text === undefined;
97
+ });
53
98
  }
54
99
  export class QuestionnaireStore {
55
100
  root;
56
101
  constructor(stateRoot) {
57
102
  this.root = join(stateRoot, 'questionnaires');
58
103
  }
59
- async ask(scope, input) {
104
+ async ask(scope, input, previousDefinitions = []) {
60
105
  const definition = questionnaireDefinitionSchema.parse(input);
106
+ const previousHashes = previousDefinitions.map((previous) => {
107
+ const parsed = questionnaireDefinitionSchema.parse(previous);
108
+ if (parsed.questionnaireId !== definition.questionnaireId)
109
+ throw new Error('Previous questionnaire content must belong to the same decision.');
110
+ return sha256(stableStringify(parsed));
111
+ });
61
112
  scopeSchema.parse(scope);
62
113
  assertSafeToPersist(definition);
63
114
  const contentHash = sha256(stableStringify(definition));
@@ -71,7 +122,8 @@ export class QuestionnaireStore {
71
122
  };
72
123
  await this.publish(this.pathFor(scope, definition.questionnaireId, 'pending.json'), question);
73
124
  const record = await this.get(scope, definition.questionnaireId);
74
- if (!record || record.contentHash !== contentHash) {
125
+ if (!record ||
126
+ (record.contentHash !== contentHash && !previousHashes.includes(record.contentHash))) {
75
127
  throw new Error('Questionnaire id was reused with different content. Resume the original question or use a new id.');
76
128
  }
77
129
  return record;
@@ -84,8 +136,10 @@ export class QuestionnaireStore {
84
136
  const definition = questionnaireDefinitionSchema.parse({
85
137
  questionnaireId: question.questionnaireId,
86
138
  message: question.message,
139
+ language: question.language,
87
140
  options: question.options,
88
141
  allowFreeText: question.allowFreeText,
142
+ textField: question.textField,
89
143
  });
90
144
  if (stableStringify(question.scope) !== stableStringify(scope) ||
91
145
  question.questionnaireId !== questionnaireId ||
@@ -97,12 +151,23 @@ export class QuestionnaireStore {
97
151
  if (!rawAnswer)
98
152
  return { ...question, status: 'pending' };
99
153
  const answer = storedAnswerSchema.parse(rawAnswer);
154
+ if (answer.withdrawn) {
155
+ if (answer.requestKey !== question.requestKey || answer.answerAvailable || answer.answer)
156
+ throw new Error('Invalid questionnaire withdrawal receipt.');
157
+ return { ...question, ...answer, status: 'withdrawn' };
158
+ }
100
159
  if (answer.requestKey !== question.requestKey ||
101
160
  (answer.answerAvailable
102
161
  ? !answer.answer || !question.options.some((option) => option.id === answer.answer.choice)
103
162
  : !question.allowFreeText || answer.answer !== undefined)) {
104
163
  throw new Error('Questionnaire answer does not match its durable question.');
105
164
  }
165
+ if (answer.answer?.text !== undefined && !question.textField)
166
+ throw new Error('Questionnaire text answer does not match its durable question.');
167
+ if (question.textField?.requiredForChoice &&
168
+ question.textField.requiredForChoice === answer.answer?.choice &&
169
+ typeof answer.answer?.text !== 'string')
170
+ throw new Error('Questionnaire text answer is required for this choice.');
106
171
  assertSafeToPersist(answer);
107
172
  return { ...question, ...answer, status: 'answered' };
108
173
  }
@@ -133,7 +198,10 @@ export class QuestionnaireStore {
133
198
  const record = await this.get(scope, questionnaireId);
134
199
  if (!record || record.requestKey !== requestKey)
135
200
  throw new Error('Questionnaire response is stale or belongs to a different scope.');
201
+ if (record.status === 'withdrawn')
202
+ throw new Error('Questionnaire was explicitly withdrawn. Start a new decision if the user resumes the work.');
136
203
  const answer = questionnaireAnswerSchema(record).parse(input);
204
+ assertSafeToPersist(answer);
137
205
  if (record.status === 'answered') {
138
206
  if (record.answer && stableStringify(record.answer) !== stableStringify(answer))
139
207
  throw new Error('Questionnaire was already answered differently.');
@@ -143,16 +211,34 @@ export class QuestionnaireStore {
143
211
  requestKey,
144
212
  answeredAt: new Date().toISOString(),
145
213
  answerAvailable: answer.choice !== '__other__',
146
- ...(answer.choice !== '__other__' ? { answer: { choice: answer.choice } } : {}),
214
+ ...(answer.choice !== '__other__'
215
+ ? { answer: { choice: answer.choice, ...(answer.text ? { text: answer.text } : {}) } }
216
+ : {}),
147
217
  };
148
218
  const published = await this.publish(this.pathFor(scope, questionnaireId, 'answer.json'), receipt);
149
219
  const resolved = (await this.get(scope, questionnaireId));
220
+ if (resolved.status === 'withdrawn')
221
+ throw new Error('Questionnaire was explicitly withdrawn before this response.');
150
222
  if (!published &&
151
223
  resolved.answer &&
152
224
  stableStringify(resolved.answer) !== stableStringify(answer))
153
225
  throw new Error('Questionnaire was already answered differently.');
154
226
  return { record: resolved, answer: published ? answer : resolved.answer, replayed: !published };
155
227
  }
228
+ async withdraw(scope, questionnaireId) {
229
+ const record = await this.get(scope, questionnaireId);
230
+ if (!record)
231
+ throw new Error('No questionnaire exists in this scope.');
232
+ if (record.status !== 'pending')
233
+ return record;
234
+ await this.publish(this.pathFor(scope, questionnaireId, 'answer.json'), {
235
+ requestKey: record.requestKey,
236
+ answeredAt: new Date().toISOString(),
237
+ answerAvailable: false,
238
+ withdrawn: true,
239
+ });
240
+ return (await this.get(scope, questionnaireId));
241
+ }
156
242
  scopeRoot(scope) {
157
243
  scopeSchema.parse(scope);
158
244
  return join(this.root, scope.principalHash, sha256(`${scope.repoFingerprint}\n${scope.projectId ?? ''}`));
package/skill/SKILL.md CHANGED
@@ -7,7 +7,7 @@ description: Enforces the Engineering Memory lifecycle for Codex and Claude in r
7
7
 
8
8
  This skill is a thin runtime protocol. It contains no proprietary engineering rules. The task-specific rules, project profile, screen logic, component mappings, service contracts, decisions, and quality gates must come from the Engineering Memory MCP server.
9
9
 
10
- Read [lifecycle.md](references/lifecycle.md) before acting in a bound repository. Read [questionnaires.md](references/questionnaires.md) when authentication, project binding, project creation, membership, correction scope, proposal review, or Git-hook choices require user input. Read [memory-updates.md](references/memory-updates.md) before changing screen, component, service, project, or organization memory. Read [scaffolding.md](references/scaffolding.md) before creating a project from the organization architecture templates or changing a template.
10
+ Read [lifecycle.md](references/lifecycle.md) before acting in a bound repository. Read [project-onboarding.md](references/project-onboarding.md) for greenfield, adopt, rework and refresh flows. Read [questionnaires.md](references/questionnaires.md) when authentication, project binding, project creation, membership, correction scope, proposal review, or Git-hook choices require user input. Read [memory-updates.md](references/memory-updates.md) before changing screen, component, service, project, or organization memory. Read [scaffolding.md](references/scaffolding.md) before creating a project from the organization architecture templates or changing a template.
11
11
 
12
12
  Mandatory behavior:
13
13