@grainulation/harvest 1.0.1 → 1.0.4

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/CONTRIBUTING.md CHANGED
@@ -15,16 +15,20 @@ No `npm install` needed -- harvest has zero dependencies.
15
15
  ## How to contribute
16
16
 
17
17
  ### Report a bug
18
+
18
19
  Open an issue with:
20
+
19
21
  - What you expected
20
22
  - What happened instead
21
23
  - Your Node version (`node --version`)
22
24
  - Steps to reproduce
23
25
 
24
26
  ### Suggest a feature
27
+
25
28
  Open an issue describing the use case, not just the solution. "I need X because Y" is more useful than "add X."
26
29
 
27
30
  ### Submit a PR
31
+
28
32
  1. Fork the repo
29
33
  2. Create a branch (`git checkout -b fix/description`)
30
34
  3. Make your changes
@@ -71,11 +75,13 @@ Tests use Node's built-in test runner. No test framework dependencies.
71
75
  ## Commit messages
72
76
 
73
77
  Follow the existing pattern:
78
+
74
79
  ```
75
80
  harvest: <what changed>
76
81
  ```
77
82
 
78
83
  Examples:
84
+
79
85
  ```
80
86
  harvest: add velocity trend chart
81
87
  harvest: fix decay calculation for stale claims
package/README.md CHANGED
@@ -3,7 +3,8 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <a href="https://www.npmjs.com/package/@grainulation/harvest"><img src="https://img.shields.io/npm/v/@grainulation/harvest" alt="npm version"></a> <a href="https://www.npmjs.com/package/@grainulation/harvest"><img src="https://img.shields.io/npm/dm/@grainulation/harvest" alt="npm downloads"></a> <a href="https://github.com/grainulation/harvest/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/@grainulation/harvest" alt="license"></a> <a href="https://nodejs.org"><img src="https://img.shields.io/node/v/@grainulation/harvest" alt="node"></a> <a href="https://github.com/grainulation/harvest/actions"><img src="https://github.com/grainulation/harvest/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
6
+ <a href="https://www.npmjs.com/package/@grainulation/harvest"><img src="https://img.shields.io/npm/v/@grainulation/harvest?label=%40grainulation%2Fharvest" alt="npm version"></a> <a href="https://www.npmjs.com/package/@grainulation/harvest"><img src="https://img.shields.io/npm/dm/@grainulation/harvest" alt="npm downloads"></a> <a href="https://github.com/grainulation/harvest/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="license"></a> <a href="https://nodejs.org"><img src="https://img.shields.io/node/v/@grainulation/harvest" alt="node"></a> <a href="https://github.com/grainulation/harvest/actions"><img src="https://github.com/grainulation/harvest/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
7
+ <a href="https://deepwiki.com/grainulation/harvest"><img src="https://deepwiki.com/badge.svg" alt="Explore on DeepWiki"></a>
7
8
  </p>
8
9
 
9
10
  <p align="center"><strong>Are your decisions getting better?</strong></p>
@@ -84,16 +85,16 @@ Node built-in modules only.
84
85
 
85
86
  ## Part of the grainulation ecosystem
86
87
 
87
- | Tool | Role |
88
- |------|------|
89
- | [wheat](https://github.com/grainulation/wheat) | Research engine -- grow structured evidence |
90
- | [farmer](https://github.com/grainulation/farmer) | Permission dashboard -- approve AI actions in real time |
91
- | [barn](https://github.com/grainulation/barn) | Shared tools -- templates, validators, sprint detection |
92
- | [mill](https://github.com/grainulation/mill) | Format conversion -- export to PDF, CSV, slides, 24 formats |
93
- | [silo](https://github.com/grainulation/silo) | Knowledge storage -- reusable claim libraries and packs |
94
- | **harvest** | Analytics -- cross-sprint patterns and prediction scoring |
95
- | [orchard](https://github.com/grainulation/orchard) | Orchestration -- multi-sprint coordination and dependencies |
96
- | [grainulation](https://github.com/grainulation/grainulation) | Unified CLI -- single entry point to the ecosystem |
88
+ | Tool | Role |
89
+ | ------------------------------------------------------------ | ----------------------------------------------------------- |
90
+ | [wheat](https://github.com/grainulation/wheat) | Research engine -- grow structured evidence |
91
+ | [farmer](https://github.com/grainulation/farmer) | Permission dashboard -- approve AI actions in real time |
92
+ | [barn](https://github.com/grainulation/barn) | Shared tools -- templates, validators, sprint detection |
93
+ | [mill](https://github.com/grainulation/mill) | Format conversion -- export to PDF, CSV, slides, 24 formats |
94
+ | [silo](https://github.com/grainulation/silo) | Knowledge storage -- reusable claim libraries and packs |
95
+ | **harvest** | Analytics -- cross-sprint patterns and prediction scoring |
96
+ | [orchard](https://github.com/grainulation/orchard) | Orchestration -- multi-sprint coordination and dependencies |
97
+ | [grainulation](https://github.com/grainulation/grainulation) | Unified CLI -- single entry point to the ecosystem |
97
98
 
98
99
  ## License
99
100
 
package/bin/harvest.js CHANGED
@@ -1,23 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- 'use strict';
4
-
5
- const path = require('node:path');
6
- const fs = require('node:fs');
7
-
8
- const { analyze } = require('../lib/analyzer.js');
9
- const { calibrate } = require('../lib/calibration.js');
10
- const { detectPatterns } = require('../lib/patterns.js');
11
- const { checkDecay } = require('../lib/decay.js');
12
- const { measureVelocity } = require('../lib/velocity.js');
13
- const { generateReport } = require('../lib/report.js');
14
- const { connect: farmerConnect } = require('../lib/farmer.js');
15
-
16
- const verbose = process.argv.includes('--verbose') || process.argv.includes('-v');
3
+ "use strict";
4
+
5
+ const path = require("node:path");
6
+ const fs = require("node:fs");
7
+
8
+ const { analyze } = require("../lib/analyzer.js");
9
+ const { calibrate } = require("../lib/calibration.js");
10
+ const { detectPatterns } = require("../lib/patterns.js");
11
+ const { checkDecay, decayAlerts } = require("../lib/decay.js");
12
+ const { measureVelocity } = require("../lib/velocity.js");
13
+ const { generateReport } = require("../lib/report.js");
14
+ const { connect: farmerConnect } = require("../lib/farmer.js");
15
+ const { analyzeTokens } = require("../lib/tokens.js");
16
+ const { trackCosts } = require("../lib/token-tracker.js");
17
+ const { generateWrapped } = require("../lib/wrapped.js");
18
+ const {
19
+ generateCard,
20
+ generateEmbedSnippet,
21
+ } = require("../lib/harvest-card.js");
22
+
23
+ const verbose =
24
+ process.argv.includes("--verbose") || process.argv.includes("-v");
17
25
  function vlog(...a) {
18
26
  if (!verbose) return;
19
27
  const ts = new Date().toISOString();
20
- process.stderr.write(`[${ts}] harvest: ${a.join(' ')}\n`);
28
+ process.stderr.write(`[${ts}] harvest: ${a.join(" ")}\n`);
21
29
  }
22
30
 
23
31
  const USAGE = `
@@ -29,8 +37,11 @@ Usage:
29
37
  harvest patterns <sprints-dir> Detect decision patterns
30
38
  harvest decay <sprints-dir> Find claims that need refreshing
31
39
  harvest velocity <sprints-dir> Sprint timing and phase analysis
40
+ harvest tokens <sprints-dir> Token cost tracking and efficiency
41
+ harvest card <sprints-dir> [-o <output>] Generate Harvest Report SVG card
32
42
  harvest report <sprints-dir> [-o <output>] Generate retrospective HTML
33
43
  harvest trends <sprints-dir> All analyses in one pass
44
+ harvest intelligence <sprints-dir> Full intelligence report (all features)
34
45
  harvest serve [--port 9096] [--root <sprints-dir>] Start the dashboard UI
35
46
  harvest connect farmer [--url <url>] Configure farmer integration
36
47
 
@@ -43,22 +54,29 @@ Options:
43
54
 
44
55
  function parseArgs(argv) {
45
56
  const args = argv.slice(2);
46
- const parsed = { command: null, dir: null, output: null, json: false, days: 90 };
57
+ const parsed = {
58
+ command: null,
59
+ dir: null,
60
+ output: null,
61
+ json: false,
62
+ days: 90,
63
+ };
47
64
 
48
- if (args.length === 0 || args.includes('-h') || args.includes('--help')) {
65
+ if (args.length === 0 || args.includes("-h") || args.includes("--help")) {
49
66
  console.log(USAGE);
50
67
  process.exit(0);
51
68
  }
52
69
 
53
70
  parsed.command = args[0];
54
- parsed.dir = (args[1] && !args[1].startsWith('-')) ? path.resolve(args[1]) : null;
71
+ parsed.dir =
72
+ args[1] && !args[1].startsWith("-") ? path.resolve(args[1]) : null;
55
73
 
56
74
  for (let i = 2; i < args.length; i++) {
57
- if ((args[i] === '-o' || args[i] === '--output') && args[i + 1]) {
75
+ if ((args[i] === "-o" || args[i] === "--output") && args[i + 1]) {
58
76
  parsed.output = path.resolve(args[++i]);
59
- } else if (args[i] === '--json') {
77
+ } else if (args[i] === "--json") {
60
78
  parsed.json = true;
61
- } else if (args[i] === '--days' && args[i + 1]) {
79
+ } else if (args[i] === "--days" && args[i + 1]) {
62
80
  parsed.days = parseInt(args[++i], 10);
63
81
  }
64
82
  }
@@ -75,7 +93,7 @@ function loadSprintData(dir) {
75
93
  const sprints = [];
76
94
 
77
95
  // Include root if it has claims.json
78
- const directClaims = path.join(dir, 'claims.json');
96
+ const directClaims = path.join(dir, "claims.json");
79
97
  if (fs.existsSync(directClaims)) {
80
98
  sprints.push(loadSingleSprint(dir));
81
99
  }
@@ -85,9 +103,9 @@ function loadSprintData(dir) {
85
103
  const entries = fs.readdirSync(dir, { withFileTypes: true });
86
104
  for (const entry of entries) {
87
105
  if (!entry.isDirectory()) continue;
88
- if (entry.name.startsWith('.')) continue;
106
+ if (entry.name.startsWith(".")) continue;
89
107
  const childDir = path.join(dir, entry.name);
90
- const childClaims = path.join(childDir, 'claims.json');
108
+ const childClaims = path.join(childDir, "claims.json");
91
109
  if (fs.existsSync(childClaims)) {
92
110
  sprints.push(loadSingleSprint(childDir));
93
111
  }
@@ -96,20 +114,26 @@ function loadSprintData(dir) {
96
114
  const subEntries = fs.readdirSync(childDir, { withFileTypes: true });
97
115
  for (const sub of subEntries) {
98
116
  if (!sub.isDirectory()) continue;
99
- if (sub.name.startsWith('.')) continue;
117
+ if (sub.name.startsWith(".")) continue;
100
118
  const subDir = path.join(childDir, sub.name);
101
- const subClaims = path.join(subDir, 'claims.json');
119
+ const subClaims = path.join(subDir, "claims.json");
102
120
  if (fs.existsSync(subClaims)) {
103
121
  sprints.push(loadSingleSprint(subDir));
104
122
  }
105
123
  }
106
- } catch { /* skip */ }
124
+ } catch {
125
+ /* skip */
126
+ }
107
127
  }
108
- } catch { /* skip */ }
128
+ } catch {
129
+ /* skip */
130
+ }
109
131
 
110
132
  if (sprints.length === 0) {
111
133
  console.error(`harvest: no sprint data found in ${dir}`);
112
- console.error('Expected claims.json in the directory or its subdirectories.');
134
+ console.error(
135
+ "Expected claims.json in the directory or its subdirectories.",
136
+ );
113
137
  process.exit(1);
114
138
  }
115
139
 
@@ -125,9 +149,9 @@ function loadSingleSprint(dir) {
125
149
  gitLog: null,
126
150
  };
127
151
 
128
- const claimsPath = path.join(dir, 'claims.json');
152
+ const claimsPath = path.join(dir, "claims.json");
129
153
  try {
130
- sprint.claims = JSON.parse(fs.readFileSync(claimsPath, 'utf8'));
154
+ sprint.claims = JSON.parse(fs.readFileSync(claimsPath, "utf8"));
131
155
  if (!Array.isArray(sprint.claims)) {
132
156
  // Handle { claims: [...] } wrapper
133
157
  sprint.claims = sprint.claims.claims || [];
@@ -136,10 +160,10 @@ function loadSingleSprint(dir) {
136
160
  console.error(`harvest: could not parse ${claimsPath}: ${e.message}`);
137
161
  }
138
162
 
139
- const compilationPath = path.join(dir, 'compilation.json');
163
+ const compilationPath = path.join(dir, "compilation.json");
140
164
  if (fs.existsSync(compilationPath)) {
141
165
  try {
142
- sprint.compilation = JSON.parse(fs.readFileSync(compilationPath, 'utf8'));
166
+ sprint.compilation = JSON.parse(fs.readFileSync(compilationPath, "utf8"));
143
167
  } catch (e) {
144
168
  // skip
145
169
  }
@@ -147,14 +171,23 @@ function loadSingleSprint(dir) {
147
171
 
148
172
  // Try to read git log for the sprint directory
149
173
  try {
150
- const { execSync } = require('node:child_process');
174
+ const { execSync } = require("node:child_process");
151
175
  sprint.gitLog = execSync(
152
176
  `git log --oneline --format="%H|%ai|%s" -- claims.json`,
153
- { cwd: dir, encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }
154
- ).trim().split('\n').filter(Boolean).map(line => {
155
- const [hash, date, ...msg] = line.split('|');
156
- return { hash, date, message: msg.join('|') };
157
- });
177
+ {
178
+ cwd: dir,
179
+ encoding: "utf8",
180
+ timeout: 5000,
181
+ stdio: ["pipe", "pipe", "pipe"],
182
+ },
183
+ )
184
+ .trim()
185
+ .split("\n")
186
+ .filter(Boolean)
187
+ .map((line) => {
188
+ const [hash, date, ...msg] = line.split("|");
189
+ return { hash, date, message: msg.join("|") };
190
+ });
158
191
  } catch (e) {
159
192
  sprint.gitLog = [];
160
193
  }
@@ -165,7 +198,7 @@ function loadSingleSprint(dir) {
165
198
  function output(result, opts) {
166
199
  if (opts.json) {
167
200
  console.log(JSON.stringify(result, null, 2));
168
- } else if (typeof result === 'string') {
201
+ } else if (typeof result === "string") {
169
202
  console.log(result);
170
203
  } else {
171
204
  console.log(JSON.stringify(result, null, 2));
@@ -174,7 +207,11 @@ function output(result, opts) {
174
207
 
175
208
  async function main() {
176
209
  const opts = parseArgs(process.argv);
177
- vlog('startup', `command=${opts.command || '(none)'}`, `dir=${opts.dir || 'none'}`);
210
+ vlog(
211
+ "startup",
212
+ `command=${opts.command || "(none)"}`,
213
+ `dir=${opts.dir || "none"}`,
214
+ );
178
215
 
179
216
  const commands = {
180
217
  analyze() {
@@ -210,11 +247,49 @@ async function main() {
210
247
  patternsFn: detectPatterns,
211
248
  decayFn: checkDecay,
212
249
  velocityFn: measureVelocity,
250
+ tokensFn: analyzeTokens,
251
+ wrappedFn: generateWrapped,
213
252
  });
214
- const outPath = opts.output || path.join(process.cwd(), 'retrospective.html');
215
- fs.writeFileSync(outPath, html, 'utf8');
253
+ const outPath =
254
+ opts.output || path.join(process.cwd(), "retrospective.html");
255
+ fs.writeFileSync(outPath, html, "utf8");
216
256
  console.log(`Retrospective written to ${outPath}`);
217
257
  },
258
+ tokens() {
259
+ const sprints = loadSprintData(opts.dir);
260
+ const result = analyzeTokens(sprints);
261
+ output(result, opts);
262
+ },
263
+ card() {
264
+ const sprints = loadSprintData(opts.dir);
265
+ const { svg, stats } = generateCard(sprints);
266
+
267
+ if (opts.json) {
268
+ output(stats, opts);
269
+ return;
270
+ }
271
+
272
+ const outPath =
273
+ opts.output || path.join(process.cwd(), "harvest-card.svg");
274
+ fs.writeFileSync(outPath, svg, "utf8");
275
+ const embed = generateEmbedSnippet(path.basename(outPath));
276
+ console.log(`Harvest card written to ${outPath}`);
277
+ console.log(`\nEmbed in README:\n ${embed.markdown}`);
278
+ console.log(`\nHTML:\n ${embed.html}`);
279
+ },
280
+ intelligence() {
281
+ const sprints = loadSprintData(opts.dir);
282
+ const result = {
283
+ analysis: analyze(sprints),
284
+ calibration: calibrate(sprints),
285
+ patterns: detectPatterns(sprints),
286
+ decay: checkDecay(sprints, { thresholdDays: opts.days }),
287
+ decayAlerts: decayAlerts(sprints),
288
+ velocity: measureVelocity(sprints),
289
+ tokens: analyzeTokens(sprints),
290
+ };
291
+ output(result, opts);
292
+ },
218
293
  trends() {
219
294
  const sprints = loadSprintData(opts.dir);
220
295
  const result = {
@@ -228,47 +303,47 @@ async function main() {
228
303
  },
229
304
  };
230
305
 
231
- if (opts.command === 'help') {
306
+ if (opts.command === "help") {
232
307
  console.log(USAGE);
233
308
  process.exit(0);
234
309
  }
235
310
 
236
- if (opts.command === 'connect') {
311
+ if (opts.command === "connect") {
237
312
  // Forward remaining args to farmer connect handler
238
- const connectArgs = process.argv.slice(process.argv.indexOf('connect') + 1);
313
+ const connectArgs = process.argv.slice(process.argv.indexOf("connect") + 1);
239
314
  await farmerConnect(opts.dir || process.cwd(), connectArgs);
240
315
  return;
241
316
  }
242
317
 
243
- if (opts.command === 'serve') {
318
+ if (opts.command === "serve") {
244
319
  // Launch the ESM server module
245
- const { execFile } = require('node:child_process');
246
- const serverPath = path.join(__dirname, '..', 'lib', 'server.js');
320
+ const { execFile } = require("node:child_process");
321
+ const serverPath = path.join(__dirname, "..", "lib", "server.js");
247
322
  const serverArgs = [];
248
323
  // Forward --port and --root
249
- const portIdx = process.argv.indexOf('--port');
324
+ const portIdx = process.argv.indexOf("--port");
250
325
  if (portIdx !== -1 && process.argv[portIdx + 1]) {
251
- serverArgs.push('--port', process.argv[portIdx + 1]);
326
+ serverArgs.push("--port", process.argv[portIdx + 1]);
252
327
  }
253
- const rootIdx = process.argv.indexOf('--root');
328
+ const rootIdx = process.argv.indexOf("--root");
254
329
  if (rootIdx !== -1 && process.argv[rootIdx + 1]) {
255
- serverArgs.push('--root', process.argv[rootIdx + 1]);
330
+ serverArgs.push("--root", process.argv[rootIdx + 1]);
256
331
  } else if (opts.dir) {
257
- serverArgs.push('--root', opts.dir);
332
+ serverArgs.push("--root", opts.dir);
258
333
  }
259
- const child = execFile('node', [serverPath, ...serverArgs], {
260
- stdio: 'inherit',
334
+ const child = execFile("node", [serverPath, ...serverArgs], {
335
+ stdio: "inherit",
261
336
  env: process.env,
262
337
  });
263
338
  child.stdout && child.stdout.pipe(process.stdout);
264
339
  child.stderr && child.stderr.pipe(process.stderr);
265
- child.on('error', (err) => {
340
+ child.on("error", (err) => {
266
341
  console.error(`harvest: error starting server: ${err.message}`);
267
342
  process.exit(1);
268
343
  });
269
- child.on('exit', (code) => process.exit(code || 0));
270
- process.on('SIGTERM', () => child.kill('SIGTERM'));
271
- process.on('SIGINT', () => child.kill('SIGINT'));
344
+ child.on("exit", (code) => process.exit(code || 0));
345
+ process.on("SIGTERM", () => child.kill("SIGTERM"));
346
+ process.on("SIGINT", () => child.kill("SIGINT"));
272
347
  return;
273
348
  }
274
349
 
package/lib/analyzer.js CHANGED
@@ -1,4 +1,4 @@
1
- 'use strict';
1
+ "use strict";
2
2
 
3
3
  /**
4
4
  * Cross-sprint claim analysis.
@@ -11,19 +11,21 @@
11
11
  */
12
12
 
13
13
  function analyze(sprints) {
14
- const allClaims = sprints.flatMap(s => s.claims.map(c => ({ ...c, _sprint: s.name })));
14
+ const allClaims = sprints.flatMap((s) =>
15
+ s.claims.map((c) => ({ ...c, _sprint: s.name })),
16
+ );
15
17
 
16
- const typeDistribution = countBy(allClaims, 'type');
17
- const evidenceDistribution = countBy(allClaims, 'evidence');
18
- const statusDistribution = countBy(allClaims, 'status');
18
+ const typeDistribution = countBy(allClaims, "type");
19
+ const evidenceDistribution = countBy(allClaims, "evidence");
20
+ const statusDistribution = countBy(allClaims, "status");
19
21
 
20
22
  // Per-sprint density
21
- const perSprint = sprints.map(s => ({
23
+ const perSprint = sprints.map((s) => ({
22
24
  name: s.name,
23
25
  claimCount: s.claims.length,
24
- types: countBy(s.claims, 'type'),
25
- evidence: countBy(s.claims, 'evidence'),
26
- statuses: countBy(s.claims, 'status'),
26
+ types: countBy(s.claims, "type"),
27
+ evidence: countBy(s.claims, "evidence"),
28
+ statuses: countBy(s.claims, "status"),
27
29
  }));
28
30
 
29
31
  // Find cross-sprint themes by looking at tags
@@ -36,35 +38,40 @@ function analyze(sprints) {
36
38
  }
37
39
 
38
40
  // Identify weak spots: claims with low evidence tiers
39
- const weakClaims = allClaims.filter(c =>
40
- c.evidence === 'stated' || c.evidence === 'web'
41
+ const weakClaims = allClaims.filter(
42
+ (c) => c.evidence === "stated" || c.evidence === "web",
41
43
  );
42
44
 
43
45
  // Type monoculture detection: sprints dominated by a single claim type
44
- const monocultures = perSprint.filter(s => {
45
- const types = Object.entries(s.types);
46
- if (types.length === 0) return false;
47
- const max = Math.max(...types.map(([, v]) => v));
48
- return max / s.claimCount > 0.7 && s.claimCount > 3;
49
- }).map(s => ({
50
- sprint: s.name,
51
- dominantType: Object.entries(s.types).sort((a, b) => b[1] - a[1])[0][0],
52
- ratio: Math.round(Math.max(...Object.values(s.types)) / s.claimCount * 100),
53
- }));
46
+ const monocultures = perSprint
47
+ .filter((s) => {
48
+ const types = Object.entries(s.types);
49
+ if (types.length === 0) return false;
50
+ const max = Math.max(...types.map(([, v]) => v));
51
+ return max / s.claimCount > 0.7 && s.claimCount > 3;
52
+ })
53
+ .map((s) => ({
54
+ sprint: s.name,
55
+ dominantType: Object.entries(s.types).sort((a, b) => b[1] - a[1])[0][0],
56
+ ratio: Math.round(
57
+ (Math.max(...Object.values(s.types)) / s.claimCount) * 100,
58
+ ),
59
+ }));
54
60
 
55
61
  return {
56
62
  summary: {
57
63
  totalSprints: sprints.length,
58
64
  totalClaims: allClaims.length,
59
- averageClaimsPerSprint: sprints.length > 0
60
- ? Math.round(allClaims.length / sprints.length * 10) / 10
61
- : 0,
65
+ averageClaimsPerSprint:
66
+ sprints.length > 0
67
+ ? Math.round((allClaims.length / sprints.length) * 10) / 10
68
+ : 0,
62
69
  },
63
70
  typeDistribution,
64
71
  evidenceDistribution,
65
72
  statusDistribution,
66
73
  tagFrequency,
67
- weakClaims: weakClaims.map(c => ({
74
+ weakClaims: weakClaims.map((c) => ({
68
75
  id: c.id,
69
76
  sprint: c._sprint,
70
77
  type: c.type,
@@ -79,7 +86,7 @@ function analyze(sprints) {
79
86
  function countBy(items, key) {
80
87
  const counts = {};
81
88
  for (const item of items) {
82
- const val = item[key] || 'unknown';
89
+ const val = item[key] || "unknown";
83
90
  counts[val] = (counts[val] || 0) + 1;
84
91
  }
85
92
  return counts;