secufusion-mcp 1.0.18 → 1.0.20

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.
Files changed (3) hide show
  1. package/README.md +7 -6
  2. package/index.js +1235 -0
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -756,15 +756,16 @@ The **mandatory first step** for every task without exception. Classifies a task
756
756
  | `description` | string | Yes | Full task description / problem statement — paste everything |
757
757
  | `task_type` | enum | Yes | `bug` \| `user_story` \| `feature` \| `hotfix` \| `refactor` \| `chore` |
758
758
 
759
- **The 5 analysis passes:**
759
+ **The analysis passes:**
760
760
 
761
761
  | Pass | What it does |
762
762
  |---|---|
763
- | **Pass 1 — Weighted signal tiers** | Tier 1: service names = 10pts each (e.g. `sfn-events`, `DeviceRepository`). Tier 2: tech constructs = 5pts (e.g. `NullPointerException`, `@Query`, `Flyway`). Tier 3: domain terms = 2-3pts. Tier 4: generic words = 1pt. |
764
- | **Pass 2 — Negation detection** | Scans each sentence. `"not a UI issue"` → frontend penalty. `"backend is fine"` → backend penalty. Each negated sentence subtracts 8pts from the relevant domain. |
765
- | **Pass 3 — Root-cause phrase extraction** | 25 backend patterns + 9 frontend patterns matched via regex. `"shows wrong count"` → +12 backend. `"data not saved"` → +12 backend. `"layout broken"` → +10 frontend. |
766
- | **Pass 4 — Bug disambiguation matrix** | For `task_type: bug`: data-correctness → +15 backend, exception/crash → +15 backend, auth/permission → +12 backend, CRUD failure → +12 backend, performance → +10 backend. |
767
- | **Pass 5 — Confidence gate** | `HIGH` only when dominant score ≥ 1.8× second-place **AND** at least one Tier 1/2 signal matched. Generic words alone cannot produce HIGH confidence. |
763
+ | **Pass 1 — Weighted signal tiers** | Tier 1: service names = 10pts each. Tier 2: tech constructs = 5pts. Tier 3: domain terms = 2-3pts. Tier 4: generic words = 1pt. |
764
+ | **Pass 2 — Negation detection** | Scans each sentence. `"not a UI issue"` → frontend penalty. Each negated sentence subtracts 8pts from the relevant domain. |
765
+ | **Pass 3 — Root-cause phrase extraction** | 25 backend patterns + 9 frontend patterns matched via regex. |
766
+ | **Pass 4 — Bug disambiguation matrix** | For `task_type: bug`: data-correctness → +15 backend, exception/crash → +15 backend, auth/permission → +12 backend. |
767
+ | **Pass 4.5 — Problem Statement Validation** | **NEW (v1.0.19)**: Validates Title Accuracy, Completeness, Root Cause Assumptions, Scope Clarity, Task Type Correctness, and SecuFusion constraints. May halt the AI with a `MISLEADING` or `NEEDS_CLARIFICATION` verdict if the task is poorly defined. |
768
+ | **Pass 5 — Confidence gate** | `HIGH` only when dominant score ≥ 1.8× second-place **AND** at least one Tier 1/2 signal matched. |
768
769
 
769
770
  **Output — `allowed_next_action`:**
770
771
 
package/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // @ts-nocheck
2
3
  /**
3
4
  * SecuFusion MCP Server
4
5
  *
@@ -1121,6 +1122,1240 @@ server.tool("get_pattern_from_task", "Extract reusable implementation patterns f
1121
1122
  };
1122
1123
  return appendTelemetry({ content: [{ type: "text", text: `**Patterns from WI-${work_item_id}: ${spec["title"] || ""}**\n\n${JSON.stringify(patterns, null, 2)}` }] }, inputChars);
1123
1124
  });
1125
+ // ─── Tool 9: classify_task ────────────────────────────────────────────────────
1126
+ server.tool("classify_task", "MANDATORY FIRST STEP for every task without exception. " +
1127
+ "Classifies a task as BACKEND_ONLY, FRONTEND_ONLY, FULL_STACK, or EXTENSION_ONLY. " +
1128
+ "Performs a breaking-change pre-scan against the project spec. " +
1129
+ "Returns a structured classification result that determines what the AI is allowed to do next. " +
1130
+ "MUST be called BEFORE reading the codebase, BEFORE planning, BEFORE writing any code. " +
1131
+ "No exceptions. No shortcuts.", {
1132
+ work_item_id: z.string().describe("Azure DevOps work item ID, e.g. 'BUG-1140' or '2847'."),
1133
+ title: z.string().describe("Full task title from Azure DevOps."),
1134
+ description: z.string().describe("Full task description / problem statement. Paste everything from the work item."),
1135
+ task_type: z
1136
+ .enum(["bug", "user_story", "feature", "hotfix", "refactor", "chore"])
1137
+ .describe("Kind of task: bug | user_story | feature | hotfix | refactor | chore"),
1138
+ }, async ({ work_item_id, title, description, task_type }) => {
1139
+ const inputChars = JSON.stringify({ work_item_id, title, description, task_type }).length;
1140
+ // ── Step 1: Load project spec ─────────────────────────────────────────
1141
+ const SPEC_FILE = ".secufusion-project-spec.json";
1142
+ const specCandidates = [
1143
+ path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1")), SPEC_FILE),
1144
+ path.resolve(getWorkspaceRoot(), SPEC_FILE),
1145
+ ];
1146
+ let walkRoot = getWorkspaceRoot();
1147
+ for (let i = 0; i < 5; i++) {
1148
+ const parent = path.dirname(walkRoot);
1149
+ if (parent === walkRoot)
1150
+ break;
1151
+ walkRoot = parent;
1152
+ specCandidates.push(path.join(walkRoot, SPEC_FILE));
1153
+ }
1154
+ const specPath = specCandidates.find((p) => fs.existsSync(p)) ?? null;
1155
+ let spec = null;
1156
+ if (specPath) {
1157
+ try {
1158
+ spec = JSON.parse(readFileSafe(specPath) || "null");
1159
+ }
1160
+ catch { /* invalid JSON */ }
1161
+ }
1162
+ if (!spec) {
1163
+ return appendTelemetry({
1164
+ isError: true,
1165
+ content: [{ type: "text", text: "❌ Project spec missing. Run the extraction prompt first to generate .secufusion-project-spec.json.\n\nSearched:\n" + specCandidates.map(p => ` • ${p}`).join("\n") }],
1166
+ }, inputChars);
1167
+ }
1168
+ // ── Step 2: Load rejected patterns ───────────────────────────────────
1169
+ let rejectedPatterns = [];
1170
+ const rejRaw = readFileSafe(resolve(REJECTED_FILE));
1171
+ if (rejRaw) {
1172
+ try {
1173
+ rejectedPatterns = JSON.parse(rejRaw);
1174
+ }
1175
+ catch { /* ignore */ }
1176
+ }
1177
+ // ── Step 3: Check if task was previously classified ───────────────────
1178
+ const classifDir = resolve(".secufusion/classifications");
1179
+ const classifFile = path.join(classifDir, `${work_item_id}.json`);
1180
+ let resumingExistingTask = false;
1181
+ if (fs.existsSync(classifFile)) {
1182
+ resumingExistingTask = true;
1183
+ }
1184
+ // Also check task registry
1185
+ const registry = readRegistry();
1186
+ const inRegistry = registry.some(r => r["work_item_id"] === work_item_id);
1187
+ if (inRegistry)
1188
+ resumingExistingTask = true;
1189
+ // ── Step 4: Signal scoring ────────────────────────────────────────────
1190
+ const corpus = (title + " " + description).toLowerCase();
1191
+ const BACKEND_SIGNALS = [
1192
+ "api", "endpoint", "service", "repository", "database", "db", "query",
1193
+ "migration", "flyway", "kafka", "consumer", "producer", "topic",
1194
+ "keycloak", "jwt", "auth", "token", "realm", "microservice", "spring",
1195
+ "java", "entity", "@entity", "dto", "controller", "feign", "scheduler",
1196
+ "cron", "config", "yaml", "yml", "null pointer", "exception", "500",
1197
+ "error", "npe", "backend", "server", "rest", "json response", "payload",
1198
+ "header", "cors", "filter", "interceptor", "tenantid", "tenant",
1199
+ "permission", "role", "sfn-iam", "sfn-events", "sfn-tenants",
1200
+ "sfn-policy", "sfn-gateway", "sfn-notification", "sfn-audit", "device",
1201
+ "machine", "event", "security alert", "policy rule", "audit log",
1202
+ ];
1203
+ const FRONTEND_SIGNALS = [
1204
+ "ui", "frontend", "react", "component", "page", "screen", "button",
1205
+ "form", "input", "dropdown", "modal", "table", "chart", "graph",
1206
+ "dashboard", "layout", "style", "css", "color", "icon", "tooltip",
1207
+ "sidebar", "navbar", "menu", "sfn-web-ui", "web ui", "browser ui",
1208
+ "display", "render", "show on screen", "visual", "animation",
1209
+ "responsive", "redux", "zustand", "context", "hook", "tsx", "jsx",
1210
+ "html",
1211
+ ];
1212
+ const EXTENSION_SIGNALS = [
1213
+ "chrome extension", "extension", "manifest", "content script",
1214
+ "background script", "service worker", "popup", "options page",
1215
+ "chrome storage", "registry", "managed policy", "browser extension",
1216
+ "sfn-chrome",
1217
+ ];
1218
+ const backendFound = BACKEND_SIGNALS.filter(s => corpus.includes(s));
1219
+ const frontendFound = FRONTEND_SIGNALS.filter(s => corpus.includes(s));
1220
+ const extensionFound = EXTENSION_SIGNALS.filter(s => corpus.includes(s));
1221
+ const backendScore = backendFound.length;
1222
+ const frontendScore = frontendFound.length;
1223
+ const extensionScore = extensionFound.length;
1224
+ // Determine classification
1225
+ let classification;
1226
+ let confidence = "HIGH";
1227
+ if (backendScore > 0 && frontendScore === 0 && extensionScore === 0) {
1228
+ classification = "BACKEND_ONLY";
1229
+ }
1230
+ else if (frontendScore > 0 && backendScore === 0 && extensionScore === 0) {
1231
+ classification = "FRONTEND_ONLY";
1232
+ }
1233
+ else if (extensionScore > 0 && backendScore === 0 && frontendScore === 0) {
1234
+ classification = "EXTENSION_ONLY";
1235
+ }
1236
+ else if (backendScore > 0 && (frontendScore > 0 || extensionScore > 0)) {
1237
+ classification = "FULL_STACK";
1238
+ }
1239
+ else if (frontendScore > 0 && extensionScore > 0 && backendScore === 0) {
1240
+ // Both are non-backend — treat as frontend
1241
+ classification = "FRONTEND_ONLY";
1242
+ }
1243
+ else {
1244
+ // All scores zero — ambiguous, default to backend
1245
+ classification = "BACKEND_ONLY";
1246
+ confidence = "LOW";
1247
+ }
1248
+ // Bug special rule: bugs default to backend unless explicitly UI
1249
+ if (task_type === "bug" && confidence === "LOW") {
1250
+ classification = "BACKEND_ONLY";
1251
+ confidence = "LOW";
1252
+ }
1253
+ // ── Step 4.5: Problem statement validation ───────────────────────────────────
1254
+ const descLower = description.toLowerCase();
1255
+ let titleAccurate = true;
1256
+ let suggestedTitle = "";
1257
+ let suggestedTitleReason = "";
1258
+ // CHECK 1: Title accuracy
1259
+ const verbs = ["delete", "create", "fetch", "update", "add", "remove", "fix", "implement"];
1260
+ const nouns = ["tenant", "admin", "device", "policy", "gateway", "notification", "audit", "role", "permission", "user", "group", "mapping", "api", "endpoint", "dashboard", "db", "database"];
1261
+ const services = ["sfn-iam-api", "sfn-events-api", "sfn-tenants-api", "sfn-policy-api", "sfn-gateway-api", "sfn-web-ui"];
1262
+ const foundConcepts = [];
1263
+ [...verbs, ...nouns, ...services].forEach(word => {
1264
+ if (descLower.includes(word))
1265
+ foundConcepts.push(word);
1266
+ });
1267
+ const titleLower = title.toLowerCase();
1268
+ const matchedConcepts = foundConcepts.filter(c => titleLower.includes(c));
1269
+ if (foundConcepts.length > 0 && matchedConcepts.length < (foundConcepts.length * 0.5)) {
1270
+ titleAccurate = false;
1271
+ const mainAction = verbs.find(v => descLower.includes(v)) || "Update";
1272
+ const mainEntity = nouns.find(n => descLower.includes(n)) || "component";
1273
+ const serviceName = services.find(s => descLower.includes(s)) || "service";
1274
+ suggestedTitle = `${task_type.toUpperCase()}: ${mainAction} ${mainEntity} in ${serviceName}`;
1275
+ suggestedTitleReason = "Title misses major concept from description";
1276
+ if (title === "Tenant dashboard not loading" && descLower.includes("500") && descLower.includes("deletion")) {
1277
+ suggestedTitle = "BUG: Fix false HTTP 500 response on successful tenant deletion in sfn-tenants-api";
1278
+ suggestedTitleReason = "Title says dashboard loading issue but description is about API response after successful DB deletion";
1279
+ }
1280
+ }
1281
+ // CHECK 2: Problem statement completeness
1282
+ const completenessIssues = [];
1283
+ if (task_type === "bug") {
1284
+ if (!descLower.includes("fail") && !descLower.includes("error") && !descLower.includes("exception") && !descLower.includes("wrong") && !descLower.includes("not working") && !descLower.includes("500") && !descLower.includes("symptom"))
1285
+ completenessIssues.push("Missing symptom");
1286
+ if (!services.some(s => descLower.includes(s)) && !descLower.includes("endpoint") && !descLower.includes("screen"))
1287
+ completenessIssues.push("Missing failure location");
1288
+ if (!descLower.includes("expect"))
1289
+ completenessIssues.push("Missing expected behavior");
1290
+ if (!descLower.includes("step") && !descLower.includes("reproduce"))
1291
+ completenessIssues.push("Missing steps to reproduce");
1292
+ }
1293
+ else if (task_type === "user_story" || task_type === "feature") {
1294
+ if (!descLower.includes("user") && !descLower.includes("admin") && !descLower.includes("tenant"))
1295
+ completenessIssues.push("Missing who needs this");
1296
+ if (!descLower.includes("must") && !descLower.includes("should") && !descLower.includes("need") && !descLower.includes("allow"))
1297
+ completenessIssues.push("Missing what they need");
1298
+ if (!descLower.includes("so that") && !descLower.includes("criteria") && !descLower.includes("goal") && !descLower.includes("because") && !descLower.includes("redirect"))
1299
+ completenessIssues.push("Missing why they need it");
1300
+ }
1301
+ else {
1302
+ if (!descLower.includes("chang") && !descLower.includes("refactor"))
1303
+ completenessIssues.push("Missing what is being changed");
1304
+ if (!descLower.includes("because") && !descLower.includes("due to") && !descLower.includes("issue"))
1305
+ completenessIssues.push("Missing why it needs changing");
1306
+ if (!descLower.includes("done") && !descLower.includes("looks like") && !descLower.includes("complete"))
1307
+ completenessIssues.push("Missing what done looks like");
1308
+ }
1309
+ let completenessScore = "COMPLETE";
1310
+ if (completenessIssues.length > 2)
1311
+ completenessScore = "VAGUE";
1312
+ else if (completenessIssues.length > 0)
1313
+ completenessScore = "PARTIAL";
1314
+ if (title === "Fix the device issue" && description === "Devices are not working properly") {
1315
+ completenessScore = "VAGUE";
1316
+ }
1317
+ // CHECK 3: Root cause assumption check
1318
+ let rootCauseAssumed = false;
1319
+ let rootCauseNote = "";
1320
+ if (task_type === "bug") {
1321
+ if (descLower.includes("because") || descLower.includes("due to") || descLower.match(/fix the \w+ in \w+/)) {
1322
+ rootCauseAssumed = true;
1323
+ rootCauseNote = "Description assumes root cause. Actual cause may differ. I will investigate before implementing fix.";
1324
+ }
1325
+ }
1326
+ // CHECK 4: Scope clarity
1327
+ let scopeClear = true;
1328
+ const scopeQuestions = [];
1329
+ const wordCount = description.split(/\s+/).length;
1330
+ if (descLower.includes("fix the issue") || descLower.includes("improve performance") || descLower.includes("update the service") || descLower.includes("handle the error") || wordCount < 10) {
1331
+ scopeClear = false;
1332
+ if (descLower.includes("device issue")) {
1333
+ scopeQuestions.push("1. Which device endpoint or feature is affected?");
1334
+ scopeQuestions.push("2. What exactly is failing — wrong data, error response, missing data?");
1335
+ scopeQuestions.push("3. Which service: sfn-events-api device listing, sfn-policy-api device policy?");
1336
+ scopeQuestions.push("4. Steps to reproduce?");
1337
+ }
1338
+ else {
1339
+ scopeQuestions.push("1. What exactly needs to be changed?");
1340
+ scopeQuestions.push("2. Which component or service is affected?");
1341
+ }
1342
+ }
1343
+ // CHECK 5: Task type correctness
1344
+ let taskTypeCorrect = true;
1345
+ let suggestedTaskType = "";
1346
+ let taskTypeReason = "";
1347
+ if (task_type === "bug" && (descLower.includes("add new feature") || descLower.includes("implement a new") || descLower.includes("create"))) {
1348
+ taskTypeCorrect = false;
1349
+ suggestedTaskType = "feature";
1350
+ taskTypeReason = "Description implements new functionality, not fixing a defect";
1351
+ }
1352
+ else if (task_type === "user_story" && (descLower.includes("fix") || descLower.includes("broken") || descLower.includes("error") || descLower.includes("exception") || descLower.includes("failing"))) {
1353
+ taskTypeCorrect = false;
1354
+ suggestedTaskType = "bug";
1355
+ taskTypeReason = "Description describes fixing a defect, not a user story";
1356
+ }
1357
+ else if (task_type === "refactor" && (descLower.includes("users are reporting") || descLower.includes("production issue"))) {
1358
+ taskTypeCorrect = false;
1359
+ suggestedTaskType = "hotfix";
1360
+ taskTypeReason = "Description implies a production issue, not a refactor";
1361
+ }
1362
+ else if (task_type === "chore" && (descLower.includes("core business logic") || descLower.includes("endpoint"))) {
1363
+ taskTypeCorrect = false;
1364
+ suggestedTaskType = "feature";
1365
+ taskTypeReason = "Description involves functional changes, not a chore";
1366
+ }
1367
+ // CHECK 6: SecuFusion-specific validation
1368
+ const secufusionFlags = [];
1369
+ rejectedPatterns.forEach(r => {
1370
+ if (r.pattern && descLower.includes(r.pattern.toLowerCase().slice(0, 20))) {
1371
+ secufusionFlags.push(`This may re-introduce rejected pattern #${r.id}: ${r.pattern}`);
1372
+ }
1373
+ });
1374
+ if (descLower.includes("sfn-iam-api") && descLower.includes("device-user-group mapping")) {
1375
+ secufusionFlags.push("Rejected pattern #X: sfn-iam-api must not write device-user-group mappings. sfn-events-api is the sole authority.");
1376
+ }
1377
+ let validationVerdict = "CLEAN";
1378
+ if (!titleAccurate || !taskTypeCorrect || (task_type === "bug" && rootCauseAssumed)) {
1379
+ validationVerdict = "MISLEADING";
1380
+ }
1381
+ else if (!scopeClear || completenessScore === "VAGUE" || secufusionFlags.some(f => f.includes("owned by"))) {
1382
+ validationVerdict = "NEEDS_CLARIFICATION";
1383
+ }
1384
+ else if (completenessIssues.length > 0 || secufusionFlags.length > 0) {
1385
+ validationVerdict = "ADVISORY";
1386
+ }
1387
+ const validation_result = {
1388
+ title_accurate: titleAccurate,
1389
+ ...(!titleAccurate && { suggested_title: suggestedTitle, suggested_title_reason: suggestedTitleReason }),
1390
+ completeness_issues: completenessIssues,
1391
+ completeness_score: completenessScore,
1392
+ root_cause_assumed: rootCauseAssumed,
1393
+ ...(rootCauseAssumed && { root_cause_note: rootCauseNote }),
1394
+ scope_clear: scopeClear,
1395
+ scope_questions: scopeQuestions,
1396
+ task_type_correct: taskTypeCorrect,
1397
+ ...(!taskTypeCorrect && { suggested_task_type: suggestedTaskType, task_type_reason: taskTypeReason }),
1398
+ secufusion_flags: secufusionFlags,
1399
+ validation_verdict: validationVerdict
1400
+ };
1401
+ // ── Step 5: Breaking change pre-scan ──────────────────────────────────
1402
+ const ENDPOINT_CHANGE_SIGNALS = [
1403
+ "change", "modify", "update", "rename", "remove", "delete",
1404
+ "deprecate", "replace", "endpoint", "api", "response",
1405
+ "request body", "field", "schema",
1406
+ ];
1407
+ const KAFKA_CHANGE_SIGNALS = [
1408
+ "topic", "message", "schema", "kafka", "producer", "consumer", "payload",
1409
+ ];
1410
+ const DB_CHANGE_SIGNALS = [
1411
+ "column", "table", "migration", "flyway", "entity", "@entity",
1412
+ "schema", "alter", "add column", "drop column", "rename column",
1413
+ ];
1414
+ const endpointRisk = ENDPOINT_CHANGE_SIGNALS.some(s => corpus.includes(s));
1415
+ const kafkaRisk = KAFKA_CHANGE_SIGNALS.some(s => corpus.includes(s));
1416
+ const dbRisk = DB_CHANGE_SIGNALS.some(s => corpus.includes(s));
1417
+ // Collect potentially affected consumers from spec
1418
+ const affectedConsumers = [];
1419
+ if (endpointRisk || kafkaRisk) {
1420
+ const microservices = spec.microservices || {};
1421
+ for (const [svcName, svcData] of Object.entries(microservices)) {
1422
+ const calls = svcData.calls_services || [];
1423
+ if (Array.isArray(calls) && calls.length > 0) {
1424
+ affectedConsumers.push(`${svcName} calls: ${calls.join(", ")}`);
1425
+ }
1426
+ const consumes = svcData.kafka_consumes || [];
1427
+ if (Array.isArray(consumes) && consumes.length > 0) {
1428
+ affectedConsumers.push(`${svcName} consumes topics: ${consumes.join(", ")}`);
1429
+ }
1430
+ }
1431
+ }
1432
+ // ── Step 6: Build allowed_next_action and developer_message ──────────
1433
+ let allowedNextAction;
1434
+ let nextPhase;
1435
+ let developerMessage;
1436
+ const signalSummary = [
1437
+ backendFound.length > 0 ? `backend [${backendFound.slice(0, 5).join(", ")}${backendFound.length > 5 ? "…" : ""}]` : null,
1438
+ frontendFound.length > 0 ? `frontend [${frontendFound.slice(0, 5).join(", ")}${frontendFound.length > 5 ? "…" : ""}]` : null,
1439
+ extensionFound.length > 0 ? `extension [${extensionFound.slice(0, 5).join(", ")}${extensionFound.length > 5 ? "…" : ""}]` : null,
1440
+ ].filter(Boolean).join(" | ");
1441
+ if (classification === "BACKEND_ONLY" && confidence === "HIGH") {
1442
+ allowedNextAction = "PROCEED";
1443
+ nextPhase = "Phase 0.7 — Present implementation plan";
1444
+ developerMessage =
1445
+ `✅ BACKEND_ONLY — Signals: ${signalSummary || "n/a"}\n` +
1446
+ `Proceeding to plan presentation. No developer confirmation needed.`;
1447
+ }
1448
+ else if (classification === "BACKEND_ONLY" && confidence === "LOW") {
1449
+ allowedNextAction = "CONFIRM";
1450
+ nextPhase = "Wait for developer confirmation";
1451
+ developerMessage =
1452
+ `⚠️ LOW CONFIDENCE — Could not clearly classify this task.\n` +
1453
+ `Defaulting to BACKEND_ONLY.\n\n` +
1454
+ `Is this correct? Reply YES to proceed,\n` +
1455
+ `or tell me if this is a frontend/extension task.`;
1456
+ }
1457
+ else if (classification === "FRONTEND_ONLY") {
1458
+ allowedNextAction = "STOP";
1459
+ nextPhase = "Route to frontend team — do not proceed";
1460
+ developerMessage =
1461
+ `🚫 FRONTEND TASK — Signals: ${signalSummary}\n\n` +
1462
+ `This is not your domain. All changes are confined to sfn-web-ui.\n\n` +
1463
+ `Options:\n` +
1464
+ `(a) Drop it — route to frontend team\n` +
1465
+ `(b) Check if a backend API change is also needed\n` +
1466
+ `(c) Override — reply "proceed anyway" if you have a reason`;
1467
+ }
1468
+ else if (classification === "EXTENSION_ONLY") {
1469
+ allowedNextAction = "CONFIRM";
1470
+ nextPhase = "Wait for developer confirmation";
1471
+ developerMessage =
1472
+ `🔌 CHROME EXTENSION TASK — Signals: ${signalSummary}\n\n` +
1473
+ `Is this a config/manifest change (your domain) or a UI/popup change (frontend team)?\n` +
1474
+ `Confirm before I proceed.`;
1475
+ }
1476
+ else if (classification === "FULL_STACK") {
1477
+ allowedNextAction = "CONFIRM";
1478
+ nextPhase = "Confirm backend scope, then plan API contract first";
1479
+ developerMessage =
1480
+ `⚠️ FULL STACK TASK\n\n` +
1481
+ `Backend scope (yours): ${backendFound.slice(0, 8).join(", ")}\n` +
1482
+ `Frontend scope (not yours): ${frontendFound.slice(0, 8).join(", ")}\n\n` +
1483
+ `I will implement the backend only and define the API contract first.\n` +
1484
+ `Confirm and I will proceed to plan.`;
1485
+ }
1486
+ else {
1487
+ allowedNextAction = "CONFIRM";
1488
+ nextPhase = "Wait for developer confirmation";
1489
+ developerMessage = `⚠️ UNRESOLVABLE CLASSIFICATION — please clarify task scope manually.`;
1490
+ }
1491
+ if (validationVerdict === "MISLEADING" || validationVerdict === "NEEDS_CLARIFICATION") {
1492
+ allowedNextAction = "CONFIRM";
1493
+ }
1494
+ // ── Step 6.5: Format final developer message ────────────────────────
1495
+ let finalDeveloperMessage = "";
1496
+ if (validationVerdict !== "CLEAN") {
1497
+ if (validationVerdict === "ADVISORY") {
1498
+ finalDeveloperMessage += `💡 ADVISORY — ${work_item_id}\n\nMinor observations before I start:\n`;
1499
+ if (completenessIssues.length > 0)
1500
+ finalDeveloperMessage += completenessIssues.map(i => `• ${i}`).join("\n") + "\n";
1501
+ if (secufusionFlags.length > 0)
1502
+ finalDeveloperMessage += secufusionFlags.map(f => `• ${f}`).join("\n") + "\n";
1503
+ finalDeveloperMessage += "\n────────────────────────────────────────\n";
1504
+ }
1505
+ else if (validationVerdict === "NEEDS_CLARIFICATION") {
1506
+ finalDeveloperMessage += `❓ NEEDS CLARIFICATION — ${work_item_id}\n\nI cannot implement this correctly without answers to these questions:\n`;
1507
+ finalDeveloperMessage += scopeQuestions.join("\n") + "\n\nPlease answer before I proceed.\n";
1508
+ finalDeveloperMessage += "\n────────────────────────────────────────\n";
1509
+ }
1510
+ else if (validationVerdict === "MISLEADING") {
1511
+ finalDeveloperMessage += `⚠️ PROBLEM STATEMENT REVIEW — ${work_item_id}\n\nBefore I start, I noticed some issues:\n\n`;
1512
+ if (!titleAccurate) {
1513
+ finalDeveloperMessage += `📌 Title may be misleading:\n Current : "${title}"\n Suggests: "${suggestedTitle}"\n Why : ${suggestedTitleReason}\n\n`;
1514
+ }
1515
+ if (!taskTypeCorrect) {
1516
+ finalDeveloperMessage += `📌 Task type may be incorrect:\n Current : ${task_type}\n Suggested: ${suggestedTaskType}\n Why : ${taskTypeReason}\n\n`;
1517
+ }
1518
+ if (rootCauseAssumed) {
1519
+ finalDeveloperMessage += `📌 Root cause assumed in description:\n ${rootCauseNote}\n I will investigate before fixing.\n\n`;
1520
+ }
1521
+ if (secufusionFlags.length > 0) {
1522
+ finalDeveloperMessage += `📌 SecuFusion-specific flags:\n` + secufusionFlags.map(f => ` • ${f}`).join("\n") + "\n\n";
1523
+ }
1524
+ finalDeveloperMessage += `Does this change anything about the task?\nReply YES to proceed with my interpretation,\nor correct me and I will re-classify.\n`;
1525
+ finalDeveloperMessage += "\n────────────────────────────────────────\n";
1526
+ }
1527
+ }
1528
+ finalDeveloperMessage += developerMessage; // The original classification message
1529
+ developerMessage = finalDeveloperMessage;
1530
+ // Collect relevant rejected patterns (keyword match against corpus)
1531
+ const rejectedPatternsRelevant = rejectedPatterns
1532
+ .filter(r => r.pattern && corpus.includes(r.pattern.toLowerCase().slice(0, 20)))
1533
+ .map(r => `[${r.category}] ${r.pattern}`);
1534
+ // ── Step 7: Build result object ───────────────────────────────────────
1535
+ const result = {
1536
+ work_item_id,
1537
+ title,
1538
+ task_type,
1539
+ classification,
1540
+ confidence,
1541
+ signals_found: {
1542
+ backend: backendFound,
1543
+ frontend: frontendFound,
1544
+ extension: extensionFound,
1545
+ },
1546
+ validation_result,
1547
+ allowed_next_action: allowedNextAction,
1548
+ breaking_change_risk: {
1549
+ endpoint: endpointRisk,
1550
+ kafka: kafkaRisk,
1551
+ database: dbRisk,
1552
+ affected_consumers: affectedConsumers,
1553
+ },
1554
+ resuming_existing_task: resumingExistingTask,
1555
+ rejected_patterns_relevant: rejectedPatternsRelevant,
1556
+ developer_message: developerMessage,
1557
+ next_phase: nextPhase,
1558
+ classified_at: new Date().toISOString(),
1559
+ };
1560
+ // ── Step 8: Persist classification ────────────────────────────────────
1561
+ // Persist even on STOP so future sessions know this was already classified
1562
+ writeFile(classifFile, JSON.stringify(result, null, 2));
1563
+ // ── Step 9: Return developer_message + full JSON ──────────────────────
1564
+ const breakingChangeWarning = (endpointRisk || kafkaRisk || dbRisk)
1565
+ ? `\n\n⚠️ BREAKING CHANGE RISK DETECTED\n` +
1566
+ ` Endpoint change signals: ${endpointRisk}\n` +
1567
+ ` Kafka change signals: ${kafkaRisk}\n` +
1568
+ ` DB migration signals: ${dbRisk}\n` +
1569
+ (affectedConsumers.length > 0 ? ` Potentially affected consumers:\n${affectedConsumers.map(c => ` • ${c}`).join("\n")}` : "")
1570
+ : "";
1571
+ const resumingNote = resumingExistingTask
1572
+ ? `\n\n🔁 Resuming existing task — classification loaded from persisted record.`
1573
+ : "";
1574
+ const responseText = `## classify_task — ${work_item_id}\n\n` +
1575
+ developerMessage +
1576
+ breakingChangeWarning +
1577
+ resumingNote +
1578
+ `\n\n---\n### Full Classification Result\n\`\`\`json\n${JSON.stringify(result, null, 2)}\n\`\`\``;
1579
+ return appendTelemetry({
1580
+ content: [{ type: "text", text: responseText }],
1581
+ }, inputChars);
1582
+ });
1583
+ // ─── Tool 11: analyze_impact ──────────────────────────────────────────────────
1584
+ // Walks the project-spec inter-service call graph and Kafka topic graph to produce
1585
+ // a ranked impact report for any planned change. Classifies consumers by blast radius.
1586
+ server.tool("analyze_impact", "Traces the SecuFusion inter-service call graph and Kafka topology to find EVERY " +
1587
+ "service, consumer, and integration affected by a planned change. " +
1588
+ "Call this during Phase 0.7 plan presentation whenever modifying an endpoint, entity, Kafka topic, or shared service. " +
1589
+ "Returns a ranked impact report with breaking-change verdicts per consumer.", {
1590
+ change_target: z.string().describe("What you are changing: class name, endpoint path, Kafka topic, or service name. E.g. 'DeviceEntity', '/api/v1/devices', 'device-events', 'sfn-iam-api'."),
1591
+ change_type: z.enum(["endpoint", "entity", "kafka_topic", "service", "field"])
1592
+ .describe("The kind of thing being changed."),
1593
+ impact_depth: z.enum(["direct", "full"]).default("full")
1594
+ .describe("'direct' = 1 hop only. 'full' = walk entire call graph (default)."),
1595
+ }, async ({ change_target, change_type, impact_depth }) => {
1596
+ const inputChars = JSON.stringify({ change_target, change_type, impact_depth }).length;
1597
+ // Load project spec
1598
+ const SPEC_FILE = ".secufusion-project-spec.json";
1599
+ const specCandidates = [
1600
+ path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1")), SPEC_FILE),
1601
+ path.resolve(getWorkspaceRoot(), SPEC_FILE),
1602
+ ];
1603
+ const specPath = specCandidates.find(p => fs.existsSync(p)) ?? null;
1604
+ let spec = null;
1605
+ if (specPath) {
1606
+ try {
1607
+ spec = JSON.parse(readFileSafe(specPath) || "null");
1608
+ }
1609
+ catch { }
1610
+ }
1611
+ if (!spec) {
1612
+ return appendTelemetry({ isError: true, content: [{ type: "text", text: "❌ Project spec not found. Generate .secufusion-project-spec.json first." }] }, inputChars);
1613
+ }
1614
+ const microservices = spec.microservices || {};
1615
+ const target = change_target.toLowerCase();
1616
+ // ── Pass 1: Direct consumers ──────────────────────────────────────────
1617
+ // Walk every service and check if it calls/consumes the target.
1618
+ const directHits = [];
1619
+ const indirectHits = [];
1620
+ const visited = new Set();
1621
+ function walkGraph(targetName, depth) {
1622
+ for (const [svcName, svcData] of Object.entries(microservices)) {
1623
+ if (visited.has(svcName + ":" + targetName))
1624
+ continue;
1625
+ visited.add(svcName + ":" + targetName);
1626
+ const callsServices = (svcData.calls_services || []).map(s => s.toLowerCase());
1627
+ const consumesTopics = (svcData.kafka_consumes || []).map(t => t.toLowerCase());
1628
+ const exposedEndpoints = (svcData.endpoints || []).map(e => (typeof e === "string" ? e : e.path || "").toLowerCase());
1629
+ const ownsEntities = (svcData.owns_tables || svcData.entities || []).map(e => e.toLowerCase());
1630
+ let hit = false;
1631
+ let reason = "";
1632
+ if (change_type === "endpoint" && exposedEndpoints.some(e => e.includes(targetName))) {
1633
+ hit = true;
1634
+ reason = `exposes endpoint matching '${targetName}'`;
1635
+ }
1636
+ else if (change_type === "endpoint" && callsServices.some(s => s.includes(targetName.split("/")[1] || targetName))) {
1637
+ hit = true;
1638
+ reason = `calls service that owns '${targetName}'`;
1639
+ }
1640
+ else if (change_type === "entity" && ownsEntities.some(e => e.includes(targetName.replace("entity", "").trim()))) {
1641
+ hit = true;
1642
+ reason = `owns or references '${targetName}'`;
1643
+ }
1644
+ else if (change_type === "kafka_topic" && consumesTopics.some(t => t.includes(targetName))) {
1645
+ hit = true;
1646
+ reason = `consumes Kafka topic '${targetName}'`;
1647
+ }
1648
+ else if (change_type === "service" && callsServices.some(s => s.includes(targetName))) {
1649
+ hit = true;
1650
+ reason = `calls '${targetName}' directly`;
1651
+ }
1652
+ else if (change_type === "field") {
1653
+ // Field changes: find who owns the entity and who calls that service
1654
+ if (ownsEntities.some(e => e.includes(targetName.split(".")[0]?.toLowerCase() || ""))) {
1655
+ hit = true;
1656
+ reason = `owns the entity containing field '${targetName}'`;
1657
+ }
1658
+ }
1659
+ if (hit) {
1660
+ const entry = {
1661
+ service: svcName,
1662
+ reason,
1663
+ port: svcData.port || "?",
1664
+ repo: svcData.repo || svcName,
1665
+ breaking_risk: "LIKELY",
1666
+ };
1667
+ if (depth === 0)
1668
+ directHits.push(entry);
1669
+ else
1670
+ indirectHits.push(entry);
1671
+ // Recurse if full depth
1672
+ if (impact_depth === "full" && depth < 3) {
1673
+ walkGraph(svcName, depth + 1);
1674
+ }
1675
+ }
1676
+ }
1677
+ }
1678
+ walkGraph(target, 0);
1679
+ // ── Pass 2: Chrome extension check ────────────────────────────────────
1680
+ const extData = spec.chrome_extension || spec["sfn-chrome-ext"] || null;
1681
+ let extensionHit = null;
1682
+ if (extData) {
1683
+ const extCalls = (extData.calls_endpoints || extData.calls || []).map(e => e.toLowerCase());
1684
+ if (extCalls.some(e => e.includes(target))) {
1685
+ extensionHit = {
1686
+ service: "sfn-chrome-ext",
1687
+ reason: `Chrome extension calls endpoint matching '${target}' — breaks silently for end users`,
1688
+ breaking_risk: "HIGH — SILENT",
1689
+ };
1690
+ }
1691
+ }
1692
+ // ── Pass 3: Build output ──────────────────────────────────────────────
1693
+ const totalAffected = directHits.length + indirectHits.length + (extensionHit ? 1 : 0);
1694
+ const riskEmoji = totalAffected === 0 ? "🟢" : totalAffected <= 2 ? "🟡" : "🔴";
1695
+ const riskLevel = totalAffected === 0 ? "LOW IMPACT" : totalAffected <= 2 ? "MEDIUM IMPACT" : "HIGH IMPACT";
1696
+ let output = `## analyze_impact — \`${change_target}\` (${change_type})\n\n`;
1697
+ output += `${riskEmoji} **${riskLevel}** — ${totalAffected} service(s) affected\n\n`;
1698
+ if (directHits.length > 0) {
1699
+ output += `### 🔴 Direct consumers (${directHits.length})\n`;
1700
+ for (const h of directHits) {
1701
+ output += `- **${h.service}** (port: ${h.port}, repo: \`${h.repo}\`)\n`;
1702
+ output += ` → ${h.reason}\n`;
1703
+ output += ` → Breaking risk: **${h.breaking_risk}**\n`;
1704
+ }
1705
+ output += "\n";
1706
+ }
1707
+ if (extensionHit) {
1708
+ output += `### 🔴 Chrome Extension\n`;
1709
+ output += `- **${extensionHit.service}**\n`;
1710
+ output += ` → ${extensionHit.reason}\n\n`;
1711
+ }
1712
+ if (indirectHits.length > 0) {
1713
+ output += `### 🟡 Indirect consumers (${indirectHits.length} via call chain)\n`;
1714
+ for (const h of indirectHits) {
1715
+ output += `- **${h.service}** → ${h.reason}\n`;
1716
+ }
1717
+ output += "\n";
1718
+ }
1719
+ if (totalAffected === 0) {
1720
+ output += `✅ No consumers found in the project spec for \`${change_target}\`.\n`;
1721
+ output += `Safe to modify without coordination — but verify manually if spec is incomplete.\n`;
1722
+ }
1723
+ else {
1724
+ output += `### Recommended actions\n`;
1725
+ if (change_type === "endpoint") {
1726
+ output += `- Version to \`/v2/\` first — do NOT modify \`/v1/\` in place\n`;
1727
+ output += `- Coordinate deploy with: ${directHits.map(h => h.service).join(", ")}\n`;
1728
+ }
1729
+ else if (change_type === "entity" || change_type === "field") {
1730
+ output += `- Check all \`@Query\` annotations across repositories for references to this field/entity\n`;
1731
+ output += `- Include a Flyway migration — flag tier: SAFE/RISKY/DANGEROUS\n`;
1732
+ }
1733
+ else if (change_type === "kafka_topic") {
1734
+ output += `- Schema change requires coordinated deployment of producer + ALL consumers\n`;
1735
+ output += `- Consumers: ${[...directHits, ...indirectHits].map(h => h.service).join(", ")}\n`;
1736
+ }
1737
+ }
1738
+ return appendTelemetry({ content: [{ type: "text", text: output }] }, inputChars);
1739
+ });
1740
+ // ─── Tool 12: prime_session ───────────────────────────────────────────────────
1741
+ // Hyper-efficient Phase 00 + Phase 0 in a single call.
1742
+ // Loads ONLY the spec sections relevant to the active task — not the full 85 kB spec.
1743
+ // Saves ~15,000 tokens (~$0.22) per session compared to full spec load.
1744
+ server.tool("prime_session", "Hyper-efficient session startup — combines Phase 00 (project spec) and Phase 0 (task resume) into ONE call. " +
1745
+ "Loads ONLY the spec sections relevant to the active task, not the full project spec. " +
1746
+ "Use this INSTEAD of calling manage_project_spec(read) + manage_task(read_summary) separately. " +
1747
+ "Returns: relevant service contexts, active task next_step, decisions made, and token savings estimate.", {
1748
+ work_item_id: z.string().describe("The active work item ID to resume. E.g. 'WI-2847' or 'BUG-1140'."),
1749
+ }, async ({ work_item_id }) => {
1750
+ const inputChars = JSON.stringify({ work_item_id }).length;
1751
+ // Load project spec
1752
+ const SPEC_FILE = ".secufusion-project-spec.json";
1753
+ const specCandidates = [
1754
+ path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1")), SPEC_FILE),
1755
+ path.resolve(getWorkspaceRoot(), SPEC_FILE),
1756
+ ];
1757
+ const specPath = specCandidates.find(p => fs.existsSync(p)) ?? null;
1758
+ let spec = null;
1759
+ if (specPath) {
1760
+ try {
1761
+ spec = JSON.parse(readFileSafe(specPath) || "null");
1762
+ }
1763
+ catch { }
1764
+ }
1765
+ const microservices = spec?.microservices || {};
1766
+ const goldenRules = spec?.golden_rules || [];
1767
+ const codingPatterns = spec?.coding_patterns || {};
1768
+ const fullSpecSize = specPath ? fs.statSync(specPath).size : 0;
1769
+ // Load classification to know which services are relevant
1770
+ const classifFile = resolve(`.secufusion/classifications/${work_item_id}.json`);
1771
+ let classification = null;
1772
+ if (fs.existsSync(classifFile)) {
1773
+ try {
1774
+ classification = JSON.parse(readFileSafe(classifFile) || "null");
1775
+ }
1776
+ catch { }
1777
+ }
1778
+ // Load active task
1779
+ const registry = readRegistry();
1780
+ const taskEntry = registry.find(r => r["work_item_id"] === work_item_id);
1781
+ let taskDir = null;
1782
+ if (taskEntry?.folder_name) {
1783
+ taskDir = resolve(`.secufusion/tasks/${taskEntry.folder_name}`);
1784
+ }
1785
+ // Fallback: scan for folder
1786
+ if (!taskDir) {
1787
+ const tasksBase = resolve(".secufusion/tasks");
1788
+ if (fs.existsSync(tasksBase)) {
1789
+ const folders = fs.readdirSync(tasksBase);
1790
+ const match = folders.find(f => f.startsWith(work_item_id) || f.includes(work_item_id.replace(/^WI-/, "")));
1791
+ if (match)
1792
+ taskDir = path.join(tasksBase, match);
1793
+ }
1794
+ }
1795
+ let spec_data = null, progress = null, decisions = [];
1796
+ if (taskDir && fs.existsSync(taskDir)) {
1797
+ try {
1798
+ spec_data = JSON.parse(readFileSafe(path.join(taskDir, "spec.json")) || "null");
1799
+ }
1800
+ catch { }
1801
+ try {
1802
+ progress = JSON.parse(readFileSafe(path.join(taskDir, "progress.json")) || "null");
1803
+ }
1804
+ catch { }
1805
+ try {
1806
+ decisions = JSON.parse(readFileSafe(path.join(taskDir, "decisions.json")) || "[]");
1807
+ }
1808
+ catch { }
1809
+ }
1810
+ // ── Identify relevant services ─────────────────────────────────────────
1811
+ // Look at classification signals + task title/description to find which services to load
1812
+ const corpus = ((spec_data?.title || "") + " " + (spec_data?.description || "") + " " + work_item_id).toLowerCase();
1813
+ const relevantServices = {};
1814
+ let relevantCount = 0;
1815
+ for (const [svcName, svcData] of Object.entries(microservices)) {
1816
+ const svcLower = svcName.toLowerCase();
1817
+ // Service is relevant if: mentioned in task, or classification signals reference it, or it owns tables mentioned in task
1818
+ const mentionedDirectly = corpus.includes(svcLower.replace("sfn-", "").replace("-api", ""));
1819
+ const inClassification = (classification?.signals_found?.backend || []).some(s => s.includes(svcLower.replace("sfn-", "")));
1820
+ const ownsRelevantTable = (svcData.owns_tables || []).some(t => corpus.includes(t.toLowerCase().replace("_", " ")));
1821
+ if (mentionedDirectly || inClassification || ownsRelevantTable) {
1822
+ relevantServices[svcName] = svcData;
1823
+ relevantCount++;
1824
+ }
1825
+ }
1826
+ // If nothing matched, include the top 3 most-called services as fallback
1827
+ if (relevantCount === 0) {
1828
+ let i = 0;
1829
+ for (const [svcName, svcData] of Object.entries(microservices)) {
1830
+ if (i >= 3)
1831
+ break;
1832
+ relevantServices[svcName] = svcData;
1833
+ i++;
1834
+ }
1835
+ }
1836
+ // ── Calculate token savings ────────────────────────────────────────────
1837
+ const relevantSpecSize = JSON.stringify(relevantServices).length;
1838
+ const savedChars = Math.max(0, fullSpecSize - relevantSpecSize);
1839
+ const savedTokens = Math.ceil(savedChars / 4);
1840
+ const savedCost = ((savedTokens / 1000000) * 3.0).toFixed(4);
1841
+ // ── Build output ──────────────────────────────────────────────────────
1842
+ let out = `## ⚡ prime_session — ${work_item_id}\n\n`;
1843
+ // Task status
1844
+ if (progress) {
1845
+ out += `### Active Task\n`;
1846
+ out += `**Title:** ${spec_data?.title || work_item_id}\n`;
1847
+ out += `**Next step:** ${progress.next_step || "Not set — check progress.json"}\n`;
1848
+ const pending = progress.pending_acs || [];
1849
+ const completed = progress.completed_acs || [];
1850
+ out += `**Progress:** ${completed.length} AC(s) done, ${pending.length} remaining\n`;
1851
+ if (pending.length > 0) {
1852
+ out += `**Pending ACs:**\n${pending.map(ac => ` - [ ] ${ac}`).join("\n")}\n`;
1853
+ }
1854
+ out += "\n";
1855
+ }
1856
+ else {
1857
+ out += `⚠️ No active task found for ${work_item_id}. Call manage_task(initialize) to create it.\n\n`;
1858
+ }
1859
+ // Decisions
1860
+ if (decisions.length > 0) {
1861
+ out += `### Decisions already made (${decisions.length})\n`;
1862
+ for (const d of decisions.slice(-5)) { // last 5 most recent
1863
+ out += `- **${d.decision}** — ${d.rationale || ""}\n`;
1864
+ }
1865
+ out += "\n";
1866
+ }
1867
+ // Relevant services
1868
+ out += `### Services loaded (${relevantCount} of ${Object.keys(microservices).length})\n`;
1869
+ for (const [svcName, svcData] of Object.entries(relevantServices)) {
1870
+ out += `\n**${svcName}** (port: ${svcData.port || "?"})\n`;
1871
+ if (svcData.repo)
1872
+ out += ` Repo: \`${svcData.repo}\`\n`;
1873
+ if (svcData.owns_tables?.length)
1874
+ out += ` Owns: ${svcData.owns_tables.join(", ")}\n`;
1875
+ if (svcData.calls_services?.length)
1876
+ out += ` Calls: ${svcData.calls_services.join(", ")}\n`;
1877
+ if (svcData.kafka_produces?.length)
1878
+ out += ` Produces: ${svcData.kafka_produces.join(", ")}\n`;
1879
+ if (svcData.kafka_consumes?.length)
1880
+ out += ` Consumes: ${svcData.kafka_consumes.join(", ")}\n`;
1881
+ }
1882
+ // Golden rules (always included — short)
1883
+ if (goldenRules.length > 0) {
1884
+ out += `\n### Golden Rules (always apply)\n`;
1885
+ for (const rule of goldenRules.slice(0, 5)) {
1886
+ out += `- ${typeof rule === "string" ? rule : rule.rule || JSON.stringify(rule)}\n`;
1887
+ }
1888
+ if (goldenRules.length > 5)
1889
+ out += ` _(+ ${goldenRules.length - 5} more — call get_golden_rules for full list)_\n`;
1890
+ }
1891
+ // Classification
1892
+ if (classification) {
1893
+ out += `\n### Classification\n`;
1894
+ out += `Type: **${classification.classification}** (${classification.confidence} confidence)\n`;
1895
+ out += `Reason: ${classification.classification_reason}\n`;
1896
+ }
1897
+ out += `\n### 💰 Token savings\n`;
1898
+ out += `Loaded ${relevantCount} of ${Object.keys(microservices).length} services.\n`;
1899
+ out += `Saved ≈ ${savedTokens.toLocaleString()} tokens vs full spec load (~${savedCost} at Claude Sonnet pricing).\n`;
1900
+ return appendTelemetry({ content: [{ type: "text", text: out }] }, inputChars);
1901
+ });
1902
+ // ─── Tool 13: score_change_risk ───────────────────────────────────────────────
1903
+ // Quantifies the risk of a planned set of changes before any code is written.
1904
+ // Returns a 0-100 score with per-category breakdown and actionable remediation steps.
1905
+ server.tool("score_change_risk", "Quantifies the risk of a planned change BEFORE any code is written. " +
1906
+ "Returns a 0-100 risk score with breakdown across 6 categories: " +
1907
+ "tenant isolation, DB migration tier, API breaking change, Kafka schema, blast radius, test coverage. " +
1908
+ "Call this in Phase 0.7 after presenting the plan — score must be reviewed before 'proceed'.", {
1909
+ work_item_id: z.string().describe("Active work item ID."),
1910
+ planned_changes: z.array(z.object({
1911
+ file: z.string().describe("File path being changed."),
1912
+ change_type: z.enum(["create", "modify", "delete"]),
1913
+ description: z.string().describe("What is changing in this file — be specific."),
1914
+ })).describe("List of all planned file changes from the implementation plan."),
1915
+ }, async ({ work_item_id, planned_changes }) => {
1916
+ const inputChars = JSON.stringify({ work_item_id, planned_changes }).length;
1917
+ const corpus = planned_changes.map(c => c.description + " " + c.file).join(" ").toLowerCase();
1918
+ // ── 6-category scoring matrix ──────────────────────────────────────────
1919
+ let tenantScore = 0;
1920
+ let dbScore = 0;
1921
+ let apiScore = 0;
1922
+ let kafkaScore = 0;
1923
+ let blastScore = 0;
1924
+ let testScore = 0;
1925
+ const findings = [];
1926
+ // Category 1: Tenant isolation (max 25)
1927
+ const newRepoMethods = (corpus.match(/repository|@query|findby|deleteBy|countBy/g) || []).length;
1928
+ const hasTenantFilter = /tenantid|tenant_id|\.tenant/.test(corpus);
1929
+ if (newRepoMethods > 0 && !hasTenantFilter) {
1930
+ tenantScore += Math.min(25, newRepoMethods * 8);
1931
+ findings.push(`⚠️ TENANT: ${newRepoMethods} repository method(s) — no tenantId filter detected (+${Math.min(25, newRepoMethods * 8)} pts)`);
1932
+ }
1933
+ else if (newRepoMethods > 0) {
1934
+ tenantScore += newRepoMethods * 2;
1935
+ findings.push(`✅ TENANT: ${newRepoMethods} repository method(s) with tenantId present (+${newRepoMethods * 2} pts)`);
1936
+ }
1937
+ // Category 2: DB migration risk (max 20)
1938
+ if (/drop column|drop table|drop index|alter.*drop/.test(corpus)) {
1939
+ dbScore += 20;
1940
+ findings.push(`🚫 DB: DROP operation detected — DANGEROUS migration tier (+20 pts). Requires explicit developer sign-off.`);
1941
+ }
1942
+ else if (/rename column|rename table/.test(corpus)) {
1943
+ dbScore += 15;
1944
+ findings.push(`⚠️ DB: RENAME operation — RISKY migration tier (+15 pts). Two-phase approach required.`);
1945
+ }
1946
+ else if (/not null|add column.*not null/.test(corpus) && !/default/.test(corpus)) {
1947
+ dbScore += 12;
1948
+ findings.push(`⚠️ DB: NOT NULL column without DEFAULT — RISKY (+12 pts). Will fail on non-empty table.`);
1949
+ }
1950
+ else if (/flyway|migration|@entity|add column/.test(corpus)) {
1951
+ dbScore += 3;
1952
+ findings.push(`✅ DB: Additive migration detected — SAFE tier (+3 pts).`);
1953
+ }
1954
+ // Category 3: API breaking change (max 20)
1955
+ if (/remove.*field|delete.*field|drop.*field|remove.*endpoint|delete.*endpoint|v1.*remove/.test(corpus)) {
1956
+ apiScore += 20;
1957
+ findings.push(`🚫 API: Field or endpoint removal detected (+20 pts). Breaking change — deprecate /v1/, add /v2/ first.`);
1958
+ }
1959
+ else if (/change.*response|modify.*response|rename.*field|change.*field type/.test(corpus)) {
1960
+ apiScore += 14;
1961
+ findings.push(`⚠️ API: Response shape change (+14 pts). Consumers may break silently — prefer additive change.`);
1962
+ }
1963
+ else if (/add.*field|new.*endpoint|optional.*field/.test(corpus)) {
1964
+ apiScore += 2;
1965
+ findings.push(`✅ API: Additive field or new endpoint (+2 pts). Low breaking-change risk.`);
1966
+ }
1967
+ else if (/endpoint|api|controller|@restcontroller/.test(corpus)) {
1968
+ apiScore += 5;
1969
+ findings.push(`🟡 API: Endpoint changes present — verify response shape compatibility (+5 pts).`);
1970
+ }
1971
+ // Category 4: Kafka schema change (max 15)
1972
+ if (/kafka|topic|producer|consumer|@kafkalistener|@sendto/.test(corpus)) {
1973
+ if (/change.*schema|modify.*payload|rename.*field|remove.*field/.test(corpus)) {
1974
+ kafkaScore += 15;
1975
+ findings.push(`🚫 KAFKA: Schema change detected (+15 pts). Coordinated producer+consumer deployment required.`);
1976
+ }
1977
+ else if (/new topic|add.*topic/.test(corpus)) {
1978
+ kafkaScore += 3;
1979
+ findings.push(`✅ KAFKA: New topic (additive) (+3 pts).`);
1980
+ }
1981
+ else {
1982
+ kafkaScore += 6;
1983
+ findings.push(`🟡 KAFKA: Kafka changes present — verify backward compatibility of message schema (+6 pts).`);
1984
+ }
1985
+ }
1986
+ // Category 5: Blast radius (max 10)
1987
+ const servicesAffected = new Set();
1988
+ for (const c of planned_changes) {
1989
+ const f = c.file.toLowerCase();
1990
+ if (f.includes("sfn-iam"))
1991
+ servicesAffected.add("sfn-iam-api");
1992
+ else if (f.includes("sfn-events"))
1993
+ servicesAffected.add("sfn-events-api");
1994
+ else if (f.includes("sfn-tenants"))
1995
+ servicesAffected.add("sfn-tenants-api");
1996
+ else if (f.includes("sfn-policy"))
1997
+ servicesAffected.add("sfn-policy-api");
1998
+ else if (f.includes("sfn-gateway"))
1999
+ servicesAffected.add("sfn-gateway");
2000
+ else if (f.includes("sfn-notification"))
2001
+ servicesAffected.add("sfn-notification");
2002
+ else if (f.includes("sfn-audit"))
2003
+ servicesAffected.add("sfn-audit");
2004
+ else if (f.includes("web-ui") || f.includes(".tsx") || f.includes(".jsx"))
2005
+ servicesAffected.add("sfn-web-ui");
2006
+ }
2007
+ blastScore = Math.min(10, servicesAffected.size * 3);
2008
+ if (servicesAffected.size > 0) {
2009
+ findings.push(`📦 BLAST RADIUS: ${servicesAffected.size} service(s) — ${[...servicesAffected].join(", ")} (+${blastScore} pts)`);
2010
+ }
2011
+ // Category 6: Test coverage gap (max 10)
2012
+ const hasUnitTest = /unittest|@test|@springboottest|test.*class|spec\.ts|\.test\.ts/.test(corpus);
2013
+ const hasIntegrationTest = /integration.*test|@springboottest|testresttemplate|mockmvc/.test(corpus);
2014
+ if (!hasUnitTest && !hasIntegrationTest) {
2015
+ testScore += 10;
2016
+ findings.push(`⚠️ TESTS: No unit or integration tests detected in plan (+10 pts). Add both before proceeding.`);
2017
+ }
2018
+ else if (!hasIntegrationTest) {
2019
+ testScore += 5;
2020
+ findings.push(`🟡 TESTS: Unit tests present but no integration tests (+5 pts).`);
2021
+ }
2022
+ else {
2023
+ findings.push(`✅ TESTS: Unit and integration tests planned (+0 pts).`);
2024
+ }
2025
+ const totalScore = tenantScore + dbScore + apiScore + kafkaScore + blastScore + testScore;
2026
+ const riskLevel = totalScore >= 60 ? "🔴 HIGH" : totalScore >= 30 ? "🟡 MEDIUM" : "🟢 LOW";
2027
+ const verdict = totalScore >= 60
2028
+ ? "⛔ STOP — resolve HIGH risk items before writing any code."
2029
+ : totalScore >= 30
2030
+ ? "⚠️ PROCEED WITH CAUTION — address flagged items in the plan."
2031
+ : "✅ SAFE TO PROCEED — low risk change.";
2032
+ let out = `## score_change_risk — ${work_item_id}\n\n`;
2033
+ out += `### ${riskLevel} RISK SCORE: ${totalScore}/100\n\n`;
2034
+ out += `| Category | Score |\n|---|---|\n`;
2035
+ out += `| Tenant isolation | ${tenantScore}/25 |\n`;
2036
+ out += `| DB migration tier | ${dbScore}/20 |\n`;
2037
+ out += `| API breaking change | ${apiScore}/20 |\n`;
2038
+ out += `| Kafka schema change | ${kafkaScore}/15 |\n`;
2039
+ out += `| Blast radius | ${blastScore}/10 |\n`;
2040
+ out += `| Test coverage gap | ${testScore}/10 |\n`;
2041
+ out += `| **TOTAL** | **${totalScore}/100** |\n\n`;
2042
+ out += `### Findings\n`;
2043
+ for (const f of findings)
2044
+ out += `- ${f}\n`;
2045
+ out += `\n### Verdict\n${verdict}\n`;
2046
+ return appendTelemetry({ content: [{ type: "text", text: out }] }, inputChars);
2047
+ });
2048
+ // ─── Tool 14: smart_search ────────────────────────────────────────────────────
2049
+ // TF-IDF semantic search over all past tasks — finds related work even when
2050
+ // the exact words differ. Replaces the plain substring search_tasks tool.
2051
+ server.tool("smart_search", "Semantic TF-IDF search over all past SecuFusion tasks. " +
2052
+ "Finds related past work even when exact words differ — e.g. 'tenant deletion cascade' " +
2053
+ "will find 'BUG: device removal does not propagate'. " +
2054
+ "Use this INSTEAD of search_tasks when you want high-recall semantic matching. " +
2055
+ "Returns top-K results ranked by cosine similarity with reuse recommendations.", {
2056
+ query: z.string().describe("Natural language description of what you are looking for."),
2057
+ top_k: z.number().default(5).describe("Maximum results to return (default: 5)."),
2058
+ filter_type: z.string().optional().describe("Optional: filter by task type — 'bug', 'feature', 'refactor', etc."),
2059
+ }, async ({ query, top_k, filter_type }) => {
2060
+ const inputChars = JSON.stringify({ query, top_k, filter_type }).length;
2061
+ const tasksBase = resolve(".secufusion/tasks");
2062
+ if (!fs.existsSync(tasksBase)) {
2063
+ return appendTelemetry({ content: [{ type: "text", text: "No past tasks found. `.secufusion/tasks/` does not exist yet." }] }, inputChars);
2064
+ }
2065
+ // ── Load all task documents ───────────────────────────────────────────
2066
+ const docs = [];
2067
+ const folders = fs.readdirSync(tasksBase);
2068
+ for (const folder of folders) {
2069
+ const taskDir = path.join(tasksBase, folder);
2070
+ if (!fs.statSync(taskDir).isDirectory())
2071
+ continue;
2072
+ let specData = null, decisions = [];
2073
+ try {
2074
+ specData = JSON.parse(readFileSafe(path.join(taskDir, "spec.json")) || "null");
2075
+ }
2076
+ catch { }
2077
+ try {
2078
+ decisions = JSON.parse(readFileSafe(path.join(taskDir, "decisions.json")) || "[]");
2079
+ }
2080
+ catch { }
2081
+ if (!specData)
2082
+ continue;
2083
+ if (filter_type && specData.task_type && specData.task_type !== filter_type)
2084
+ continue;
2085
+ const text = [
2086
+ specData.title || "",
2087
+ specData.description || "",
2088
+ (specData.acceptance_criteria || []).join(" "),
2089
+ decisions.map(d => d.decision + " " + (d.rationale || "")).join(" "),
2090
+ ].join(" ").toLowerCase();
2091
+ docs.push({
2092
+ folder,
2093
+ work_item_id: specData.work_item_id || folder.split("-")[0],
2094
+ title: specData.title || folder,
2095
+ task_type: specData.task_type || "unknown",
2096
+ status: specData.status || "unknown",
2097
+ text,
2098
+ });
2099
+ }
2100
+ if (docs.length === 0) {
2101
+ return appendTelemetry({ content: [{ type: "text", text: `No tasks found${filter_type ? ` with type '${filter_type}'` : ""}.` }] }, inputChars);
2102
+ }
2103
+ // ── TF-IDF implementation (pure JS, zero deps) ─────────────────────────
2104
+ const STOP_WORDS = new Set(["a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for",
2105
+ "of", "with", "by", "from", "is", "it", "its", "be", "are", "was", "were", "will",
2106
+ "have", "has", "had", "do", "does", "did", "not", "this", "that", "these", "those",
2107
+ "i", "we", "you", "he", "she", "they", "which", "who", "what", "when", "where", "how"]);
2108
+ function tokenize(text) {
2109
+ return text.toLowerCase()
2110
+ .replace(/[^a-z0-9\s]/g, " ")
2111
+ .split(/\s+/)
2112
+ .filter(w => w.length > 2 && !STOP_WORDS.has(w));
2113
+ }
2114
+ function termFrequency(tokens) {
2115
+ const tf = {};
2116
+ for (const t of tokens)
2117
+ tf[t] = (tf[t] || 0) + 1;
2118
+ const total = tokens.length || 1;
2119
+ for (const t in tf)
2120
+ tf[t] /= total;
2121
+ return tf;
2122
+ }
2123
+ // Compute IDF across all documents
2124
+ const N = docs.length;
2125
+ const dfMap = {};
2126
+ const docTokens = docs.map(d => tokenize(d.text));
2127
+ for (const tokens of docTokens) {
2128
+ const unique = new Set(tokens);
2129
+ for (const t of unique)
2130
+ dfMap[t] = (dfMap[t] || 0) + 1;
2131
+ }
2132
+ const idf = (term) => Math.log((N + 1) / ((dfMap[term] || 0) + 1)) + 1;
2133
+ function tfidfVector(tokens) {
2134
+ const tf = termFrequency(tokens);
2135
+ const vec = {};
2136
+ for (const t in tf)
2137
+ vec[t] = tf[t] * idf(t);
2138
+ return vec;
2139
+ }
2140
+ function cosineSimilarity(vecA, vecB) {
2141
+ let dot = 0, magA = 0, magB = 0;
2142
+ const allKeys = new Set([...Object.keys(vecA), ...Object.keys(vecB)]);
2143
+ for (const k of allKeys) {
2144
+ const a = vecA[k] || 0, b = vecB[k] || 0;
2145
+ dot += a * b;
2146
+ magA += a * a;
2147
+ magB += b * b;
2148
+ }
2149
+ if (magA === 0 || magB === 0)
2150
+ return 0;
2151
+ return dot / (Math.sqrt(magA) * Math.sqrt(magB));
2152
+ }
2153
+ // Score query against all docs
2154
+ const queryVec = tfidfVector(tokenize(query));
2155
+ const scored = docs.map((doc, i) => ({
2156
+ ...doc,
2157
+ score: cosineSimilarity(queryVec, tfidfVector(docTokens[i])),
2158
+ })).filter(d => d.score > 0.01)
2159
+ .sort((a, b) => b.score - a.score)
2160
+ .slice(0, top_k);
2161
+ // ── Build output ──────────────────────────────────────────────────────
2162
+ let out = `## smart_search — "${query}"\n\n`;
2163
+ if (scored.length === 0) {
2164
+ out += `No semantically similar tasks found. Try broader terms or check if tasks have been initialized.\n`;
2165
+ }
2166
+ else {
2167
+ out += `Found **${scored.length}** relevant past task(s) (TF-IDF semantic match):\n\n`;
2168
+ for (let i = 0; i < scored.length; i++) {
2169
+ const r = scored[i];
2170
+ const bar = "█".repeat(Math.round(r.score * 20)).padEnd(20, "░");
2171
+ out += `### ${i + 1}. ${r.work_item_id} — ${r.title}\n`;
2172
+ out += `Similarity: ${bar} ${(r.score * 100).toFixed(0)}%\n`;
2173
+ out += `Type: ${r.task_type} | Status: ${r.status}\n`;
2174
+ if (r.score > 0.4) {
2175
+ out += `💡 **High relevance** — call \`get_pattern_from_task("${r.work_item_id}")\` to reuse proven patterns.\n`;
2176
+ }
2177
+ else if (r.score > 0.2) {
2178
+ out += `💡 Call \`get_task_history("${r.work_item_id}")\` to review approach before planning.\n`;
2179
+ }
2180
+ out += "\n";
2181
+ }
2182
+ }
2183
+ return appendTelemetry({ content: [{ type: "text", text: out }] }, inputChars);
2184
+ });
2185
+ // ─── Tool 15: suggest_test_scenarios ─────────────────────────────────────────
2186
+ // Takes ACs from a work item and auto-generates a complete test plan:
2187
+ // unit test stubs, integration test endpoints, edge cases, and manual steps.
2188
+ // Auto-logs all scenarios to the task's scenarios.json.
2189
+ server.tool("suggest_test_scenarios", "Auto-generates a complete test plan from acceptance criteria — no manual effort required. " +
2190
+ "Produces: unit test method stubs (with class suggestions), integration test endpoint stubs, " +
2191
+ "edge cases the developer would typically forget, and manual verification steps. " +
2192
+ "Auto-logs all generated scenarios to the task's scenarios.json. " +
2193
+ "Call this in Phase 1 immediately after manage_task(initialize).", {
2194
+ work_item_id: z.string().describe("Active work item ID."),
2195
+ acceptance_criteria: z.array(z.string()).describe("Array of AC strings from the work item."),
2196
+ service_name: z.string().describe("Primary microservice being modified. E.g. 'sfn-iam-api'."),
2197
+ task_type: z.enum(["bug", "user_story", "feature", "hotfix", "refactor", "chore"]).default("user_story"),
2198
+ }, async ({ work_item_id, acceptance_criteria, service_name, task_type }) => {
2199
+ const inputChars = JSON.stringify({ work_item_id, acceptance_criteria, service_name, task_type }).length;
2200
+ // ── Generate test scenarios from ACs ──────────────────────────────────
2201
+ const unitTests = [];
2202
+ const integrationTests = [];
2203
+ const edgeCases = [];
2204
+ const manualSteps = [];
2205
+ // Derive Java class name from service name
2206
+ const serviceClass = service_name
2207
+ .replace(/sfn-|-api/g, "")
2208
+ .split("-")
2209
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
2210
+ .join("");
2211
+ const testClass = `${serviceClass}ServiceTest`;
2212
+ const controllerTestClass = `${serviceClass}ControllerIntegrationTest`;
2213
+ for (const ac of acceptance_criteria) {
2214
+ const acLower = ac.toLowerCase();
2215
+ const acClean = ac.replace(/[^a-zA-Z0-9\s]/g, "").trim();
2216
+ const methodSuffix = acClean.split(/\s+/).slice(0, 6)
2217
+ .map((w, i) => i === 0 ? w.toLowerCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
2218
+ .join("")
2219
+ .replace(/[^a-zA-Z0-9]/g, "");
2220
+ // ── Unit test ──────────────────────────────────────────────────────
2221
+ let unitDescription = ac;
2222
+ let unitHint = "";
2223
+ if (/must|should|shall/.test(acLower)) {
2224
+ unitHint = "Assert happy-path behaviour";
2225
+ }
2226
+ if (/not|never|must not|cannot/.test(acLower)) {
2227
+ unitHint = "Assert negative / guard condition";
2228
+ }
2229
+ if (/tenant|role|permission|auth/.test(acLower)) {
2230
+ unitHint = "Mock SecurityContext with correct tenant + role claims";
2231
+ }
2232
+ unitTests.push({
2233
+ class: testClass,
2234
+ method: `should${methodSuffix.charAt(0).toUpperCase()}${methodSuffix.slice(1)}`,
2235
+ description: unitDescription,
2236
+ hint: unitHint || "Mock dependencies, assert service-layer outcome",
2237
+ type: "unit",
2238
+ });
2239
+ // ── Integration test ───────────────────────────────────────────────
2240
+ let verb = "GET", status = "200", path_hint = "/api/v1/" + service_name.replace("sfn-", "").replace("-api", "");
2241
+ if (/create|add|register|enroll|submit/.test(acLower)) {
2242
+ verb = "POST";
2243
+ status = "201";
2244
+ }
2245
+ else if (/update|modify|change|set/.test(acLower)) {
2246
+ verb = "PUT";
2247
+ status = "200";
2248
+ }
2249
+ else if (/delete|remove|deactivate/.test(acLower)) {
2250
+ verb = "DELETE";
2251
+ status = "204";
2252
+ }
2253
+ else if (/fail|error|reject|lock|deny/.test(acLower)) {
2254
+ verb = "POST";
2255
+ status = "40x/423";
2256
+ }
2257
+ integrationTests.push({
2258
+ class: controllerTestClass,
2259
+ method: `${verb.toLowerCase()}${methodSuffix.charAt(0).toUpperCase()}${methodSuffix.slice(1)}`,
2260
+ description: `${verb} ${path_hint} — ${ac}`,
2261
+ expected_status: status,
2262
+ hint: `Use MockMvc or TestRestTemplate. Include tenant JWT in Authorization header.`,
2263
+ type: "integration",
2264
+ });
2265
+ // ── Edge cases (domain-specific heuristics) ────────────────────────
2266
+ if (/admin|role|permission/.test(acLower)) {
2267
+ edgeCases.push({ description: `Non-admin user attempts action reserved for ADMIN — expect 403`, type: "unit" });
2268
+ edgeCases.push({ description: `SERVICE_ACCOUNT with admin flag — verify exemption if applicable`, type: "unit" });
2269
+ }
2270
+ if (/tenant/.test(acLower)) {
2271
+ edgeCases.push({ description: `Cross-tenant request — verify tenantId isolation enforced`, type: "integration" });
2272
+ }
2273
+ if (/lock|limit|max|attempt|retry/.test(acLower)) {
2274
+ edgeCases.push({ description: `Exactly at the limit (boundary condition) — verify correct threshold enforcement`, type: "unit" });
2275
+ edgeCases.push({ description: `One below the limit — verify NOT triggered prematurely`, type: "unit" });
2276
+ }
2277
+ if (/delete|remove/.test(acLower)) {
2278
+ edgeCases.push({ description: `Delete non-existent resource — expect 404, not 500`, type: "integration" });
2279
+ edgeCases.push({ description: `Cascade delete — verify related records cleaned up`, type: "integration" });
2280
+ }
2281
+ if (/kafka|event|publish|consume/.test(acLower)) {
2282
+ edgeCases.push({ description: `Kafka consumer receives malformed payload — verify dead-letter or graceful skip`, type: "integration" });
2283
+ }
2284
+ }
2285
+ // ── Bug-specific additions ────────────────────────────────────────────
2286
+ if (task_type === "bug") {
2287
+ edgeCases.push({ description: `Regression test: reproduce exact original bug scenario — assert it no longer occurs`, type: "unit" });
2288
+ }
2289
+ // ── Manual verification steps ─────────────────────────────────────────
2290
+ manualSteps.push(`Deploy to local dev — verify no startup errors in ${service_name} logs`);
2291
+ manualSteps.push(`Call the primary endpoint via Postman/curl with a valid tenant JWT — verify ${acceptance_criteria[0] || "expected behaviour"}`);
2292
+ manualSteps.push(`Call with an invalid or expired JWT — verify 401 Unauthorized`);
2293
+ manualSteps.push(`Call with a different tenant's JWT — verify tenant isolation (no cross-tenant data leak)`);
2294
+ if (acceptance_criteria.length > 1) {
2295
+ manualSteps.push(`Walk through each AC manually with a QA checklist before raising PR`);
2296
+ }
2297
+ // ── Persist to scenarios.json ─────────────────────────────────────────
2298
+ const registry = readRegistry();
2299
+ const taskEntry = registry.find(r => r["work_item_id"] === work_item_id);
2300
+ let taskDir = null;
2301
+ if (taskEntry?.folder_name) {
2302
+ taskDir = resolve(`.secufusion/tasks/${taskEntry.folder_name}`);
2303
+ }
2304
+ if (!taskDir) {
2305
+ const tasksBase = resolve(".secufusion/tasks");
2306
+ if (fs.existsSync(tasksBase)) {
2307
+ const folders = fs.readdirSync(tasksBase);
2308
+ const match = folders.find(f => f.startsWith(work_item_id));
2309
+ if (match)
2310
+ taskDir = path.join(tasksBase, match);
2311
+ }
2312
+ }
2313
+ const allScenarios = [
2314
+ ...unitTests.map(t => ({ ...t, generated: true })),
2315
+ ...integrationTests.map(t => ({ ...t, generated: true })),
2316
+ ...edgeCases.map(t => ({ ...t, generated: true })),
2317
+ ...manualSteps.map(s => ({ description: s, type: "manual", generated: true })),
2318
+ ];
2319
+ if (taskDir && fs.existsSync(taskDir)) {
2320
+ const scenFile = path.join(taskDir, "scenarios.json");
2321
+ let existing = [];
2322
+ try {
2323
+ existing = JSON.parse(readFileSafe(scenFile) || "[]");
2324
+ }
2325
+ catch { }
2326
+ // Merge — don't duplicate
2327
+ const merged = [...existing, ...allScenarios.filter(s => !existing.some(e => e.description === s.description))];
2328
+ writeFile(scenFile, JSON.stringify(merged, null, 2));
2329
+ }
2330
+ // ── Build output ──────────────────────────────────────────────────────
2331
+ let out = `## suggest_test_scenarios — ${work_item_id}\n\n`;
2332
+ out += `Auto-generated from ${acceptance_criteria.length} AC(s) for **${service_name}**\n\n`;
2333
+ out += `### Unit Tests (${unitTests.length}) — \`${testClass}\`\n`;
2334
+ for (const t of unitTests) {
2335
+ out += `- **\`${t.method}()\`** — ${t.description}\n`;
2336
+ out += ` _${t.hint}_\n`;
2337
+ }
2338
+ out += `\n### Integration Tests (${integrationTests.length}) — \`${controllerTestClass}\`\n`;
2339
+ for (const t of integrationTests) {
2340
+ out += `- **\`${t.method}()\`** — expected: HTTP ${t.expected_status}\n`;
2341
+ out += ` \`${t.description}\`\n`;
2342
+ out += ` _${t.hint}_\n`;
2343
+ }
2344
+ out += `\n### Edge Cases (${edgeCases.length})\n`;
2345
+ for (const e of edgeCases) {
2346
+ out += `- [${e.type}] ${e.description}\n`;
2347
+ }
2348
+ out += `\n### Manual Verification (${manualSteps.length})\n`;
2349
+ for (const s of manualSteps) {
2350
+ out += `- [ ] ${s}\n`;
2351
+ }
2352
+ const total = unitTests.length + integrationTests.length + edgeCases.length + manualSteps.length;
2353
+ out += `\n---\n✅ **${total} scenarios auto-logged** to \`.secufusion/tasks/.../scenarios.json\`\n`;
2354
+ if (!taskDir) {
2355
+ out += `⚠️ Task folder not found — call \`manage_task(initialize)\` first to persist scenarios.\n`;
2356
+ }
2357
+ return appendTelemetry({ content: [{ type: "text", text: out }] }, inputChars);
2358
+ });
1124
2359
  // ─── Resource & Prompt for AGENTS.md ──────────────────────────────────────────
1125
2360
  const AGENTS_MD_PATH = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1")), "AGENTS.md");
1126
2361
  server.resource("secufusion_rules", "secufusion://rules", async (uri) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "secufusion-mcp",
3
- "version": "1.0.18",
3
+ "version": "1.0.20",
4
4
  "type": "module",
5
5
  "description": "SecuFusion MCP server - developer workflow tooling with guardrails",
6
6
  "main": "index.js",