@grainulation/orchard 1.0.2 → 1.1.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/CONTRIBUTING.md +1 -1
- package/README.md +1 -1
- package/bin/orchard.js +11 -0
- package/lib/emit.js +72 -0
- package/lib/farmer.js +81 -13
- package/lib/server.js +8 -0
- package/package.json +2 -2
package/CONTRIBUTING.md
CHANGED
|
@@ -63,7 +63,7 @@ The key architectural principle: **orchard operates above individual sprints.**
|
|
|
63
63
|
|
|
64
64
|
- Zero dependencies. If you need something, write it or use Node built-ins.
|
|
65
65
|
- No transpilation. Ship what you write.
|
|
66
|
-
- ESM imports (`import`/`export`). Node
|
|
66
|
+
- ESM imports (`import`/`export`). Node 20+ required.
|
|
67
67
|
- Keep functions small. If a function needs a scroll, split it.
|
|
68
68
|
- No emojis in code, CLI output, or dashboards.
|
|
69
69
|
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<a href="https://www.npmjs.com/package/@grainulation/orchard"><img src="https://img.shields.io/npm/v/@grainulation/orchard" alt="npm version"></a> <a href="https://www.npmjs.com/package/@grainulation/orchard"><img src="https://img.shields.io/npm/dm/@grainulation/orchard" alt="npm downloads"></a> <a href="https://github.com/grainulation/orchard/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/orchard" alt="node"></a> <a href="https://github.com/grainulation/orchard/actions"><img src="https://github.com/grainulation/orchard/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
6
|
+
<a href="https://www.npmjs.com/package/@grainulation/orchard"><img src="https://img.shields.io/npm/v/@grainulation/orchard?label=%40grainulation%2Forchard" alt="npm version"></a> <a href="https://www.npmjs.com/package/@grainulation/orchard"><img src="https://img.shields.io/npm/dm/@grainulation/orchard" alt="npm downloads"></a> <a href="https://github.com/grainulation/orchard/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/orchard" alt="node"></a> <a href="https://github.com/grainulation/orchard/actions"><img src="https://github.com/grainulation/orchard/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
7
7
|
<a href="https://deepwiki.com/grainulation/orchard"><img src="https://deepwiki.com/badge.svg" alt="Explore on DeepWiki"></a>
|
|
8
8
|
</p>
|
|
9
9
|
|
package/bin/orchard.js
CHANGED
|
@@ -24,6 +24,7 @@ const COMMANDS = {
|
|
|
24
24
|
dashboard: "Generate unified HTML dashboard",
|
|
25
25
|
serve: "Start the portfolio dashboard web server",
|
|
26
26
|
connect: "Connect to a farmer instance",
|
|
27
|
+
next: "Show sprints ready for grainulator execution",
|
|
27
28
|
doctor: "Check health of orchard setup",
|
|
28
29
|
help: "Show this help message",
|
|
29
30
|
};
|
|
@@ -336,6 +337,16 @@ async function main() {
|
|
|
336
337
|
}
|
|
337
338
|
break;
|
|
338
339
|
}
|
|
340
|
+
case "next": {
|
|
341
|
+
const { emitInstructions, printNext } = require("../lib/emit.js");
|
|
342
|
+
if (jsonMode) {
|
|
343
|
+
const instructions = emitInstructions(config, root);
|
|
344
|
+
console.log(JSON.stringify(instructions, null, 2));
|
|
345
|
+
} else {
|
|
346
|
+
printNext(config, root);
|
|
347
|
+
}
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
339
350
|
case "doctor": {
|
|
340
351
|
const { runChecks, printReport } = require("../lib/doctor.js");
|
|
341
352
|
const result = runChecks(root || process.cwd());
|
package/lib/emit.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { findReady } = require("./sync.js");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Emit grainulator-compatible instructions for all ready sprints.
|
|
9
|
+
*
|
|
10
|
+
* Reads findReady() output + each sprint's claims.json meta to build
|
|
11
|
+
* a structured instruction per sprint: { dir, question, tools, model }.
|
|
12
|
+
*
|
|
13
|
+
* @param {object} config - Parsed orchard.json
|
|
14
|
+
* @param {string} root - Orchard root directory
|
|
15
|
+
* @returns {object[]} Array of grainulator instructions
|
|
16
|
+
*/
|
|
17
|
+
function emitInstructions(config, root) {
|
|
18
|
+
const ready = findReady(config, root);
|
|
19
|
+
const instructions = [];
|
|
20
|
+
|
|
21
|
+
for (const sprint of ready) {
|
|
22
|
+
const sprintDir = path.resolve(root, sprint.path);
|
|
23
|
+
const claimsPath = path.join(sprintDir, "claims.json");
|
|
24
|
+
|
|
25
|
+
let question = sprint.question || null;
|
|
26
|
+
|
|
27
|
+
// Try to extract question from claims.json meta if not in orchard config
|
|
28
|
+
if (!question) {
|
|
29
|
+
try {
|
|
30
|
+
const raw = fs.readFileSync(claimsPath, "utf8");
|
|
31
|
+
const data = JSON.parse(raw);
|
|
32
|
+
question = data.meta?.question || null;
|
|
33
|
+
} catch {
|
|
34
|
+
// No claims.json or unreadable -- skip this sprint
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!question) continue;
|
|
39
|
+
|
|
40
|
+
instructions.push({
|
|
41
|
+
dir: sprintDir,
|
|
42
|
+
question,
|
|
43
|
+
tools: ["Read", "Write", "Edit", "Bash", "Glob", "Grep", "WebSearch", "WebFetch"],
|
|
44
|
+
model: sprint.model || "sonnet",
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return instructions;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Print ready sprint instructions to stdout (CLI-friendly).
|
|
53
|
+
*/
|
|
54
|
+
function printNext(config, root) {
|
|
55
|
+
const instructions = emitInstructions(config, root);
|
|
56
|
+
|
|
57
|
+
if (instructions.length === 0) {
|
|
58
|
+
console.log("\n No sprints are ready. Check dependencies with `orchard plan`.\n");
|
|
59
|
+
return instructions;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log(`\n ${instructions.length} sprint(s) ready:\n`);
|
|
63
|
+
for (const inst of instructions) {
|
|
64
|
+
console.log(` - ${path.basename(inst.dir)}`);
|
|
65
|
+
console.log(` Question: ${inst.question}`);
|
|
66
|
+
console.log(` Run: /grainulator:research "${inst.question}"\n`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return instructions;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { emitInstructions, printNext };
|
package/lib/farmer.js
CHANGED
|
@@ -5,13 +5,27 @@ const path = require("node:path");
|
|
|
5
5
|
const http = require("node:http");
|
|
6
6
|
const https = require("node:https");
|
|
7
7
|
|
|
8
|
+
/** Track whether we have already warned about missing token */
|
|
9
|
+
let _warnedNoToken = false;
|
|
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({
|
|
@@ -23,16 +37,21 @@ function notify(farmerUrl, event) {
|
|
|
23
37
|
const url = new URL(`${farmerUrl}/hooks/activity`);
|
|
24
38
|
const transport = url.protocol === "https:" ? https : http;
|
|
25
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
|
+
|
|
26
48
|
const req = transport.request(
|
|
27
49
|
{
|
|
28
50
|
hostname: url.hostname,
|
|
29
51
|
port: url.port,
|
|
30
52
|
path: url.pathname,
|
|
31
53
|
method: "POST",
|
|
32
|
-
headers
|
|
33
|
-
"Content-Type": "application/json",
|
|
34
|
-
"Content-Length": Buffer.byteLength(payload),
|
|
35
|
-
},
|
|
54
|
+
headers,
|
|
36
55
|
timeout: 5000,
|
|
37
56
|
},
|
|
38
57
|
(res) => {
|
|
@@ -76,7 +95,7 @@ async function connect(targetDir, args) {
|
|
|
76
95
|
const subcommand = args[0];
|
|
77
96
|
if (subcommand !== "farmer") {
|
|
78
97
|
console.error(
|
|
79
|
-
"Usage: orchard connect farmer
|
|
98
|
+
"Usage: orchard connect farmer --url http://localhost:9090 [--token <t>]",
|
|
80
99
|
);
|
|
81
100
|
process.exit(1);
|
|
82
101
|
}
|
|
@@ -86,16 +105,29 @@ async function connect(targetDir, args) {
|
|
|
86
105
|
const urlIdx = args.indexOf("--url");
|
|
87
106
|
if (urlIdx !== -1 && args[urlIdx + 1]) {
|
|
88
107
|
const url = args[urlIdx + 1];
|
|
89
|
-
|
|
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 };
|
|
90
114
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
|
|
91
115
|
console.log(`Farmer connection saved to ${configPath}`);
|
|
92
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
|
+
}
|
|
93
124
|
|
|
94
125
|
// Test the connection
|
|
95
|
-
const result = await notify(
|
|
96
|
-
|
|
97
|
-
data: { tool: "orchard" },
|
|
98
|
-
|
|
126
|
+
const result = await notify(
|
|
127
|
+
url,
|
|
128
|
+
{ type: "connect", data: { tool: "orchard" } },
|
|
129
|
+
{ token },
|
|
130
|
+
);
|
|
99
131
|
if (result.ok) {
|
|
100
132
|
console.log(" Connection test: OK");
|
|
101
133
|
} else {
|
|
@@ -113,11 +145,47 @@ async function connect(targetDir, args) {
|
|
|
113
145
|
if (fs.existsSync(configPath)) {
|
|
114
146
|
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
115
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
|
+
}
|
|
116
153
|
console.log(`Config: ${configPath}`);
|
|
117
154
|
} else {
|
|
118
155
|
console.log("No farmer connection configured.");
|
|
119
|
-
console.log(
|
|
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" });
|
|
120
187
|
}
|
|
188
|
+
return notify(config.url, event, { token: config.token || null });
|
|
121
189
|
}
|
|
122
190
|
|
|
123
|
-
module.exports = { connect, notify };
|
|
191
|
+
module.exports = { connect, notify, loadConfig, notifyFromConfig };
|
package/lib/server.js
CHANGED
|
@@ -790,6 +790,14 @@ ${ROUTES.map((r) => "<tr><td><code>" + r.method + "</code></td><td><code>" + r.p
|
|
|
790
790
|
let filePath = url.pathname;
|
|
791
791
|
filePath = join(PUBLIC_DIR, filePath);
|
|
792
792
|
|
|
793
|
+
// Prevent directory traversal
|
|
794
|
+
const resolved = resolve(filePath);
|
|
795
|
+
if (!resolved.startsWith(PUBLIC_DIR + "/") && resolved !== PUBLIC_DIR) {
|
|
796
|
+
res.writeHead(403);
|
|
797
|
+
res.end("Forbidden");
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
|
|
793
801
|
if (existsSync(filePath) && !statSync(filePath).isDirectory()) {
|
|
794
802
|
const ext = extname(filePath);
|
|
795
803
|
const mime = MIME[ext] || "application/octet-stream";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grainulation/orchard",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Multi-sprint research orchestrator — coordinate parallel research across teams",
|
|
5
5
|
"main": "lib/planner.js",
|
|
6
6
|
"exports": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"./conflicts": "./lib/conflicts.js",
|
|
12
12
|
"./hackathon": "./lib/hackathon.js",
|
|
13
13
|
"./decompose": "./lib/decompose.js",
|
|
14
|
+
"./emit": "./lib/emit.js",
|
|
14
15
|
"./doctor": "./lib/doctor.js",
|
|
15
16
|
"./package.json": "./package.json"
|
|
16
17
|
},
|
|
@@ -41,7 +42,6 @@
|
|
|
41
42
|
],
|
|
42
43
|
"author": "grainulation contributors",
|
|
43
44
|
"license": "MIT",
|
|
44
|
-
"type": "module",
|
|
45
45
|
"repository": {
|
|
46
46
|
"type": "git",
|
|
47
47
|
"url": "git+https://github.com/grainulation/orchard.git"
|