engineering-memory 1.10.2 → 1.10.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dispatcher/sections.mjs +4 -2
- package/package.json +1 -1
- package/runtime/dist/src/git/source-snapshot.js +450 -0
- package/runtime/dist/src/index.js +4 -0
- package/runtime/dist/src/mcp/onboarding-tools.js +663 -0
- package/runtime/dist/src/mcp/questionnaire-tools.js +149 -0
- package/runtime/dist/src/mcp/server.js +4 -1
- package/runtime/dist/src/mcp/tool-definitions.js +71 -0
- package/runtime/dist/src/project/project-intake.js +406 -0
- package/runtime/dist/src/runtime/api-client.js +3 -0
- package/runtime/dist/src/runtime/bridge-service.js +393 -8
- package/runtime/dist/src/runtime/onboarding-store.js +47 -0
- package/runtime/dist/src/runtime/questionnaire-store.js +259 -0
- package/runtime/dist/src/utilities/files.js +8 -1
- package/skill/SKILL.md +2 -2
- package/skill/references/lifecycle.md +47 -20
- package/skill/references/project-onboarding.md +146 -0
- package/skill/references/questionnaires.md +85 -7
- package/skill/references/scaffolding.md +13 -4
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { acceptedContent, CLIENT_CAPABILITIES_META_KEY, inputRequired, inputResponse, } from '@modelcontextprotocol/server';
|
|
2
|
+
import { questionnaireAnswerSchema, } from '../runtime/questionnaire-store.js';
|
|
3
|
+
export async function askQuestionnaire(server, service, input, context) {
|
|
4
|
+
try {
|
|
5
|
+
return await present(server, service, await service.questionnaireAsk(input), input.repoRoot, context, input.preparation);
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
return failure(error);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export async function resumeQuestionnaire(server, service, input, context) {
|
|
12
|
+
try {
|
|
13
|
+
return await present(server, service, await service.questionnaireResume(input), input.repoRoot, context, input.preparation);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
return failure(error);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
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
|
+
});
|
|
27
|
+
if (record.status === 'answered')
|
|
28
|
+
return answered(record, record.answer, true);
|
|
29
|
+
if (context.mcpReq.signal.aborted)
|
|
30
|
+
return pending(record, 'interrupted');
|
|
31
|
+
const responses = context.mcpReq.inputResponses;
|
|
32
|
+
const response = inputResponse(responses, record.requestKey);
|
|
33
|
+
if (response.kind === 'elicit' && response.action === 'accept') {
|
|
34
|
+
const answer = acceptedContent(responses, record.requestKey, questionnaireAnswerSchema(record));
|
|
35
|
+
if (!answer)
|
|
36
|
+
return pending(record, 'invalid_response');
|
|
37
|
+
const resolved = await service.questionnaireAccept({
|
|
38
|
+
repoRoot,
|
|
39
|
+
preparation,
|
|
40
|
+
questionnaireId: record.questionnaireId,
|
|
41
|
+
requestKey: record.requestKey,
|
|
42
|
+
scope: record.scope,
|
|
43
|
+
answer,
|
|
44
|
+
});
|
|
45
|
+
return answered(resolved.record, resolved.answer, resolved.replayed);
|
|
46
|
+
}
|
|
47
|
+
if (response.kind === 'elicit')
|
|
48
|
+
return pending(record, response.action);
|
|
49
|
+
if (responses !== undefined || context.mcpReq.droppedInputResponseKeys?.length)
|
|
50
|
+
return pending(record, 'missing_or_stale_response');
|
|
51
|
+
const envelope = context.mcpReq.envelope;
|
|
52
|
+
const capabilities = envelope?.[CLIENT_CAPABILITIES_META_KEY] ?? server.server.getClientCapabilities();
|
|
53
|
+
const elicitation = capabilities?.elicitation;
|
|
54
|
+
if (!elicitation || (!elicitation.form && elicitation.url !== undefined))
|
|
55
|
+
return pending(record, 'native_form_unavailable');
|
|
56
|
+
return inputRequired({
|
|
57
|
+
inputRequests: {
|
|
58
|
+
[record.requestKey]: inputRequired.elicit({
|
|
59
|
+
mode: 'form',
|
|
60
|
+
message: `${record.message}\n\nChoose an answer to resolve this decision. Closing or cancelling the form leaves it pending.${record.allowFreeText ? ' For another answer choose Other and fill in text. Free text is not stored and cannot be replayed.' : ''}${record.textField ? ' The bounded text field is stored as part of this exact decision and is replayed after restart.' : ''}`,
|
|
61
|
+
requestedSchema: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {
|
|
64
|
+
choice: {
|
|
65
|
+
type: 'string',
|
|
66
|
+
title: 'Answer',
|
|
67
|
+
enum: [
|
|
68
|
+
...record.options.map((option) => option.id),
|
|
69
|
+
...(record.allowFreeText ? ['__other__'] : []),
|
|
70
|
+
],
|
|
71
|
+
enumNames: [
|
|
72
|
+
...record.options.map((option) => option.label),
|
|
73
|
+
...(record.allowFreeText ? ['Other'] : []),
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
...(record.allowFreeText
|
|
77
|
+
? {
|
|
78
|
+
text: {
|
|
79
|
+
type: 'string',
|
|
80
|
+
title: 'Other answer',
|
|
81
|
+
minLength: 1,
|
|
82
|
+
maxLength: 2000,
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
: {}),
|
|
86
|
+
...(record.textField
|
|
87
|
+
? {
|
|
88
|
+
text: {
|
|
89
|
+
type: 'string',
|
|
90
|
+
title: record.textField.title,
|
|
91
|
+
minLength: 1,
|
|
92
|
+
maxLength: record.textField.maxLength,
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
: {}),
|
|
96
|
+
},
|
|
97
|
+
required: ['choice'],
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function pending(record, reason) {
|
|
104
|
+
return result({
|
|
105
|
+
questionnaireId: record.questionnaireId,
|
|
106
|
+
status: 'pending',
|
|
107
|
+
reason,
|
|
108
|
+
answerAvailable: false,
|
|
109
|
+
nextAction: reason === 'native_form_unavailable'
|
|
110
|
+
? 'This host does not advertise native MCP forms. The decision remains pending. Explain the limitation and continue only independently authorized work. Use a supported native host and questionnaire.resume to answer; do not silently substitute an asynchronous question.'
|
|
111
|
+
: 'The decision remains pending without expiry. Call questionnaire.resume with this questionnaireId to show the same question again. Do not infer an answer or continue dependent work.',
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function answered(record, answer, replayed) {
|
|
115
|
+
return result({
|
|
116
|
+
questionnaireId: record.questionnaireId,
|
|
117
|
+
status: 'answered',
|
|
118
|
+
answerAvailable: answer !== undefined,
|
|
119
|
+
...(answer ? { answer } : {}),
|
|
120
|
+
replayed,
|
|
121
|
+
...(!answer
|
|
122
|
+
? {
|
|
123
|
+
nextAction: 'The free-text answer was accepted earlier but was not stored for privacy. It cannot be recovered or inferred. Use the original answer if still present in this conversation; otherwise collect it again through a new questionnaire or supported native input before dependent work.',
|
|
124
|
+
}
|
|
125
|
+
: {}),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function result(data) {
|
|
129
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, data }) }] };
|
|
130
|
+
}
|
|
131
|
+
function failure(error) {
|
|
132
|
+
return {
|
|
133
|
+
content: [
|
|
134
|
+
{
|
|
135
|
+
type: 'text',
|
|
136
|
+
text: JSON.stringify({
|
|
137
|
+
ok: false,
|
|
138
|
+
error: {
|
|
139
|
+
kind: 'questionnaire_error',
|
|
140
|
+
message: error instanceof Error ? error.message : 'Questionnaire operation failed.',
|
|
141
|
+
recovery: 'session.entry',
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
isError: true,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
//# sourceMappingURL=questionnaire-tools.js.map
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
-
import {
|
|
2
|
+
import { registerOnboardingTools } from './onboarding-tools.js';
|
|
3
|
+
import { registerEngineeringMemoryTools, registerQuestionnaireTools } from './tool-definitions.js';
|
|
3
4
|
export function createEngineeringMemoryServer(service) {
|
|
4
5
|
const server = new McpServer({
|
|
5
6
|
name: 'engineering-memory',
|
|
6
7
|
version: '0.1.0',
|
|
7
8
|
});
|
|
8
9
|
registerEngineeringMemoryTools(server, service);
|
|
10
|
+
registerQuestionnaireTools(server, service);
|
|
11
|
+
registerOnboardingTools(server, service);
|
|
9
12
|
return server;
|
|
10
13
|
}
|
|
11
14
|
//# sourceMappingURL=server.js.map
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as z from 'zod/v4';
|
|
2
2
|
import { validationIds } from '../runtime/bridge-service.js';
|
|
3
|
+
import { questionnaireDefinitionSchema } from '../runtime/questionnaire-store.js';
|
|
4
|
+
import { askQuestionnaire, resumeQuestionnaire } from './questionnaire-tools.js';
|
|
3
5
|
const optionalRepoRoot = z.string().min(1).optional();
|
|
4
6
|
const stringList = z.array(z.string().min(1));
|
|
5
7
|
const jsonValue = z.lazy(() => z.union([
|
|
@@ -45,6 +47,9 @@ const checkpointBase = {
|
|
|
45
47
|
};
|
|
46
48
|
export const engineeringMemoryToolNames = [
|
|
47
49
|
'audit.list',
|
|
50
|
+
'questionnaire.ask',
|
|
51
|
+
'questionnaire.resume',
|
|
52
|
+
'questionnaire.withdraw',
|
|
48
53
|
'session.entry',
|
|
49
54
|
'session.set_decision',
|
|
50
55
|
'session.decline_update',
|
|
@@ -53,6 +58,24 @@ export const engineeringMemoryToolNames = [
|
|
|
53
58
|
'session.resume',
|
|
54
59
|
'context.prepare_change',
|
|
55
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',
|
|
56
79
|
'memory.query',
|
|
57
80
|
'memory.history',
|
|
58
81
|
'memory.propose_revision',
|
|
@@ -116,6 +139,53 @@ const reconciliationEntry = z.object({
|
|
|
116
139
|
revisionId: z.string().optional(),
|
|
117
140
|
reason: z.string().optional(),
|
|
118
141
|
});
|
|
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
|
+
});
|
|
173
|
+
server.registerTool('questionnaire.ask', {
|
|
174
|
+
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.',
|
|
175
|
+
inputSchema: questionnaireDefinitionSchema.extend({
|
|
176
|
+
repoRoot: optionalRepoRoot,
|
|
177
|
+
preparation: z.boolean().optional(),
|
|
178
|
+
}),
|
|
179
|
+
}, async (input, context) => await askQuestionnaire(server, service, input, context));
|
|
180
|
+
server.registerTool('questionnaire.resume', {
|
|
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.',
|
|
182
|
+
inputSchema: z.strictObject({
|
|
183
|
+
repoRoot: optionalRepoRoot,
|
|
184
|
+
questionnaireId: z.string().regex(/^[A-Za-z0-9_-]{1,100}$/),
|
|
185
|
+
preparation: z.boolean().optional(),
|
|
186
|
+
}),
|
|
187
|
+
}, async (input, context) => await resumeQuestionnaire(server, service, input, context));
|
|
188
|
+
}
|
|
119
189
|
export function registerEngineeringMemoryTools(server, service) {
|
|
120
190
|
server.registerTool('audit.list', {
|
|
121
191
|
description: 'List one authorized organization or project audit trail with an exact cursor. Choose exactly one scope and send both cursor fields together.',
|
|
@@ -269,6 +339,7 @@ export function registerEngineeringMemoryTools(server, service) {
|
|
|
269
339
|
tags: stringList.optional(),
|
|
270
340
|
metadata: jsonObject.optional(),
|
|
271
341
|
provenance: jsonObject.optional(),
|
|
342
|
+
status: z.enum(['active', 'archived']).optional(),
|
|
272
343
|
}),
|
|
273
344
|
}, async (input) => toolResult(await service.memoryProposeRevision(input)));
|
|
274
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
|