@grainulation/harvest 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/harvest.js +284 -0
- package/lib/analyzer.js +88 -0
- package/lib/calibration.js +153 -0
- package/lib/dashboard.js +126 -0
- package/lib/decay.js +124 -0
- package/lib/farmer.js +107 -0
- package/lib/patterns.js +199 -0
- package/lib/report.js +125 -0
- package/lib/server.js +494 -0
- package/lib/templates.js +80 -0
- package/lib/velocity.js +177 -0
- package/package.json +51 -0
- package/public/index.html +982 -0
- package/templates/dashboard.html +1230 -0
- package/templates/retrospective.html +315 -0
package/lib/dashboard.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Slim a claims array for dashboard embedding (compact keys).
|
|
8
|
+
*/
|
|
9
|
+
function slim(claims) {
|
|
10
|
+
return claims.map(c => ({
|
|
11
|
+
i: c.id, t: c.type, tp: c.topic,
|
|
12
|
+
c: c.content || c.text || c.claim || c.description || '',
|
|
13
|
+
e: c.evidence, s: c.status,
|
|
14
|
+
p: c.phase_added, ts: c.timestamp || c.created || c.date,
|
|
15
|
+
cf: (c.conflicts_with || []).length > 0 ? c.conflicts_with : undefined,
|
|
16
|
+
r: c.resolved_by || undefined,
|
|
17
|
+
tg: (c.tags || []).length > 0 ? c.tags : undefined
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Load a claims.json file and return parsed data, or null on failure.
|
|
23
|
+
*/
|
|
24
|
+
function loadClaims(filePath) {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Scan for claims.json files in target directory, archive/, and sprints/ subdirs.
|
|
34
|
+
* Two levels deep to handle structures like root/sprints/<name>/claims.json.
|
|
35
|
+
*/
|
|
36
|
+
function findSprintFiles(targetDir) {
|
|
37
|
+
const found = [];
|
|
38
|
+
|
|
39
|
+
// Direct claims.json in target dir
|
|
40
|
+
const direct = path.join(targetDir, 'claims.json');
|
|
41
|
+
if (fs.existsSync(direct)) {
|
|
42
|
+
found.push({ file: direct, name: path.basename(targetDir), cat: 'root' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Archive subdir (flat JSON files)
|
|
46
|
+
const archiveDir = path.join(targetDir, 'archive');
|
|
47
|
+
if (fs.existsSync(archiveDir) && fs.statSync(archiveDir).isDirectory()) {
|
|
48
|
+
for (const f of fs.readdirSync(archiveDir)) {
|
|
49
|
+
if (f.endsWith('.json') && f.includes('claims')) {
|
|
50
|
+
found.push({ file: path.join(archiveDir, f), name: f.replace('.json', '').replace(/-/g, ' '), cat: 'archive' });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Scan subdirectories (two levels: sprints/<name>/claims.json, examples/<name>/claims.json, etc.)
|
|
56
|
+
try {
|
|
57
|
+
const entries = fs.readdirSync(targetDir, { withFileTypes: true });
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
if (!entry.isDirectory()) continue;
|
|
60
|
+
if (entry.name.startsWith('.') || entry.name === 'archive' || entry.name === 'node_modules') continue;
|
|
61
|
+
const childDir = path.join(targetDir, entry.name);
|
|
62
|
+
const childClaims = path.join(childDir, 'claims.json');
|
|
63
|
+
if (fs.existsSync(childClaims)) {
|
|
64
|
+
found.push({ file: childClaims, name: entry.name, cat: 'active' });
|
|
65
|
+
}
|
|
66
|
+
// Second level
|
|
67
|
+
try {
|
|
68
|
+
const subEntries = fs.readdirSync(childDir, { withFileTypes: true });
|
|
69
|
+
for (const sub of subEntries) {
|
|
70
|
+
if (!sub.isDirectory()) continue;
|
|
71
|
+
if (sub.name.startsWith('.')) continue;
|
|
72
|
+
const subClaims = path.join(childDir, sub.name, 'claims.json');
|
|
73
|
+
if (fs.existsSync(subClaims)) {
|
|
74
|
+
found.push({ file: subClaims, name: sub.name, cat: 'active' });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch { /* skip */ }
|
|
78
|
+
}
|
|
79
|
+
} catch { /* skip */ }
|
|
80
|
+
|
|
81
|
+
return found;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Load all sprint data from a target directory.
|
|
86
|
+
* Returns array of { n, p, q, cat, c } sprint objects ready for template embedding.
|
|
87
|
+
*/
|
|
88
|
+
function loadSprints(targetDir) {
|
|
89
|
+
const sources = findSprintFiles(targetDir);
|
|
90
|
+
const sprints = [];
|
|
91
|
+
for (const src of sources) {
|
|
92
|
+
const data = loadClaims(src.file);
|
|
93
|
+
if (!data) continue;
|
|
94
|
+
const claims = Array.isArray(data) ? data : data.claims || [];
|
|
95
|
+
if (claims.length === 0) continue;
|
|
96
|
+
sprints.push({
|
|
97
|
+
n: src.name,
|
|
98
|
+
p: data.meta?.phase || 'unknown',
|
|
99
|
+
q: data.meta?.question || '',
|
|
100
|
+
cat: src.cat || 'active',
|
|
101
|
+
c: slim(claims)
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return sprints;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build the dashboard HTML string from a sprints array.
|
|
109
|
+
* @param {Array} sprints - Array of { n, p, q, cat, c } sprint objects
|
|
110
|
+
* @returns {string} Complete HTML string
|
|
111
|
+
*/
|
|
112
|
+
function buildHtml(sprints) {
|
|
113
|
+
const templatePath = path.join(__dirname, '..', 'templates', 'dashboard.html');
|
|
114
|
+
const template = fs.readFileSync(templatePath, 'utf8');
|
|
115
|
+
const jsonData = JSON.stringify(sprints).replace(/<\/script/gi, '<\\/script');
|
|
116
|
+
return template.replace('__SPRINT_DATA__', jsonData);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Return paths to all claims.json files for watching.
|
|
121
|
+
*/
|
|
122
|
+
function claimsPaths(targetDir) {
|
|
123
|
+
return findSprintFiles(targetDir).map(s => s.file);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { loadSprints, buildHtml, claimsPaths, findSprintFiles, slim };
|
package/lib/decay.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Knowledge freshness tracking.
|
|
5
|
+
*
|
|
6
|
+
* Identifies claims that may have become stale:
|
|
7
|
+
* - Claims older than a threshold with no revalidation
|
|
8
|
+
* - Claims referencing external tools/APIs that change frequently
|
|
9
|
+
* - Claims with evidence tier "web" that are old (web content changes)
|
|
10
|
+
* - Claims that were challenged but never resolved
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const VOLATILE_EVIDENCE = new Set(['stated', 'web']);
|
|
14
|
+
const DEFAULT_THRESHOLD_DAYS = 90;
|
|
15
|
+
|
|
16
|
+
function checkDecay(sprints, opts = {}) {
|
|
17
|
+
const thresholdDays = opts.thresholdDays || DEFAULT_THRESHOLD_DAYS;
|
|
18
|
+
const now = new Date();
|
|
19
|
+
|
|
20
|
+
const allClaims = sprints.flatMap(s => s.claims.map(c => ({ ...c, _sprint: s.name })));
|
|
21
|
+
|
|
22
|
+
const decaying = [];
|
|
23
|
+
const stale = [];
|
|
24
|
+
const unresolved = [];
|
|
25
|
+
|
|
26
|
+
for (const claim of allClaims) {
|
|
27
|
+
const created = claim.created || claim.date || claim.timestamp;
|
|
28
|
+
const age = created ? daysBetween(new Date(created), now) : null;
|
|
29
|
+
|
|
30
|
+
// Stale: old claims with volatile evidence
|
|
31
|
+
if (age !== null && age > thresholdDays && VOLATILE_EVIDENCE.has(claim.evidence)) {
|
|
32
|
+
stale.push({
|
|
33
|
+
id: claim.id,
|
|
34
|
+
sprint: claim._sprint,
|
|
35
|
+
type: claim.type,
|
|
36
|
+
evidence: claim.evidence,
|
|
37
|
+
ageDays: age,
|
|
38
|
+
text: truncate(claim.text || claim.claim || claim.description, 120),
|
|
39
|
+
reason: `${claim.evidence}-tier evidence is ${age} days old (threshold: ${thresholdDays}).`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Decaying: any claim past threshold, regardless of evidence
|
|
44
|
+
if (age !== null && age > thresholdDays * 1.5) {
|
|
45
|
+
decaying.push({
|
|
46
|
+
id: claim.id,
|
|
47
|
+
sprint: claim._sprint,
|
|
48
|
+
type: claim.type,
|
|
49
|
+
evidence: claim.evidence,
|
|
50
|
+
ageDays: age,
|
|
51
|
+
text: truncate(claim.text || claim.claim || claim.description, 120),
|
|
52
|
+
reason: `Claim is ${age} days old with no revalidation.`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Unresolved: challenged claims still marked as contested
|
|
57
|
+
if (claim.status === 'contested' || claim.status === 'challenged') {
|
|
58
|
+
unresolved.push({
|
|
59
|
+
id: claim.id,
|
|
60
|
+
sprint: claim._sprint,
|
|
61
|
+
type: claim.type,
|
|
62
|
+
text: truncate(claim.text || claim.claim || claim.description, 120),
|
|
63
|
+
reason: 'Claim was challenged but never resolved.',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Deduplicate (a claim might appear in both stale and decaying)
|
|
69
|
+
const decayingIds = new Set(decaying.map(c => c.id));
|
|
70
|
+
const dedupedStale = stale.filter(c => !decayingIds.has(c.id));
|
|
71
|
+
|
|
72
|
+
// Sort by age descending
|
|
73
|
+
const sortByAge = (a, b) => (b.ageDays || 0) - (a.ageDays || 0);
|
|
74
|
+
decaying.sort(sortByAge);
|
|
75
|
+
dedupedStale.sort(sortByAge);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
summary: {
|
|
79
|
+
totalClaims: allClaims.length,
|
|
80
|
+
staleCount: dedupedStale.length,
|
|
81
|
+
decayingCount: decaying.length,
|
|
82
|
+
unresolvedCount: unresolved.length,
|
|
83
|
+
thresholdDays,
|
|
84
|
+
},
|
|
85
|
+
stale: dedupedStale,
|
|
86
|
+
decaying,
|
|
87
|
+
unresolved,
|
|
88
|
+
insight: generateDecayInsight(allClaims.length, dedupedStale, decaying, unresolved),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function daysBetween(a, b) {
|
|
93
|
+
return Math.floor(Math.abs(b - a) / (1000 * 60 * 60 * 24));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function truncate(str, maxLen) {
|
|
97
|
+
if (!str) return '';
|
|
98
|
+
return str.length > maxLen ? str.slice(0, maxLen - 3) + '...' : str;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function generateDecayInsight(total, stale, decaying, unresolved) {
|
|
102
|
+
const parts = [];
|
|
103
|
+
|
|
104
|
+
if (decaying.length > 0) {
|
|
105
|
+
parts.push(`${decaying.length} claim(s) are significantly outdated and should be revalidated or archived.`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (stale.length > 0) {
|
|
109
|
+
parts.push(`${stale.length} claim(s) have volatile evidence (stated/web) that may no longer be accurate.`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (unresolved.length > 0) {
|
|
113
|
+
parts.push(`${unresolved.length} challenged claim(s) remain unresolved -- use /resolve to settle them.`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const decayRate = total > 0 ? Math.round((stale.length + decaying.length) / total * 100) : 0;
|
|
117
|
+
if (decayRate > 30) {
|
|
118
|
+
parts.push(`Knowledge decay rate is ${decayRate}% -- consider a refresh sprint.`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return parts.length > 0 ? parts.join(' ') : 'Knowledge base looks fresh -- no decay detected.';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { checkDecay };
|
package/lib/farmer.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const http = require('node:http');
|
|
6
|
+
const https = require('node:https');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* POST an activity event to farmer.
|
|
10
|
+
* Graceful failure -- catch and warn, never crash.
|
|
11
|
+
* @param {string} farmerUrl - Base URL of farmer (e.g. http://localhost:9090)
|
|
12
|
+
* @param {object} event - Event object (e.g. { type: "analyze", data: {...} })
|
|
13
|
+
*/
|
|
14
|
+
function notify(farmerUrl, event) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
try {
|
|
17
|
+
const payload = JSON.stringify({
|
|
18
|
+
tool: 'harvest',
|
|
19
|
+
event,
|
|
20
|
+
timestamp: new Date().toISOString()
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const url = new URL(`${farmerUrl}/hooks/activity`);
|
|
24
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
25
|
+
|
|
26
|
+
const req = transport.request({
|
|
27
|
+
hostname: url.hostname,
|
|
28
|
+
port: url.port,
|
|
29
|
+
path: url.pathname,
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: {
|
|
32
|
+
'Content-Type': 'application/json',
|
|
33
|
+
'Content-Length': Buffer.byteLength(payload)
|
|
34
|
+
},
|
|
35
|
+
timeout: 5000
|
|
36
|
+
}, (res) => {
|
|
37
|
+
let body = '';
|
|
38
|
+
res.on('data', chunk => { body += chunk; });
|
|
39
|
+
res.on('end', () => resolve({ ok: res.statusCode < 400, status: res.statusCode, body }));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
req.on('error', (err) => {
|
|
43
|
+
console.error(`[harvest] farmer notify failed: ${err.message}`);
|
|
44
|
+
resolve({ ok: false, error: err.message });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
req.on('timeout', () => {
|
|
48
|
+
req.destroy();
|
|
49
|
+
console.error('[harvest] farmer notify timed out');
|
|
50
|
+
resolve({ ok: false, error: 'timeout' });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
req.write(payload);
|
|
54
|
+
req.end();
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.error(`[harvest] farmer notify failed: ${err.message}`);
|
|
57
|
+
resolve({ ok: false, error: err.message });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* CLI handler for `harvest connect farmer`.
|
|
64
|
+
* Reads/writes .farmer.json in targetDir.
|
|
65
|
+
* @param {string} targetDir - Working directory
|
|
66
|
+
* @param {string[]} args - CLI arguments (e.g. ["farmer", "--url", "http://..."])
|
|
67
|
+
*/
|
|
68
|
+
async function connect(targetDir, args) {
|
|
69
|
+
const subcommand = args[0];
|
|
70
|
+
if (subcommand !== 'farmer') {
|
|
71
|
+
console.error('Usage: harvest connect farmer [--url http://localhost:9090]');
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const configPath = path.join(targetDir, '.farmer.json');
|
|
76
|
+
|
|
77
|
+
const urlIdx = args.indexOf('--url');
|
|
78
|
+
if (urlIdx !== -1 && args[urlIdx + 1]) {
|
|
79
|
+
const url = args[urlIdx + 1];
|
|
80
|
+
const config = { url };
|
|
81
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
82
|
+
console.log(`Farmer connection saved to ${configPath}`);
|
|
83
|
+
console.log(` URL: ${url}`);
|
|
84
|
+
|
|
85
|
+
// Test the connection
|
|
86
|
+
const result = await notify(url, { type: 'connect', data: { tool: 'harvest' } });
|
|
87
|
+
if (result.ok) {
|
|
88
|
+
console.log(' Connection test: OK');
|
|
89
|
+
} else {
|
|
90
|
+
console.log(` Connection test: failed (${result.error || 'status ' + result.status})`);
|
|
91
|
+
console.log(' Farmer may not be running. The URL is saved and will be used when farmer is available.');
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Show current config
|
|
97
|
+
if (fs.existsSync(configPath)) {
|
|
98
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
99
|
+
console.log(`Farmer connection: ${config.url}`);
|
|
100
|
+
console.log(`Config: ${configPath}`);
|
|
101
|
+
} else {
|
|
102
|
+
console.log('No farmer connection configured.');
|
|
103
|
+
console.log('Usage: harvest connect farmer --url http://localhost:9090');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { connect, notify };
|
package/lib/patterns.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Decision pattern detection.
|
|
5
|
+
*
|
|
6
|
+
* Analyzes what research approaches lead to better outcomes by examining:
|
|
7
|
+
* - Which command sequences produce the most well-evidenced claims
|
|
8
|
+
* - Whether prototyping before recommending leads to better accuracy
|
|
9
|
+
* - Whether challenge/witness steps improve final outcomes
|
|
10
|
+
* - Common anti-patterns (e.g., jumping to recommendations without research)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const EVIDENCE_RANK = {
|
|
14
|
+
stated: 1,
|
|
15
|
+
web: 2,
|
|
16
|
+
documented: 3,
|
|
17
|
+
tested: 4,
|
|
18
|
+
production: 5,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function detectPatterns(sprints) {
|
|
22
|
+
const patterns = [];
|
|
23
|
+
const antiPatterns = [];
|
|
24
|
+
|
|
25
|
+
for (const sprint of sprints) {
|
|
26
|
+
const claims = sprint.claims;
|
|
27
|
+
if (claims.length === 0) continue;
|
|
28
|
+
|
|
29
|
+
const phases = extractPhases(claims);
|
|
30
|
+
const avgEvidence = averageEvidenceLevel(claims);
|
|
31
|
+
const hasPrototype = claims.some(c => c.id && c.id.startsWith('p'));
|
|
32
|
+
const hasChallenge = claims.some(c => c.id && c.id.startsWith('x'));
|
|
33
|
+
const hasWitness = claims.some(c => c.id && c.id.startsWith('w'));
|
|
34
|
+
const recommendations = claims.filter(c => c.type === 'recommendation');
|
|
35
|
+
const estimates = claims.filter(c => c.type === 'estimate');
|
|
36
|
+
|
|
37
|
+
// Pattern: prototype-before-recommend
|
|
38
|
+
if (hasPrototype && recommendations.length > 0) {
|
|
39
|
+
const protoIndices = claims
|
|
40
|
+
.map((c, i) => c.id && c.id.startsWith('p') ? i : -1)
|
|
41
|
+
.filter(i => i >= 0);
|
|
42
|
+
const recIndices = claims
|
|
43
|
+
.map((c, i) => c.type === 'recommendation' ? i : -1)
|
|
44
|
+
.filter(i => i >= 0);
|
|
45
|
+
|
|
46
|
+
const prototypedFirst = protoIndices.some(pi =>
|
|
47
|
+
recIndices.some(ri => pi < ri)
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (prototypedFirst) {
|
|
51
|
+
patterns.push({
|
|
52
|
+
sprint: sprint.name,
|
|
53
|
+
pattern: 'prototype-before-recommend',
|
|
54
|
+
description: 'Prototyped before making recommendations -- tends to produce higher-evidence claims.',
|
|
55
|
+
evidenceLevel: avgEvidence,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Pattern: adversarial testing
|
|
61
|
+
if (hasChallenge) {
|
|
62
|
+
patterns.push({
|
|
63
|
+
sprint: sprint.name,
|
|
64
|
+
pattern: 'adversarial-testing',
|
|
65
|
+
description: 'Used /challenge to stress-test claims -- builds confidence in findings.',
|
|
66
|
+
claimsChallenged: claims.filter(c => c.id && c.id.startsWith('x')).length,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Pattern: external corroboration
|
|
71
|
+
if (hasWitness) {
|
|
72
|
+
patterns.push({
|
|
73
|
+
sprint: sprint.name,
|
|
74
|
+
pattern: 'external-corroboration',
|
|
75
|
+
description: 'Used /witness to corroborate claims with external sources.',
|
|
76
|
+
witnessCount: claims.filter(c => c.id && c.id.startsWith('w')).length,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Anti-pattern: recommend without research
|
|
81
|
+
const researchClaims = claims.filter(c => c.id && c.id.startsWith('r'));
|
|
82
|
+
if (recommendations.length > 0 && researchClaims.length === 0) {
|
|
83
|
+
antiPatterns.push({
|
|
84
|
+
sprint: sprint.name,
|
|
85
|
+
pattern: 'recommend-without-research',
|
|
86
|
+
description: 'Recommendations made without dedicated research claims.',
|
|
87
|
+
severity: 'high',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Anti-pattern: estimate without evidence
|
|
92
|
+
const weakEstimates = estimates.filter(c =>
|
|
93
|
+
(EVIDENCE_RANK[c.evidence] || 0) <= 2
|
|
94
|
+
);
|
|
95
|
+
if (weakEstimates.length > estimates.length * 0.5 && estimates.length > 0) {
|
|
96
|
+
antiPatterns.push({
|
|
97
|
+
sprint: sprint.name,
|
|
98
|
+
pattern: 'weak-estimates',
|
|
99
|
+
description: `${weakEstimates.length}/${estimates.length} estimates have weak evidence (stated/web only).`,
|
|
100
|
+
severity: 'medium',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Anti-pattern: type monoculture
|
|
105
|
+
const typeCounts = {};
|
|
106
|
+
for (const c of claims) {
|
|
107
|
+
typeCounts[c.type || 'unknown'] = (typeCounts[c.type || 'unknown'] || 0) + 1;
|
|
108
|
+
}
|
|
109
|
+
const maxType = Object.entries(typeCounts).sort((a, b) => b[1] - a[1])[0];
|
|
110
|
+
if (maxType && maxType[1] / claims.length > 0.7 && claims.length > 5) {
|
|
111
|
+
antiPatterns.push({
|
|
112
|
+
sprint: sprint.name,
|
|
113
|
+
pattern: 'type-monoculture',
|
|
114
|
+
description: `${Math.round(maxType[1] / claims.length * 100)}% of claims are "${maxType[0]}" -- diversity of analysis may be lacking.`,
|
|
115
|
+
severity: 'low',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Cross-sprint patterns
|
|
121
|
+
const crossSprint = analyzeCrossSprintTrends(sprints, patterns);
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
summary: {
|
|
125
|
+
totalSprints: sprints.length,
|
|
126
|
+
patternsFound: patterns.length,
|
|
127
|
+
antiPatternsFound: antiPatterns.length,
|
|
128
|
+
},
|
|
129
|
+
patterns,
|
|
130
|
+
antiPatterns,
|
|
131
|
+
crossSprint,
|
|
132
|
+
insight: generatePatternInsight(patterns, antiPatterns, crossSprint),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function extractPhases(claims) {
|
|
137
|
+
const phases = new Set();
|
|
138
|
+
for (const c of claims) {
|
|
139
|
+
if (!c.id) continue;
|
|
140
|
+
const prefix = c.id.replace(/\d+$/, '');
|
|
141
|
+
phases.add(prefix);
|
|
142
|
+
}
|
|
143
|
+
return [...phases];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function averageEvidenceLevel(claims) {
|
|
147
|
+
const levels = claims.map(c => EVIDENCE_RANK[c.evidence] || 0).filter(l => l > 0);
|
|
148
|
+
if (levels.length === 0) return 0;
|
|
149
|
+
return Math.round(levels.reduce((a, b) => a + b, 0) / levels.length * 10) / 10;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function analyzeCrossSprintTrends(sprints, patterns) {
|
|
153
|
+
const trends = {};
|
|
154
|
+
|
|
155
|
+
// How many sprints use each pattern?
|
|
156
|
+
for (const p of patterns) {
|
|
157
|
+
trends[p.pattern] = trends[p.pattern] || { count: 0, sprints: [] };
|
|
158
|
+
trends[p.pattern].count++;
|
|
159
|
+
trends[p.pattern].sprints.push(p.sprint);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Evidence level trend across sprints (are we getting better?)
|
|
163
|
+
const evidenceTrend = sprints.map(s => ({
|
|
164
|
+
sprint: s.name,
|
|
165
|
+
avgEvidence: averageEvidenceLevel(s.claims),
|
|
166
|
+
claimCount: s.claims.length,
|
|
167
|
+
}));
|
|
168
|
+
|
|
169
|
+
return { patternFrequency: trends, evidenceTrend };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function generatePatternInsight(patterns, antiPatterns, crossSprint) {
|
|
173
|
+
const parts = [];
|
|
174
|
+
|
|
175
|
+
const protoBeforeRec = patterns.filter(p => p.pattern === 'prototype-before-recommend');
|
|
176
|
+
if (protoBeforeRec.length > 0) {
|
|
177
|
+
parts.push(`${protoBeforeRec.length} sprint(s) prototyped before recommending -- good practice.`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const noResearch = antiPatterns.filter(p => p.pattern === 'recommend-without-research');
|
|
181
|
+
if (noResearch.length > 0) {
|
|
182
|
+
parts.push(`${noResearch.length} sprint(s) recommended without dedicated research -- consider adding /research steps.`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const trend = crossSprint.evidenceTrend;
|
|
186
|
+
if (trend.length >= 2) {
|
|
187
|
+
const first = trend[0].avgEvidence;
|
|
188
|
+
const last = trend[trend.length - 1].avgEvidence;
|
|
189
|
+
if (last > first) {
|
|
190
|
+
parts.push('Evidence quality is trending up across sprints.');
|
|
191
|
+
} else if (last < first) {
|
|
192
|
+
parts.push('Evidence quality has declined -- recent sprints may need stronger validation.');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return parts.length > 0 ? parts.join(' ') : 'Not enough sprint data to detect clear patterns.';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = { detectPatterns };
|
package/lib/report.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generate retrospective HTML reports.
|
|
8
|
+
*
|
|
9
|
+
* Combines all analysis modules into a single dark-themed HTML report.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
function generateReport(sprints, fns) {
|
|
13
|
+
const analysis = fns.analyzeFn(sprints);
|
|
14
|
+
const calibration = fns.calibrateFn(sprints);
|
|
15
|
+
const patterns = fns.patternsFn(sprints);
|
|
16
|
+
const decay = fns.decayFn(sprints);
|
|
17
|
+
const velocity = fns.velocityFn(sprints);
|
|
18
|
+
|
|
19
|
+
// Try to load template
|
|
20
|
+
const templatePath = path.join(__dirname, '..', 'templates', 'retrospective.html');
|
|
21
|
+
let template;
|
|
22
|
+
try {
|
|
23
|
+
template = fs.readFileSync(templatePath, 'utf8');
|
|
24
|
+
} catch {
|
|
25
|
+
template = getDefaultTemplate();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Inject data into template
|
|
29
|
+
const html = template
|
|
30
|
+
.replace('{{GENERATED_DATE}}', new Date().toISOString().split('T')[0])
|
|
31
|
+
.replace('{{SPRINT_COUNT}}', String(analysis.summary.totalSprints))
|
|
32
|
+
.replace('{{CLAIM_COUNT}}', String(analysis.summary.totalClaims))
|
|
33
|
+
.replace('{{AVG_CLAIMS}}', String(analysis.summary.averageClaimsPerSprint))
|
|
34
|
+
.replace('{{ACCURACY_RATE}}', calibration.summary.accuracyRate !== null ? calibration.summary.accuracyRate + '%' : 'N/A')
|
|
35
|
+
.replace('{{ESTIMATE_COUNT}}', String(calibration.summary.totalEstimates))
|
|
36
|
+
.replace('{{MATCHED_COUNT}}', String(calibration.summary.matched))
|
|
37
|
+
.replace('{{UNMATCHED_COUNT}}', String(calibration.summary.unmatched))
|
|
38
|
+
.replace('{{CALIBRATION_INSIGHT}}', escapeHtml(calibration.insight))
|
|
39
|
+
.replace('{{PATTERN_COUNT}}', String(patterns.summary.patternsFound))
|
|
40
|
+
.replace('{{ANTI_PATTERN_COUNT}}', String(patterns.summary.antiPatternsFound))
|
|
41
|
+
.replace('{{PATTERN_INSIGHT}}', escapeHtml(patterns.insight))
|
|
42
|
+
.replace('{{PATTERNS_LIST}}', renderPatternsList(patterns))
|
|
43
|
+
.replace('{{ANTI_PATTERNS_LIST}}', renderAntiPatternsList(patterns))
|
|
44
|
+
.replace('{{STALE_COUNT}}', String(decay.summary.staleCount))
|
|
45
|
+
.replace('{{DECAYING_COUNT}}', String(decay.summary.decayingCount))
|
|
46
|
+
.replace('{{UNRESOLVED_COUNT}}', String(decay.summary.unresolvedCount))
|
|
47
|
+
.replace('{{DECAY_INSIGHT}}', escapeHtml(decay.insight))
|
|
48
|
+
.replace('{{AVG_DURATION}}', velocity.summary.avgDurationDays !== null ? velocity.summary.avgDurationDays + ' days' : 'N/A')
|
|
49
|
+
.replace('{{AVG_CLAIMS_PER_DAY}}', velocity.summary.avgClaimsPerDay !== null ? String(velocity.summary.avgClaimsPerDay) : 'N/A')
|
|
50
|
+
.replace('{{TOTAL_STALLS}}', String(velocity.summary.totalStalls))
|
|
51
|
+
.replace('{{VELOCITY_INSIGHT}}', escapeHtml(velocity.insight))
|
|
52
|
+
.replace('{{TYPE_DISTRIBUTION}}', renderDistribution(analysis.typeDistribution))
|
|
53
|
+
.replace('{{EVIDENCE_DISTRIBUTION}}', renderDistribution(analysis.evidenceDistribution))
|
|
54
|
+
.replace('{{WEAK_CLAIMS_TABLE}}', renderWeakClaimsTable(analysis.weakClaims))
|
|
55
|
+
.replace('{{DECAY_TABLE}}', renderDecayTable([...decay.stale, ...decay.decaying]))
|
|
56
|
+
.replace('{{VELOCITY_TABLE}}', renderVelocityTable(velocity.sprints));
|
|
57
|
+
|
|
58
|
+
return html;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function escapeHtml(str) {
|
|
62
|
+
if (!str) return '';
|
|
63
|
+
return str
|
|
64
|
+
.replace(/&/g, '&')
|
|
65
|
+
.replace(/</g, '<')
|
|
66
|
+
.replace(/>/g, '>')
|
|
67
|
+
.replace(/"/g, '"');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function renderDistribution(dist) {
|
|
71
|
+
return Object.entries(dist)
|
|
72
|
+
.sort((a, b) => b[1] - a[1])
|
|
73
|
+
.map(([key, val]) => `<div class="bar-row"><span class="bar-label">${escapeHtml(key)}</span><div class="bar" style="width: ${Math.min(100, val * 5)}%">${val}</div></div>`)
|
|
74
|
+
.join('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function renderPatternsList(patterns) {
|
|
78
|
+
if (patterns.patterns.length === 0) return '<p class="muted">No positive patterns detected yet.</p>';
|
|
79
|
+
return '<ul>' + patterns.patterns.map(p =>
|
|
80
|
+
`<li><strong>${escapeHtml(p.pattern)}</strong> (${escapeHtml(p.sprint)}): ${escapeHtml(p.description)}</li>`
|
|
81
|
+
).join('') + '</ul>';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function renderAntiPatternsList(patterns) {
|
|
85
|
+
if (patterns.antiPatterns.length === 0) return '<p class="muted">No anti-patterns detected.</p>';
|
|
86
|
+
return '<ul>' + patterns.antiPatterns.map(p =>
|
|
87
|
+
`<li class="severity-${p.severity}"><strong>${escapeHtml(p.pattern)}</strong> (${escapeHtml(p.sprint)}): ${escapeHtml(p.description)}</li>`
|
|
88
|
+
).join('') + '</ul>';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function renderWeakClaimsTable(claims) {
|
|
92
|
+
if (claims.length === 0) return '<p class="muted">All claims have solid evidence.</p>';
|
|
93
|
+
const rows = claims.slice(0, 20).map(c =>
|
|
94
|
+
`<tr><td>${escapeHtml(c.id)}</td><td>${escapeHtml(c.sprint)}</td><td>${escapeHtml(c.type)}</td><td>${escapeHtml(c.evidence)}</td><td>${escapeHtml(String(c.text || ''))}</td></tr>`
|
|
95
|
+
).join('');
|
|
96
|
+
return `<table><thead><tr><th>ID</th><th>Sprint</th><th>Type</th><th>Evidence</th><th>Claim</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function renderDecayTable(items) {
|
|
100
|
+
if (items.length === 0) return '<p class="muted">No knowledge decay detected.</p>';
|
|
101
|
+
const rows = items.slice(0, 20).map(c =>
|
|
102
|
+
`<tr><td>${escapeHtml(c.id)}</td><td>${escapeHtml(c.sprint)}</td><td>${c.ageDays || '?'} days</td><td>${escapeHtml(c.reason)}</td></tr>`
|
|
103
|
+
).join('');
|
|
104
|
+
return `<table><thead><tr><th>ID</th><th>Sprint</th><th>Age</th><th>Reason</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function renderVelocityTable(sprintResults) {
|
|
108
|
+
if (sprintResults.length === 0) return '<p class="muted">No velocity data available.</p>';
|
|
109
|
+
const rows = sprintResults.map(s =>
|
|
110
|
+
`<tr><td>${escapeHtml(s.sprint)}</td><td>${s.durationDays ?? 'N/A'}</td><td>${s.totalClaims ?? '?'}</td><td>${s.claimsPerDay ?? 'N/A'}</td><td>${s.stalls.length}</td></tr>`
|
|
111
|
+
).join('');
|
|
112
|
+
return `<table><thead><tr><th>Sprint</th><th>Days</th><th>Claims</th><th>Claims/Day</th><th>Stalls</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getDefaultTemplate() {
|
|
116
|
+
// Fallback inline template if file not found
|
|
117
|
+
return `<!DOCTYPE html>
|
|
118
|
+
<html lang="en"><head><meta charset="utf-8"><title>Harvest Retrospective</title>
|
|
119
|
+
<style>body{background:#0f0f0f;color:#e5e5e5;font-family:system-ui;padding:2rem;max-width:900px;margin:0 auto}
|
|
120
|
+
h1,h2{color:#f97316}.muted{color:#888}</style></head>
|
|
121
|
+
<body><h1>Harvest Retrospective</h1><p>Generated: {{GENERATED_DATE}}</p>
|
|
122
|
+
<p>{{SPRINT_COUNT}} sprints, {{CLAIM_COUNT}} claims</p></body></html>`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { generateReport };
|