@grainulation/orchard 1.0.0 → 1.0.2

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/lib/farmer.js CHANGED
@@ -1,9 +1,9 @@
1
- 'use strict';
1
+ "use strict";
2
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');
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
7
 
8
8
  /**
9
9
  * POST an activity event to farmer.
@@ -15,39 +15,46 @@ function notify(farmerUrl, event) {
15
15
  return new Promise((resolve) => {
16
16
  try {
17
17
  const payload = JSON.stringify({
18
- tool: 'orchard',
18
+ tool: "orchard",
19
19
  event,
20
- timestamp: new Date().toISOString()
20
+ timestamp: new Date().toISOString(),
21
21
  });
22
22
 
23
23
  const url = new URL(`${farmerUrl}/hooks/activity`);
24
- const transport = url.protocol === 'https:' ? https : http;
24
+ const transport = url.protocol === "https:" ? https : http;
25
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)
26
+ const req = transport.request(
27
+ {
28
+ hostname: url.hostname,
29
+ port: url.port,
30
+ path: url.pathname,
31
+ method: "POST",
32
+ headers: {
33
+ "Content-Type": "application/json",
34
+ "Content-Length": Buffer.byteLength(payload),
35
+ },
36
+ timeout: 5000,
34
37
  },
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
- });
38
+ (res) => {
39
+ let body = "";
40
+ res.on("data", (chunk) => {
41
+ body += chunk;
42
+ });
43
+ res.on("end", () =>
44
+ resolve({ ok: res.statusCode < 400, status: res.statusCode, body }),
45
+ );
46
+ },
47
+ );
41
48
 
42
- req.on('error', (err) => {
49
+ req.on("error", (err) => {
43
50
  console.error(`[orchard] farmer notify failed: ${err.message}`);
44
51
  resolve({ ok: false, error: err.message });
45
52
  });
46
53
 
47
- req.on('timeout', () => {
54
+ req.on("timeout", () => {
48
55
  req.destroy();
49
- console.error('[orchard] farmer notify timed out');
50
- resolve({ ok: false, error: 'timeout' });
56
+ console.error("[orchard] farmer notify timed out");
57
+ resolve({ ok: false, error: "timeout" });
51
58
  });
52
59
 
53
60
  req.write(payload);
@@ -67,40 +74,49 @@ function notify(farmerUrl, event) {
67
74
  */
68
75
  async function connect(targetDir, args) {
69
76
  const subcommand = args[0];
70
- if (subcommand !== 'farmer') {
71
- console.error('Usage: orchard connect farmer [--url http://localhost:9090]');
77
+ if (subcommand !== "farmer") {
78
+ console.error(
79
+ "Usage: orchard connect farmer [--url http://localhost:9090]",
80
+ );
72
81
  process.exit(1);
73
82
  }
74
83
 
75
- const configPath = path.join(targetDir, '.farmer.json');
84
+ const configPath = path.join(targetDir, ".farmer.json");
76
85
 
77
- const urlIdx = args.indexOf('--url');
86
+ const urlIdx = args.indexOf("--url");
78
87
  if (urlIdx !== -1 && args[urlIdx + 1]) {
79
88
  const url = args[urlIdx + 1];
80
89
  const config = { url };
81
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
90
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
82
91
  console.log(`Farmer connection saved to ${configPath}`);
83
92
  console.log(` URL: ${url}`);
84
93
 
85
94
  // Test the connection
86
- const result = await notify(url, { type: 'connect', data: { tool: 'orchard' } });
95
+ const result = await notify(url, {
96
+ type: "connect",
97
+ data: { tool: "orchard" },
98
+ });
87
99
  if (result.ok) {
88
- console.log(' Connection test: OK');
100
+ console.log(" Connection test: OK");
89
101
  } 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.');
102
+ console.log(
103
+ ` Connection test: failed (${result.error || "status " + result.status})`,
104
+ );
105
+ console.log(
106
+ " Farmer may not be running. The URL is saved and will be used when farmer is available.",
107
+ );
92
108
  }
93
109
  return;
94
110
  }
95
111
 
96
112
  // Show current config
97
113
  if (fs.existsSync(configPath)) {
98
- const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
114
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
99
115
  console.log(`Farmer connection: ${config.url}`);
100
116
  console.log(`Config: ${configPath}`);
101
117
  } else {
102
- console.log('No farmer connection configured.');
103
- console.log('Usage: orchard connect farmer --url http://localhost:9090');
118
+ console.log("No farmer connection configured.");
119
+ console.log("Usage: orchard connect farmer --url http://localhost:9090");
104
120
  }
105
121
  }
106
122
 
@@ -0,0 +1,349 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+
6
+ /**
7
+ * Hackathon coordinator mode.
8
+ *
9
+ * Uses existing orchard infrastructure to run time-boxed research hackathons.
10
+ * Teams get assigned sprints, a timer tracks the event, and a leaderboard
11
+ * ranks teams by claim count, evidence quality, and compilation status.
12
+ */
13
+
14
+ const HACKATHON_FILE = "hackathon.json";
15
+
16
+ /**
17
+ * Default hackathon configuration schema for orchard.json.
18
+ * Users can add a "hackathon" section to orchard.json to preconfigure defaults.
19
+ *
20
+ * {
21
+ * "sprints": [...],
22
+ * "hackathon": {
23
+ * "time_limit": 120,
24
+ * "categories": ["research-depth", "evidence-quality", "presentation"],
25
+ * "judging_weights": {
26
+ * "claim_count": 1,
27
+ * "evidence_quality": 2,
28
+ * "compilation_bonus": 10,
29
+ * "category_bonus": 5
30
+ * }
31
+ * }
32
+ * }
33
+ */
34
+ const DEFAULT_HACKATHON_CONFIG = {
35
+ time_limit: 120,
36
+ categories: [],
37
+ judging_weights: {
38
+ claim_count: 1,
39
+ evidence_quality: 1,
40
+ compilation_bonus: 10,
41
+ category_bonus: 5,
42
+ },
43
+ };
44
+
45
+ /**
46
+ * Load hackathon configuration from orchard.json.
47
+ * Merges with defaults for any missing fields.
48
+ */
49
+ function loadHackathonConfig(root) {
50
+ const orchardPath = path.join(root, "orchard.json");
51
+ if (!fs.existsSync(orchardPath)) return { ...DEFAULT_HACKATHON_CONFIG };
52
+
53
+ try {
54
+ const config = JSON.parse(fs.readFileSync(orchardPath, "utf8"));
55
+ const hackConfig = config.hackathon || {};
56
+ return {
57
+ time_limit: hackConfig.time_limit || DEFAULT_HACKATHON_CONFIG.time_limit,
58
+ categories: hackConfig.categories || DEFAULT_HACKATHON_CONFIG.categories,
59
+ judging_weights: {
60
+ ...DEFAULT_HACKATHON_CONFIG.judging_weights,
61
+ ...(hackConfig.judging_weights || {}),
62
+ },
63
+ };
64
+ } catch {
65
+ return { ...DEFAULT_HACKATHON_CONFIG };
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Initialize a hackathon event.
71
+ * Creates hackathon.json alongside orchard.json.
72
+ * Reads default config from orchard.json "hackathon" section if present.
73
+ */
74
+ function initHackathon(root, opts = {}) {
75
+ const hackPath = path.join(root, HACKATHON_FILE);
76
+ if (fs.existsSync(hackPath)) {
77
+ throw new Error(
78
+ "hackathon.json already exists — end the current hackathon first",
79
+ );
80
+ }
81
+
82
+ // Load preconfigured defaults from orchard.json hackathon section
83
+ const hackConfig = loadHackathonConfig(root);
84
+
85
+ const durationMinutes = opts.duration || hackConfig.time_limit;
86
+ const now = new Date();
87
+ const endTime = new Date(now.getTime() + durationMinutes * 60 * 1000);
88
+
89
+ const hackathon = {
90
+ name: opts.name || "Research Hackathon",
91
+ startTime: now.toISOString(),
92
+ endTime: endTime.toISOString(),
93
+ durationMinutes,
94
+ categories: hackConfig.categories,
95
+ judging_weights: hackConfig.judging_weights,
96
+ teams: [],
97
+ status: "active",
98
+ };
99
+
100
+ fs.writeFileSync(hackPath, JSON.stringify(hackathon, null, 2) + "\n", "utf8");
101
+ return hackathon;
102
+ }
103
+
104
+ /**
105
+ * Load hackathon state.
106
+ */
107
+ function loadHackathon(root) {
108
+ const hackPath = path.join(root, HACKATHON_FILE);
109
+ if (!fs.existsSync(hackPath)) return null;
110
+ return JSON.parse(fs.readFileSync(hackPath, "utf8"));
111
+ }
112
+
113
+ /**
114
+ * Add a team to the hackathon. Each team gets a sprint directory.
115
+ */
116
+ function addTeam(root, teamName, question) {
117
+ const hack = loadHackathon(root);
118
+ if (!hack)
119
+ throw new Error("No active hackathon — run orchard hackathon init first");
120
+ if (hack.status !== "active") throw new Error("Hackathon is not active");
121
+
122
+ const sprintPath = path.join(
123
+ "sprints",
124
+ `hackathon-${teamName.toLowerCase().replace(/\s+/g, "-")}`,
125
+ );
126
+ const absPath = path.join(root, sprintPath);
127
+
128
+ // Create sprint directory with initial claims.json
129
+ fs.mkdirSync(absPath, { recursive: true });
130
+ const initialClaims = {
131
+ schema_version: "1.0",
132
+ meta: {
133
+ question: question || `${teamName}'s hackathon research`,
134
+ initiated: new Date().toISOString().split("T")[0],
135
+ audience: ["hackathon"],
136
+ phase: "define",
137
+ connectors: [],
138
+ },
139
+ claims: [],
140
+ };
141
+ fs.writeFileSync(
142
+ path.join(absPath, "claims.json"),
143
+ JSON.stringify(initialClaims, null, 2) + "\n",
144
+ "utf8",
145
+ );
146
+
147
+ hack.teams.push({
148
+ name: teamName,
149
+ sprintPath,
150
+ joinedAt: new Date().toISOString(),
151
+ });
152
+
153
+ const hackPath = path.join(root, HACKATHON_FILE);
154
+ fs.writeFileSync(hackPath, JSON.stringify(hack, null, 2) + "\n", "utf8");
155
+
156
+ // Also add to orchard.json if it exists
157
+ const orchardPath = path.join(root, "orchard.json");
158
+ if (fs.existsSync(orchardPath)) {
159
+ const config = JSON.parse(fs.readFileSync(orchardPath, "utf8"));
160
+ const exists = (config.sprints || []).some((s) => s.path === sprintPath);
161
+ if (!exists) {
162
+ config.sprints = config.sprints || [];
163
+ config.sprints.push({
164
+ path: sprintPath,
165
+ name: `hackathon-${teamName}`,
166
+ question: question || `${teamName}'s hackathon research`,
167
+ assigned_to: teamName,
168
+ });
169
+ fs.writeFileSync(
170
+ orchardPath,
171
+ JSON.stringify(config, null, 2) + "\n",
172
+ "utf8",
173
+ );
174
+ }
175
+ }
176
+
177
+ return { teamName, sprintPath };
178
+ }
179
+
180
+ /**
181
+ * Build leaderboard from current sprint states.
182
+ * Ranks teams by configurable judging_weights from orchard.json hackathon section.
183
+ * Default: compilation (10pts), claim count (1pt each), evidence quality (1pt each).
184
+ */
185
+ function leaderboard(root) {
186
+ const hack = loadHackathon(root);
187
+ if (!hack) return [];
188
+
189
+ const weights =
190
+ hack.judging_weights || DEFAULT_HACKATHON_CONFIG.judging_weights;
191
+ const categories = hack.categories || [];
192
+ const evidenceScore = { tested: 4, web: 3, documented: 2, stated: 1 };
193
+
194
+ const board = hack.teams.map((team) => {
195
+ const absPath = path.join(root, team.sprintPath);
196
+ const claimsPath = path.join(absPath, "claims.json");
197
+ const compilationPath = path.join(absPath, "compilation.json");
198
+
199
+ let claimCount = 0;
200
+ let totalEvidence = 0;
201
+ let types = {};
202
+ let categoryMatches = 0;
203
+
204
+ if (fs.existsSync(claimsPath)) {
205
+ try {
206
+ const raw = JSON.parse(fs.readFileSync(claimsPath, "utf8"));
207
+ const claims = Array.isArray(raw) ? raw : raw.claims || [];
208
+ claimCount = claims.length;
209
+ for (const c of claims) {
210
+ totalEvidence += evidenceScore[c.evidence] || 0;
211
+ const t = c.type || "unknown";
212
+ types[t] = (types[t] || 0) + 1;
213
+
214
+ // Check if claim tags match any hackathon categories
215
+ if (categories.length > 0 && Array.isArray(c.tags)) {
216
+ for (const tag of c.tags) {
217
+ if (categories.includes(tag)) categoryMatches++;
218
+ }
219
+ }
220
+ }
221
+ } catch {
222
+ /* ignore */
223
+ }
224
+ }
225
+
226
+ const hasCompilation = fs.existsSync(compilationPath);
227
+ const compilationBonus = hasCompilation
228
+ ? weights.compilation_bonus || 10
229
+ : 0;
230
+ const claimScore = claimCount * (weights.claim_count || 1);
231
+ const evidenceWeighted = totalEvidence * (weights.evidence_quality || 1);
232
+ const categoryScore = categoryMatches * (weights.category_bonus || 0);
233
+ const score =
234
+ claimScore + evidenceWeighted + compilationBonus + categoryScore;
235
+
236
+ return {
237
+ team: team.name,
238
+ sprintPath: team.sprintPath,
239
+ claimCount,
240
+ evidenceScore: totalEvidence,
241
+ hasCompilation,
242
+ types,
243
+ categoryMatches,
244
+ score,
245
+ };
246
+ });
247
+
248
+ board.sort((a, b) => b.score - a.score);
249
+ return board;
250
+ }
251
+
252
+ /**
253
+ * Get hackathon timer status.
254
+ */
255
+ function timerStatus(root) {
256
+ const hack = loadHackathon(root);
257
+ if (!hack) return null;
258
+
259
+ const now = new Date();
260
+ const end = new Date(hack.endTime);
261
+ const start = new Date(hack.startTime);
262
+ const totalMs = end - start;
263
+ const elapsedMs = now - start;
264
+ const remainingMs = Math.max(0, end - now);
265
+
266
+ return {
267
+ name: hack.name,
268
+ status: remainingMs > 0 ? "active" : "ended",
269
+ elapsed: Math.floor(elapsedMs / 60000),
270
+ remaining: Math.ceil(remainingMs / 60000),
271
+ total: Math.floor(totalMs / 60000),
272
+ progress: Math.min(1, elapsedMs / totalMs),
273
+ teamCount: hack.teams.length,
274
+ };
275
+ }
276
+
277
+ /**
278
+ * End the hackathon and print final results.
279
+ */
280
+ function endHackathon(root) {
281
+ const hack = loadHackathon(root);
282
+ if (!hack) throw new Error("No active hackathon");
283
+
284
+ hack.status = "ended";
285
+ hack.endedAt = new Date().toISOString();
286
+
287
+ const hackPath = path.join(root, HACKATHON_FILE);
288
+ fs.writeFileSync(hackPath, JSON.stringify(hack, null, 2) + "\n", "utf8");
289
+
290
+ return leaderboard(root);
291
+ }
292
+
293
+ /**
294
+ * Print hackathon status to stdout.
295
+ */
296
+ function printHackathon(root) {
297
+ const timer = timerStatus(root);
298
+ if (!timer) {
299
+ console.log("");
300
+ console.log(
301
+ " No active hackathon. Start one with: orchard hackathon init",
302
+ );
303
+ console.log("");
304
+ return;
305
+ }
306
+
307
+ const board = leaderboard(root);
308
+
309
+ console.log("");
310
+ console.log(` ${timer.name}`);
311
+ console.log(" " + "=".repeat(50));
312
+ console.log(` Status: ${timer.status}`);
313
+ console.log(
314
+ ` Time: ${timer.elapsed}m elapsed / ${timer.remaining}m remaining (${timer.total}m total)`,
315
+ );
316
+ console.log(
317
+ ` Progress: ${"#".repeat(Math.floor(timer.progress * 30))}${"·".repeat(30 - Math.floor(timer.progress * 30))} ${Math.floor(timer.progress * 100)}%`,
318
+ );
319
+ console.log(` Teams: ${timer.teamCount}`);
320
+
321
+ if (board.length > 0) {
322
+ console.log("");
323
+ console.log(" Leaderboard:");
324
+ console.log(" " + "-".repeat(50));
325
+ for (let i = 0; i < board.length; i++) {
326
+ const t = board[i];
327
+ const medal =
328
+ i === 0 ? "1st" : i === 1 ? "2nd" : i === 2 ? "3rd" : `${i + 1}th`;
329
+ const compiled = t.hasCompilation ? " [compiled]" : "";
330
+ console.log(
331
+ ` ${medal} ${t.team} — ${t.score}pts (${t.claimCount} claims, evidence: ${t.evidenceScore})${compiled}`,
332
+ );
333
+ }
334
+ }
335
+
336
+ console.log("");
337
+ }
338
+
339
+ module.exports = {
340
+ initHackathon,
341
+ loadHackathon,
342
+ loadHackathonConfig,
343
+ addTeam,
344
+ leaderboard,
345
+ timerStatus,
346
+ endHackathon,
347
+ printHackathon,
348
+ DEFAULT_HACKATHON_CONFIG,
349
+ };