pumuki 6.3.13 → 6.3.14
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/README.md +95 -7
- package/VERSION +1 -1
- package/bin/pumuki-mcp-enterprise.js +5 -0
- package/bin/pumuki-pre-write.js +11 -0
- package/docs/API_REFERENCE.md +2 -1
- package/docs/INSTALLATION.md +101 -54
- package/docs/MCP_SERVERS.md +167 -74
- package/docs/PUMUKI_FULL_VALIDATION_CHECKLIST.md +46 -45
- package/docs/PUMUKI_OPENSPEC_SDD_ROADMAP.md +55 -0
- package/docs/README.md +5 -0
- package/docs/REFRACTOR_PROGRESS.md +102 -3
- package/docs/USAGE.md +115 -8
- package/docs/validation/README.md +2 -0
- package/docs/validation/phase12-go-no-go-report.md +73 -0
- package/docs/validation/post-phase12-next-lot-decision.md +75 -0
- package/integrations/config/skillsRuleSet.ts +53 -6
- package/integrations/evidence/buildEvidence.ts +42 -3
- package/integrations/evidence/generateEvidence.test.ts +59 -0
- package/integrations/evidence/readEvidence.test.ts +61 -0
- package/integrations/evidence/schema.test.ts +81 -0
- package/integrations/evidence/schema.ts +11 -0
- package/integrations/evidence/writeEvidence.test.ts +18 -0
- package/integrations/evidence/writeEvidence.ts +11 -0
- package/integrations/git/resolveGitRefs.ts +2 -2
- package/integrations/git/runPlatformGate.ts +64 -0
- package/integrations/git/runPlatformGateEvidence.ts +13 -0
- package/integrations/git/stageRunners.ts +10 -1
- package/integrations/lifecycle/artifacts.ts +57 -4
- package/integrations/lifecycle/cli.ts +248 -12
- package/integrations/lifecycle/constants.ts +1 -0
- package/integrations/lifecycle/gitService.ts +1 -0
- package/integrations/lifecycle/install.ts +24 -1
- package/integrations/lifecycle/openSpecBootstrap.ts +190 -0
- package/integrations/lifecycle/state.ts +57 -0
- package/integrations/lifecycle/uninstall.ts +3 -1
- package/integrations/lifecycle/update.ts +11 -0
- package/integrations/mcp/enterpriseServer.cli.ts +12 -0
- package/integrations/mcp/enterpriseServer.ts +762 -0
- package/integrations/mcp/index.ts +1 -0
- package/integrations/sdd/index.ts +11 -0
- package/integrations/sdd/openSpecCli.ts +180 -0
- package/integrations/sdd/policy.ts +190 -0
- package/integrations/sdd/sessionStore.ts +152 -0
- package/integrations/sdd/types.ts +69 -0
- package/package.json +10 -4
- package/scripts/framework-menu-runner-path-lib.ts +10 -3
- package/scripts/framework-menu.ts +86 -5
- package/scripts/package-install-smoke-gate-lib.ts +6 -1
- package/scripts/package-install-smoke-lifecycle-lib.ts +3 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import type { IncomingMessage } from 'node:http';
|
|
3
|
+
import type { Server } from 'node:http';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { readLifecycleStatus } from '../lifecycle';
|
|
8
|
+
import { evaluateSddPolicy, readSddStatus } from '../sdd';
|
|
9
|
+
import type { SddStage } from '../sdd';
|
|
10
|
+
import { readEvidence, toStatusPayload } from './evidencePayloads';
|
|
11
|
+
|
|
12
|
+
export interface EnterpriseServerOptions {
|
|
13
|
+
host?: string;
|
|
14
|
+
port?: number;
|
|
15
|
+
repoRoot?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface EnterpriseServerHandle {
|
|
19
|
+
server: Server;
|
|
20
|
+
host: string;
|
|
21
|
+
port: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type EnterpriseStatusPayload = {
|
|
25
|
+
status: 'ok';
|
|
26
|
+
repoRoot: string;
|
|
27
|
+
capabilities: {
|
|
28
|
+
resources: ReadonlyArray<string>;
|
|
29
|
+
tools: ReadonlyArray<string>;
|
|
30
|
+
mode: 'baseline';
|
|
31
|
+
};
|
|
32
|
+
lifecycle: ReturnType<typeof readLifecycleStatus> | null;
|
|
33
|
+
sdd: ReturnType<typeof readSddStatus> | null;
|
|
34
|
+
evidence: ReturnType<typeof toStatusPayload>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const ENTERPRISE_RESOURCES = [
|
|
38
|
+
'evidence://status',
|
|
39
|
+
'gitflow://state',
|
|
40
|
+
'context://active',
|
|
41
|
+
'sdd://status',
|
|
42
|
+
'sdd://active-change',
|
|
43
|
+
] as const;
|
|
44
|
+
type EnterpriseResourceUri = (typeof ENTERPRISE_RESOURCES)[number];
|
|
45
|
+
|
|
46
|
+
const ENTERPRISE_RESOURCE_DESCRIPTORS: ReadonlyArray<{
|
|
47
|
+
uri: EnterpriseResourceUri;
|
|
48
|
+
description: string;
|
|
49
|
+
}> = [
|
|
50
|
+
{
|
|
51
|
+
uri: 'evidence://status',
|
|
52
|
+
description: 'Current evidence status summary for the active repository.',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
uri: 'gitflow://state',
|
|
56
|
+
description: 'Current Git branch/upstream/status context for enterprise guardrails.',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
uri: 'context://active',
|
|
60
|
+
description: 'Consolidated active context (repo, lifecycle, gitflow, SDD).',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
uri: 'sdd://status',
|
|
64
|
+
description: 'Current SDD/OpenSpec status for the active repository.',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
uri: 'sdd://active-change',
|
|
68
|
+
description: 'Current active SDD change/session details.',
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const ENTERPRISE_TOOLS = [
|
|
73
|
+
'ai_gate_check',
|
|
74
|
+
'check_sdd_status',
|
|
75
|
+
'validate_and_fix',
|
|
76
|
+
'sync_branches',
|
|
77
|
+
'cleanup_stale_branches',
|
|
78
|
+
] as const;
|
|
79
|
+
type EnterpriseToolName = (typeof ENTERPRISE_TOOLS)[number];
|
|
80
|
+
|
|
81
|
+
const ENTERPRISE_TOOL_DESCRIPTORS: ReadonlyArray<{
|
|
82
|
+
name: EnterpriseToolName;
|
|
83
|
+
description: string;
|
|
84
|
+
mutating: boolean;
|
|
85
|
+
safeByDefault: boolean;
|
|
86
|
+
}> = [
|
|
87
|
+
{
|
|
88
|
+
name: 'ai_gate_check',
|
|
89
|
+
description: 'Reads .ai_evidence.json and reports AI gate compatibility status.',
|
|
90
|
+
mutating: false,
|
|
91
|
+
safeByDefault: true,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: 'check_sdd_status',
|
|
95
|
+
description: 'Evaluates SDD/OpenSpec policy for a stage without changing repository state.',
|
|
96
|
+
mutating: false,
|
|
97
|
+
safeByDefault: true,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'validate_and_fix',
|
|
101
|
+
description: 'Returns validation status and suggested fixes in preview mode (no writes).',
|
|
102
|
+
mutating: true,
|
|
103
|
+
safeByDefault: true,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: 'sync_branches',
|
|
107
|
+
description: 'Builds a git synchronization plan in preview mode (no branch changes).',
|
|
108
|
+
mutating: true,
|
|
109
|
+
safeByDefault: true,
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'cleanup_stale_branches',
|
|
113
|
+
description: 'Builds stale-branch cleanup plan in preview mode (no deletions).',
|
|
114
|
+
mutating: true,
|
|
115
|
+
safeByDefault: true,
|
|
116
|
+
},
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
const SDD_STAGES: ReadonlyArray<SddStage> = [
|
|
120
|
+
'PRE_WRITE',
|
|
121
|
+
'PRE_COMMIT',
|
|
122
|
+
'PRE_PUSH',
|
|
123
|
+
'CI',
|
|
124
|
+
] as const;
|
|
125
|
+
const MUTATING_ENTERPRISE_TOOLS = new Set<EnterpriseToolName>([
|
|
126
|
+
'validate_and_fix',
|
|
127
|
+
'sync_branches',
|
|
128
|
+
'cleanup_stale_branches',
|
|
129
|
+
]);
|
|
130
|
+
const CRITICAL_ENTERPRISE_TOOLS = new Set<EnterpriseToolName>([
|
|
131
|
+
'validate_and_fix',
|
|
132
|
+
'sync_branches',
|
|
133
|
+
'cleanup_stale_branches',
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
const isEnterpriseToolName = (value: string | null): value is EnterpriseToolName =>
|
|
137
|
+
value !== null && ENTERPRISE_TOOLS.includes(value as EnterpriseToolName);
|
|
138
|
+
|
|
139
|
+
const toSddStage = (value: unknown, fallback: SddStage): SddStage => {
|
|
140
|
+
if (typeof value !== 'string') {
|
|
141
|
+
return fallback;
|
|
142
|
+
}
|
|
143
|
+
return (SDD_STAGES as ReadonlyArray<string>).includes(value) ? (value as SddStage) : fallback;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const readJsonBody = async (req: IncomingMessage): Promise<unknown> => {
|
|
147
|
+
const chunks: Buffer[] = [];
|
|
148
|
+
await new Promise<void>((resolve, reject) => {
|
|
149
|
+
req.on('data', (chunk: Buffer | string) => {
|
|
150
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
151
|
+
});
|
|
152
|
+
req.on('end', () => resolve());
|
|
153
|
+
req.on('error', (error) => reject(error));
|
|
154
|
+
});
|
|
155
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
156
|
+
if (raw.length === 0) {
|
|
157
|
+
return {};
|
|
158
|
+
}
|
|
159
|
+
return JSON.parse(raw) as unknown;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const safeRunGit = (
|
|
163
|
+
repoRoot: string,
|
|
164
|
+
args: ReadonlyArray<string>
|
|
165
|
+
): string | undefined => {
|
|
166
|
+
if (!existsSync(join(repoRoot, '.git'))) {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
return execFileSync('git', args, {
|
|
171
|
+
cwd: repoRoot,
|
|
172
|
+
encoding: 'utf8',
|
|
173
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
174
|
+
}).trim();
|
|
175
|
+
} catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const readGitflowState = (repoRoot: string): {
|
|
181
|
+
available: boolean;
|
|
182
|
+
branch?: string;
|
|
183
|
+
upstream?: string;
|
|
184
|
+
ahead?: number;
|
|
185
|
+
behind?: number;
|
|
186
|
+
dirty?: boolean;
|
|
187
|
+
staged?: number;
|
|
188
|
+
unstaged?: number;
|
|
189
|
+
} => {
|
|
190
|
+
if (!existsSync(join(repoRoot, '.git'))) {
|
|
191
|
+
return { available: false };
|
|
192
|
+
}
|
|
193
|
+
const branch = safeRunGit(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
194
|
+
const upstream = safeRunGit(repoRoot, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
|
|
195
|
+
const statusShort = safeRunGit(repoRoot, ['status', '--short']) ?? '';
|
|
196
|
+
const statusLines = statusShort
|
|
197
|
+
.split('\n')
|
|
198
|
+
.map((line) => line.trimEnd())
|
|
199
|
+
.filter((line) => line.length > 0);
|
|
200
|
+
const staged = statusLines.filter((line) => line[0] && line[0] !== '?' && line[0] !== ' ').length;
|
|
201
|
+
const unstaged = statusLines.filter((line) => line[1] && line[1] !== ' ').length;
|
|
202
|
+
|
|
203
|
+
let ahead = 0;
|
|
204
|
+
let behind = 0;
|
|
205
|
+
if (upstream) {
|
|
206
|
+
const aheadBehindRaw = safeRunGit(repoRoot, ['rev-list', '--left-right', '--count', `${upstream}...HEAD`]);
|
|
207
|
+
if (aheadBehindRaw) {
|
|
208
|
+
const parts = aheadBehindRaw.split(/\s+/).map((value) => Number.parseInt(value, 10));
|
|
209
|
+
behind = Number.isFinite(parts[0]) ? parts[0] : 0;
|
|
210
|
+
ahead = Number.isFinite(parts[1]) ? parts[1] : 0;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
available: true,
|
|
216
|
+
branch: branch ?? undefined,
|
|
217
|
+
upstream: upstream ?? undefined,
|
|
218
|
+
ahead,
|
|
219
|
+
behind,
|
|
220
|
+
dirty: statusLines.length > 0,
|
|
221
|
+
staged,
|
|
222
|
+
unstaged,
|
|
223
|
+
};
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const safeReadLifecycleStatus = (repoRoot: string): ReturnType<typeof readLifecycleStatus> | null => {
|
|
227
|
+
if (!existsSync(join(repoRoot, '.git'))) {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
return readLifecycleStatus({
|
|
232
|
+
cwd: repoRoot,
|
|
233
|
+
});
|
|
234
|
+
} catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const safeReadSddStatus = (repoRoot: string): ReturnType<typeof readSddStatus> | null => {
|
|
240
|
+
if (!existsSync(join(repoRoot, '.git'))) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
return readSddStatus(repoRoot);
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const readResourcePayload = (
|
|
251
|
+
uri: EnterpriseResourceUri,
|
|
252
|
+
repoRoot: string
|
|
253
|
+
): unknown => {
|
|
254
|
+
const sddStatus = safeReadSddStatus(repoRoot);
|
|
255
|
+
switch (uri) {
|
|
256
|
+
case 'evidence://status':
|
|
257
|
+
return toStatusPayload(repoRoot);
|
|
258
|
+
case 'gitflow://state':
|
|
259
|
+
return readGitflowState(repoRoot);
|
|
260
|
+
case 'context://active':
|
|
261
|
+
return {
|
|
262
|
+
repoRoot,
|
|
263
|
+
gitflow: readGitflowState(repoRoot),
|
|
264
|
+
lifecycle: safeReadLifecycleStatus(repoRoot),
|
|
265
|
+
sdd: sddStatus,
|
|
266
|
+
};
|
|
267
|
+
case 'sdd://status':
|
|
268
|
+
return sddStatus ?? { available: false };
|
|
269
|
+
case 'sdd://active-change':
|
|
270
|
+
return {
|
|
271
|
+
available: Boolean(sddStatus),
|
|
272
|
+
active: sddStatus?.session.active ?? false,
|
|
273
|
+
valid: sddStatus?.session.valid ?? false,
|
|
274
|
+
changeId: sddStatus?.session.changeId ?? null,
|
|
275
|
+
remainingSeconds: sddStatus?.session.remainingSeconds ?? null,
|
|
276
|
+
};
|
|
277
|
+
default:
|
|
278
|
+
return { error: 'Unsupported resource' };
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const isEnterpriseResourceUri = (value: string | null): value is EnterpriseResourceUri =>
|
|
283
|
+
value !== null && ENTERPRISE_RESOURCES.includes(value as EnterpriseResourceUri);
|
|
284
|
+
|
|
285
|
+
type EnterpriseToolExecution = {
|
|
286
|
+
name: EnterpriseToolName;
|
|
287
|
+
success: boolean;
|
|
288
|
+
dryRun: boolean;
|
|
289
|
+
executed: boolean;
|
|
290
|
+
data: unknown;
|
|
291
|
+
warnings?: ReadonlyArray<string>;
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
type CriticalToolGuardResult = {
|
|
295
|
+
allowed: boolean;
|
|
296
|
+
stage: SddStage;
|
|
297
|
+
evaluation?: ReturnType<typeof evaluateSddPolicy>;
|
|
298
|
+
decision?: {
|
|
299
|
+
allowed: boolean;
|
|
300
|
+
code: string;
|
|
301
|
+
message: string;
|
|
302
|
+
details?: Record<string, unknown>;
|
|
303
|
+
};
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const evaluateCriticalToolGuard = (
|
|
307
|
+
repoRoot: string,
|
|
308
|
+
toolName: EnterpriseToolName,
|
|
309
|
+
args: Record<string, unknown>
|
|
310
|
+
): CriticalToolGuardResult => {
|
|
311
|
+
if (!CRITICAL_ENTERPRISE_TOOLS.has(toolName)) {
|
|
312
|
+
return {
|
|
313
|
+
allowed: true,
|
|
314
|
+
stage: toSddStage(args.stage, 'PRE_COMMIT'),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const stage = toSddStage(args.stage, 'PRE_COMMIT');
|
|
319
|
+
try {
|
|
320
|
+
const evaluation = evaluateSddPolicy({
|
|
321
|
+
stage,
|
|
322
|
+
repoRoot,
|
|
323
|
+
});
|
|
324
|
+
return {
|
|
325
|
+
allowed: evaluation.decision.allowed,
|
|
326
|
+
stage,
|
|
327
|
+
evaluation,
|
|
328
|
+
};
|
|
329
|
+
} catch (error) {
|
|
330
|
+
const message = error instanceof Error ? error.message : 'Unknown SDD policy error.';
|
|
331
|
+
return {
|
|
332
|
+
allowed: false,
|
|
333
|
+
stage,
|
|
334
|
+
decision: {
|
|
335
|
+
allowed: false,
|
|
336
|
+
code: 'SDD_VALIDATION_ERROR',
|
|
337
|
+
message: 'Critical tool blocked: SDD policy/session guard failed.',
|
|
338
|
+
details: {
|
|
339
|
+
error: message,
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const executeEnterpriseTool = (
|
|
347
|
+
repoRoot: string,
|
|
348
|
+
toolName: EnterpriseToolName,
|
|
349
|
+
args: Record<string, unknown>,
|
|
350
|
+
dryRun: boolean
|
|
351
|
+
): EnterpriseToolExecution => {
|
|
352
|
+
switch (toolName) {
|
|
353
|
+
case 'ai_gate_check': {
|
|
354
|
+
const evidence = readEvidence(repoRoot);
|
|
355
|
+
if (!evidence) {
|
|
356
|
+
return {
|
|
357
|
+
name: toolName,
|
|
358
|
+
success: false,
|
|
359
|
+
dryRun: true,
|
|
360
|
+
executed: true,
|
|
361
|
+
data: {
|
|
362
|
+
present: false,
|
|
363
|
+
status: 'UNKNOWN',
|
|
364
|
+
message: 'Evidence file is missing or invalid.',
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
name: toolName,
|
|
370
|
+
success: evidence.ai_gate.status === 'ALLOWED',
|
|
371
|
+
dryRun: true,
|
|
372
|
+
executed: true,
|
|
373
|
+
data: {
|
|
374
|
+
present: true,
|
|
375
|
+
status: evidence.ai_gate.status,
|
|
376
|
+
violations: evidence.ai_gate.violations,
|
|
377
|
+
snapshotOutcome: evidence.snapshot.outcome,
|
|
378
|
+
stage: evidence.snapshot.stage,
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
case 'check_sdd_status': {
|
|
383
|
+
const stage = toSddStage(args.stage, 'PRE_COMMIT');
|
|
384
|
+
let evaluation: ReturnType<typeof evaluateSddPolicy>;
|
|
385
|
+
try {
|
|
386
|
+
evaluation = evaluateSddPolicy({
|
|
387
|
+
stage,
|
|
388
|
+
repoRoot,
|
|
389
|
+
});
|
|
390
|
+
} catch (error) {
|
|
391
|
+
const message = error instanceof Error ? error.message : 'Unknown SDD evaluation error.';
|
|
392
|
+
return {
|
|
393
|
+
name: toolName,
|
|
394
|
+
success: false,
|
|
395
|
+
dryRun: true,
|
|
396
|
+
executed: true,
|
|
397
|
+
warnings: ['SDD policy evaluation failed safely.'],
|
|
398
|
+
data: {
|
|
399
|
+
stage,
|
|
400
|
+
decision: {
|
|
401
|
+
allowed: false,
|
|
402
|
+
code: 'SDD_VALIDATION_ERROR',
|
|
403
|
+
message: 'SDD policy evaluation failed before completion.',
|
|
404
|
+
details: {
|
|
405
|
+
error: message,
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
name: toolName,
|
|
413
|
+
success: evaluation.decision.allowed,
|
|
414
|
+
dryRun: true,
|
|
415
|
+
executed: true,
|
|
416
|
+
data: evaluation,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
case 'validate_and_fix': {
|
|
420
|
+
const stage = toSddStage(args.stage, 'PRE_COMMIT');
|
|
421
|
+
let evaluation: ReturnType<typeof evaluateSddPolicy>;
|
|
422
|
+
try {
|
|
423
|
+
evaluation = evaluateSddPolicy({
|
|
424
|
+
stage,
|
|
425
|
+
repoRoot,
|
|
426
|
+
});
|
|
427
|
+
} catch (error) {
|
|
428
|
+
const message = error instanceof Error ? error.message : 'Unknown SDD evaluation error.';
|
|
429
|
+
return {
|
|
430
|
+
name: toolName,
|
|
431
|
+
success: false,
|
|
432
|
+
dryRun: true,
|
|
433
|
+
executed: false,
|
|
434
|
+
warnings: [
|
|
435
|
+
'Mutating auto-fixes are disabled in enterprise baseline mode.',
|
|
436
|
+
'SDD policy evaluation failed safely.',
|
|
437
|
+
],
|
|
438
|
+
data: {
|
|
439
|
+
evaluation: null,
|
|
440
|
+
suggestedActions: [
|
|
441
|
+
'Run inside a managed git repository with OpenSpec bootstrapped.',
|
|
442
|
+
`Error: ${message}`,
|
|
443
|
+
],
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
name: toolName,
|
|
449
|
+
success: evaluation.decision.allowed,
|
|
450
|
+
dryRun: true,
|
|
451
|
+
executed: false,
|
|
452
|
+
warnings: [
|
|
453
|
+
'Mutating auto-fixes are disabled in enterprise baseline mode.',
|
|
454
|
+
],
|
|
455
|
+
data: {
|
|
456
|
+
evaluation,
|
|
457
|
+
suggestedActions: [
|
|
458
|
+
'Review SDD policy decision and fix issues manually.',
|
|
459
|
+
'Use standard git/openSpec workflow in a reviewed branch.',
|
|
460
|
+
],
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
case 'sync_branches': {
|
|
465
|
+
const gitflow = readGitflowState(repoRoot);
|
|
466
|
+
if (!gitflow.available) {
|
|
467
|
+
return {
|
|
468
|
+
name: toolName,
|
|
469
|
+
success: false,
|
|
470
|
+
dryRun: true,
|
|
471
|
+
executed: false,
|
|
472
|
+
warnings: ['Git repository is unavailable.'],
|
|
473
|
+
data: {
|
|
474
|
+
gitflow,
|
|
475
|
+
plan: [],
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
const plan: string[] = [];
|
|
480
|
+
if (!gitflow.upstream) {
|
|
481
|
+
plan.push('No upstream configured for current branch.');
|
|
482
|
+
} else {
|
|
483
|
+
if ((gitflow.behind ?? 0) > 0) {
|
|
484
|
+
plan.push('git pull --rebase --autostash');
|
|
485
|
+
}
|
|
486
|
+
if ((gitflow.ahead ?? 0) > 0) {
|
|
487
|
+
plan.push('git push');
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (gitflow.dirty) {
|
|
491
|
+
plan.push('Working tree is dirty; sync should run only from clean state.');
|
|
492
|
+
}
|
|
493
|
+
if (plan.length === 0) {
|
|
494
|
+
plan.push('Branch is already synchronized with upstream.');
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
name: toolName,
|
|
498
|
+
success: true,
|
|
499
|
+
dryRun: true,
|
|
500
|
+
executed: false,
|
|
501
|
+
warnings: dryRun
|
|
502
|
+
? ['Dry-run mode active: no git command executed.']
|
|
503
|
+
: ['Mutating sync is disabled in enterprise baseline mode.'],
|
|
504
|
+
data: {
|
|
505
|
+
gitflow,
|
|
506
|
+
plan,
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
case 'cleanup_stale_branches': {
|
|
511
|
+
const gitflow = readGitflowState(repoRoot);
|
|
512
|
+
if (!gitflow.available) {
|
|
513
|
+
return {
|
|
514
|
+
name: toolName,
|
|
515
|
+
success: false,
|
|
516
|
+
dryRun: true,
|
|
517
|
+
executed: false,
|
|
518
|
+
warnings: ['Git repository is unavailable.'],
|
|
519
|
+
data: {
|
|
520
|
+
candidates: [],
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
const protectedBranches = new Set([
|
|
525
|
+
gitflow.branch ?? '',
|
|
526
|
+
'main',
|
|
527
|
+
'master',
|
|
528
|
+
'develop',
|
|
529
|
+
'dev',
|
|
530
|
+
'production',
|
|
531
|
+
'staging',
|
|
532
|
+
]);
|
|
533
|
+
const mergedRaw = safeRunGit(repoRoot, ['branch', '--format', '%(refname:short)', '--merged']) ?? '';
|
|
534
|
+
const candidates = mergedRaw
|
|
535
|
+
.split('\n')
|
|
536
|
+
.map((value) => value.trim())
|
|
537
|
+
.filter((value) => value.length > 0 && !protectedBranches.has(value))
|
|
538
|
+
.sort();
|
|
539
|
+
return {
|
|
540
|
+
name: toolName,
|
|
541
|
+
success: true,
|
|
542
|
+
dryRun: true,
|
|
543
|
+
executed: false,
|
|
544
|
+
warnings: dryRun
|
|
545
|
+
? ['Dry-run mode active: no branch deleted.']
|
|
546
|
+
: ['Branch deletion is disabled in enterprise baseline mode.'],
|
|
547
|
+
data: {
|
|
548
|
+
candidates,
|
|
549
|
+
plan: candidates.map((branch) => `git branch -d ${branch}`),
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
default:
|
|
554
|
+
return {
|
|
555
|
+
name: toolName,
|
|
556
|
+
success: false,
|
|
557
|
+
dryRun: true,
|
|
558
|
+
executed: false,
|
|
559
|
+
data: { error: 'Unsupported tool' },
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
const buildStatusPayload = (repoRoot: string): EnterpriseStatusPayload => ({
|
|
565
|
+
status: 'ok',
|
|
566
|
+
repoRoot,
|
|
567
|
+
capabilities: {
|
|
568
|
+
resources: [...ENTERPRISE_RESOURCES],
|
|
569
|
+
tools: [...ENTERPRISE_TOOLS],
|
|
570
|
+
mode: 'baseline',
|
|
571
|
+
},
|
|
572
|
+
lifecycle: safeReadLifecycleStatus(repoRoot),
|
|
573
|
+
sdd: safeReadSddStatus(repoRoot),
|
|
574
|
+
evidence: toStatusPayload(repoRoot),
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
export const startEnterpriseMcpServer = (
|
|
578
|
+
options: EnterpriseServerOptions = {}
|
|
579
|
+
): EnterpriseServerHandle => {
|
|
580
|
+
const host = options.host ?? '127.0.0.1';
|
|
581
|
+
const port = options.port ?? 7391;
|
|
582
|
+
const repoRoot = options.repoRoot ?? process.cwd();
|
|
583
|
+
|
|
584
|
+
const sendJson = (
|
|
585
|
+
res: import('node:http').ServerResponse,
|
|
586
|
+
status: number,
|
|
587
|
+
body: unknown
|
|
588
|
+
): void => {
|
|
589
|
+
const payload = JSON.stringify(body);
|
|
590
|
+
res.writeHead(status, {
|
|
591
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
592
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
593
|
+
'Cache-Control': 'no-store',
|
|
594
|
+
});
|
|
595
|
+
res.end(payload);
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
const server = createServer((req, res) => {
|
|
599
|
+
const requestUrl = new URL(req.url ?? '/', `http://${host}:${port}`);
|
|
600
|
+
const pathname = requestUrl.pathname.replace(/\/+$/, '') || '/';
|
|
601
|
+
|
|
602
|
+
if (pathname === '/health') {
|
|
603
|
+
if (req.method !== 'GET') {
|
|
604
|
+
sendJson(res, 405, { error: 'Method not allowed' });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
sendJson(res, 200, { status: 'ok' });
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (pathname === '/status') {
|
|
612
|
+
if (req.method !== 'GET') {
|
|
613
|
+
sendJson(res, 405, { error: 'Method not allowed' });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
sendJson(res, 200, buildStatusPayload(repoRoot));
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (pathname === '/resources') {
|
|
621
|
+
if (req.method !== 'GET') {
|
|
622
|
+
sendJson(res, 405, { error: 'Method not allowed' });
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
sendJson(res, 200, {
|
|
626
|
+
resources: ENTERPRISE_RESOURCE_DESCRIPTORS,
|
|
627
|
+
});
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (pathname === '/resource') {
|
|
632
|
+
if (req.method !== 'GET') {
|
|
633
|
+
sendJson(res, 405, { error: 'Method not allowed' });
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const uri = requestUrl.searchParams.get('uri');
|
|
637
|
+
if (!isEnterpriseResourceUri(uri)) {
|
|
638
|
+
sendJson(res, 404, {
|
|
639
|
+
error: 'Unknown enterprise resource URI',
|
|
640
|
+
requestedUri: uri,
|
|
641
|
+
});
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
sendJson(res, 200, {
|
|
645
|
+
uri,
|
|
646
|
+
payload: readResourcePayload(uri, repoRoot),
|
|
647
|
+
});
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (pathname === '/tools') {
|
|
652
|
+
if (req.method !== 'GET') {
|
|
653
|
+
sendJson(res, 405, { error: 'Method not allowed' });
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
sendJson(res, 200, {
|
|
657
|
+
tools: ENTERPRISE_TOOL_DESCRIPTORS,
|
|
658
|
+
});
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (pathname === '/tool') {
|
|
663
|
+
if (req.method !== 'POST') {
|
|
664
|
+
sendJson(res, 405, { error: 'Method not allowed' });
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
void readJsonBody(req)
|
|
668
|
+
.then((body) => {
|
|
669
|
+
if (typeof body !== 'object' || body === null) {
|
|
670
|
+
sendJson(res, 400, {
|
|
671
|
+
error: 'Invalid request body.',
|
|
672
|
+
});
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const payload = body as {
|
|
676
|
+
name?: unknown;
|
|
677
|
+
args?: unknown;
|
|
678
|
+
dryRun?: unknown;
|
|
679
|
+
};
|
|
680
|
+
const toolNameCandidate = typeof payload.name === 'string' ? payload.name : null;
|
|
681
|
+
if (!isEnterpriseToolName(toolNameCandidate)) {
|
|
682
|
+
sendJson(res, 404, {
|
|
683
|
+
error: 'Unknown enterprise tool.',
|
|
684
|
+
requestedTool: payload.name ?? null,
|
|
685
|
+
});
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const toolName = toolNameCandidate;
|
|
689
|
+
const args = typeof payload.args === 'object' && payload.args !== null
|
|
690
|
+
? (payload.args as Record<string, unknown>)
|
|
691
|
+
: {};
|
|
692
|
+
const requestedDryRun = typeof payload.dryRun === 'boolean' ? payload.dryRun : true;
|
|
693
|
+
const forcedDryRun = requestedDryRun || MUTATING_ENTERPRISE_TOOLS.has(toolName);
|
|
694
|
+
const guard = evaluateCriticalToolGuard(repoRoot, toolName, args);
|
|
695
|
+
if (!guard.allowed) {
|
|
696
|
+
const decision = guard.evaluation?.decision ?? guard.decision;
|
|
697
|
+
sendJson(res, 200, {
|
|
698
|
+
tool: toolName,
|
|
699
|
+
dryRun: true,
|
|
700
|
+
executed: false,
|
|
701
|
+
success: false,
|
|
702
|
+
warnings: ['Critical tool blocked by SDD policy/session guard.'],
|
|
703
|
+
result: {
|
|
704
|
+
guard: {
|
|
705
|
+
stage: guard.stage,
|
|
706
|
+
decision,
|
|
707
|
+
status: guard.evaluation?.status ?? null,
|
|
708
|
+
},
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
let result: EnterpriseToolExecution;
|
|
714
|
+
try {
|
|
715
|
+
result = executeEnterpriseTool(
|
|
716
|
+
repoRoot,
|
|
717
|
+
toolName,
|
|
718
|
+
args,
|
|
719
|
+
forcedDryRun
|
|
720
|
+
);
|
|
721
|
+
} catch (error) {
|
|
722
|
+
const message = error instanceof Error ? error.message : 'Unknown tool execution error.';
|
|
723
|
+
sendJson(res, 200, {
|
|
724
|
+
tool: toolName,
|
|
725
|
+
dryRun: true,
|
|
726
|
+
executed: false,
|
|
727
|
+
success: false,
|
|
728
|
+
warnings: ['Tool execution failed safely.'],
|
|
729
|
+
result: {
|
|
730
|
+
error: message,
|
|
731
|
+
},
|
|
732
|
+
});
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
sendJson(res, 200, {
|
|
736
|
+
tool: toolName,
|
|
737
|
+
dryRun: result.dryRun,
|
|
738
|
+
executed: result.executed,
|
|
739
|
+
success: result.success,
|
|
740
|
+
warnings: result.warnings ?? [],
|
|
741
|
+
result: result.data,
|
|
742
|
+
});
|
|
743
|
+
})
|
|
744
|
+
.catch(() => {
|
|
745
|
+
sendJson(res, 400, {
|
|
746
|
+
error: 'Invalid JSON body.',
|
|
747
|
+
});
|
|
748
|
+
});
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
sendJson(res, 404, { error: 'Not found' });
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
server.listen(port, host);
|
|
756
|
+
|
|
757
|
+
return {
|
|
758
|
+
server,
|
|
759
|
+
host,
|
|
760
|
+
port,
|
|
761
|
+
};
|
|
762
|
+
};
|