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.
- 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 +730 -0
- package/runtime/dist/src/mcp/questionnaire-tools.js +45 -8
- package/runtime/dist/src/mcp/server.js +2 -0
- package/runtime/dist/src/mcp/tool-definitions.js +56 -2
- 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 +318 -17
- package/runtime/dist/src/runtime/onboarding-store.js +47 -0
- package/runtime/dist/src/runtime/questionnaire-store.js +92 -6
- package/skill/SKILL.md +1 -1
- package/skill/references/lifecycle.md +45 -20
- package/skill/references/project-onboarding.md +159 -0
- package/skill/references/questionnaires.md +79 -6
- package/skill/references/scaffolding.md +13 -4
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { acceptedContent, CLIENT_CAPABILITIES_META_KEY, inputRequired, inputResponse, } from '@modelcontextprotocol/server';
|
|
2
2
|
import { questionnaireAnswerSchema, } from '../runtime/questionnaire-store.js';
|
|
3
|
-
export async function askQuestionnaire(server, service, input, context) {
|
|
3
|
+
export async function askQuestionnaire(server, service, input, context, previousDefinitions = []) {
|
|
4
4
|
try {
|
|
5
|
-
return await present(server, service, await service.questionnaireAsk(input), input.repoRoot, context);
|
|
5
|
+
return await present(server, service, await service.questionnaireAsk(input, previousDefinitions), input.repoRoot, context, input.preparation);
|
|
6
6
|
}
|
|
7
7
|
catch (error) {
|
|
8
8
|
return failure(error);
|
|
@@ -10,13 +10,20 @@ export async function askQuestionnaire(server, service, input, context) {
|
|
|
10
10
|
}
|
|
11
11
|
export async function resumeQuestionnaire(server, service, input, context) {
|
|
12
12
|
try {
|
|
13
|
-
return await present(server, service, await service.questionnaireResume(input), input.repoRoot, context);
|
|
13
|
+
return await present(server, service, await service.questionnaireResume(input), input.repoRoot, context, input.preparation);
|
|
14
14
|
}
|
|
15
15
|
catch (error) {
|
|
16
16
|
return failure(error);
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
-
async function present(server, service, record, repoRoot, context) {
|
|
19
|
+
async function present(server, service, record, repoRoot, context, preparation) {
|
|
20
|
+
if (record.status === 'withdrawn')
|
|
21
|
+
return result({
|
|
22
|
+
questionnaireId: record.questionnaireId,
|
|
23
|
+
status: 'withdrawn',
|
|
24
|
+
answerAvailable: false,
|
|
25
|
+
nextAction: 'This decision was explicitly withdrawn. Do not continue dependent work. Use a new questionnaireId or onboarding requestKey only if the user resumes that work.',
|
|
26
|
+
});
|
|
20
27
|
if (record.status === 'answered')
|
|
21
28
|
return answered(record, record.answer, true);
|
|
22
29
|
if (context.mcpReq.signal.aborted)
|
|
@@ -29,6 +36,7 @@ async function present(server, service, record, repoRoot, context) {
|
|
|
29
36
|
return pending(record, 'invalid_response');
|
|
30
37
|
const resolved = await service.questionnaireAccept({
|
|
31
38
|
repoRoot,
|
|
39
|
+
preparation,
|
|
32
40
|
questionnaireId: record.questionnaireId,
|
|
33
41
|
requestKey: record.requestKey,
|
|
34
42
|
scope: record.scope,
|
|
@@ -45,36 +53,47 @@ async function present(server, service, record, repoRoot, context) {
|
|
|
45
53
|
const elicitation = capabilities?.elicitation;
|
|
46
54
|
if (!elicitation || (!elicitation.form && elicitation.url !== undefined))
|
|
47
55
|
return pending(record, 'native_form_unavailable');
|
|
56
|
+
const copy = questionnaireCopy[record.language ?? 'en'];
|
|
48
57
|
return inputRequired({
|
|
49
58
|
inputRequests: {
|
|
50
59
|
[record.requestKey]: inputRequired.elicit({
|
|
51
60
|
mode: 'form',
|
|
52
|
-
message: `${record.message}\n\
|
|
61
|
+
message: `${record.message}\n\n${copy.pending}${record.allowFreeText ? copy.freeText : ''}${record.textField ? copy.storedText : ''}`,
|
|
53
62
|
requestedSchema: {
|
|
54
63
|
type: 'object',
|
|
55
64
|
properties: {
|
|
56
65
|
choice: {
|
|
57
66
|
type: 'string',
|
|
58
|
-
title:
|
|
67
|
+
title: copy.answer,
|
|
59
68
|
enum: [
|
|
60
69
|
...record.options.map((option) => option.id),
|
|
61
70
|
...(record.allowFreeText ? ['__other__'] : []),
|
|
62
71
|
],
|
|
63
72
|
enumNames: [
|
|
64
73
|
...record.options.map((option) => option.label),
|
|
65
|
-
...(record.allowFreeText ? [
|
|
74
|
+
...(record.allowFreeText ? [copy.other] : []),
|
|
66
75
|
],
|
|
67
76
|
},
|
|
68
77
|
...(record.allowFreeText
|
|
69
78
|
? {
|
|
70
79
|
text: {
|
|
71
80
|
type: 'string',
|
|
72
|
-
title:
|
|
81
|
+
title: copy.otherAnswer,
|
|
73
82
|
minLength: 1,
|
|
74
83
|
maxLength: 2000,
|
|
75
84
|
},
|
|
76
85
|
}
|
|
77
86
|
: {}),
|
|
87
|
+
...(record.textField
|
|
88
|
+
? {
|
|
89
|
+
text: {
|
|
90
|
+
type: 'string',
|
|
91
|
+
title: record.textField.title,
|
|
92
|
+
minLength: 1,
|
|
93
|
+
maxLength: record.textField.maxLength,
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
: {}),
|
|
78
97
|
},
|
|
79
98
|
required: ['choice'],
|
|
80
99
|
},
|
|
@@ -82,6 +101,24 @@ async function present(server, service, record, repoRoot, context) {
|
|
|
82
101
|
},
|
|
83
102
|
});
|
|
84
103
|
}
|
|
104
|
+
const questionnaireCopy = {
|
|
105
|
+
en: {
|
|
106
|
+
pending: 'Choose an answer to resolve this decision. Closing or cancelling the form leaves it pending.',
|
|
107
|
+
freeText: ' For another answer choose Other and fill in text. Free text is not stored and cannot be replayed.',
|
|
108
|
+
storedText: ' The bounded text field is stored as part of this exact decision and is replayed after restart.',
|
|
109
|
+
answer: 'Answer',
|
|
110
|
+
other: 'Other',
|
|
111
|
+
otherAnswer: 'Other answer',
|
|
112
|
+
},
|
|
113
|
+
tr: {
|
|
114
|
+
pending: 'Kararını kaydetmek için bir seçenek seç. Formu kapatmak veya iptal etmek onay sayılmaz; soru beklemede kalır.',
|
|
115
|
+
freeText: ' Farklı bir yanıt için Diğer seçeneğini seçip metin alanını doldur. Serbest yanıt saklanmaz ve daha sonra geri getirilemez.',
|
|
116
|
+
storedText: ' Bu metin alanındaki yanıt kararınla birlikte saklanır; yeniden başlattığında korunur.',
|
|
117
|
+
answer: 'Yanıt',
|
|
118
|
+
other: 'Diğer',
|
|
119
|
+
otherAnswer: 'Diğer yanıt',
|
|
120
|
+
},
|
|
121
|
+
};
|
|
85
122
|
function pending(record, reason) {
|
|
86
123
|
return result({
|
|
87
124
|
questionnaireId: record.questionnaireId,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
+
import { registerOnboardingTools } from './onboarding-tools.js';
|
|
2
3
|
import { registerEngineeringMemoryTools, registerQuestionnaireTools } from './tool-definitions.js';
|
|
3
4
|
export function createEngineeringMemoryServer(service) {
|
|
4
5
|
const server = new McpServer({
|
|
@@ -7,6 +8,7 @@ export function createEngineeringMemoryServer(service) {
|
|
|
7
8
|
});
|
|
8
9
|
registerEngineeringMemoryTools(server, service);
|
|
9
10
|
registerQuestionnaireTools(server, service);
|
|
11
|
+
registerOnboardingTools(server, service);
|
|
10
12
|
return server;
|
|
11
13
|
}
|
|
12
14
|
//# sourceMappingURL=server.js.map
|
|
@@ -49,6 +49,7 @@ export const engineeringMemoryToolNames = [
|
|
|
49
49
|
'audit.list',
|
|
50
50
|
'questionnaire.ask',
|
|
51
51
|
'questionnaire.resume',
|
|
52
|
+
'questionnaire.withdraw',
|
|
52
53
|
'session.entry',
|
|
53
54
|
'session.set_decision',
|
|
54
55
|
'session.decline_update',
|
|
@@ -57,6 +58,24 @@ export const engineeringMemoryToolNames = [
|
|
|
57
58
|
'session.resume',
|
|
58
59
|
'context.prepare_change',
|
|
59
60
|
'context.refresh',
|
|
61
|
+
'project.inspect',
|
|
62
|
+
'project.onboard',
|
|
63
|
+
'project.onboarding_status',
|
|
64
|
+
'project.rework_plan',
|
|
65
|
+
'project.initialize',
|
|
66
|
+
'memory.source',
|
|
67
|
+
'memory.sync_start',
|
|
68
|
+
'memory.sync_status',
|
|
69
|
+
'memory.sync_inventory',
|
|
70
|
+
'memory.sync_plan',
|
|
71
|
+
'memory.sync_next',
|
|
72
|
+
'memory.sync_submit',
|
|
73
|
+
'memory.sync_reopen',
|
|
74
|
+
'memory.sync_delta',
|
|
75
|
+
'memory.sync_verify',
|
|
76
|
+
'memory.catalog',
|
|
77
|
+
'memory.read_revisions',
|
|
78
|
+
'memory.review_batch',
|
|
60
79
|
'memory.query',
|
|
61
80
|
'memory.history',
|
|
62
81
|
'memory.propose_revision',
|
|
@@ -121,15 +140,49 @@ const reconciliationEntry = z.object({
|
|
|
121
140
|
reason: z.string().optional(),
|
|
122
141
|
});
|
|
123
142
|
export function registerQuestionnaireTools(server, service) {
|
|
143
|
+
server.registerTool('questionnaire.withdraw', {
|
|
144
|
+
description: 'Withdraw a pending decision only when the user explicitly cancels that decision or its owning workflow. This is not approval. An answered decision is immutable. Interruption, timeout and dismissal alone never authorize withdrawal.',
|
|
145
|
+
inputSchema: z.strictObject({
|
|
146
|
+
repoRoot: optionalRepoRoot,
|
|
147
|
+
questionnaireId: z.string().regex(/^[A-Za-z0-9_-]{1,100}$/),
|
|
148
|
+
preparation: z.boolean().optional(),
|
|
149
|
+
}),
|
|
150
|
+
}, async (input) => {
|
|
151
|
+
try {
|
|
152
|
+
const record = await service.questionnaireWithdraw(input);
|
|
153
|
+
return toolResult({
|
|
154
|
+
ok: true,
|
|
155
|
+
data: {
|
|
156
|
+
questionnaireId: record.questionnaireId,
|
|
157
|
+
status: record.status,
|
|
158
|
+
answerAvailable: record.answerAvailable ?? false,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return toolResult({
|
|
164
|
+
ok: false,
|
|
165
|
+
error: {
|
|
166
|
+
kind: 'bridge_error',
|
|
167
|
+
message: 'Cannot withdraw this questionnaire. Check the account and repository with session.entry and the exact questionnaireId with questionnaire.resume.',
|
|
168
|
+
retryable: false,
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
});
|
|
124
173
|
server.registerTool('questionnaire.ask', {
|
|
125
|
-
description: 'Persist a required decision before displaying a native questionnaire. Use a questionnaireId unique to this decision occurrence or task, and reuse it only for identical retries. A later decision or changed wording/options requires a new id. Only a schema-validated native acceptance answers it. Dismissal, timeout and missing replies remain pending without expiry. Never supply answers in tool arguments. Do not put personal information or secrets in the question or options. Free text is returned once and never stored; use explicit options for replayable decisions.',
|
|
126
|
-
inputSchema: questionnaireDefinitionSchema.extend({
|
|
174
|
+
description: 'Persist a required decision before displaying a native questionnaire. Write the message and labels in the conversation language and set language (tr or en) for the native form help. Use a questionnaireId unique to this decision occurrence or task, and reuse it only for identical retries. A later decision or changed wording/options requires a new id. Only a schema-validated native acceptance answers it. Dismissal, timeout and missing replies remain pending without expiry. Never supply answers in tool arguments. Do not put personal information or secrets in the question or options. Free text is returned once and never stored; use explicit options for replayable decisions.',
|
|
175
|
+
inputSchema: questionnaireDefinitionSchema.extend({
|
|
176
|
+
repoRoot: optionalRepoRoot,
|
|
177
|
+
preparation: z.boolean().optional(),
|
|
178
|
+
}),
|
|
127
179
|
}, async (input, context) => await askQuestionnaire(server, service, input, context));
|
|
128
180
|
server.registerTool('questionnaire.resume', {
|
|
129
181
|
description: 'Display the original durable questionnaire after interruption or return its saved accepted choice. No timeout or dismissal resolves a pending question. A saved free-text receipt cannot replay the text and must never be treated as a recovered answer.',
|
|
130
182
|
inputSchema: z.strictObject({
|
|
131
183
|
repoRoot: optionalRepoRoot,
|
|
132
184
|
questionnaireId: z.string().regex(/^[A-Za-z0-9_-]{1,100}$/),
|
|
185
|
+
preparation: z.boolean().optional(),
|
|
133
186
|
}),
|
|
134
187
|
}, async (input, context) => await resumeQuestionnaire(server, service, input, context));
|
|
135
188
|
}
|
|
@@ -286,6 +339,7 @@ export function registerEngineeringMemoryTools(server, service) {
|
|
|
286
339
|
tags: stringList.optional(),
|
|
287
340
|
metadata: jsonObject.optional(),
|
|
288
341
|
provenance: jsonObject.optional(),
|
|
342
|
+
status: z.enum(['active', 'archived']).optional(),
|
|
289
343
|
}),
|
|
290
344
|
}, async (input) => toolResult(await service.memoryProposeRevision(input)));
|
|
291
345
|
server.registerTool('memory.list_proposals', {
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { lstat, readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { canonicalPath, ensureManagedDirectory } from '../utilities/files.js';
|
|
4
|
+
import { NativeCommandRunner } from '../utilities/process.js';
|
|
5
|
+
export class ProjectIntake {
|
|
6
|
+
runner;
|
|
7
|
+
constructor(runner = new NativeCommandRunner()) {
|
|
8
|
+
this.runner = runner;
|
|
9
|
+
}
|
|
10
|
+
async inspect(repoPath) {
|
|
11
|
+
const requested = resolve(repoPath);
|
|
12
|
+
let root;
|
|
13
|
+
try {
|
|
14
|
+
const requestedInfo = await lstat(requested);
|
|
15
|
+
if (requestedInfo.isSymbolicLink())
|
|
16
|
+
throw new Error('Project path must not be a symbolic link');
|
|
17
|
+
root = await canonicalPath(requested);
|
|
18
|
+
const info = await lstat(root);
|
|
19
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
20
|
+
throw new Error('Project path must be a real directory');
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error instanceof Error &&
|
|
24
|
+
'code' in error &&
|
|
25
|
+
error.code === 'ENOENT') {
|
|
26
|
+
return emptyReport(requested, 'missing', ['directory does not exist']);
|
|
27
|
+
}
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
31
|
+
if (entries.length === 0)
|
|
32
|
+
return emptyReport(root, 'empty', ['directory exists and is empty']);
|
|
33
|
+
const inventory = await collectInventory(root);
|
|
34
|
+
const evidence = [];
|
|
35
|
+
const stacks = [];
|
|
36
|
+
for (const detected of await detectStacks(root, inventory.manifestCandidates)) {
|
|
37
|
+
if (stacks.includes(detected.stack))
|
|
38
|
+
continue;
|
|
39
|
+
stacks.push(detected.stack);
|
|
40
|
+
evidence.push(detected.evidence);
|
|
41
|
+
}
|
|
42
|
+
const hasGit = await this.runner.run('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
43
|
+
cwd: root,
|
|
44
|
+
});
|
|
45
|
+
const applicationAreas = [
|
|
46
|
+
'state',
|
|
47
|
+
'services',
|
|
48
|
+
'network',
|
|
49
|
+
'persistence',
|
|
50
|
+
'architecture',
|
|
51
|
+
'screens',
|
|
52
|
+
'components',
|
|
53
|
+
'navigation',
|
|
54
|
+
].filter((area) => inventory.areaCounts[area] > 0);
|
|
55
|
+
const hasApplicationAreas = applicationAreas.length > 0;
|
|
56
|
+
if (hasApplicationAreas)
|
|
57
|
+
evidence.push(`application structural sources found in ${applicationAreas.join(', ')}`);
|
|
58
|
+
const recommendation = hasApplicationAreas
|
|
59
|
+
? 'established'
|
|
60
|
+
: meaningfulEntries(entries).length === 0
|
|
61
|
+
? 'empty'
|
|
62
|
+
: 'scaffold';
|
|
63
|
+
const reasons = stacks.length > 0
|
|
64
|
+
? hasApplicationAreas
|
|
65
|
+
? [`detected ${stacks.join(', ')} with application structural evidence`]
|
|
66
|
+
: [`detected ${stacks.join(', ')} without application structural evidence`]
|
|
67
|
+
: hasApplicationAreas
|
|
68
|
+
? [`application structural sources found in ${applicationAreas.join(', ')}`]
|
|
69
|
+
: meaningfulEntries(entries).length === 0
|
|
70
|
+
? ['directory contains only Git metadata']
|
|
71
|
+
: hasGit.exitCode === 0
|
|
72
|
+
? [
|
|
73
|
+
'Git repository has no recognized stack manifest or application architectural signals',
|
|
74
|
+
]
|
|
75
|
+
: [
|
|
76
|
+
'directory has content but no recognized stack manifest or application architectural signals',
|
|
77
|
+
];
|
|
78
|
+
return { repoPath: root, recommendation, reasons, stacks, evidence, inventory };
|
|
79
|
+
}
|
|
80
|
+
async initialize(repoPath) {
|
|
81
|
+
const requested = resolve(repoPath);
|
|
82
|
+
await assertNoRepositoryAncestor(requested, this.runner);
|
|
83
|
+
let exists = true;
|
|
84
|
+
try {
|
|
85
|
+
const requestedInfo = await lstat(requested);
|
|
86
|
+
if (requestedInfo.isSymbolicLink())
|
|
87
|
+
throw new Error('Project path must not be a symbolic link');
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error instanceof Error &&
|
|
91
|
+
'code' in error &&
|
|
92
|
+
error.code === 'ENOENT')
|
|
93
|
+
exists = false;
|
|
94
|
+
else
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
if (!exists) {
|
|
98
|
+
await ensureManagedDirectory(dirname(requested), requested);
|
|
99
|
+
}
|
|
100
|
+
const root = await canonicalPath(requested);
|
|
101
|
+
const info = await lstat(root);
|
|
102
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
103
|
+
throw new Error('Project path must be a real directory');
|
|
104
|
+
const parentGit = await this.runner.run('git', ['rev-parse', '--show-toplevel'], { cwd: root });
|
|
105
|
+
if (parentGit.exitCode === 0 && resolve(parentGit.stdout.trim()) !== root)
|
|
106
|
+
throw new Error('Refusing to initialize inside an existing parent repository');
|
|
107
|
+
const gitPath = join(root, '.git');
|
|
108
|
+
const existingGit = await lstat(gitPath).then((entry) => {
|
|
109
|
+
if (entry.isSymbolicLink())
|
|
110
|
+
throw new Error('Refusing to use a symbolic-link .git path');
|
|
111
|
+
return true;
|
|
112
|
+
}, (error) => (error.code === 'ENOENT' ? false : Promise.reject(error)));
|
|
113
|
+
if (existingGit)
|
|
114
|
+
return { repoPath: root, initialized: false };
|
|
115
|
+
const result = await this.runner.run('git', ['init', '--quiet', '--'], { cwd: root });
|
|
116
|
+
if (result.exitCode !== 0)
|
|
117
|
+
throw new Error(`Git initialization failed: ${result.stderr.trim()}`);
|
|
118
|
+
return { repoPath: root, initialized: true };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function emptyReport(repoPath, recommendation, reasons) {
|
|
122
|
+
return {
|
|
123
|
+
repoPath,
|
|
124
|
+
recommendation,
|
|
125
|
+
reasons,
|
|
126
|
+
stacks: [],
|
|
127
|
+
evidence: [],
|
|
128
|
+
inventory: {
|
|
129
|
+
all: [],
|
|
130
|
+
state: [],
|
|
131
|
+
services: [],
|
|
132
|
+
network: [],
|
|
133
|
+
persistence: [],
|
|
134
|
+
architecture: [],
|
|
135
|
+
screens: [],
|
|
136
|
+
components: [],
|
|
137
|
+
navigation: [],
|
|
138
|
+
symlinks: [],
|
|
139
|
+
totalFiles: 0,
|
|
140
|
+
omittedFiles: 0,
|
|
141
|
+
prunedDirectories: [],
|
|
142
|
+
prunedDirectoryCount: 0,
|
|
143
|
+
omittedPrunedDirectories: 0,
|
|
144
|
+
sampleLimit: INVENTORY_SAMPLE_LIMIT,
|
|
145
|
+
areaCounts: emptyAreaCounts(),
|
|
146
|
+
manifestCandidates: [],
|
|
147
|
+
manifestCandidatesOmitted: 0,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
async function collectInventory(root) {
|
|
152
|
+
const all = [];
|
|
153
|
+
const state = [];
|
|
154
|
+
const services = [];
|
|
155
|
+
const network = [];
|
|
156
|
+
const persistence = [];
|
|
157
|
+
const architecture = [];
|
|
158
|
+
const screens = [];
|
|
159
|
+
const components = [];
|
|
160
|
+
const navigation = [];
|
|
161
|
+
const symlinks = [];
|
|
162
|
+
const prunedDirectories = [];
|
|
163
|
+
let prunedDirectoryCount = 0;
|
|
164
|
+
const areaCounts = emptyAreaCounts();
|
|
165
|
+
const manifestCandidates = [];
|
|
166
|
+
let manifestCandidatesOmitted = 0;
|
|
167
|
+
let totalFiles = 0;
|
|
168
|
+
const walk = async (directory) => {
|
|
169
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
170
|
+
if (entry.name === '.git')
|
|
171
|
+
continue;
|
|
172
|
+
const path = join(directory, entry.name);
|
|
173
|
+
const relativePath = path.slice(root.length + 1).replace(/\\/g, '/');
|
|
174
|
+
if (entry.isSymbolicLink()) {
|
|
175
|
+
if (symlinks.length < INVENTORY_SAMPLE_LIMIT) {
|
|
176
|
+
all.push(relativePath);
|
|
177
|
+
symlinks.push(relativePath);
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (entry.isDirectory()) {
|
|
182
|
+
if (await shouldPruneDirectory(root, relativePath)) {
|
|
183
|
+
prunedDirectoryCount += 1;
|
|
184
|
+
if (prunedDirectories.length < INVENTORY_SAMPLE_LIMIT)
|
|
185
|
+
prunedDirectories.push(relativePath);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
await walk(path);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
totalFiles += 1;
|
|
192
|
+
const sampled = totalFiles <= INVENTORY_SAMPLE_LIMIT;
|
|
193
|
+
if (sampled)
|
|
194
|
+
all.push(relativePath);
|
|
195
|
+
const lower = relativePath.toLowerCase();
|
|
196
|
+
if (['package.json', 'pubspec.yaml', 'pom.xml', 'build.gradle', 'build.gradle.kts'].includes(entry.name.toLowerCase())) {
|
|
197
|
+
if (manifestCandidates.length < MANIFEST_CANDIDATE_LIMIT)
|
|
198
|
+
manifestCandidates.push(relativePath);
|
|
199
|
+
else
|
|
200
|
+
manifestCandidatesOmitted += 1;
|
|
201
|
+
}
|
|
202
|
+
const addArea = (area, target) => {
|
|
203
|
+
areaCounts[area] += 1;
|
|
204
|
+
if (target.length < INVENTORY_SAMPLE_LIMIT)
|
|
205
|
+
target.push(relativePath);
|
|
206
|
+
};
|
|
207
|
+
if (/(^|\/)(architecture|adr|adrs|design|docs?)(\/|\.|$)|(^|\/)(architecture|adr)[^/]*\.(md|ya?ml|json)$/.test(lower))
|
|
208
|
+
addArea('architecture', architecture);
|
|
209
|
+
if (!entry.isFile() ||
|
|
210
|
+
!/\.(?:[cm]?[jt]sx?|dart|java|kt|kts|swift|cs|go|rs|py|rb|vue|svelte|openapi|graphql)$/.test(lower) ||
|
|
211
|
+
/(^|\/)(node_modules|vendor|pods|\.dart_tool|\.next|\.nuxt)(\/|$)|\.(g|freezed)\.dart$|(^|\/)(__tests__|tests?|spec)(\/|$)|\.(test|spec)\.[^/]+$|_test\.[^/]+$/.test(lower) ||
|
|
212
|
+
isCredentialDataPath(lower))
|
|
213
|
+
continue;
|
|
214
|
+
if (/(^|\/)(state|store|stores|reducers?|providers?|bloc|cubits?)(\/|\.|$)|(?:state|store|reducer|provider|bloc|cubit)\.[^/]+$/.test(lower))
|
|
215
|
+
addArea('state', state);
|
|
216
|
+
if (/(^|\/)(service|services|use-cases?|usecases|domain)(\/|\.|$)|service\.[^/]+$/.test(lower))
|
|
217
|
+
addArea('services', services);
|
|
218
|
+
if (/(^|\/)(api|apis|network|http|graphql|routes?|controllers?|clients?)(\/|\.|$)|(?:controller|client)\.[^/]+$|\.(openapi|graphql)$/.test(lower))
|
|
219
|
+
addArea('network', network);
|
|
220
|
+
if (/(^|\/)(persistence|repositories?|storage|database|db|migrations?|entities|models?)(\/|\.|$)|(?:repository|entity|model)\.[^/]+$/.test(lower))
|
|
221
|
+
addArea('persistence', persistence);
|
|
222
|
+
if (/(^|\/)(screens?|pages?|views?)(\/|\.|$)|(^|\/)[^/]*(?:screen|page|view)\.[^/]+$/.test(lower))
|
|
223
|
+
addArea('screens', screens);
|
|
224
|
+
if (/(^|\/)(components?|widgets?)(\/|\.|$)|(^|\/)[^/]*component\.[^/]+$/.test(lower))
|
|
225
|
+
addArea('components', components);
|
|
226
|
+
if (/(^|\/)(navigation|routing|routes?|router)(\/|\.|$)|(^|\/)[^/]*(?:navigation|router|routes?)\.[^/]+$/.test(lower))
|
|
227
|
+
addArea('navigation', navigation);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
await walk(root);
|
|
231
|
+
const sort = (values) => values.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
|
|
232
|
+
return {
|
|
233
|
+
all: sort(all),
|
|
234
|
+
state: sort(state),
|
|
235
|
+
services: sort(services),
|
|
236
|
+
network: sort(network),
|
|
237
|
+
persistence: sort(persistence),
|
|
238
|
+
architecture: sort(architecture),
|
|
239
|
+
screens: sort(screens),
|
|
240
|
+
components: sort(components),
|
|
241
|
+
navigation: sort(navigation),
|
|
242
|
+
symlinks: sort(symlinks),
|
|
243
|
+
totalFiles,
|
|
244
|
+
omittedFiles: Math.max(0, totalFiles - INVENTORY_SAMPLE_LIMIT),
|
|
245
|
+
prunedDirectories: sort(prunedDirectories),
|
|
246
|
+
prunedDirectoryCount,
|
|
247
|
+
omittedPrunedDirectories: Math.max(0, prunedDirectoryCount - INVENTORY_SAMPLE_LIMIT),
|
|
248
|
+
sampleLimit: INVENTORY_SAMPLE_LIMIT,
|
|
249
|
+
areaCounts,
|
|
250
|
+
manifestCandidates: manifestCandidates.sort(),
|
|
251
|
+
manifestCandidatesOmitted,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const INVENTORY_SAMPLE_LIMIT = 1000;
|
|
255
|
+
const MANIFEST_CANDIDATE_LIMIT = 512;
|
|
256
|
+
function emptyAreaCounts() {
|
|
257
|
+
return {
|
|
258
|
+
state: 0,
|
|
259
|
+
services: 0,
|
|
260
|
+
network: 0,
|
|
261
|
+
persistence: 0,
|
|
262
|
+
architecture: 0,
|
|
263
|
+
screens: 0,
|
|
264
|
+
components: 0,
|
|
265
|
+
navigation: 0,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
async function shouldPruneDirectory(root, relativePath) {
|
|
269
|
+
const segments = relativePath.toLowerCase().split('/');
|
|
270
|
+
const name = segments.at(-1) ?? '';
|
|
271
|
+
if (/^(node_modules|vendor|pods|\.venv|\.gradle|\.dart_tool|\.next|\.nuxt|coverage)$/.test(name))
|
|
272
|
+
return true;
|
|
273
|
+
if (!/^(dist|build|out|target|bin|obj)$/.test(name))
|
|
274
|
+
return false;
|
|
275
|
+
const parent = dirname(join(root, relativePath));
|
|
276
|
+
const names = (await readdir(parent)).map((entry) => entry.toLowerCase());
|
|
277
|
+
if (names.some((entry) => /^(package\.json|pubspec\.yaml|pom\.xml|build\.gradle(?:\.kts)?)$|\.(csproj|fsproj|vbproj)$/.test(entry)))
|
|
278
|
+
return true;
|
|
279
|
+
if (segments.slice(0, -1).some((segment) => /^(src|lib|app|packages)$/.test(segment)))
|
|
280
|
+
return false;
|
|
281
|
+
return !['bin', 'obj'].includes(name);
|
|
282
|
+
}
|
|
283
|
+
function isCredentialDataPath(path) {
|
|
284
|
+
const segments = path.split('/');
|
|
285
|
+
const basename = segments.at(-1) ?? '';
|
|
286
|
+
if (/(^|\/)(\.env(?:\.[^/]*)?)(\/|$)/.test(path) ||
|
|
287
|
+
/\.(pem|key|p12|pfx|jks|keystore)$/.test(path) ||
|
|
288
|
+
/(^|\/)id_(rsa|dsa|ecdsa|ed25519)$/.test(path))
|
|
289
|
+
return true;
|
|
290
|
+
const parentIsSensitive = segments
|
|
291
|
+
.slice(0, -1)
|
|
292
|
+
.some((segment) => /^(?:secret|secrets|credential|credentials)$/i.test(segment));
|
|
293
|
+
return parentIsSensitive && /\.(json|ya?ml|toml|ini|conf|cfg|txt)$/i.test(basename);
|
|
294
|
+
}
|
|
295
|
+
async function detectStacks(root, manifestCandidates) {
|
|
296
|
+
const detected = [];
|
|
297
|
+
for (const candidate of manifestCandidates) {
|
|
298
|
+
const directory = dirname(join(root, candidate));
|
|
299
|
+
const packageManifest = candidate.toLowerCase().endsWith('package.json')
|
|
300
|
+
? await readJsonManifest(join(root, candidate))
|
|
301
|
+
: null;
|
|
302
|
+
const dependencies = new Set([
|
|
303
|
+
...Object.keys(packageManifest?.dependencies ?? {}),
|
|
304
|
+
...Object.keys(packageManifest?.devDependencies ?? {}),
|
|
305
|
+
]);
|
|
306
|
+
const relative = directory.slice(root.length + 1).replace(/\\/g, '/') || '.';
|
|
307
|
+
const prefix = relative === '.' ? '' : `${relative}/`;
|
|
308
|
+
if (candidate.toLowerCase().endsWith('pubspec.yaml')) {
|
|
309
|
+
const pubspec = await readBoundedText(join(root, candidate));
|
|
310
|
+
if (pubspec !== null && isFlutterManifest(pubspec))
|
|
311
|
+
detected.push({ stack: 'Flutter', evidence: `${prefix}pubspec.yaml declares Flutter` });
|
|
312
|
+
}
|
|
313
|
+
if (candidate.toLowerCase().endsWith('package.json') && dependencies.has('@nestjs/core'))
|
|
314
|
+
detected.push({ stack: 'Nest', evidence: `${prefix}package.json declares @nestjs/core` });
|
|
315
|
+
if (candidate.toLowerCase().endsWith('package.json') &&
|
|
316
|
+
(dependencies.has('react') || dependencies.has('react-dom')))
|
|
317
|
+
detected.push({ stack: 'React', evidence: `${prefix}package.json declares React` });
|
|
318
|
+
if (/build\.gradle(?:\.kts)?$/i.test(candidate)) {
|
|
319
|
+
const gradle = await readBoundedText(join(root, candidate));
|
|
320
|
+
if (gradle !== null && isSpringGradleManifest(gradle))
|
|
321
|
+
detected.push({ stack: 'Spring', evidence: `${candidate} declares Spring` });
|
|
322
|
+
}
|
|
323
|
+
if (candidate.toLowerCase().endsWith('pom.xml')) {
|
|
324
|
+
const pom = await readBoundedText(join(root, candidate));
|
|
325
|
+
if (pom !== null && isSpringManifest(pom))
|
|
326
|
+
detected.push({ stack: 'Spring', evidence: `${prefix}pom.xml declares Spring` });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return detected;
|
|
330
|
+
}
|
|
331
|
+
function meaningfulEntries(entries) {
|
|
332
|
+
return entries.filter((entry) => entry.name !== '.git');
|
|
333
|
+
}
|
|
334
|
+
async function readBoundedText(path, maxBytes = 1024 * 1024) {
|
|
335
|
+
try {
|
|
336
|
+
const info = await lstat(path);
|
|
337
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > maxBytes)
|
|
338
|
+
return null;
|
|
339
|
+
return await readFile(path, 'utf8');
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
if (error instanceof Error &&
|
|
343
|
+
'code' in error &&
|
|
344
|
+
error.code === 'ENOENT')
|
|
345
|
+
return null;
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
async function readJsonManifest(path) {
|
|
350
|
+
const text = await readBoundedText(path);
|
|
351
|
+
if (text === null)
|
|
352
|
+
return null;
|
|
353
|
+
try {
|
|
354
|
+
const value = JSON.parse(text);
|
|
355
|
+
return value !== null && typeof value === 'object'
|
|
356
|
+
? value
|
|
357
|
+
: null;
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function isFlutterManifest(text) {
|
|
364
|
+
return (/(^|\n)\s*(?:flutter|flutter_test|flutter_lints)\s*:/m.test(text) ||
|
|
365
|
+
/(^|\n)\s*sdk\s*:\s*flutter\s*$/m.test(text));
|
|
366
|
+
}
|
|
367
|
+
function isSpringGradleManifest(text) {
|
|
368
|
+
const declarations = text.replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, '');
|
|
369
|
+
return /\bid\s*(?:\(\s*)?["']org\.springframework\.boot["']|\bapply\s+plugin\s*:\s*["']org\.springframework\.boot["']|["']org\.springframework(?:\.boot)?:[^"'\r\n]+["']/.test(declarations);
|
|
370
|
+
}
|
|
371
|
+
function isSpringManifest(text) {
|
|
372
|
+
return /org\.springframework|spring-boot-(?:starter|maven-plugin)|spring-boot-starter-parent/i.test(text);
|
|
373
|
+
}
|
|
374
|
+
async function assertNoRepositoryAncestor(path, runner) {
|
|
375
|
+
const target = resolve(path);
|
|
376
|
+
let current = target;
|
|
377
|
+
let checkedRepository = false;
|
|
378
|
+
while (true) {
|
|
379
|
+
try {
|
|
380
|
+
const info = await lstat(current);
|
|
381
|
+
if (info.isSymbolicLink())
|
|
382
|
+
throw new Error('Project path ancestry must not contain symbolic links');
|
|
383
|
+
if (!info.isDirectory())
|
|
384
|
+
throw new Error('Project path must be a real directory');
|
|
385
|
+
if (!checkedRepository) {
|
|
386
|
+
const result = await runner.run('git', ['rev-parse', '--show-toplevel'], { cwd: current });
|
|
387
|
+
if (result.exitCode === 0 &&
|
|
388
|
+
resolve(result.stdout.trim()) !== (await canonicalPath(target))) {
|
|
389
|
+
throw new Error('Refusing to initialize inside an existing parent repository');
|
|
390
|
+
}
|
|
391
|
+
checkedRepository = true;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
if (!(error instanceof Error &&
|
|
396
|
+
'code' in error &&
|
|
397
|
+
error.code === 'ENOENT'))
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
const parent = dirname(current);
|
|
401
|
+
if (parent === current)
|
|
402
|
+
return;
|
|
403
|
+
current = parent;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
//# sourceMappingURL=project-intake.js.map
|
|
@@ -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',
|