reis-mobile 0.10.0 → 1.0.0
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +32 -0
- package/README.md +81 -32
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/agents/lead-mobile.md +14 -3
- package/agents/mobile-ui-ux-designer.md +33 -0
- package/bin/reis-mobile.mjs +58 -58
- package/commands/debate.md +21 -14
- package/core/commands/payloads.mjs +63 -0
- package/core/mcp/server.mjs +188 -0
- package/core/mcp/tools.mjs +226 -0
- package/core/router/intents.mjs +8 -0
- package/package.json +7 -3
- package/skills/mobile-android/SKILL.md +5 -1
- package/skills/mobile-flutter/SKILL.md +5 -1
- package/skills/mobile-ios/SKILL.md +5 -1
- package/skills/mobile-kmp/SKILL.md +5 -1
- package/skills/mobile-rn/SKILL.md +5 -1
- package/skills/mobile-security/references/ios/keychain.md +105 -0
- package/skills/mobile-security/references/ios.md +1 -1
- package/skills/mobile-ui-ux/SKILL.md +53 -0
- package/skills/mobile-ui-ux/references/android.md +11 -0
- package/skills/mobile-ui-ux/references/apple-design.md +32 -0
- package/skills/mobile-ui-ux/references/flutter.md +11 -0
- package/skills/mobile-ui-ux/references/ios.md +11 -0
- package/skills/mobile-ui-ux/references/kotlin-multiplatform.md +11 -0
- package/skills/mobile-ui-ux/references/react-native.md +11 -0
package/bin/reis-mobile.mjs
CHANGED
|
@@ -3,19 +3,25 @@ import { readFile } from 'node:fs/promises';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
5
|
|
|
6
|
+
import {
|
|
7
|
+
debugPayload,
|
|
8
|
+
detectPayload,
|
|
9
|
+
doctorPayload,
|
|
10
|
+
releasePayload,
|
|
11
|
+
reviewPayload,
|
|
12
|
+
routePayload,
|
|
13
|
+
testPayload,
|
|
14
|
+
} from '../core/commands/payloads.mjs';
|
|
6
15
|
import { LANGUAGES, configPath, removeConfig, resolveLanguage, saveLanguage } from '../core/config/language.mjs';
|
|
7
16
|
import { resolveProjectDir } from '../core/config/project.mjs';
|
|
8
|
-
import { collectReviewContext } from '../core/context/context-engine.mjs';
|
|
9
17
|
import { detectStack } from '../core/detection/stack-detector.mjs';
|
|
10
|
-
import { runDoctor } from '../core/diagnostics/doctor.mjs';
|
|
11
18
|
import { runInit } from '../core/install/init.mjs';
|
|
19
|
+
import { serveStdio } from '../core/mcp/server.mjs';
|
|
12
20
|
import { PLUGIN_ROOT } from '../core/paths.mjs';
|
|
13
21
|
import { DEFAULT_TIMEOUT_MS, consult, listProviderModels } from '../core/providers/consult.mjs';
|
|
14
22
|
import { parseProviderList, providerAllowlist, resolveProviders, selectProviders } from '../core/providers/providers.mjs';
|
|
15
23
|
import { loadRegistry, validateRegistry } from '../core/registry/registry.mjs';
|
|
16
|
-
import { releaseContext } from '../core/release/context.mjs';
|
|
17
24
|
import { INTENT_IDS } from '../core/router/intents.mjs';
|
|
18
|
-
import { route } from '../core/router/router.mjs';
|
|
19
25
|
import { checkForUpdate, clearUpdateCache } from '../core/update/check.mjs';
|
|
20
26
|
|
|
21
27
|
const USAGE = `reis-mobile — AI agents for mobile engineering
|
|
@@ -34,12 +40,13 @@ Commands:
|
|
|
34
40
|
release [request...] Prepare release evidence context (does not build or publish)
|
|
35
41
|
providers List the external model providers and what each one needs
|
|
36
42
|
consult [question...] Ask the configured external providers the same question
|
|
43
|
+
mcp Serve the read-only commands to MCP clients over stdio (Cursor, Claude Code, Codex...)
|
|
37
44
|
agents | skills | stacks List registered components
|
|
38
45
|
validate Validate the plugin's agents, skills and stacks
|
|
39
46
|
update-check Check npm for a newer reis-mobile release
|
|
40
47
|
|
|
41
48
|
Options:
|
|
42
|
-
--dir <path> Project directory (default: current directory, or "app" in .reis-mobile/config.yaml)
|
|
49
|
+
--dir <path> Project directory (default: current directory, or "app" in .reis-mobile/config.yaml); mcp: default for tool calls
|
|
43
50
|
--intent <id> Force an intent (${INTENT_IDS.join(', ')})
|
|
44
51
|
--base <ref> review/test: compare <ref>...HEAD instead of the working tree
|
|
45
52
|
--all doctor: check every tool, not only the relevant ones
|
|
@@ -91,6 +98,7 @@ const COMMANDS = {
|
|
|
91
98
|
release: commandRelease,
|
|
92
99
|
providers: commandProviders,
|
|
93
100
|
consult: commandConsult,
|
|
101
|
+
mcp: commandMcp,
|
|
94
102
|
agents: (options) => commandList('agents', options),
|
|
95
103
|
skills: (options) => commandList('skills', options),
|
|
96
104
|
stacks: (options) => commandList('stacks', options),
|
|
@@ -182,10 +190,9 @@ async function commandLang({ prompt, json }) {
|
|
|
182
190
|
return 0;
|
|
183
191
|
}
|
|
184
192
|
|
|
185
|
-
async function commandDetect({
|
|
186
|
-
const detection = await
|
|
187
|
-
|
|
188
|
-
if (json) return print({ ...detection, language, projectConfig: project.config?.path ?? null });
|
|
193
|
+
async function commandDetect({ project, json, lang }) {
|
|
194
|
+
const detection = await detectPayload({ project, lang });
|
|
195
|
+
if (json) return print(detection);
|
|
189
196
|
|
|
190
197
|
printProject(project);
|
|
191
198
|
|
|
@@ -195,21 +202,20 @@ async function commandDetect({ dir, project, json, lang }) {
|
|
|
195
202
|
for (const evidence of detection.evidence) console.log(` · ${evidence}`);
|
|
196
203
|
const others = detection.candidates.slice(1).map((candidate) => candidate.stack);
|
|
197
204
|
if (others.length) console.log(`Also matches ${others.join(', ')}`);
|
|
198
|
-
printLanguage(language);
|
|
205
|
+
printLanguage(detection.language);
|
|
199
206
|
return detection.stack === 'unknown' ? 2 : 0;
|
|
200
207
|
}
|
|
201
208
|
|
|
202
|
-
async function commandDoctor({
|
|
203
|
-
const report = await
|
|
204
|
-
const language = await resolveLanguage({ flag: lang });
|
|
209
|
+
async function commandDoctor({ project, all, strict, json, lang }) {
|
|
210
|
+
const report = await doctorPayload({ project, all, lang });
|
|
205
211
|
if (json) {
|
|
206
|
-
print(
|
|
212
|
+
print(report);
|
|
207
213
|
} else {
|
|
208
214
|
console.log('reis-mobile doctor\n');
|
|
209
215
|
printProject(project);
|
|
210
216
|
printDoctorChecks(report);
|
|
211
217
|
console.log(report.warnings ? `${report.warnings} warning(s)` : 'No issues found');
|
|
212
|
-
printLanguage(language);
|
|
218
|
+
printLanguage(report.language);
|
|
213
219
|
}
|
|
214
220
|
return strict && report.warnings ? 1 : 0;
|
|
215
221
|
}
|
|
@@ -226,30 +232,27 @@ function printDoctorChecks(report) {
|
|
|
226
232
|
|
|
227
233
|
const SYMBOLS = { ok: '✓', warning: '✗', info: '-', skipped: '-' };
|
|
228
234
|
|
|
229
|
-
async function commandRoute({
|
|
235
|
+
async function commandRoute({ project, intent, prompt, json, lang }) {
|
|
230
236
|
if (!prompt && !intent) {
|
|
231
237
|
console.error('route needs a prompt or --intent.');
|
|
232
238
|
return 1;
|
|
233
239
|
}
|
|
234
|
-
const result = await
|
|
235
|
-
|
|
236
|
-
if (json) return print({ ...summarizeRoute(result), language, projectConfig: project.config?.path ?? null });
|
|
240
|
+
const result = await routePayload({ project, prompt, intent, lang });
|
|
241
|
+
if (json) return print(result);
|
|
237
242
|
printProject(project);
|
|
238
243
|
printRoute(result);
|
|
239
|
-
printLanguage(language);
|
|
244
|
+
printLanguage(result.language);
|
|
240
245
|
return 0;
|
|
241
246
|
}
|
|
242
247
|
|
|
243
|
-
async function commandReview({
|
|
244
|
-
const result = await
|
|
245
|
-
|
|
246
|
-
const language = await resolveLanguage({ flag: lang });
|
|
247
|
-
if (json) return print({ ...summarizeRoute(result), language, projectConfig: project.config?.path ?? null, context });
|
|
248
|
+
async function commandReview({ project, base, prompt, json, lang }) {
|
|
249
|
+
const result = await reviewPayload({ project, base, prompt, lang });
|
|
250
|
+
if (json) return print(result);
|
|
248
251
|
|
|
249
252
|
printProject(project);
|
|
250
253
|
printRoute(result);
|
|
251
|
-
printLanguage(language);
|
|
252
|
-
printContext(context);
|
|
254
|
+
printLanguage(result.language);
|
|
255
|
+
printContext(result.context);
|
|
253
256
|
return 0;
|
|
254
257
|
}
|
|
255
258
|
|
|
@@ -264,55 +267,57 @@ function printContext(context) {
|
|
|
264
267
|
* Routes a failure to the debug agent and skills, and adds the doctor report: most build
|
|
265
268
|
* failures are toolchain mismatches (JDK, Gradle, Xcode, CocoaPods), which the report shows.
|
|
266
269
|
*/
|
|
267
|
-
async function commandDebug({
|
|
268
|
-
const result = await
|
|
269
|
-
|
|
270
|
-
const language = await resolveLanguage({ flag: lang });
|
|
271
|
-
if (json) return print({ ...summarizeRoute(result), language, projectConfig: project.config?.path ?? null, doctor });
|
|
270
|
+
async function commandDebug({ project, prompt, json, lang }) {
|
|
271
|
+
const result = await debugPayload({ project, prompt, lang });
|
|
272
|
+
if (json) return print(result);
|
|
272
273
|
|
|
273
274
|
printProject(project);
|
|
274
275
|
printRoute(result);
|
|
275
|
-
printLanguage(language);
|
|
276
|
+
printLanguage(result.language);
|
|
276
277
|
console.log('');
|
|
277
|
-
printDoctorChecks(doctor);
|
|
278
|
+
printDoctorChecks(result.doctor);
|
|
278
279
|
return 0;
|
|
279
280
|
}
|
|
280
281
|
|
|
281
282
|
/** Prepare evidence for the test agent; running the selected suite is the agent's job. */
|
|
282
|
-
async function commandTest({
|
|
283
|
-
const result = await
|
|
284
|
-
|
|
285
|
-
const doctor = await runDoctor({ projectDir: dir });
|
|
286
|
-
const language = await resolveLanguage({ flag: lang });
|
|
287
|
-
if (json) return print({ ...summarizeRoute(result), language, projectConfig: project.config?.path ?? null, context, doctor });
|
|
283
|
+
async function commandTest({ project, base, prompt, json, lang }) {
|
|
284
|
+
const result = await testPayload({ project, base, prompt, lang });
|
|
285
|
+
if (json) return print(result);
|
|
288
286
|
|
|
289
287
|
// A test runner needs an explicit working directory even without a monorepo config.
|
|
290
288
|
printProject(project, true);
|
|
291
289
|
printRoute(result);
|
|
292
|
-
printLanguage(language);
|
|
293
|
-
printContext(context);
|
|
290
|
+
printLanguage(result.language);
|
|
291
|
+
printContext(result.context);
|
|
294
292
|
console.log('\nTest context prepared; no test suites were executed.\n');
|
|
295
|
-
printDoctorChecks(doctor);
|
|
293
|
+
printDoctorChecks(result.doctor);
|
|
296
294
|
return 0;
|
|
297
295
|
}
|
|
298
296
|
|
|
299
297
|
/** Prepare release evidence; the specialist audits it before assigning a verdict. */
|
|
300
|
-
async function commandRelease({
|
|
301
|
-
const result = await
|
|
302
|
-
|
|
303
|
-
const doctor = await runDoctor({ projectDir: dir });
|
|
304
|
-
const language = await resolveLanguage({ flag: lang });
|
|
305
|
-
const release = releaseContext(result.detection);
|
|
306
|
-
if (json) return print({ ...summarizeRoute(result), language, projectConfig: project.config?.path ?? null, context, doctor, release });
|
|
298
|
+
async function commandRelease({ project, prompt, json, lang }) {
|
|
299
|
+
const result = await releasePayload({ project, prompt, lang });
|
|
300
|
+
if (json) return print(result);
|
|
307
301
|
|
|
302
|
+
const { release } = result;
|
|
308
303
|
printProject(project, true);
|
|
309
304
|
printRoute(result);
|
|
310
|
-
printLanguage(language);
|
|
311
|
-
printContext(context);
|
|
305
|
+
printLanguage(result.language);
|
|
306
|
+
printContext(result.context);
|
|
312
307
|
console.log(`\nRelease platforms (verify) ${release.platforms.join(', ') || 'unknown'}`);
|
|
313
308
|
for (const reference of release.references) console.log(` Reference ${reference}`);
|
|
314
309
|
console.log('\nRelease context prepared; readiness has not been evaluated. No builds, tests or uploads were executed.\n');
|
|
315
|
-
printDoctorChecks(doctor);
|
|
310
|
+
printDoctorChecks(result.doctor);
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Serves the read-only commands to MCP clients over stdio. Nothing but protocol messages may
|
|
316
|
+
* reach stdout, so this command prints nothing itself; `--dir` is the default for tool calls.
|
|
317
|
+
*/
|
|
318
|
+
async function commandMcp({ dir }) {
|
|
319
|
+
const pkg = JSON.parse(await readFile(join(PLUGIN_ROOT, 'package.json'), 'utf8'));
|
|
320
|
+
await serveStdio({ version: pkg.version, defaultDir: dir });
|
|
316
321
|
return 0;
|
|
317
322
|
}
|
|
318
323
|
|
|
@@ -450,11 +455,6 @@ async function commandValidate({ json }) {
|
|
|
450
455
|
return errors.length ? 1 : 0;
|
|
451
456
|
}
|
|
452
457
|
|
|
453
|
-
function summarizeRoute(result) {
|
|
454
|
-
const component = (item) => item && { name: item.name, description: item.description, path: item.path };
|
|
455
|
-
return { ...result, agent: component(result.agent), skills: result.skills.map(component) };
|
|
456
|
-
}
|
|
457
|
-
|
|
458
458
|
function printRoute(result) {
|
|
459
459
|
const confidence = result.intentSource === 'explicit' ? 'explicit' : `confidence ${result.confidence}`;
|
|
460
460
|
console.log(`Intent ${result.intent ?? '-'} (${confidence})${result.area ? ` · area ${result.area}` : ''}`);
|
package/commands/debate.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Structured debate between the mobile specialists —
|
|
2
|
+
description: Structured debate between the mobile specialists — lead-mobile selects the participants, specialists defend and rebut positions, and lead-mobile returns to decide
|
|
3
3
|
argument-hint: "[--rounds N] [--agents a,b,c] [--external] [--providers a,b] <question>"
|
|
4
4
|
allowed-tools: ["Bash(node:*)", "Bash(mkdir:*)", "Agent", "Read", "Write", "Grep", "Glob"]
|
|
5
5
|
---
|
|
@@ -8,7 +8,7 @@ allowed-tools: ["Bash(node:*)", "Bash(mkdir:*)", "Agent", "Read", "Write", "Grep
|
|
|
8
8
|
|
|
9
9
|
Arguments received: `$ARGUMENTS`
|
|
10
10
|
|
|
11
|
-
Use this command for
|
|
11
|
+
Use this command for mobile decisions with real trade-offs, including architecture, UI/UX, accessibility and release readiness. A debate runs several specialists over the configured rounds; avoid it for routine questions. If the question has a single answer that can be verified in the code, answer it directly and tell the user why it was not worth a debate.
|
|
12
12
|
|
|
13
13
|
## 1. Parse the arguments
|
|
14
14
|
|
|
@@ -31,9 +31,11 @@ The JSON carries the stack and the `language` for the whole debate: `en` English
|
|
|
31
31
|
|
|
32
32
|
If the question mentions files or directories, resolve the paths and read them now: participants receive the content in the briefing, not the path. If it fails, show the error and stop.
|
|
33
33
|
|
|
34
|
-
## 3.
|
|
34
|
+
## 3. Lead selects the participants
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
Invoke `Agent(subagent_type: "reis-mobile:lead-mobile", model: "opus")` with `phase: selection`, following the **Debate selection** section of `${CLAUDE_PLUGIN_ROOT}/agents/lead-mobile.md`. Pass the question, detected stack and native targets, language, relevant file contents, flags and the complete candidate/model table below. The lead owns participant selection; the command executes the returned plan and does not independently replace participants.
|
|
37
|
+
|
|
38
|
+
Ask the lead to choose **three** distinct specialists automatically, with relevant perspectives that can expose the question's trade-offs. Disagreement must come from evidence and competing constraints, not a requirement to invent conflict. Every specialist in the plugin is eligible; the lead itself is the selector and moderator, not a debater.
|
|
37
39
|
|
|
38
40
|
| Agent | Defends | Model |
|
|
39
41
|
|---|---|---|
|
|
@@ -55,19 +57,24 @@ Choose **three** agents whose priorities genuinely conflict on the question. A d
|
|
|
55
57
|
| `reis-mobile:plugin-native-expert` | Flutter–native boundary, platform channels, lifecycle | `opus` |
|
|
56
58
|
| `reis-mobile:mobile-staff-engineer` | Build, release, migration, cross-platform trade-offs | `opus` |
|
|
57
59
|
| `reis-mobile:mobile-code-reviewer` | Concrete risk in the code that already exists | `sonnet` |
|
|
60
|
+
| `reis-mobile:mobile-ui-ux-designer` | User journeys, visual hierarchy, interaction cost and design consistency | `opus` |
|
|
61
|
+
| `reis-mobile:mobile-accessibility-auditor` | Assistive interaction, readable content, focus and inclusive task completion | `sonnet` |
|
|
62
|
+
| `reis-mobile:mobile-release-engineer` | Artifact provenance, release gates, rollout and production readiness | `opus` |
|
|
58
63
|
|
|
59
|
-
Pass the `model` from the table on
|
|
64
|
+
Pass the `model` from the table on each participant's `Agent` call. The lead uses `opus` for both selection and synthesis. Model diversity is a configured perspective check, not proof of independent reasoning or correct conclusions.
|
|
60
65
|
|
|
61
|
-
Automatic selection must end with **at least two different models** among the participants.
|
|
66
|
+
Automatic selection must end with **at least two different models** among the participants. The lead must choose a relevant adjacent specialist using the other model when needed and explain the trade-off. If no relevant mixed-model trio fits the evidence, the lead reports that limitation instead of adding an unrelated role; resolve the scope before launching rounds.
|
|
62
67
|
|
|
63
68
|
Selection rules:
|
|
64
69
|
|
|
65
|
-
- The detected stack rules.
|
|
66
|
-
-
|
|
67
|
-
- `--agents` overrides
|
|
68
|
-
- `reis-mobile:lead-mobile` never debates
|
|
70
|
+
- The detected stack rules. Match `flutter-*` to Flutter, `android-*` to native Android, `ios-*` to native iOS, `rn-*` to React Native and `kmp-*` Kotlin Multiplatform. For architecture questions, consider the stack's architect, performance and test specialists. For design, accessibility or release questions, prioritize the corresponding cross-stack specialists. All `mobile-*` specialists fit any stack; native specialists may join cross-platform projects when the question implicates their native paths.
|
|
71
|
+
- Select `plugin-native-expert` only for Flutter projects with an implicated native bridge or plugin path.
|
|
72
|
+
- `--agents` overrides automatic membership, but not the models. The lead validates the supplied names (with or without the `reis-mobile:` prefix), requires at least two distinct specialists and preserves valid explicit choices. Unknown names, duplicates or the lead as a participant: explain the error and stop before rounds. An explicit same-model selection is allowed; disclose that limitation without substituting participants.
|
|
73
|
+
- `reis-mobile:lead-mobile` never debates: it selects in step 3 and returns to moderate in step 7. Neither lead call launches participants or external providers.
|
|
74
|
+
|
|
75
|
+
The lead returns a selection plan containing each participant's exact name, model, assigned question, rationale and evidence scope, plus unresolved constraints. Check the returned names/models against the table, uniqueness, explicit membership and the automatic model mix. If invalid, return the validation errors to the lead for correction before starting; do not silently repair its choices. If Agent delegation is unavailable, report that the debate could not run rather than claiming specialists participated.
|
|
69
76
|
|
|
70
|
-
Before starting, tell the user in one line the stack, the participants with their models and the number of rounds.
|
|
77
|
+
Before starting, tell the user in one line the stack, the lead-selected participants with their models and the number of rounds.
|
|
71
78
|
|
|
72
79
|
## 4. Prepare the folder
|
|
73
80
|
|
|
@@ -75,11 +82,11 @@ Before starting, tell the user in one line the stack, the participants with thei
|
|
|
75
82
|
mkdir -p ".reis-mobile/debates/<NNN-question-slug>/rounds"
|
|
76
83
|
```
|
|
77
84
|
|
|
78
|
-
`NNN` is sequential within `.reis-mobile/debates/`. Write `context.md` in the folder with the question, the stack, the language, the participants, the flags and the context read in step 2.
|
|
85
|
+
`NNN` is sequential within `.reis-mobile/debates/`. Write `context.md` in the folder with the question, the stack, the language, the participants, the flags, the lead's selection plan with its rationale and the context read in step 2.
|
|
79
86
|
|
|
80
87
|
## 5. Round 1 — blind positions
|
|
81
88
|
|
|
82
|
-
Launch the participants **in parallel**, each with `Agent(subagent_type: "<agent>", model: "<model from the table>", run_in_background: true)`, in a single tool block. Nobody sees anyone else's position in this round: that is what keeps the first to answer from anchoring the rest.
|
|
89
|
+
Launch the participants **in parallel**, each with `Agent(subagent_type: "<agent>", model: "<model from the table>", run_in_background: true)`, in a single tool block. Pass each specialist its assigned question and evidence scope from the lead's plan. Instruct every participant to return analysis only, without editing app code or launching subagents. Nobody sees anyone else's position in this round: that is what keeps the first to answer from anchoring the rest.
|
|
83
90
|
|
|
84
91
|
Each briefing, self-contained:
|
|
85
92
|
|
|
@@ -140,7 +147,7 @@ Rebutting the generic thesis does not count: every rebuttal cites a concrete cla
|
|
|
140
147
|
|
|
141
148
|
## 7. Synthesis
|
|
142
149
|
|
|
143
|
-
Invoke `reis-mobile:lead-mobile` with all rounds, following the **Debate moderation** section of `${CLAUDE_PLUGIN_ROOT}/agents/lead-mobile.md`. It resolves the disagreements and
|
|
150
|
+
Invoke `Agent(subagent_type: "reis-mobile:lead-mobile", model: "opus")` again with `phase: synthesis`, the selection plan, original context, all completed rounds and any missing/failed participant statuses, following the **Debate moderation** section of `${CLAUDE_PLUGIN_ROOT}/agents/lead-mobile.md`. It reads the rounds, verifies decisive evidence, resolves the disagreements and delivers the decision with an ordered action plan. This call does not select new participants or start additional rounds.
|
|
144
151
|
|
|
145
152
|
Before accepting the synthesis, confirm any decisive claim in the code yourself: participants do not verify each other. Write the result to `.reis-mobile/debates/<id>/synthesis.md`.
|
|
146
153
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { resolveLanguage } from '../config/language.mjs';
|
|
2
|
+
import { collectReviewContext } from '../context/context-engine.mjs';
|
|
3
|
+
import { detectStack } from '../detection/stack-detector.mjs';
|
|
4
|
+
import { runDoctor } from '../diagnostics/doctor.mjs';
|
|
5
|
+
import { releaseContext } from '../release/context.mjs';
|
|
6
|
+
import { route } from '../router/router.mjs';
|
|
7
|
+
|
|
8
|
+
// The `--json` output of the read-only commands. The CLI prints it and the MCP server returns
|
|
9
|
+
// it, so both surfaces describe a project the same way. `project` is resolveProjectDir's result.
|
|
10
|
+
|
|
11
|
+
export async function detectPayload({ project, lang }) {
|
|
12
|
+
const detection = await detectStack(project.projectDir);
|
|
13
|
+
return { ...detection, ...(await common(project, lang)) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function doctorPayload({ project, all, lang }) {
|
|
17
|
+
const report = await runDoctor({ projectDir: project.projectDir, all });
|
|
18
|
+
return { ...report, ...(await common(project, lang)) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function routePayload({ project, prompt, intent, lang }) {
|
|
22
|
+
const result = await route({ prompt, intent, projectDir: project.projectDir });
|
|
23
|
+
return { ...summarizeRoute(result), ...(await common(project, lang)) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function reviewPayload({ project, base, prompt, lang }) {
|
|
27
|
+
const result = await route({ prompt, intent: 'review', projectDir: project.projectDir });
|
|
28
|
+
const context = collectReviewContext({ projectDir: project.projectDir, base });
|
|
29
|
+
return { ...summarizeRoute(result), ...(await common(project, lang)), context };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Most build failures are toolchain mismatches, so the doctor report travels with the route. */
|
|
33
|
+
export async function debugPayload({ project, prompt, lang }) {
|
|
34
|
+
const result = await route({ prompt, intent: 'debug', projectDir: project.projectDir });
|
|
35
|
+
const doctor = await runDoctor({ projectDir: project.projectDir });
|
|
36
|
+
return { ...summarizeRoute(result), ...(await common(project, lang)), doctor };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Evidence for the test agent; running the selected suite is the agent's job. */
|
|
40
|
+
export async function testPayload({ project, base, prompt, lang }) {
|
|
41
|
+
const result = await route({ prompt, intent: 'test', projectDir: project.projectDir });
|
|
42
|
+
const context = collectReviewContext({ projectDir: project.projectDir, base });
|
|
43
|
+
const doctor = await runDoctor({ projectDir: project.projectDir });
|
|
44
|
+
return { ...summarizeRoute(result), ...(await common(project, lang)), context, doctor };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Release evidence; the specialist audits it before assigning a verdict. */
|
|
48
|
+
export async function releasePayload({ project, prompt, lang }) {
|
|
49
|
+
const result = await route({ prompt, intent: 'release', projectDir: project.projectDir });
|
|
50
|
+
const context = collectReviewContext({ projectDir: project.projectDir });
|
|
51
|
+
const doctor = await runDoctor({ projectDir: project.projectDir });
|
|
52
|
+
const release = releaseContext(result.detection);
|
|
53
|
+
return { ...summarizeRoute(result), ...(await common(project, lang)), context, doctor, release };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function common(project, lang) {
|
|
57
|
+
return { language: await resolveLanguage({ flag: lang }), projectConfig: project.config?.path ?? null };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function summarizeRoute(result) {
|
|
61
|
+
const component = (item) => item && { name: item.name, description: item.description, path: item.path };
|
|
62
|
+
return { ...result, agent: component(result.agent), skills: result.skills.map(component) };
|
|
63
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { once } from 'node:events';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
|
|
4
|
+
import { callTool, hasTool, listTools } from './tools.mjs';
|
|
5
|
+
|
|
6
|
+
// A dual-era MCP server. Modern clients (2026-07-28) send the protocol version and their
|
|
7
|
+
// capabilities in every request's `_meta` and may probe with `server/discover`; legacy clients
|
|
8
|
+
// (2025-11-25 and earlier) open with an `initialize` handshake. Both are served on one process.
|
|
9
|
+
export const MODERN_VERSIONS = ['2026-07-28'];
|
|
10
|
+
export const LEGACY_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
|
|
11
|
+
const SUPPORTED_VERSIONS = [...MODERN_VERSIONS, ...LEGACY_VERSIONS];
|
|
12
|
+
|
|
13
|
+
const META_VERSION = 'io.modelcontextprotocol/protocolVersion';
|
|
14
|
+
const META_CAPABILITIES = 'io.modelcontextprotocol/clientCapabilities';
|
|
15
|
+
const META_SERVER_INFO = 'io.modelcontextprotocol/serverInfo';
|
|
16
|
+
|
|
17
|
+
export const ERROR_CODES = {
|
|
18
|
+
parse: -32700,
|
|
19
|
+
invalidRequest: -32600,
|
|
20
|
+
methodNotFound: -32601,
|
|
21
|
+
invalidParams: -32602,
|
|
22
|
+
internal: -32603,
|
|
23
|
+
unsupportedVersion: -32022,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const CAPABILITIES = { tools: {} };
|
|
27
|
+
|
|
28
|
+
const INSTRUCTIONS =
|
|
29
|
+
'reis-mobile prepares context for Flutter, native Android, native iOS, React Native/Expo and Kotlin Multiplatform apps. ' +
|
|
30
|
+
'Call reis_mobile_route, or reis_mobile_review, reis_mobile_debug, reis_mobile_test or reis_mobile_release for those tasks, ' +
|
|
31
|
+
'with the app directory as "dir". Then read the returned agent and skill files with reis_mobile_read_instructions and follow them; ' +
|
|
32
|
+
'open only the references that match the stack and the task. Every tool is read-only and offline: none edits the project, ' +
|
|
33
|
+
'builds, runs tests or calls a network service.';
|
|
34
|
+
|
|
35
|
+
class RpcError extends Error {
|
|
36
|
+
constructor(code, message, data) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.code = code;
|
|
39
|
+
this.data = data;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Handles one decoded JSON-RPC message and resolves to the response, or to null for a
|
|
45
|
+
* notification. It never rejects: every failure becomes a JSON-RPC error or a tool error.
|
|
46
|
+
*/
|
|
47
|
+
export function createMcpServer({ version, defaultDir = process.cwd() }) {
|
|
48
|
+
const serverInfo = { name: 'reis-mobile', title: 'reis-mobile', version };
|
|
49
|
+
// Set by `initialize`: from then on, requests without `_meta` follow that legacy revision.
|
|
50
|
+
let legacyVersion = null;
|
|
51
|
+
|
|
52
|
+
async function handle(message) {
|
|
53
|
+
if (typeof message !== 'object' || message === null || Array.isArray(message)) {
|
|
54
|
+
return failure(null, new RpcError(ERROR_CODES.invalidRequest, 'Expected one JSON-RPC 2.0 message object; batches are not supported.'));
|
|
55
|
+
}
|
|
56
|
+
const isRequest = Object.hasOwn(message, 'id');
|
|
57
|
+
if (message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
|
|
58
|
+
return isRequest ? failure(requestId(message.id), new RpcError(ERROR_CODES.invalidRequest, 'Expected a JSON-RPC 2.0 request with a method.')) : null;
|
|
59
|
+
}
|
|
60
|
+
// notifications/initialized needs no reply; cancellation is handled by the transport.
|
|
61
|
+
if (!isRequest) return null;
|
|
62
|
+
|
|
63
|
+
const { id, method, params } = message;
|
|
64
|
+
if (requestId(id) === null) return failure(null, new RpcError(ERROR_CODES.invalidRequest, 'The request id must be a string or a number.'));
|
|
65
|
+
try {
|
|
66
|
+
if (params !== undefined && (typeof params !== 'object' || params === null || Array.isArray(params))) {
|
|
67
|
+
throw new RpcError(ERROR_CODES.invalidParams, 'params must be an object.');
|
|
68
|
+
}
|
|
69
|
+
if (method === 'initialize') return success(id, initialize(params ?? {}));
|
|
70
|
+
|
|
71
|
+
const modern = isModernRequest(method, params);
|
|
72
|
+
const result = await dispatch(method, params ?? {}, modern);
|
|
73
|
+
return success(id, modern ? { resultType: 'complete', ...result, _meta: { [META_SERVER_INFO]: serverInfo } } : result);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return failure(id, error instanceof RpcError ? error : new RpcError(ERROR_CODES.internal, `Internal error: ${error.message}`));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function initialize(params) {
|
|
80
|
+
const requested = params.protocolVersion;
|
|
81
|
+
legacyVersion = LEGACY_VERSIONS.includes(requested) ? requested : LEGACY_VERSIONS[0];
|
|
82
|
+
return { protocolVersion: legacyVersion, capabilities: CAPABILITIES, serverInfo, instructions: INSTRUCTIONS };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A request is modern when it carries a protocol version in `_meta`; legacy once initialized. */
|
|
86
|
+
function isModernRequest(method, params) {
|
|
87
|
+
const meta = params?._meta;
|
|
88
|
+
const requested = meta?.[META_VERSION];
|
|
89
|
+
if (requested !== undefined) {
|
|
90
|
+
if (!MODERN_VERSIONS.includes(requested)) {
|
|
91
|
+
throw new RpcError(ERROR_CODES.unsupportedVersion, 'Unsupported protocol version', { supported: SUPPORTED_VERSIONS, requested });
|
|
92
|
+
}
|
|
93
|
+
const capabilities = meta[META_CAPABILITIES];
|
|
94
|
+
if (typeof capabilities !== 'object' || capabilities === null || Array.isArray(capabilities)) {
|
|
95
|
+
throw new RpcError(ERROR_CODES.invalidParams, `Missing _meta["${META_CAPABILITIES}"]: every ${requested} request carries the client capabilities.`);
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
if (legacyVersion || method === 'ping') return false;
|
|
100
|
+
throw new RpcError(
|
|
101
|
+
ERROR_CODES.invalidParams,
|
|
102
|
+
`Missing _meta["${META_VERSION}"]. Send initialize first (protocol ${LEGACY_VERSIONS[0]} and earlier) or include the per-request _meta of ${MODERN_VERSIONS[0]}.`,
|
|
103
|
+
{ supported: SUPPORTED_VERSIONS },
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function dispatch(method, params, modern) {
|
|
108
|
+
switch (method) {
|
|
109
|
+
case 'server/discover':
|
|
110
|
+
if (!modern) break;
|
|
111
|
+
return { supportedVersions: MODERN_VERSIONS, capabilities: CAPABILITIES, instructions: INSTRUCTIONS };
|
|
112
|
+
case 'ping':
|
|
113
|
+
return {};
|
|
114
|
+
case 'tools/list':
|
|
115
|
+
return { tools: listTools() };
|
|
116
|
+
case 'tools/call': {
|
|
117
|
+
const { name, arguments: args } = params;
|
|
118
|
+
if (typeof name !== 'string' || !hasTool(name)) throw new RpcError(ERROR_CODES.invalidParams, `Unknown tool: ${name}`);
|
|
119
|
+
if (args !== undefined && (typeof args !== 'object' || args === null || Array.isArray(args))) {
|
|
120
|
+
throw new RpcError(ERROR_CODES.invalidParams, 'tools/call arguments must be an object.');
|
|
121
|
+
}
|
|
122
|
+
return callTool(name, args ?? {}, { defaultDir });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
throw new RpcError(ERROR_CODES.methodNotFound, `Method not found: ${method}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { handle };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Serves newline-delimited JSON-RPC over stdio until the input closes. Only protocol messages
|
|
133
|
+
* are written to `output`; a cancelled request gets no reply, as the transport requires.
|
|
134
|
+
*/
|
|
135
|
+
export async function serveStdio({ version, defaultDir, input = process.stdin, output = process.stdout }) {
|
|
136
|
+
const server = createMcpServer({ version, defaultDir });
|
|
137
|
+
const inFlight = new Set();
|
|
138
|
+
const cancelled = new Set();
|
|
139
|
+
const pending = new Set();
|
|
140
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
141
|
+
|
|
142
|
+
const write = (message) => {
|
|
143
|
+
if (!output.writableEnded && !output.destroyed) output.write(`${JSON.stringify(message)}\n`);
|
|
144
|
+
};
|
|
145
|
+
// The client closing its end mid-reply is a shutdown, not a crash.
|
|
146
|
+
output.on('error', () => lines.close());
|
|
147
|
+
|
|
148
|
+
lines.on('line', (line) => {
|
|
149
|
+
if (!line.trim()) return;
|
|
150
|
+
let message;
|
|
151
|
+
try {
|
|
152
|
+
message = JSON.parse(line);
|
|
153
|
+
} catch {
|
|
154
|
+
write(failure(null, new RpcError(ERROR_CODES.parse, 'Parse error: each line must be one JSON-RPC message.')));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (message?.method === 'notifications/cancelled') {
|
|
158
|
+
const target = message.params?.requestId;
|
|
159
|
+
if (inFlight.has(target)) cancelled.add(target);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const id = Object.hasOwn(message ?? {}, 'id') ? message.id : undefined;
|
|
164
|
+
if (id !== undefined) inFlight.add(id);
|
|
165
|
+
const task = server.handle(message).then((response) => {
|
|
166
|
+
inFlight.delete(id);
|
|
167
|
+
if (cancelled.delete(id) || !response) return;
|
|
168
|
+
write(response);
|
|
169
|
+
});
|
|
170
|
+
pending.add(task);
|
|
171
|
+
task.finally(() => pending.delete(task));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await once(lines, 'close');
|
|
175
|
+
await Promise.allSettled([...pending]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function requestId(id) {
|
|
179
|
+
return typeof id === 'string' || typeof id === 'number' ? id : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function success(id, result) {
|
|
183
|
+
return { jsonrpc: '2.0', id, result };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function failure(id, error) {
|
|
187
|
+
return { jsonrpc: '2.0', id, error: { code: error.code, message: error.message, ...(error.data !== undefined && { data: error.data }) } };
|
|
188
|
+
}
|