@grainulation/orchard 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/lib/farmer.js CHANGED
@@ -1,53 +1,79 @@
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
+
8
+ /** Track whether we have already warned about missing token */
9
+ let _warnedNoToken = false;
7
10
 
8
11
  /**
9
12
  * POST an activity event to farmer.
10
13
  * Graceful failure -- catch and warn, never crash.
11
14
  * @param {string} farmerUrl - Base URL of farmer (e.g. http://localhost:9090)
12
15
  * @param {object} event - Event object (e.g. { type: "scan", data: {...} })
16
+ * @param {object} [opts] - Options
17
+ * @param {string} [opts.token] - Bearer token for Authorization header
13
18
  */
14
- function notify(farmerUrl, event) {
19
+ function notify(farmerUrl, event, opts) {
20
+ const token = (opts && opts.token) || null;
21
+
22
+ if (!token && !_warnedNoToken) {
23
+ _warnedNoToken = true;
24
+ process.stderr.write(
25
+ "[orchard] no farmer token configured -- requests are unauthenticated\n",
26
+ );
27
+ }
28
+
15
29
  return new Promise((resolve) => {
16
30
  try {
17
31
  const payload = JSON.stringify({
18
- tool: 'orchard',
32
+ tool: "orchard",
19
33
  event,
20
- timestamp: new Date().toISOString()
34
+ timestamp: new Date().toISOString(),
21
35
  });
22
36
 
23
37
  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)
38
+ const transport = url.protocol === "https:" ? https : http;
39
+
40
+ const headers = {
41
+ "Content-Type": "application/json",
42
+ "Content-Length": Buffer.byteLength(payload),
43
+ };
44
+ if (token) {
45
+ headers["Authorization"] = `Bearer ${token}`;
46
+ }
47
+
48
+ const req = transport.request(
49
+ {
50
+ hostname: url.hostname,
51
+ port: url.port,
52
+ path: url.pathname,
53
+ method: "POST",
54
+ headers,
55
+ timeout: 5000,
34
56
  },
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
- });
57
+ (res) => {
58
+ let body = "";
59
+ res.on("data", (chunk) => {
60
+ body += chunk;
61
+ });
62
+ res.on("end", () =>
63
+ resolve({ ok: res.statusCode < 400, status: res.statusCode, body }),
64
+ );
65
+ },
66
+ );
41
67
 
42
- req.on('error', (err) => {
68
+ req.on("error", (err) => {
43
69
  console.error(`[orchard] farmer notify failed: ${err.message}`);
44
70
  resolve({ ok: false, error: err.message });
45
71
  });
46
72
 
47
- req.on('timeout', () => {
73
+ req.on("timeout", () => {
48
74
  req.destroy();
49
- console.error('[orchard] farmer notify timed out');
50
- resolve({ ok: false, error: 'timeout' });
75
+ console.error("[orchard] farmer notify timed out");
76
+ resolve({ ok: false, error: "timeout" });
51
77
  });
52
78
 
53
79
  req.write(payload);
@@ -67,41 +93,99 @@ function notify(farmerUrl, event) {
67
93
  */
68
94
  async function connect(targetDir, args) {
69
95
  const subcommand = args[0];
70
- if (subcommand !== 'farmer') {
71
- console.error('Usage: orchard connect farmer [--url http://localhost:9090]');
96
+ if (subcommand !== "farmer") {
97
+ console.error(
98
+ "Usage: orchard connect farmer --url http://localhost:9090 [--token <t>]",
99
+ );
72
100
  process.exit(1);
73
101
  }
74
102
 
75
- const configPath = path.join(targetDir, '.farmer.json');
103
+ const configPath = path.join(targetDir, ".farmer.json");
76
104
 
77
- const urlIdx = args.indexOf('--url');
105
+ const urlIdx = args.indexOf("--url");
78
106
  if (urlIdx !== -1 && args[urlIdx + 1]) {
79
107
  const url = args[urlIdx + 1];
80
- const config = { url };
81
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
108
+
109
+ // Read optional --token flag
110
+ const tokenIdx = args.indexOf("--token");
111
+ const token = tokenIdx !== -1 && args[tokenIdx + 1] ? args[tokenIdx + 1] : null;
112
+
113
+ const config = token ? { url, token } : { url };
114
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
82
115
  console.log(`Farmer connection saved to ${configPath}`);
83
116
  console.log(` URL: ${url}`);
117
+ if (token) {
118
+ console.log(" Token: configured");
119
+ } else {
120
+ console.log(
121
+ " Token: not configured (use --token <t> to enable authenticated requests)",
122
+ );
123
+ }
84
124
 
85
125
  // Test the connection
86
- const result = await notify(url, { type: 'connect', data: { tool: 'orchard' } });
126
+ const result = await notify(
127
+ url,
128
+ { type: "connect", data: { tool: "orchard" } },
129
+ { token },
130
+ );
87
131
  if (result.ok) {
88
- console.log(' Connection test: OK');
132
+ console.log(" Connection test: OK");
89
133
  } 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.');
134
+ console.log(
135
+ ` Connection test: failed (${result.error || "status " + result.status})`,
136
+ );
137
+ console.log(
138
+ " Farmer may not be running. The URL is saved and will be used when farmer is available.",
139
+ );
92
140
  }
93
141
  return;
94
142
  }
95
143
 
96
144
  // Show current config
97
145
  if (fs.existsSync(configPath)) {
98
- const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
146
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
99
147
  console.log(`Farmer connection: ${config.url}`);
148
+ if (config.token) {
149
+ console.log(" Token: configured");
150
+ } else {
151
+ console.log(" Token: not configured");
152
+ }
100
153
  console.log(`Config: ${configPath}`);
101
154
  } else {
102
- console.log('No farmer connection configured.');
103
- console.log('Usage: orchard connect farmer --url http://localhost:9090');
155
+ console.log("No farmer connection configured.");
156
+ console.log(
157
+ "Usage: orchard connect farmer --url http://localhost:9090 [--token <t>]",
158
+ );
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Read .farmer.json from a directory.
164
+ * @param {string} dir - Directory containing .farmer.json
165
+ * @returns {{ url: string, token?: string } | null}
166
+ */
167
+ function loadConfig(dir) {
168
+ const configPath = path.join(dir, ".farmer.json");
169
+ if (!fs.existsSync(configPath)) return null;
170
+ try {
171
+ return JSON.parse(fs.readFileSync(configPath, "utf8"));
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Convenience: read .farmer.json and notify with auth if token exists.
179
+ * @param {string} dir - Directory containing .farmer.json
180
+ * @param {object} event - Event object
181
+ * @returns {Promise<{ok: boolean, status?: number, body?: string, error?: string}>}
182
+ */
183
+ function notifyFromConfig(dir, event) {
184
+ const config = loadConfig(dir);
185
+ if (!config || !config.url) {
186
+ return Promise.resolve({ ok: false, error: "no farmer configured" });
104
187
  }
188
+ return notify(config.url, event, { token: config.token || null });
105
189
  }
106
190
 
107
- module.exports = { connect, notify };
191
+ module.exports = { connect, notify, loadConfig, notifyFromConfig };
@@ -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
+ };