thumbgate 1.29.2 → 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +54 -16
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +143 -14
- package/adapters/opencode/opencode.json +1 -1
- package/bench/observability-eval-suite.json +2 -2
- package/bin/cli.js +154 -36
- package/config/evals/generation-quality-golden.json +95 -0
- package/config/evals/rag-answer-quality-golden.json +91 -0
- package/config/evals/retrieval-hybrid-ablation.json +66 -0
- package/config/evals/retrieval-ranking-golden.json +522 -0
- package/config/gates/default.json +217 -50
- package/config/mcp-allowlists.json +215 -185
- package/config/model-tiers.json +7 -2
- package/config/post-deploy-marketing-pages.json +26 -1
- package/glama.json +6 -0
- package/package.json +94 -11
- package/public/architecture.html +130 -0
- package/public/assets/diagrams/agent-integration.png +0 -0
- package/public/assets/diagrams/before-after.svg +22 -0
- package/public/assets/diagrams/decision.svg +36 -0
- package/public/assets/diagrams/feedback-pipeline.png +0 -0
- package/public/assets/diagrams/hero-thumbs.svg +68 -0
- package/public/assets/diagrams/loop.svg +40 -0
- package/public/assets/diagrams/plugin-topology.png +0 -0
- package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
- package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
- package/public/assets/diagrams/stack.svg +18 -0
- package/public/assets/diagrams/thumbgate-architecture.png +0 -0
- package/public/case-studies.html +151 -0
- package/public/compare.html +1 -0
- package/public/dashboard.html +126 -28
- package/public/eval-scorecard.html +195 -0
- package/public/eval-scorecard.json +18 -0
- package/public/evaluations.html +168 -0
- package/public/index.html +143 -13
- package/public/numbers.html +3 -2
- package/public/pricing.html +143 -30
- package/public/whitepaper.html +189 -0
- package/scripts/a-plus-evidence-scorecard.js +303 -0
- package/scripts/activation-quickstart.js +1 -0
- package/scripts/agent-outcome-monitor.js +71 -1
- package/scripts/async-eval-observability.js +36 -11
- package/scripts/audit-trail.js +37 -1
- package/scripts/auto-promote-gates.js +149 -34
- package/scripts/billing.js +3 -1
- package/scripts/claude-feedback-sync.js +3 -2
- package/scripts/cli-feedback.js +13 -7
- package/scripts/colbert-style-maxsim.js +236 -0
- package/scripts/cross-encoder-reranker.js +359 -126
- package/scripts/dashboard-chat.js +350 -17
- package/scripts/document-intake.js +283 -7
- package/scripts/eval-quality-suite.js +204 -0
- package/scripts/feedback-aggregate.js +5 -2
- package/scripts/feedback-loop.js +359 -189
- package/scripts/feedback-paths.js +32 -13
- package/scripts/feedback-quality.js +53 -0
- package/scripts/filesystem-search.js +17 -7
- package/scripts/gates-engine.js +98 -4
- package/scripts/generate-case-study-outreach.js +253 -0
- package/scripts/generate-eval-scorecard.js +276 -0
- package/scripts/growth-campaigns.js +183 -0
- package/scripts/harness-tool-names.js +70 -0
- package/scripts/hook-runtime.js +10 -3
- package/scripts/jsonl-watcher.js +1 -0
- package/scripts/lesson-db.js +16 -5
- package/scripts/lesson-embedding-index.js +67 -20
- package/scripts/lesson-embedding-maintenance.js +177 -0
- package/scripts/lesson-inference.js +23 -4
- package/scripts/lesson-reranker.js +55 -9
- package/scripts/lesson-retrieval.js +375 -32
- package/scripts/lesson-search.js +48 -11
- package/scripts/llm-client.js +304 -15
- package/scripts/mcp-config.js +26 -5
- package/scripts/mcp-oauth.js +37 -2
- package/scripts/model-eval.js +308 -0
- package/scripts/model-tier-router.js +593 -0
- package/scripts/parallel-workflow-orchestrator.js +86 -22
- package/scripts/pragmatic-hybrid-search.js +379 -0
- package/scripts/published-cli.js +11 -1
- package/scripts/rag-document-pipeline.js +461 -0
- package/scripts/rag-structured-output.js +441 -0
- package/scripts/ragas-style-metrics.js +351 -0
- package/scripts/refresh-proof-pack.js +261 -0
- package/scripts/request-envelope.js +178 -0
- package/scripts/rerank-pipeline.js +370 -0
- package/scripts/rerank-quality-eval.js +155 -0
- package/scripts/retrieval-hybrid-ablation.js +120 -0
- package/scripts/retrieval-quality-tier.js +118 -0
- package/scripts/risk-scorer.js +144 -15
- package/scripts/secret-scanner.js +395 -4
- package/scripts/self-distill-agent.js +7 -1
- package/scripts/self-healing-check.js +25 -0
- package/scripts/skill-packs.js +183 -0
- package/scripts/slow-loop.js +72 -0
- package/scripts/statusline-links.js +1 -1
- package/scripts/statusline-local-stats.js +1 -1
- package/scripts/statusline.sh +8 -1
- package/scripts/telemetry-analytics.js +13 -1
- package/scripts/thumbgate-bench.js +13 -0
- package/scripts/thumbgate-search.js +98 -6
- package/scripts/tier-budget-guard.js +186 -0
- package/scripts/tool-kpi-tracker.js +124 -0
- package/scripts/tool-registry.js +95 -1
- package/scripts/vector-store.js +108 -4
- package/scripts/verify-marketing-pages-deployed.js +85 -3
- package/server.json +44 -0
- package/smithery.yaml +17 -0
- package/src/api/server.js +424 -99
package/scripts/feedback-loop.js
CHANGED
|
@@ -21,6 +21,7 @@ const {
|
|
|
21
21
|
buildClarificationMessage,
|
|
22
22
|
isGenericFeedbackText,
|
|
23
23
|
normalizeFeedbackText,
|
|
24
|
+
scoreFeedbackReward,
|
|
24
25
|
} = require('./feedback-quality');
|
|
25
26
|
const {
|
|
26
27
|
buildRubricEvaluation,
|
|
@@ -62,6 +63,11 @@ const FEEDBACK_EVENT_CLAIM_STALE_MS = 60 * 1000;
|
|
|
62
63
|
const FEEDBACK_EVENT_CLAIM_WAIT_MS = 15 * 1000;
|
|
63
64
|
const FEEDBACK_EVENT_CLAIM_POLL_MS = 25;
|
|
64
65
|
|
|
66
|
+
function normalizeReviewOrigin(value) {
|
|
67
|
+
const origin = String(value || '').trim().toLowerCase();
|
|
68
|
+
return ['human', 'automated', 'imported'].includes(origin) ? origin : 'unverified';
|
|
69
|
+
}
|
|
70
|
+
|
|
65
71
|
function isSelfHarnessOptimizerEnabled(env = process.env) {
|
|
66
72
|
return /^(?:1|true)$/i.test(String(env.THUMBGATE_SELF_HARNESS_OPTIMIZER || '').trim());
|
|
67
73
|
}
|
|
@@ -387,6 +393,8 @@ function normalizeAnalysisShape(analysis = {}) {
|
|
|
387
393
|
byImportance: Array.isArray(analysis.byImportance) ? analysis.byImportance : [],
|
|
388
394
|
recentLessons: Array.isArray(analysis.recentLessons) ? analysis.recentLessons : [],
|
|
389
395
|
sessionCount: Number.isFinite(analysis.sessionCount) ? analysis.sessionCount : 0,
|
|
396
|
+
rawTotal: analysis.rawTotal ?? total,
|
|
397
|
+
excludedTotal: analysis.excludedTotal ?? 0,
|
|
390
398
|
};
|
|
391
399
|
}
|
|
392
400
|
|
|
@@ -1431,6 +1439,7 @@ function captureFeedback(params) {
|
|
|
1431
1439
|
structuredRule: structuredRule || null,
|
|
1432
1440
|
...(reflection && { reflection }),
|
|
1433
1441
|
gateAction: params.gateAction || null,
|
|
1442
|
+
reviewOrigin: normalizeReviewOrigin(params.reviewOrigin),
|
|
1434
1443
|
sourceEvent: publicFeedbackSourceMetadata(params.sourceEvent),
|
|
1435
1444
|
timestamp: now,
|
|
1436
1445
|
};
|
|
@@ -1562,6 +1571,7 @@ function captureFeedback(params) {
|
|
|
1562
1571
|
diagnosis: storedDiagnosis,
|
|
1563
1572
|
structuredRule: structuredRule || null,
|
|
1564
1573
|
sourceFeedbackId: feedbackEvent.id,
|
|
1574
|
+
reviewOrigin: feedbackEvent.reviewOrigin,
|
|
1565
1575
|
timestamp: now,
|
|
1566
1576
|
};
|
|
1567
1577
|
|
|
@@ -1604,6 +1614,14 @@ function captureFeedback(params) {
|
|
|
1604
1614
|
return firewallBlocked;
|
|
1605
1615
|
}
|
|
1606
1616
|
|
|
1617
|
+
// Grade the correction the operator actually wrote, and persist it with the
|
|
1618
|
+
// entry. assessFeedbackActionability already answered the binary question
|
|
1619
|
+
// (promotable at all); this is the graded one, and without computing it here
|
|
1620
|
+
// the scorer would be reachable-but-never-called — the same defect class it
|
|
1621
|
+
// was added to help detect.
|
|
1622
|
+
const rewardScore = scoreFeedbackReward(feedbackEvent);
|
|
1623
|
+
if (rewardScore) feedbackEvent.rewardScore = rewardScore;
|
|
1624
|
+
|
|
1607
1625
|
appendJSONL(FEEDBACK_LOG_PATH, feedbackEvent);
|
|
1608
1626
|
emitAnonymousFeedbackPing(signal);
|
|
1609
1627
|
|
|
@@ -1885,6 +1903,9 @@ function captureFeedback(params) {
|
|
|
1885
1903
|
inferredLesson: memoryRecord ? memoryRecord.title : (feedbackEvent.context || '').slice(0, 200),
|
|
1886
1904
|
confidence: memoryRecord ? 70 : 40,
|
|
1887
1905
|
tags: feedbackEvent.tags || [],
|
|
1906
|
+
metadata: {
|
|
1907
|
+
reviewOrigin: feedbackEvent.reviewOrigin,
|
|
1908
|
+
},
|
|
1888
1909
|
});
|
|
1889
1910
|
} catch { /* non-critical — lesson creation should never block feedback */ }
|
|
1890
1911
|
});
|
|
@@ -1910,143 +1931,146 @@ function captureFeedbackIdempotent(params = {}) {
|
|
|
1910
1931
|
}
|
|
1911
1932
|
}
|
|
1912
1933
|
|
|
1913
|
-
function
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
// Prefer the JSONL mirror for full analytics fidelity. Fall back to SQLite only
|
|
1924
|
-
// when the mirror is unavailable so dashboards and proof paths keep their full shape.
|
|
1925
|
-
const db = shouldUseSQLite ? getLessonDB() : null;
|
|
1926
|
-
if (db && entries.length === 0) {
|
|
1927
|
-
try {
|
|
1928
|
-
const { getStatsFromDB } = require('./lesson-db');
|
|
1929
|
-
const sqliteStats = getStatsFromDB(db);
|
|
1930
|
-
if (sqliteStats.total > 0) return normalizeAnalysisShape(sqliteStats);
|
|
1931
|
-
} catch { /* fall through to JSONL scan */ }
|
|
1934
|
+
function incrementBucket(buckets, key, signal) {
|
|
1935
|
+
if (!key) return;
|
|
1936
|
+
if (!buckets[key]) buckets[key] = { positive: 0, negative: 0, total: 0 };
|
|
1937
|
+
buckets[key][signal] += 1;
|
|
1938
|
+
buckets[key].total += 1;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
function summarizeRubric(entry, summary) {
|
|
1942
|
+
if (entry.actionType === 'no-action' && typeof entry.actionReason === 'string' && entry.actionReason.includes('Rubric gate')) {
|
|
1943
|
+
summary.blocked += 1;
|
|
1932
1944
|
}
|
|
1945
|
+
if (entry.rubric?.weightedScore != null) summary.samples += 1;
|
|
1933
1946
|
|
|
1934
|
-
const
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1947
|
+
for (const criterion of entry.rubric?.failingCriteria || []) {
|
|
1948
|
+
if (!summary.criteria[criterion]) summary.criteria[criterion] = { failures: 0 };
|
|
1949
|
+
summary.criteria[criterion].failures += 1;
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1939
1952
|
|
|
1940
|
-
|
|
1941
|
-
|
|
1953
|
+
function summarizeFeedbackEntries(entries) {
|
|
1954
|
+
const summary = {
|
|
1955
|
+
skills: {},
|
|
1956
|
+
tags: {},
|
|
1957
|
+
criteria: {},
|
|
1958
|
+
samples: 0,
|
|
1959
|
+
blocked: 0,
|
|
1960
|
+
positive: 0,
|
|
1961
|
+
negative: 0,
|
|
1962
|
+
};
|
|
1942
1963
|
|
|
1943
1964
|
for (const entry of entries) {
|
|
1944
|
-
if (entry.signal === 'positive')
|
|
1945
|
-
if (entry.signal === 'negative')
|
|
1946
|
-
|
|
1947
|
-
if (entry.skill) {
|
|
1948
|
-
if (!skills[entry.skill]) skills[entry.skill] = { positive: 0, negative: 0, total: 0 };
|
|
1949
|
-
skills[entry.skill][entry.signal] += 1;
|
|
1950
|
-
skills[entry.skill].total += 1;
|
|
1951
|
-
}
|
|
1965
|
+
if (entry.signal === 'positive') summary.positive += 1;
|
|
1966
|
+
if (entry.signal === 'negative') summary.negative += 1;
|
|
1952
1967
|
|
|
1968
|
+
incrementBucket(summary.skills, entry.skill, entry.signal);
|
|
1953
1969
|
for (const tag of entry.tags || []) {
|
|
1954
|
-
|
|
1955
|
-
tags[tag][entry.signal] += 1;
|
|
1956
|
-
tags[tag].total += 1;
|
|
1970
|
+
incrementBucket(summary.tags, tag, entry.signal);
|
|
1957
1971
|
}
|
|
1972
|
+
summarizeRubric(entry, summary);
|
|
1973
|
+
}
|
|
1958
1974
|
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
}
|
|
1975
|
+
return summary;
|
|
1976
|
+
}
|
|
1962
1977
|
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1978
|
+
function roundedRate(numerator, denominator) {
|
|
1979
|
+
return denominator > 0 ? Math.round((numerator / denominator) * 1000) / 1000 : 0;
|
|
1980
|
+
}
|
|
1966
1981
|
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
}
|
|
1972
|
-
}
|
|
1973
|
-
}
|
|
1982
|
+
function addRec(output, message, remediation) {
|
|
1983
|
+
output.messages.push(message);
|
|
1984
|
+
output.remediations.push(remediation);
|
|
1985
|
+
}
|
|
1974
1986
|
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1987
|
+
function feedbackTrend(windowStats, rate7d, rate30d) {
|
|
1988
|
+
if (windowStats['7d'].total === 0 || windowStats['30d'].total === 0) return 'stable';
|
|
1989
|
+
if (rate7d > rate30d + 0.05) return 'improving';
|
|
1990
|
+
if (rate7d < rate30d - 0.05) return 'degrading';
|
|
1991
|
+
return 'stable';
|
|
1992
|
+
}
|
|
1980
1993
|
|
|
1981
|
-
|
|
1994
|
+
function feedbackWindows(entries, positive, total) {
|
|
1982
1995
|
const now = Date.now();
|
|
1983
|
-
const
|
|
1984
|
-
|
|
1985
|
-
|
|
1996
|
+
const windowStats = {
|
|
1997
|
+
'7d': { total: 0, positive: 0 },
|
|
1998
|
+
'30d': { total: 0, positive: 0 },
|
|
1999
|
+
};
|
|
2000
|
+
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
|
|
2001
|
+
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
|
|
2002
|
+
|
|
1986
2003
|
for (const entry of entries) {
|
|
1987
|
-
const
|
|
1988
|
-
const age = now -
|
|
1989
|
-
if (age <=
|
|
1990
|
-
windowStats['7d'].total
|
|
1991
|
-
if (entry.signal === 'positive') windowStats['7d'].positive
|
|
2004
|
+
const timestamp = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
|
|
2005
|
+
const age = now - timestamp;
|
|
2006
|
+
if (age <= sevenDaysMs) {
|
|
2007
|
+
windowStats['7d'].total += 1;
|
|
2008
|
+
if (entry.signal === 'positive') windowStats['7d'].positive += 1;
|
|
1992
2009
|
}
|
|
1993
|
-
if (age <=
|
|
1994
|
-
windowStats['30d'].total
|
|
1995
|
-
if (entry.signal === 'positive') windowStats['30d'].positive
|
|
2010
|
+
if (age <= thirtyDaysMs) {
|
|
2011
|
+
windowStats['30d'].total += 1;
|
|
2012
|
+
if (entry.signal === 'positive') windowStats['30d'].positive += 1;
|
|
1996
2013
|
}
|
|
1997
2014
|
}
|
|
1998
|
-
const rate7d = windowStats['7d'].total > 0
|
|
1999
|
-
? Math.round((windowStats['7d'].positive / windowStats['7d'].total) * 1000) / 1000 : 0;
|
|
2000
|
-
const rate30d = windowStats['30d'].total > 0
|
|
2001
|
-
? Math.round((windowStats['30d'].positive / windowStats['30d'].total) * 1000) / 1000 : 0;
|
|
2002
|
-
const TREND_THRESHOLD = 0.05;
|
|
2003
|
-
const hasTrendData = windowStats['7d'].total > 0 && windowStats['30d'].total > 0;
|
|
2004
|
-
const trend = !hasTrendData ? 'stable'
|
|
2005
|
-
: rate7d > rate30d + TREND_THRESHOLD ? 'improving'
|
|
2006
|
-
: rate7d < rate30d - TREND_THRESHOLD ? 'degrading' : 'stable';
|
|
2007
|
-
const windows = {
|
|
2008
|
-
'7d': { ...windowStats['7d'], rate: rate7d },
|
|
2009
|
-
'30d': { ...windowStats['30d'], rate: rate30d },
|
|
2010
|
-
lifetime: { total, positive: totalPositive, rate: approvalRate },
|
|
2011
|
-
};
|
|
2012
2015
|
|
|
2013
|
-
const
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2016
|
+
const rate7d = roundedRate(windowStats['7d'].positive, windowStats['7d'].total);
|
|
2017
|
+
const rate30d = roundedRate(windowStats['30d'].positive, windowStats['30d'].total);
|
|
2018
|
+
const trend = feedbackTrend(windowStats, rate7d, rate30d);
|
|
2019
|
+
|
|
2020
|
+
return {
|
|
2021
|
+
rate7d,
|
|
2022
|
+
rate30d,
|
|
2023
|
+
trend,
|
|
2024
|
+
windows: {
|
|
2025
|
+
'7d': { ...windowStats['7d'], rate: rate7d },
|
|
2026
|
+
'30d': { ...windowStats['30d'], rate: rate30d },
|
|
2027
|
+
lifetime: { total, positive, rate: roundedRate(positive, total) },
|
|
2028
|
+
},
|
|
2029
|
+
};
|
|
2030
|
+
}
|
|
2018
2031
|
|
|
2032
|
+
function addSkillRecs(skills, output) {
|
|
2019
2033
|
for (const [skill, stat] of Object.entries(skills)) {
|
|
2020
2034
|
const negRate = stat.total > 0 ? stat.negative / stat.total : 0;
|
|
2021
|
-
if (stat.total
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2035
|
+
if (stat.total < 3 || negRate < 0.5) continue;
|
|
2036
|
+
addRec(output, `IMPROVE skill '${skill}' (${stat.negative}/${stat.total} negative)`, {
|
|
2037
|
+
type: 'skill-improve',
|
|
2038
|
+
target: skill,
|
|
2039
|
+
evidence: {
|
|
2040
|
+
positive: stat.positive,
|
|
2041
|
+
negative: stat.negative,
|
|
2042
|
+
total: stat.total,
|
|
2043
|
+
negativeRate: roundedRate(stat.negative, stat.total),
|
|
2044
|
+
},
|
|
2045
|
+
action: 'review-and-update-skill',
|
|
2046
|
+
rationale: `Skill '${skill}' has ${stat.negative}/${stat.total} negative feedback events (${Math.round(negRate * 100)}% negative rate).`,
|
|
2047
|
+
});
|
|
2031
2048
|
}
|
|
2049
|
+
}
|
|
2032
2050
|
|
|
2051
|
+
function addTagRecs(tags, output) {
|
|
2033
2052
|
for (const [tag, stat] of Object.entries(tags)) {
|
|
2034
2053
|
const posRate = stat.total > 0 ? stat.positive / stat.total : 0;
|
|
2035
|
-
if (stat.total
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2054
|
+
if (stat.total < 3 || posRate < 0.8) continue;
|
|
2055
|
+
addRec(output, `REUSE pattern '${tag}' (${stat.positive}/${stat.total} positive)`, {
|
|
2056
|
+
type: 'pattern-reuse',
|
|
2057
|
+
target: tag,
|
|
2058
|
+
evidence: {
|
|
2059
|
+
positive: stat.positive,
|
|
2060
|
+
negative: stat.negative,
|
|
2061
|
+
total: stat.total,
|
|
2062
|
+
positiveRate: roundedRate(stat.positive, stat.total),
|
|
2063
|
+
},
|
|
2064
|
+
action: 'replicate-pattern',
|
|
2065
|
+
rationale: `Pattern '${tag}' has ${stat.positive}/${stat.total} positive feedback events (${Math.round(posRate * 100)}% positive rate).`,
|
|
2066
|
+
});
|
|
2045
2067
|
}
|
|
2068
|
+
}
|
|
2046
2069
|
|
|
2070
|
+
function addTrendRecs(metrics, output) {
|
|
2071
|
+
const { recent, recentRate, approvalRate, trend, rate7d, rate30d } = metrics;
|
|
2047
2072
|
if (recent.length >= 10 && recentRate < approvalRate - 0.1) {
|
|
2048
|
-
|
|
2049
|
-
actionableRemediations.push({
|
|
2073
|
+
addRec(output, 'DECLINING trend in last 20 signals; tighten verification before response.', {
|
|
2050
2074
|
type: 'trend-declining',
|
|
2051
2075
|
target: 'recent-signals',
|
|
2052
2076
|
evidence: { recentRate, approvalRate, sampleSize: recent.length },
|
|
@@ -2054,93 +2078,135 @@ function analyzeFeedback(logPath) {
|
|
|
2054
2078
|
rationale: `Recent approval rate (${Math.round(recentRate * 100)}%) has dropped ≥10pp below lifetime (${Math.round(approvalRate * 100)}%).`,
|
|
2055
2079
|
});
|
|
2056
2080
|
}
|
|
2057
|
-
if (trend
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2081
|
+
if (trend !== 'degrading') return;
|
|
2082
|
+
addRec(output, `DEGRADING 7d trend (${rate7d}) vs 30d (${rate30d}); increase prevention rule injection.`, {
|
|
2083
|
+
type: 'trend-degrading',
|
|
2084
|
+
target: '7d-window',
|
|
2085
|
+
evidence: { rate7d, rate30d, delta: Math.round((rate7d - rate30d) * 1000) / 1000 },
|
|
2086
|
+
action: 'increase-prevention-rule-injection',
|
|
2087
|
+
rationale: `7d rate (${rate7d}) is below 30d rate (${rate30d}) by more than threshold.`,
|
|
2088
|
+
});
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
function addRiskBuckets(buckets, kind, output) {
|
|
2092
|
+
for (const bucket of buckets.slice(0, 2)) {
|
|
2093
|
+
addRec(output, `CHECK high-risk ${kind} '${bucket.key}' (${bucket.highRisk}/${bucket.total} high-risk)`, {
|
|
2094
|
+
type: `high-risk-${kind}`,
|
|
2095
|
+
target: bucket.key,
|
|
2096
|
+
evidence: { highRisk: bucket.highRisk, total: bucket.total, riskRate: bucket.riskRate },
|
|
2097
|
+
action: `audit-${kind}-failures`,
|
|
2098
|
+
rationale: `${kind === 'domain' ? 'Domain' : 'Tag'} '${bucket.key}' has ${bucket.highRisk}/${bucket.total} high-risk events (${Math.round((bucket.riskRate || 0) * 100)}% risk rate).`,
|
|
2065
2099
|
});
|
|
2066
2100
|
}
|
|
2101
|
+
}
|
|
2067
2102
|
|
|
2068
|
-
|
|
2103
|
+
function addRiskRecs(feedbackDir, output) {
|
|
2069
2104
|
try {
|
|
2070
2105
|
const riskScorer = getRiskScorerModule();
|
|
2071
|
-
if (riskScorer)
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
type: 'high-risk-domain',
|
|
2078
|
-
target: bucket.key,
|
|
2079
|
-
evidence: { highRisk: bucket.highRisk, total: bucket.total, riskRate: bucket.riskRate },
|
|
2080
|
-
action: 'audit-domain-failures',
|
|
2081
|
-
rationale: `Domain '${bucket.key}' has ${bucket.highRisk}/${bucket.total} high-risk events (${Math.round((bucket.riskRate || 0) * 100)}% risk rate).`,
|
|
2082
|
-
});
|
|
2083
|
-
});
|
|
2084
|
-
boostedRisk.highRiskTags.slice(0, 2).forEach((bucket) => {
|
|
2085
|
-
recommendations.push(`CHECK high-risk tag '${bucket.key}' (${bucket.highRisk}/${bucket.total} high-risk)`);
|
|
2086
|
-
actionableRemediations.push({
|
|
2087
|
-
type: 'high-risk-tag',
|
|
2088
|
-
target: bucket.key,
|
|
2089
|
-
evidence: { highRisk: bucket.highRisk, total: bucket.total, riskRate: bucket.riskRate },
|
|
2090
|
-
action: 'audit-tag-failures',
|
|
2091
|
-
rationale: `Tag '${bucket.key}' has ${bucket.highRisk}/${bucket.total} high-risk events (${Math.round((bucket.riskRate || 0) * 100)}% risk rate).`,
|
|
2092
|
-
});
|
|
2093
|
-
});
|
|
2094
|
-
}
|
|
2095
|
-
}
|
|
2106
|
+
if (!riskScorer) return null;
|
|
2107
|
+
const boostedRisk = riskScorer.getRiskSummary(feedbackDir);
|
|
2108
|
+
if (!boostedRisk) return null;
|
|
2109
|
+
addRiskBuckets(boostedRisk.highRiskDomains, 'domain', output);
|
|
2110
|
+
addRiskBuckets(boostedRisk.highRiskTags, 'tag', output);
|
|
2111
|
+
return boostedRisk;
|
|
2096
2112
|
} catch {
|
|
2097
|
-
|
|
2113
|
+
return null;
|
|
2098
2114
|
}
|
|
2099
|
-
|
|
2100
|
-
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
function addDelegationRecs(feedbackDir, output) {
|
|
2101
2118
|
try {
|
|
2102
2119
|
const delegationRuntime = getDelegationRuntimeModule();
|
|
2103
|
-
if (delegationRuntime
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
recommendations.push(`REVIEW delegation policy: ${delegation.avoidedDelegationCount} handoff starts were blocked before execution`);
|
|
2117
|
-
actionableRemediations.push({
|
|
2118
|
-
type: 'delegation-policy-review',
|
|
2119
|
-
target: 'handoff-blocks',
|
|
2120
|
-
evidence: { avoidedDelegationCount: delegation.avoidedDelegationCount },
|
|
2121
|
-
action: 'review-delegation-policy',
|
|
2122
|
-
rationale: `${delegation.avoidedDelegationCount} handoff starts were blocked before execution.`,
|
|
2123
|
-
});
|
|
2124
|
-
}
|
|
2120
|
+
if (!delegationRuntime || typeof delegationRuntime.summarizeDelegation !== 'function') return null;
|
|
2121
|
+
const delegation = delegationRuntime.summarizeDelegation(feedbackDir);
|
|
2122
|
+
if (delegation.attemptCount >= 3 && delegation.verificationFailureRate >= 0.5) {
|
|
2123
|
+
addRec(output, `REDUCE delegation: verification failure rate is ${Math.round(delegation.verificationFailureRate * 100)}%`, {
|
|
2124
|
+
type: 'delegation-reduce',
|
|
2125
|
+
target: 'verification-failure-rate',
|
|
2126
|
+
evidence: {
|
|
2127
|
+
verificationFailureRate: delegation.verificationFailureRate,
|
|
2128
|
+
attemptCount: delegation.attemptCount,
|
|
2129
|
+
},
|
|
2130
|
+
action: 'reduce-delegation-use',
|
|
2131
|
+
rationale: `Delegation verification failure rate is ${Math.round(delegation.verificationFailureRate * 100)}% across ${delegation.attemptCount} attempts.`,
|
|
2132
|
+
});
|
|
2125
2133
|
}
|
|
2134
|
+
if (delegation.avoidedDelegationCount >= 3) {
|
|
2135
|
+
addRec(output, `REVIEW delegation policy: ${delegation.avoidedDelegationCount} handoff starts were blocked before execution`, {
|
|
2136
|
+
type: 'delegation-policy-review',
|
|
2137
|
+
target: 'handoff-blocks',
|
|
2138
|
+
evidence: { avoidedDelegationCount: delegation.avoidedDelegationCount },
|
|
2139
|
+
action: 'review-delegation-policy',
|
|
2140
|
+
rationale: `${delegation.avoidedDelegationCount} handoff starts were blocked before execution.`,
|
|
2141
|
+
});
|
|
2142
|
+
}
|
|
2143
|
+
return delegation;
|
|
2126
2144
|
} catch {
|
|
2127
|
-
|
|
2145
|
+
return null;
|
|
2128
2146
|
}
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
function addDiagnosticRecs(diagnostics, output) {
|
|
2150
|
+
for (const bucket of diagnostics.categories.slice(0, 2)) {
|
|
2151
|
+
addRec(output, `DIAGNOSE '${bucket.key}' failures (${bucket.count})`, {
|
|
2132
2152
|
type: 'diagnose-failure-category',
|
|
2133
2153
|
target: bucket.key,
|
|
2134
2154
|
evidence: { count: bucket.count },
|
|
2135
2155
|
action: 'investigate-failure-category',
|
|
2136
2156
|
rationale: `Failure category '${bucket.key}' has ${bucket.count} diagnosed events.`,
|
|
2137
2157
|
});
|
|
2138
|
-
}
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
function getSQLiteFallback(useSQLite, entries) {
|
|
2162
|
+
const db = useSQLite ? getLessonDB() : null;
|
|
2163
|
+
if (!db || entries.length > 0) return null;
|
|
2164
|
+
try {
|
|
2165
|
+
const { getStatsFromDB } = require('./lesson-db');
|
|
2166
|
+
const sqliteStats = getStatsFromDB(db);
|
|
2167
|
+
return sqliteStats.total > 0 ? normalizeAnalysisShape(sqliteStats) : null;
|
|
2168
|
+
} catch {
|
|
2169
|
+
return null;
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
function analyzeFeedback(logPath, options = {}) {
|
|
2174
|
+
const { FEEDBACK_LOG_PATH } = getFeedbackPaths();
|
|
2175
|
+
const resolvedPath = logPath || FEEDBACK_LOG_PATH;
|
|
2176
|
+
const feedbackDir = path.dirname(resolvedPath);
|
|
2177
|
+
const paths = buildFeedbackPathsFromDir(feedbackDir);
|
|
2178
|
+
const useSQLite = !options.humanOnly && (!logPath || path.resolve(resolvedPath) === path.resolve(FEEDBACK_LOG_PATH));
|
|
2179
|
+
let entries = readJSONL(resolvedPath, { maxLines: 0 });
|
|
2180
|
+
const rawTotal = entries.length;
|
|
2181
|
+
if (options.humanOnly) {
|
|
2182
|
+
entries = entries.filter((entry) => normalizeReviewOrigin(entry.reviewOrigin) === 'human');
|
|
2183
|
+
}
|
|
2184
|
+
const fallback = getSQLiteFallback(useSQLite, entries);
|
|
2185
|
+
if (fallback) return fallback;
|
|
2186
|
+
|
|
2187
|
+
const diagnosticEntries = readDiagnosticEntries(path.join(feedbackDir, 'diagnostic-log.jsonl'));
|
|
2188
|
+
const summary = summarizeFeedbackEntries(entries);
|
|
2189
|
+
const { skills, tags, criteria, samples, blocked, positive, negative } = summary;
|
|
2190
|
+
const total = positive + negative;
|
|
2191
|
+
const approvalRate = roundedRate(positive, total);
|
|
2192
|
+
const recent = entries.slice(-20);
|
|
2193
|
+
const recentPos = recent.filter((e) => e.signal === 'positive').length;
|
|
2194
|
+
const recentRate = roundedRate(recentPos, recent.length);
|
|
2195
|
+
const { rate7d, rate30d, trend, windows } = feedbackWindows(entries, positive, total);
|
|
2196
|
+
const recs = { messages: [], remediations: [] };
|
|
2197
|
+
|
|
2198
|
+
addSkillRecs(skills, recs);
|
|
2199
|
+
addTagRecs(tags, recs);
|
|
2200
|
+
addTrendRecs({ recent, recentRate, approvalRate, trend, rate7d, rate30d }, recs);
|
|
2201
|
+
const boostedRisk = addRiskRecs(paths.FEEDBACK_DIR, recs);
|
|
2202
|
+
const diagnostics = aggregateFailureDiagnostics([...entries, ...diagnosticEntries]);
|
|
2203
|
+
const delegation = addDelegationRecs(paths.FEEDBACK_DIR, recs);
|
|
2204
|
+
addDiagnosticRecs(diagnostics, recs);
|
|
2139
2205
|
|
|
2140
2206
|
return normalizeAnalysisShape({
|
|
2141
2207
|
total,
|
|
2142
|
-
totalPositive,
|
|
2143
|
-
totalNegative,
|
|
2208
|
+
totalPositive: positive,
|
|
2209
|
+
totalNegative: negative,
|
|
2144
2210
|
approvalRate,
|
|
2145
2211
|
recentRate,
|
|
2146
2212
|
windows,
|
|
@@ -2148,15 +2214,17 @@ function analyzeFeedback(logPath) {
|
|
|
2148
2214
|
skills,
|
|
2149
2215
|
tags,
|
|
2150
2216
|
rubric: {
|
|
2151
|
-
samples
|
|
2152
|
-
blockedPromotions,
|
|
2153
|
-
failingCriteria:
|
|
2217
|
+
samples,
|
|
2218
|
+
blockedPromotions: blocked,
|
|
2219
|
+
failingCriteria: criteria,
|
|
2154
2220
|
},
|
|
2155
2221
|
diagnostics,
|
|
2156
2222
|
delegation,
|
|
2157
2223
|
boostedRisk,
|
|
2158
|
-
recommendations,
|
|
2159
|
-
actionableRemediations,
|
|
2224
|
+
recommendations: recs.messages,
|
|
2225
|
+
actionableRemediations: recs.remediations,
|
|
2226
|
+
rawTotal,
|
|
2227
|
+
excludedTotal: rawTotal - entries.length,
|
|
2160
2228
|
});
|
|
2161
2229
|
}
|
|
2162
2230
|
|
|
@@ -2184,20 +2252,100 @@ function buildPreventionRules(minOccurrences = 2, options = {}) {
|
|
|
2184
2252
|
return Math.exp(-lambda * daysSince);
|
|
2185
2253
|
}
|
|
2186
2254
|
|
|
2255
|
+
// CEO contract tags beat generic richContext.domain=general so honesty/overclaim
|
|
2256
|
+
// whatToChange is not drowned by hook noise in the "general" bucket.
|
|
2257
|
+
const PRIORITY_DOMAIN_TAGS = [
|
|
2258
|
+
'honesty',
|
|
2259
|
+
'overclaim',
|
|
2260
|
+
'completion-claim',
|
|
2261
|
+
'production-truth',
|
|
2262
|
+
'false-completion',
|
|
2263
|
+
'pr-hygiene',
|
|
2264
|
+
'ceo-feedback',
|
|
2265
|
+
];
|
|
2266
|
+
const GENERIC_TAGS = new Set([
|
|
2267
|
+
'feedback',
|
|
2268
|
+
'negative',
|
|
2269
|
+
'positive',
|
|
2270
|
+
'entity:Customer',
|
|
2271
|
+
'thumbs-down',
|
|
2272
|
+
'thumbs-up',
|
|
2273
|
+
]);
|
|
2274
|
+
|
|
2275
|
+
function isNoiseTitle(title) {
|
|
2276
|
+
const t = String(title || '');
|
|
2277
|
+
return /hookEventName|user_prompt_submit|"sessionId"/i.test(t);
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
function extractAvoidLine(content) {
|
|
2281
|
+
return String(content || '')
|
|
2282
|
+
.split('\n')
|
|
2283
|
+
.find((l) => l.toLowerCase().startsWith('how to avoid:')) || null;
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
function domainKeyForMemory(m) {
|
|
2287
|
+
const tags = Array.isArray(m.tags) ? m.tags : [];
|
|
2288
|
+
for (const tag of PRIORITY_DOMAIN_TAGS) {
|
|
2289
|
+
if (tags.includes(tag)) return tag;
|
|
2290
|
+
}
|
|
2291
|
+
const rcDomain = m.richContext && m.richContext.domain;
|
|
2292
|
+
if (rcDomain && rcDomain !== 'unknown' && rcDomain !== 'general') {
|
|
2293
|
+
return rcDomain;
|
|
2294
|
+
}
|
|
2295
|
+
return tags.find((t) => !GENERIC_TAGS.has(t)) || 'general';
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
function pickBestMemory(items) {
|
|
2299
|
+
let best = items[items.length - 1];
|
|
2300
|
+
let bestScore = -Infinity;
|
|
2301
|
+
items.forEach((m, index) => {
|
|
2302
|
+
let score = index;
|
|
2303
|
+
if (isNoiseTitle(m.title)) score -= 1000;
|
|
2304
|
+
const avoid = extractAvoidLine(m.content);
|
|
2305
|
+
if (avoid) score += 100;
|
|
2306
|
+
if (avoid && /never/i.test(avoid)) score += 50;
|
|
2307
|
+
if ((m.tags || []).some((t) => PRIORITY_DOMAIN_TAGS.includes(t))) score += 40;
|
|
2308
|
+
if ((m.occurrences || 1) > 1) score += 10;
|
|
2309
|
+
if (score >= bestScore) {
|
|
2310
|
+
bestScore = score;
|
|
2311
|
+
best = m;
|
|
2312
|
+
}
|
|
2313
|
+
});
|
|
2314
|
+
return best;
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2187
2317
|
const buckets = {};
|
|
2188
2318
|
const rubricBuckets = {};
|
|
2189
2319
|
const diagnosisBuckets = {};
|
|
2190
2320
|
const repeatedViolationBuckets = {};
|
|
2321
|
+
const priorityContracts = [];
|
|
2191
2322
|
for (const m of memories) {
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2323
|
+
if (isNoiseTitle(m.title) && !extractAvoidLine(m.content)) {
|
|
2324
|
+
// Skip pure hook-noise shells with no actionable avoid line.
|
|
2325
|
+
continue;
|
|
2326
|
+
}
|
|
2327
|
+
const key = domainKeyForMemory(m);
|
|
2195
2328
|
if (!buckets[key]) buckets[key] = { items: [], weightedCount: 0 };
|
|
2196
2329
|
const w = decayWeight(m);
|
|
2197
2330
|
const occ = m.occurrences || 1;
|
|
2198
2331
|
buckets[key].items.push(m);
|
|
2199
2332
|
buckets[key].weightedCount += w * occ;
|
|
2200
2333
|
|
|
2334
|
+
const tags = Array.isArray(m.tags) ? m.tags : [];
|
|
2335
|
+
const avoid = extractAvoidLine(m.content);
|
|
2336
|
+
if (
|
|
2337
|
+
avoid
|
|
2338
|
+
&& tags.some((t) => PRIORITY_DOMAIN_TAGS.includes(t))
|
|
2339
|
+
) {
|
|
2340
|
+
priorityContracts.push({
|
|
2341
|
+
id: m.id,
|
|
2342
|
+
tags: tags.filter((t) => PRIORITY_DOMAIN_TAGS.includes(t)),
|
|
2343
|
+
rule: avoid.replace(/^How to avoid:\s*/i, ''),
|
|
2344
|
+
title: m.title,
|
|
2345
|
+
occurrences: occ,
|
|
2346
|
+
});
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2201
2349
|
const failed = m.rubricSummary && Array.isArray(m.rubricSummary.failingCriteria)
|
|
2202
2350
|
? m.rubricSummary.failingCriteria
|
|
2203
2351
|
: [];
|
|
@@ -2235,18 +2383,37 @@ function buildPreventionRules(minOccurrences = 2, options = {}) {
|
|
|
2235
2383
|
|
|
2236
2384
|
const lines = ['# Prevention Rules', '', 'Generated from negative feedback memories (time-weighted, half-life: ' + decayHalfLifeDays + 'd).'];
|
|
2237
2385
|
|
|
2386
|
+
// High-priority CEO contracts: always emit actionable whatToChange (threshold 1).
|
|
2387
|
+
if (priorityContracts.length > 0) {
|
|
2388
|
+
lines.push('');
|
|
2389
|
+
lines.push('## High-Priority Contracts');
|
|
2390
|
+
const seen = new Set();
|
|
2391
|
+
priorityContracts
|
|
2392
|
+
.sort((a, b) => (b.occurrences || 1) - (a.occurrences || 1))
|
|
2393
|
+
.forEach((c) => {
|
|
2394
|
+
const dedupe = c.rule.slice(0, 120);
|
|
2395
|
+
if (seen.has(dedupe)) return;
|
|
2396
|
+
seen.add(dedupe);
|
|
2397
|
+
lines.push(`- **[${c.tags.join(', ')}]** ${c.rule}`);
|
|
2398
|
+
lines.push(` - Source: ${c.title}`);
|
|
2399
|
+
});
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2238
2402
|
Object.entries(buckets)
|
|
2239
2403
|
.sort((a, b) => b[1].weightedCount - a[1].weightedCount)
|
|
2240
2404
|
.forEach(([domain, { items, weightedCount }]) => {
|
|
2241
2405
|
const effectiveOccurrences = Math.round(weightedCount);
|
|
2242
|
-
|
|
2243
|
-
const
|
|
2244
|
-
|
|
2406
|
+
// Priority domains promote with a single solid memory (CEO contracts).
|
|
2407
|
+
const threshold = PRIORITY_DOMAIN_TAGS.includes(domain) ? 1 : resolvedMinOccurrences;
|
|
2408
|
+
if (effectiveOccurrences < threshold) return;
|
|
2409
|
+
const best = pickBestMemory(items);
|
|
2410
|
+
const avoid = extractAvoidLine(best.content)
|
|
2411
|
+
|| 'How to avoid: Investigate and prevent recurrence';
|
|
2245
2412
|
lines.push('');
|
|
2246
2413
|
lines.push(`## ${domain}`);
|
|
2247
2414
|
lines.push(`- Recurrence count: ${items.length} (weighted: ${weightedCount.toFixed(1)})`);
|
|
2248
2415
|
lines.push(`- Rule: ${avoid.replace(/^How to avoid:\s*/i, '')}`);
|
|
2249
|
-
lines.push(`- Latest mistake: ${
|
|
2416
|
+
lines.push(`- Latest mistake: ${best.title}`);
|
|
2250
2417
|
});
|
|
2251
2418
|
|
|
2252
2419
|
const rubricEntries = Object.entries(rubricBuckets)
|
|
@@ -2313,7 +2480,10 @@ function writePreventionRules(filePath, minOccurrences = 2) {
|
|
|
2313
2480
|
|
|
2314
2481
|
function feedbackSummary(recentN = 20, options = {}) {
|
|
2315
2482
|
const { FEEDBACK_LOG_PATH } = getFeedbackPaths(options);
|
|
2316
|
-
|
|
2483
|
+
let entries = readJSONL(FEEDBACK_LOG_PATH);
|
|
2484
|
+
if (options.humanOnly) {
|
|
2485
|
+
entries = entries.filter((entry) => normalizeReviewOrigin(entry.reviewOrigin) === 'human');
|
|
2486
|
+
}
|
|
2317
2487
|
if (entries.length === 0) {
|
|
2318
2488
|
return '## Feedback Summary\nNo feedback recorded yet.';
|
|
2319
2489
|
}
|
|
@@ -2323,7 +2493,7 @@ function feedbackSummary(recentN = 20, options = {}) {
|
|
|
2323
2493
|
const negative = recent.filter((e) => e.signal === 'negative').length;
|
|
2324
2494
|
const pct = Math.round((positive / recent.length) * 100);
|
|
2325
2495
|
|
|
2326
|
-
const analysis = analyzeFeedback(FEEDBACK_LOG_PATH);
|
|
2496
|
+
const analysis = analyzeFeedback(FEEDBACK_LOG_PATH, { humanOnly: options.humanOnly });
|
|
2327
2497
|
|
|
2328
2498
|
const lines = [
|
|
2329
2499
|
`## Feedback Summary (last ${recent.length})`,
|
|
@@ -2396,7 +2566,7 @@ function runCli() {
|
|
|
2396
2566
|
}
|
|
2397
2567
|
|
|
2398
2568
|
if (args.summary) {
|
|
2399
|
-
console.log(feedbackSummary(Number(args.recent || 20)));
|
|
2569
|
+
console.log(feedbackSummary(Number(args.recent || 20), { humanOnly: true }));
|
|
2400
2570
|
return;
|
|
2401
2571
|
}
|
|
2402
2572
|
|