secufusion-mcp 1.0.28 → 1.0.30
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/.secufusion-project-spec.json +3 -0
- package/index.js +193 -113
- package/package.json +1 -1
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
"branch_naming_convention": "Mixed, not strictly enforced. Common patterns: feature/<kebab-description>, fix/<desc> or bugfix/<ticket-or-desc>, ticket-only (TASK-####, TASK-####-<desc>, BUG-####-<desc>), release-<major>.<minor>.<patch> (dash, not slash) for release branches. Trunk branches are 'main' and 'develop' in every repo, but which one is checked out as the working default varies per repo (sfn-events-api and sfn-web-ui default to develop; snf-browser-extn defaults to main). Many ad hoc/free-text branch names also exist.",
|
|
13
13
|
"commit_message_pattern": "No single enforced convention. Recurring mixture: (1) Azure Repos auto-generated 'Merged PR <number>: <title>' on PR completion (dominant pattern in all 3 sampled repos), (2) ticket-prefixed 'TASK-<number>: <desc>' or 'TASK-<number>-<desc>' (common in sfn-events-api), (3) conventional-commit style 'feat(<scope>): ...' / 'fix(<scope>): ...' (used consistently within recent 'ai-ops' feature work in sfn-web-ui, not elsewhere), (4) plain imperative descriptive messages with no prefix at all. Work items referenced via https://dev.azure.com/secufusion/SFCloud-MSSP/_workitems/edit/<id>."
|
|
14
14
|
},
|
|
15
|
+
"coding_patterns": {
|
|
16
|
+
"timestamp_handling": "backend ONLY produces UTC timestamp strings. All time formatting and local timezone conversion MUST be handled by the frontend."
|
|
17
|
+
},
|
|
15
18
|
"microservices": {
|
|
16
19
|
"sfn-auth-api": {
|
|
17
20
|
"artifact_id": "sfn-auth-api",
|
package/index.js
CHANGED
|
@@ -1462,6 +1462,73 @@ server.tool("record_retrospective", "Records a structured retrospective after ta
|
|
|
1462
1462
|
}
|
|
1463
1463
|
return appendTelemetry({ content: [{ type: "text", text: out }] }, inputChars);
|
|
1464
1464
|
});
|
|
1465
|
+
const STOP_WORDS = new Set([
|
|
1466
|
+
"the", "a", "an", "is", "are", "was", "were", "be",
|
|
1467
|
+
"been", "being", "have", "has", "had", "do", "does",
|
|
1468
|
+
"did", "will", "would", "could", "should", "may",
|
|
1469
|
+
"might", "shall", "can", "to", "of", "in", "on", "at",
|
|
1470
|
+
"by", "for", "with", "about", "against", "between",
|
|
1471
|
+
"into", "through", "during", "before", "after",
|
|
1472
|
+
"from", "up", "down", "out", "off", "over", "under",
|
|
1473
|
+
"and", "but", "or", "nor", "so", "yet", "both",
|
|
1474
|
+
"either", "neither", "not", "only", "same", "than",
|
|
1475
|
+
"too", "very", "just", "that", "this", "these",
|
|
1476
|
+
"those", "it", "its", "itself", "which", "who",
|
|
1477
|
+
"what", "where", "when", "why", "how", "all", "each",
|
|
1478
|
+
"every", "few", "more", "most", "other", "some",
|
|
1479
|
+
"such", "no", "own", "also", "instead", "rather",
|
|
1480
|
+
"shows", "show", "display", "displays", "showing",
|
|
1481
|
+
"shown", "currently", "now", "then", "also", "there",
|
|
1482
|
+
"their", "they", "them", "we", "our", "your", "my",
|
|
1483
|
+
"his", "her", "its", "page", "modal", "screen"
|
|
1484
|
+
]);
|
|
1485
|
+
function extractSymptomWords(text) {
|
|
1486
|
+
return text
|
|
1487
|
+
.toLowerCase()
|
|
1488
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
1489
|
+
.split(/\s+/)
|
|
1490
|
+
.filter(w => w.length > 3)
|
|
1491
|
+
.filter(w => !STOP_WORDS.has(w))
|
|
1492
|
+
.filter(w => !/^\d+$/.test(w));
|
|
1493
|
+
}
|
|
1494
|
+
function levenshteinClose(a, b, threshold = 2) {
|
|
1495
|
+
if (Math.abs(a.length - b.length) > threshold)
|
|
1496
|
+
return false;
|
|
1497
|
+
let matrix = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
1498
|
+
for (let j = 0; j <= b.length; j++)
|
|
1499
|
+
matrix[0][j] = j;
|
|
1500
|
+
for (let i = 1; i <= a.length; i++) {
|
|
1501
|
+
for (let j = 1; j <= b.length; j++) {
|
|
1502
|
+
matrix[i][j] = a[i - 1] === b[j - 1]
|
|
1503
|
+
? matrix[i - 1][j - 1]
|
|
1504
|
+
: 1 + Math.min(matrix[i - 1][j], matrix[i][j - 1], matrix[i - 1][j - 1]);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
return matrix[a.length][b.length] <= threshold;
|
|
1508
|
+
}
|
|
1509
|
+
function buildScopeText(signals, domain) {
|
|
1510
|
+
if (signals.length === 0) {
|
|
1511
|
+
return `${domain} changes required`;
|
|
1512
|
+
}
|
|
1513
|
+
const serviceNames = signals.filter(s => s.includes("-api") || s.includes("-service") ||
|
|
1514
|
+
s.includes("sfn-"));
|
|
1515
|
+
const techTerms = signals.filter(s => !s.includes("-api") && !s.includes("-service") &&
|
|
1516
|
+
!s.includes("sfn-"));
|
|
1517
|
+
const parts = [];
|
|
1518
|
+
if (serviceNames.length > 0) {
|
|
1519
|
+
parts.push(`Services: ${serviceNames.join(", ")}`);
|
|
1520
|
+
}
|
|
1521
|
+
if (techTerms.length > 0 && techTerms.length <= 4) {
|
|
1522
|
+
parts.push(`Changes: ${techTerms.join(", ")}`);
|
|
1523
|
+
}
|
|
1524
|
+
else if (techTerms.length > 4) {
|
|
1525
|
+
parts.push(`Changes: ${techTerms.slice(0, 3).join(", ")}` +
|
|
1526
|
+
` (+${techTerms.length - 3} more)`);
|
|
1527
|
+
}
|
|
1528
|
+
return parts.length > 0
|
|
1529
|
+
? parts.join("\n ")
|
|
1530
|
+
: `${domain} implementation required`;
|
|
1531
|
+
}
|
|
1465
1532
|
// ─── Tool 9: classify_task ────────────────────────────────────────────────────
|
|
1466
1533
|
server.tool("classify_task", "MANDATORY FIRST STEP for every task without exception. " +
|
|
1467
1534
|
"Classifies a task as BACKEND_ONLY, FRONTEND_ONLY, FULL_STACK, or EXTENSION_ONLY. " +
|
|
@@ -1612,56 +1679,53 @@ server.tool("classify_task", "MANDATORY FIRST STEP for every task without except
|
|
|
1612
1679
|
let suggestedTitle = "";
|
|
1613
1680
|
let suggestedTitleReason = "";
|
|
1614
1681
|
// CHECK 1: Title accuracy
|
|
1615
|
-
const
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
"
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
.filter(w => w.length > 3)
|
|
1642
|
-
.filter(w => !STOP_WORDS.has(w))
|
|
1643
|
-
.filter(w => !w.match(/^\d+$/));
|
|
1644
|
-
}
|
|
1645
|
-
const titleSymptoms = extractSymptomWords(title);
|
|
1646
|
-
const descSymptoms = extractSymptomWords(description);
|
|
1647
|
-
const firstSentence = description.split('.')[0] || "";
|
|
1648
|
-
const coreSymptoms = extractSymptomWords(firstSentence).slice(0, 5);
|
|
1649
|
-
const titleCoverage = coreSymptoms.filter(sym => titleSymptoms.some(ts => ts.includes(sym) || sym.includes(ts))).length / Math.max(coreSymptoms.length, 1);
|
|
1650
|
-
titleAccurate = titleCoverage >= 0.4;
|
|
1651
|
-
function detectDomain(text) {
|
|
1652
|
-
const t = text.toLowerCase();
|
|
1653
|
-
if (t.includes("modal") || t.includes("display") || t.includes("shows") || t.includes("screen"))
|
|
1654
|
-
return "display/UI";
|
|
1655
|
-
if (t.includes("database") || t.includes("query") || t.includes("table"))
|
|
1656
|
-
return "database";
|
|
1657
|
-
return "unknown";
|
|
1658
|
-
}
|
|
1682
|
+
const services = [];
|
|
1683
|
+
if (spec) {
|
|
1684
|
+
if (spec.microservices)
|
|
1685
|
+
Object.keys(spec.microservices).forEach(k => services.push(k.toLowerCase()));
|
|
1686
|
+
if (spec.frontend && spec.frontend.repo)
|
|
1687
|
+
services.push(spec.frontend.repo.toLowerCase());
|
|
1688
|
+
if (spec.chrome_extension && spec.chrome_extension.repo)
|
|
1689
|
+
services.push(spec.chrome_extension.repo.toLowerCase());
|
|
1690
|
+
}
|
|
1691
|
+
if (services.length === 0) {
|
|
1692
|
+
services.push("sfn-iam-api", "sfn-events-api", "sfn-tenants-api", "sfn-policy-api", "sfn-gateway-api", "sfn-web-ui");
|
|
1693
|
+
}
|
|
1694
|
+
const firstSentence = description.split(/[.!?]/)[0] || description;
|
|
1695
|
+
const coreSymptoms = extractSymptomWords(firstSentence).slice(0, 6);
|
|
1696
|
+
const titleWords = extractSymptomWords(title);
|
|
1697
|
+
// Title is accurate if covers >= 35% of
|
|
1698
|
+
// core symptom words from first sentence
|
|
1699
|
+
const matchCount = coreSymptoms.filter(sym => titleWords.some(tw => tw.includes(sym) || sym.includes(tw) ||
|
|
1700
|
+
levenshteinClose(tw, sym))).length;
|
|
1701
|
+
const titleCoverage = coreSymptoms.length > 0
|
|
1702
|
+
? matchCount / coreSymptoms.length
|
|
1703
|
+
: 1.0;
|
|
1704
|
+
titleAccurate = titleCoverage >= 0.35;
|
|
1705
|
+
// Secondary check: if title and desc are
|
|
1706
|
+
// about same domain → not misleading
|
|
1707
|
+
// even if coverage is low
|
|
1659
1708
|
if (!titleAccurate) {
|
|
1660
|
-
const
|
|
1661
|
-
|
|
1662
|
-
|
|
1709
|
+
const DISPLAY_WORDS = [
|
|
1710
|
+
"show", "display", "render", "modal", "page",
|
|
1711
|
+
"screen", "ui", "view", "format", "time", "date",
|
|
1712
|
+
"timezone", "utc", "local", "color", "layout",
|
|
1713
|
+
"button", "icon", "text", "label"
|
|
1714
|
+
];
|
|
1715
|
+
const BACKEND_WORDS = [
|
|
1716
|
+
"api", "service", "endpoint", "db", "database",
|
|
1717
|
+
"query", "kafka", "migration", "entity", "null",
|
|
1718
|
+
"exception", "500", "error", "timeout", "token"
|
|
1719
|
+
];
|
|
1720
|
+
const titleIsDisplay = DISPLAY_WORDS.some(w => title.toLowerCase().includes(w));
|
|
1721
|
+
const descIsDisplay = DISPLAY_WORDS.some(w => description.toLowerCase().includes(w));
|
|
1722
|
+
const titleIsBackend = BACKEND_WORDS.some(w => title.toLowerCase().includes(w));
|
|
1723
|
+
const descIsBackend = BACKEND_WORDS.some(w => description.toLowerCase().includes(w));
|
|
1724
|
+
// Same domain = not misleading
|
|
1725
|
+
if ((titleIsDisplay && descIsDisplay) ||
|
|
1726
|
+
(titleIsBackend && descIsBackend)) {
|
|
1663
1727
|
titleAccurate = true;
|
|
1664
|
-
//
|
|
1728
|
+
// Optional advisory — not a blocking flag
|
|
1665
1729
|
}
|
|
1666
1730
|
else {
|
|
1667
1731
|
suggestedTitle = "BUG: " + firstSentence.slice(0, 50) + "...";
|
|
@@ -1735,29 +1799,26 @@ server.tool("classify_task", "MANDATORY FIRST STEP for every task without except
|
|
|
1735
1799
|
scopeQuestions.push("2. Which component or service is affected?");
|
|
1736
1800
|
}
|
|
1737
1801
|
}
|
|
1738
|
-
// CHECK 5: Task type correctness
|
|
1739
|
-
let taskTypeCorrect = true;
|
|
1740
|
-
let suggestedTaskType = "";
|
|
1741
|
-
let taskTypeReason = "";
|
|
1742
1802
|
const DEFECT_SIGNALS = [
|
|
1743
|
-
"instead of", "rather than", "
|
|
1744
|
-
"
|
|
1745
|
-
"
|
|
1746
|
-
"
|
|
1747
|
-
"
|
|
1748
|
-
"
|
|
1749
|
-
"
|
|
1750
|
-
"
|
|
1803
|
+
"instead of", "rather than", "incorrect", "wrong",
|
|
1804
|
+
"broken", "failing", "error", "exception", "crash",
|
|
1805
|
+
"null", "missing", "unexpected", "should show",
|
|
1806
|
+
"should display", "expected", "actual", "not working",
|
|
1807
|
+
"doesn't work", "fails", "not showing", "bug",
|
|
1808
|
+
"fix", "issue", "problem", "invalid", "400", "500",
|
|
1809
|
+
"404", "403", "401", "timeout", "slow", "blank",
|
|
1810
|
+
"empty", "duplicate", "stale", "outdated"
|
|
1751
1811
|
];
|
|
1752
|
-
const hasDefectSignal = DEFECT_SIGNALS.some(s => (title + " " + description)
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1812
|
+
const hasDefectSignal = DEFECT_SIGNALS.some(s => (title + " " + description)
|
|
1813
|
+
.toLowerCase()
|
|
1814
|
+
.includes(s));
|
|
1815
|
+
// Only flag wrong task type when NO defect
|
|
1816
|
+
// signals exist at all
|
|
1817
|
+
let taskTypeCorrect = task_type !== "bug" || hasDefectSignal;
|
|
1818
|
+
let suggestedTaskType = !taskTypeCorrect ? "feature" : task_type;
|
|
1819
|
+
let taskTypeReason = !taskTypeCorrect
|
|
1820
|
+
? "No defect signals found in title or description. Description appears to implement new functionality."
|
|
1821
|
+
: "";
|
|
1761
1822
|
// CHECK 6: SecuFusion-specific validation
|
|
1762
1823
|
const secufusionFlags = [];
|
|
1763
1824
|
rejectedPatterns.forEach(r => {
|
|
@@ -1766,51 +1827,70 @@ server.tool("classify_task", "MANDATORY FIRST STEP for every task without except
|
|
|
1766
1827
|
}
|
|
1767
1828
|
});
|
|
1768
1829
|
const architectureOwnershipSignals = [];
|
|
1769
|
-
|
|
1770
|
-
|
|
1830
|
+
const FRONTEND_OWNERSHIP_PHRASES = [
|
|
1831
|
+
"frontend must",
|
|
1832
|
+
"frontend should",
|
|
1833
|
+
"handled by the frontend",
|
|
1834
|
+
"must be handled by the frontend",
|
|
1835
|
+
"frontend handles",
|
|
1836
|
+
"frontend converts",
|
|
1837
|
+
"ui must",
|
|
1838
|
+
"client must",
|
|
1839
|
+
"client side",
|
|
1840
|
+
"never returns pre-formatted",
|
|
1841
|
+
"only produces",
|
|
1842
|
+
"backend only produces",
|
|
1843
|
+
"browser must",
|
|
1844
|
+
"all time formatting",
|
|
1845
|
+
"timezone conversion must"
|
|
1846
|
+
];
|
|
1847
|
+
const BACKEND_OWNERSHIP_PHRASES = [
|
|
1848
|
+
"backend must",
|
|
1849
|
+
"backend should",
|
|
1850
|
+
"server must",
|
|
1851
|
+
"service must",
|
|
1852
|
+
"always scoped with",
|
|
1853
|
+
"must filter by",
|
|
1854
|
+
"never expose",
|
|
1855
|
+
"controller resolves",
|
|
1856
|
+
"service layer must",
|
|
1857
|
+
"repository must",
|
|
1858
|
+
"always include tenantid"
|
|
1859
|
+
];
|
|
1771
1860
|
if (spec && spec.coding_patterns) {
|
|
1772
|
-
const FRONTEND_OWNERSHIP_PHRASES = [
|
|
1773
|
-
"frontend must", "frontend should", "handled by the frontend",
|
|
1774
|
-
"must be handled by the frontend", "frontend handles", "ui must",
|
|
1775
|
-
"client must", "client side", "never returns pre-formatted",
|
|
1776
|
-
"frontend converts", "browser must", "only produces", "backend only produces"
|
|
1777
|
-
];
|
|
1778
|
-
const BACKEND_OWNERSHIP_PHRASES = [
|
|
1779
|
-
"backend must", "backend should", "server must", "service must",
|
|
1780
|
-
"always scoped with", "must filter by", "never expose",
|
|
1781
|
-
"controller resolves", "service layer must"
|
|
1782
|
-
];
|
|
1783
|
-
const extractKw = (text) => text.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(w => w.length > 3);
|
|
1784
1861
|
for (const [ruleName, ruleText] of Object.entries(spec.coding_patterns)) {
|
|
1785
|
-
const
|
|
1786
|
-
const
|
|
1787
|
-
const
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1862
|
+
const ruleStr = String(ruleText).toLowerCase();
|
|
1863
|
+
const taskText = (title + " " + description).toLowerCase();
|
|
1864
|
+
const ruleWords = ruleName
|
|
1865
|
+
.toLowerCase()
|
|
1866
|
+
.replace(/_/g, " ")
|
|
1867
|
+
.split(" ")
|
|
1868
|
+
.filter(w => w.length > 3);
|
|
1869
|
+
const ruleApplies = ruleWords.some(w => taskText.includes(w)) || Object.keys(spec.coding_patterns).some(k => taskText.includes(k.toLowerCase().replace(/_/g, " ")));
|
|
1870
|
+
if (!ruleApplies)
|
|
1791
1871
|
continue;
|
|
1792
|
-
const isFrontendOwnership = FRONTEND_OWNERSHIP_PHRASES.some(phrase =>
|
|
1793
|
-
const isBackendOwnership = BACKEND_OWNERSHIP_PHRASES.some(phrase =>
|
|
1872
|
+
const isFrontendOwnership = FRONTEND_OWNERSHIP_PHRASES.some(phrase => ruleStr.includes(phrase));
|
|
1873
|
+
const isBackendOwnership = BACKEND_OWNERSHIP_PHRASES.some(phrase => ruleStr.includes(phrase));
|
|
1794
1874
|
if (isFrontendOwnership) {
|
|
1795
|
-
frontendScore
|
|
1796
|
-
ownershipNotes.push(`Architecture rule '${ruleName}' confirms frontend ownership: ${rText.slice(0, 80)}...`);
|
|
1875
|
+
frontendScore = (frontendScore || 0) + 8;
|
|
1797
1876
|
architectureOwnershipSignals.push({
|
|
1798
1877
|
rule: ruleName,
|
|
1799
1878
|
direction: "FRONTEND",
|
|
1800
|
-
note:
|
|
1879
|
+
note: String(ruleText).slice(0, 120)
|
|
1801
1880
|
});
|
|
1802
1881
|
}
|
|
1803
1882
|
else if (isBackendOwnership) {
|
|
1804
|
-
backendScore
|
|
1805
|
-
ownershipNotes.push(`Architecture rule '${ruleName}' confirms backend ownership: ${rText.slice(0, 80)}...`);
|
|
1883
|
+
backendScore = (backendScore || 0) + 8;
|
|
1806
1884
|
architectureOwnershipSignals.push({
|
|
1807
1885
|
rule: ruleName,
|
|
1808
1886
|
direction: "BACKEND",
|
|
1809
|
-
note:
|
|
1887
|
+
note: String(ruleText).slice(0, 120)
|
|
1810
1888
|
});
|
|
1811
1889
|
}
|
|
1812
1890
|
else {
|
|
1813
|
-
secufusionFlags.push(`Note — Architecture Rule (${ruleName}):
|
|
1891
|
+
secufusionFlags.push(`Note — Architecture Rule (${ruleName}): ` +
|
|
1892
|
+
`${String(ruleText).slice(0, 100)}` +
|
|
1893
|
+
` (verify this applies to your implementation)`);
|
|
1814
1894
|
}
|
|
1815
1895
|
}
|
|
1816
1896
|
}
|
|
@@ -1960,22 +2040,18 @@ server.tool("classify_task", "MANDATORY FIRST STEP for every task without except
|
|
|
1960
2040
|
else if (classification === "FULL_STACK") {
|
|
1961
2041
|
allowedNextAction = "CONFIRM";
|
|
1962
2042
|
nextPhase = "Confirm backend scope, then plan API contract first";
|
|
1963
|
-
const
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
}
|
|
1972
|
-
const backendScopeText = backendScopeLines.length > 0
|
|
1973
|
-
? backendScopeLines.join("\n → ")
|
|
1974
|
-
: "Backend logic changes required";
|
|
2043
|
+
const uniqueBackendSignals = [
|
|
2044
|
+
...new Set(backendFound.filter(Boolean))
|
|
2045
|
+
];
|
|
2046
|
+
const uniqueFrontendSignals = [
|
|
2047
|
+
...new Set(frontendFound.filter(Boolean))
|
|
2048
|
+
];
|
|
2049
|
+
const backendScopeText = buildScopeText(uniqueBackendSignals, "backend");
|
|
2050
|
+
const frontendScopeText = buildScopeText(uniqueFrontendSignals, "frontend");
|
|
1975
2051
|
developerMessage =
|
|
1976
2052
|
`⚠️ FULL STACK TASK\n\n` +
|
|
1977
2053
|
`Backend scope (yours):\n → ${backendScopeText}\n` +
|
|
1978
|
-
`Frontend scope (not yours)
|
|
2054
|
+
`Frontend scope (not yours):\n → ${frontendScopeText}\n\n` +
|
|
1979
2055
|
`I will implement the backend only and define the API contract first.\n` +
|
|
1980
2056
|
`Confirm and I will proceed to plan.`;
|
|
1981
2057
|
}
|
|
@@ -2022,8 +2098,12 @@ server.tool("classify_task", "MANDATORY FIRST STEP for every task without except
|
|
|
2022
2098
|
}
|
|
2023
2099
|
}
|
|
2024
2100
|
if (architectureOwnershipSignals.length > 0) {
|
|
2025
|
-
finalDeveloperMessage +=
|
|
2026
|
-
|
|
2101
|
+
finalDeveloperMessage += `\n📐 Architecture ownership signals:\n`;
|
|
2102
|
+
for (const sig of architectureOwnershipSignals) {
|
|
2103
|
+
finalDeveloperMessage +=
|
|
2104
|
+
` → [${sig.direction}] Rule '${sig.rule}':\n` +
|
|
2105
|
+
` ${sig.note}\n`;
|
|
2106
|
+
}
|
|
2027
2107
|
}
|
|
2028
2108
|
if (learnedLogs.length > 0) {
|
|
2029
2109
|
finalDeveloperMessage += `🧠 RETROSPECTIVE INTELLIGENCE APPLIED\n`;
|