engineering-memory 1.10.3 → 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 +3 -1
- package/package.json +1 -1
- package/runtime/dist/src/git/source-snapshot.js +450 -0
- package/runtime/dist/src/index.js +2 -0
- package/runtime/dist/src/mcp/onboarding-tools.js +663 -0
- package/runtime/dist/src/mcp/questionnaire-tools.js +22 -4
- package/runtime/dist/src/mcp/server.js +2 -0
- package/runtime/dist/src/mcp/tool-definitions.js +55 -1
- 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 +316 -15
- package/runtime/dist/src/runtime/onboarding-store.js +47 -0
- package/runtime/dist/src/runtime/questionnaire-store.js +78 -4
- package/skill/SKILL.md +1 -1
- package/skill/references/lifecycle.md +45 -20
- package/skill/references/project-onboarding.md +146 -0
- package/skill/references/questionnaires.md +66 -6
- package/skill/references/scaffolding.md +13 -4
|
@@ -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';
|
|
@@ -34,31 +37,63 @@ export class BridgeService {
|
|
|
34
37
|
this.deferDeliveries = dependencies.deferDeliveries ?? true;
|
|
35
38
|
}
|
|
36
39
|
async questionnaireAsk(input) {
|
|
37
|
-
const scope = await this.questionnaireScope(input.repoRoot);
|
|
38
|
-
const { repoRoot: _repoRoot, ...definition } = input;
|
|
40
|
+
const scope = await this.questionnaireScope(input.repoRoot, input.preparation);
|
|
41
|
+
const { repoRoot: _repoRoot, preparation: _preparation, ...definition } = input;
|
|
39
42
|
return await this.dependencies.questionnaires.ask(scope, definition);
|
|
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
|
|
59
|
-
|
|
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:
|
|
71
|
-
|
|
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.
|
|
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
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
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
|
|
@@ -21,6 +21,17 @@ export const questionnaireDefinitionSchema = z.strictObject({
|
|
|
21
21
|
.max(12)
|
|
22
22
|
.refine((values) => new Set(values.map((value) => value.id)).size === values.length),
|
|
23
23
|
allowFreeText: z.boolean().default(false),
|
|
24
|
+
textField: z
|
|
25
|
+
.strictObject({
|
|
26
|
+
title: z.string().trim().min(1).max(120),
|
|
27
|
+
maxLength: z.number().int().min(1).max(2000),
|
|
28
|
+
requiredForChoice: z
|
|
29
|
+
.string()
|
|
30
|
+
.regex(/^[A-Za-z0-9_-]{1,80}$/)
|
|
31
|
+
.optional(),
|
|
32
|
+
valueType: z.enum(['text', 'uuid_list']).optional(),
|
|
33
|
+
})
|
|
34
|
+
.optional(),
|
|
24
35
|
});
|
|
25
36
|
const scopeSchema = z.strictObject({
|
|
26
37
|
principalHash: z.string().regex(/^[0-9a-f]{64}$/),
|
|
@@ -38,18 +49,48 @@ const storedAnswerSchema = z.strictObject({
|
|
|
38
49
|
requestKey: z.string().regex(/^questionnaire_[0-9a-f]{64}$/),
|
|
39
50
|
answeredAt: z.string().datetime(),
|
|
40
51
|
answerAvailable: z.boolean(),
|
|
41
|
-
|
|
52
|
+
withdrawn: z.literal(true).optional(),
|
|
53
|
+
answer: z.strictObject({ choice: z.string(), text: z.string().optional() }).optional(),
|
|
42
54
|
});
|
|
43
55
|
export function questionnaireAnswerSchema(record) {
|
|
56
|
+
const textSchema = record.textField
|
|
57
|
+
? record.textField.valueType === 'uuid_list'
|
|
58
|
+
? z
|
|
59
|
+
.string()
|
|
60
|
+
.trim()
|
|
61
|
+
.min(2)
|
|
62
|
+
.max(record.textField.maxLength)
|
|
63
|
+
.refine((value) => {
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(value);
|
|
66
|
+
if (!Array.isArray(parsed) || new Set(parsed).size !== parsed.length)
|
|
67
|
+
return false;
|
|
68
|
+
return z.array(z.string().uuid()).max(10).safeParse(parsed).success;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}, 'Enter a JSON array containing valid project IDs.')
|
|
74
|
+
.optional()
|
|
75
|
+
: z.string().trim().min(1).max(record.textField.maxLength).optional()
|
|
76
|
+
: record.allowFreeText
|
|
77
|
+
? z.string().trim().min(1).max(2000).optional()
|
|
78
|
+
: z.never().optional();
|
|
44
79
|
return z
|
|
45
80
|
.strictObject({
|
|
46
81
|
choice: z.enum([
|
|
47
82
|
...record.options.map((option) => option.id),
|
|
48
83
|
...(record.allowFreeText ? ['__other__'] : []),
|
|
49
84
|
]),
|
|
50
|
-
text: z.preprocess((value) => (typeof value === 'string' && value.trim() === '' ? undefined : value),
|
|
85
|
+
text: z.preprocess((value) => (typeof value === 'string' && value.trim() === '' ? undefined : value), textSchema),
|
|
51
86
|
})
|
|
52
|
-
.refine((answer) =>
|
|
87
|
+
.refine((answer) => {
|
|
88
|
+
if (record.textField?.requiredForChoice === answer.choice)
|
|
89
|
+
return typeof answer.text === 'string';
|
|
90
|
+
if (answer.choice === '__other__')
|
|
91
|
+
return typeof answer.text === 'string';
|
|
92
|
+
return answer.text === undefined;
|
|
93
|
+
});
|
|
53
94
|
}
|
|
54
95
|
export class QuestionnaireStore {
|
|
55
96
|
root;
|
|
@@ -86,6 +127,7 @@ export class QuestionnaireStore {
|
|
|
86
127
|
message: question.message,
|
|
87
128
|
options: question.options,
|
|
88
129
|
allowFreeText: question.allowFreeText,
|
|
130
|
+
textField: question.textField,
|
|
89
131
|
});
|
|
90
132
|
if (stableStringify(question.scope) !== stableStringify(scope) ||
|
|
91
133
|
question.questionnaireId !== questionnaireId ||
|
|
@@ -97,12 +139,23 @@ export class QuestionnaireStore {
|
|
|
97
139
|
if (!rawAnswer)
|
|
98
140
|
return { ...question, status: 'pending' };
|
|
99
141
|
const answer = storedAnswerSchema.parse(rawAnswer);
|
|
142
|
+
if (answer.withdrawn) {
|
|
143
|
+
if (answer.requestKey !== question.requestKey || answer.answerAvailable || answer.answer)
|
|
144
|
+
throw new Error('Invalid questionnaire withdrawal receipt.');
|
|
145
|
+
return { ...question, ...answer, status: 'withdrawn' };
|
|
146
|
+
}
|
|
100
147
|
if (answer.requestKey !== question.requestKey ||
|
|
101
148
|
(answer.answerAvailable
|
|
102
149
|
? !answer.answer || !question.options.some((option) => option.id === answer.answer.choice)
|
|
103
150
|
: !question.allowFreeText || answer.answer !== undefined)) {
|
|
104
151
|
throw new Error('Questionnaire answer does not match its durable question.');
|
|
105
152
|
}
|
|
153
|
+
if (answer.answer?.text !== undefined && !question.textField)
|
|
154
|
+
throw new Error('Questionnaire text answer does not match its durable question.');
|
|
155
|
+
if (question.textField?.requiredForChoice &&
|
|
156
|
+
question.textField.requiredForChoice === answer.answer?.choice &&
|
|
157
|
+
typeof answer.answer?.text !== 'string')
|
|
158
|
+
throw new Error('Questionnaire text answer is required for this choice.');
|
|
106
159
|
assertSafeToPersist(answer);
|
|
107
160
|
return { ...question, ...answer, status: 'answered' };
|
|
108
161
|
}
|
|
@@ -133,7 +186,10 @@ export class QuestionnaireStore {
|
|
|
133
186
|
const record = await this.get(scope, questionnaireId);
|
|
134
187
|
if (!record || record.requestKey !== requestKey)
|
|
135
188
|
throw new Error('Questionnaire response is stale or belongs to a different scope.');
|
|
189
|
+
if (record.status === 'withdrawn')
|
|
190
|
+
throw new Error('Questionnaire was explicitly withdrawn. Start a new decision if the user resumes the work.');
|
|
136
191
|
const answer = questionnaireAnswerSchema(record).parse(input);
|
|
192
|
+
assertSafeToPersist(answer);
|
|
137
193
|
if (record.status === 'answered') {
|
|
138
194
|
if (record.answer && stableStringify(record.answer) !== stableStringify(answer))
|
|
139
195
|
throw new Error('Questionnaire was already answered differently.');
|
|
@@ -143,16 +199,34 @@ export class QuestionnaireStore {
|
|
|
143
199
|
requestKey,
|
|
144
200
|
answeredAt: new Date().toISOString(),
|
|
145
201
|
answerAvailable: answer.choice !== '__other__',
|
|
146
|
-
...(answer.choice !== '__other__'
|
|
202
|
+
...(answer.choice !== '__other__'
|
|
203
|
+
? { answer: { choice: answer.choice, ...(answer.text ? { text: answer.text } : {}) } }
|
|
204
|
+
: {}),
|
|
147
205
|
};
|
|
148
206
|
const published = await this.publish(this.pathFor(scope, questionnaireId, 'answer.json'), receipt);
|
|
149
207
|
const resolved = (await this.get(scope, questionnaireId));
|
|
208
|
+
if (resolved.status === 'withdrawn')
|
|
209
|
+
throw new Error('Questionnaire was explicitly withdrawn before this response.');
|
|
150
210
|
if (!published &&
|
|
151
211
|
resolved.answer &&
|
|
152
212
|
stableStringify(resolved.answer) !== stableStringify(answer))
|
|
153
213
|
throw new Error('Questionnaire was already answered differently.');
|
|
154
214
|
return { record: resolved, answer: published ? answer : resolved.answer, replayed: !published };
|
|
155
215
|
}
|
|
216
|
+
async withdraw(scope, questionnaireId) {
|
|
217
|
+
const record = await this.get(scope, questionnaireId);
|
|
218
|
+
if (!record)
|
|
219
|
+
throw new Error('No questionnaire exists in this scope.');
|
|
220
|
+
if (record.status !== 'pending')
|
|
221
|
+
return record;
|
|
222
|
+
await this.publish(this.pathFor(scope, questionnaireId, 'answer.json'), {
|
|
223
|
+
requestKey: record.requestKey,
|
|
224
|
+
answeredAt: new Date().toISOString(),
|
|
225
|
+
answerAvailable: false,
|
|
226
|
+
withdrawn: true,
|
|
227
|
+
});
|
|
228
|
+
return (await this.get(scope, questionnaireId));
|
|
229
|
+
}
|
|
156
230
|
scopeRoot(scope) {
|
|
157
231
|
scopeSchema.parse(scope);
|
|
158
232
|
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
|
|