@grainulation/orchard 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 +118 -0
- package/bin/orchard.js +209 -0
- package/lib/assignments.js +98 -0
- package/lib/conflicts.js +150 -0
- package/lib/dashboard.js +291 -0
- package/lib/doctor.js +137 -0
- package/lib/export.js +98 -0
- package/lib/farmer.js +107 -0
- package/lib/planner.js +198 -0
- package/lib/server.js +707 -0
- package/lib/sync.js +100 -0
- package/lib/tracker.js +124 -0
- package/package.json +50 -0
- package/public/index.html +922 -0
- package/templates/dashboard.html +981 -0
- package/templates/orchard-dashboard.html +171 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 grainulation contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# @grainulation/orchard
|
|
2
|
+
|
|
3
|
+
> 12 sprints running. One command to see them all.
|
|
4
|
+
|
|
5
|
+
**Orchard** is the multi-sprint orchestrator for [Wheat](https://github.com/grainulation/wheat) research sprints. It coordinates parallel research across teams with dependency tracking, conflict detection, and unified dashboards.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx @grainulation/orchard status
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## What it does
|
|
14
|
+
|
|
15
|
+
- **Run multiple wheat sprints in parallel** with dependency tracking
|
|
16
|
+
- **Sprint dependency graphs** -- "sprint B needs sprint A's results first"
|
|
17
|
+
- **Team assignment** -- who's running which sprint
|
|
18
|
+
- **Resource allocation** -- distribute research bandwidth
|
|
19
|
+
- **Cross-sprint conflict detection** -- when two sprints reach opposing conclusions
|
|
20
|
+
- **Unified status dashboard** across all active sprints
|
|
21
|
+
- **Sprint scheduling and deadline tracking**
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
Create a `orchard.json` in your project root:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"sprints": [
|
|
30
|
+
{
|
|
31
|
+
"path": "./sprints/auth-scaling",
|
|
32
|
+
"question": "How should we scale auth for 10x traffic?",
|
|
33
|
+
"depends_on": [],
|
|
34
|
+
"assigned_to": "alice",
|
|
35
|
+
"deadline": "2026-03-20",
|
|
36
|
+
"status": "active"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"path": "./sprints/data-migration",
|
|
40
|
+
"question": "What's the safest migration path for the user table?",
|
|
41
|
+
"depends_on": ["./sprints/auth-scaling"],
|
|
42
|
+
"assigned_to": "bob",
|
|
43
|
+
"deadline": "2026-03-25",
|
|
44
|
+
"status": "active"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Then:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# See the dependency graph
|
|
54
|
+
orchard plan
|
|
55
|
+
|
|
56
|
+
# Check status of all sprints
|
|
57
|
+
orchard status
|
|
58
|
+
|
|
59
|
+
# Assign someone to a sprint
|
|
60
|
+
orchard assign ./sprints/data-migration carol
|
|
61
|
+
|
|
62
|
+
# Sync status from sprint directories
|
|
63
|
+
orchard sync
|
|
64
|
+
|
|
65
|
+
# Generate HTML dashboard
|
|
66
|
+
orchard dashboard
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Commands
|
|
70
|
+
|
|
71
|
+
| Command | Description |
|
|
72
|
+
|---------|-------------|
|
|
73
|
+
| `orchard plan` | Show sprint dependency graph as ASCII |
|
|
74
|
+
| `orchard status` | Show status of all tracked sprints |
|
|
75
|
+
| `orchard assign <path> <person>` | Assign a person to a sprint |
|
|
76
|
+
| `orchard sync` | Sync sprint states from their directories |
|
|
77
|
+
| `orchard dashboard [outfile]` | Generate unified HTML dashboard |
|
|
78
|
+
| `orchard init` | Initialize orchard.json in the current directory |
|
|
79
|
+
| `orchard serve` | Start the portfolio dashboard web server |
|
|
80
|
+
| `orchard help` | Show help |
|
|
81
|
+
|
|
82
|
+
## orchard.json schema
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
{
|
|
86
|
+
sprints: Array<{
|
|
87
|
+
path: string; // Relative path to sprint directory
|
|
88
|
+
question: string; // The research question
|
|
89
|
+
depends_on: string[]; // Paths of prerequisite sprints
|
|
90
|
+
assigned_to: string; // Person responsible
|
|
91
|
+
deadline: string; // ISO date string
|
|
92
|
+
status: string; // active | done | blocked | not-started
|
|
93
|
+
}>
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## How it works
|
|
98
|
+
|
|
99
|
+
Orchard reads `orchard.json` for the sprint graph, then scans each sprint directory for `claims.json` and `compilation.json` to determine actual state. It detects conflicts by comparing claims across sprints that share tags.
|
|
100
|
+
|
|
101
|
+
### Conflict detection
|
|
102
|
+
|
|
103
|
+
Orchard flags two types of cross-sprint conflicts:
|
|
104
|
+
|
|
105
|
+
1. **Opposing recommendations** -- two sprints make recommendations on the same topic that may contradict
|
|
106
|
+
2. **Constraint-recommendation tension** -- one sprint's constraints conflict with another's recommendations
|
|
107
|
+
|
|
108
|
+
### Dependency tracking
|
|
109
|
+
|
|
110
|
+
Sprints can declare dependencies. Orchard uses topological sorting to determine execution order and flags cycles. The `plan` command renders this as ASCII art.
|
|
111
|
+
|
|
112
|
+
## Zero dependencies
|
|
113
|
+
|
|
114
|
+
Orchard uses only Node.js built-in modules. No npm install required.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
package/bin/orchard.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
|
|
7
|
+
const verbose = process.argv.includes('--verbose') || process.argv.includes('-v');
|
|
8
|
+
function vlog(...a) {
|
|
9
|
+
if (!verbose) return;
|
|
10
|
+
const ts = new Date().toISOString();
|
|
11
|
+
process.stderr.write(`[${ts}] orchard: ${a.join(' ')}\n`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const COMMANDS = {
|
|
15
|
+
init: 'Initialize orchard.json in the current directory',
|
|
16
|
+
plan: 'Show sprint dependency graph as ASCII',
|
|
17
|
+
status: 'Show status of all tracked sprints',
|
|
18
|
+
assign: 'Assign a person to a sprint',
|
|
19
|
+
sync: 'Sync sprint states from their directories',
|
|
20
|
+
dashboard: 'Generate unified HTML dashboard',
|
|
21
|
+
serve: 'Start the portfolio dashboard web server',
|
|
22
|
+
connect: 'Connect to a farmer instance',
|
|
23
|
+
doctor: 'Check health of orchard setup',
|
|
24
|
+
help: 'Show this help message',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function loadConfig(dir) {
|
|
28
|
+
const configPath = path.join(dir, 'orchard.json');
|
|
29
|
+
if (!fs.existsSync(configPath)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function findOrchardRoot() {
|
|
36
|
+
let dir = process.cwd();
|
|
37
|
+
while (dir !== path.dirname(dir)) {
|
|
38
|
+
if (fs.existsSync(path.join(dir, 'orchard.json'))) return dir;
|
|
39
|
+
dir = path.dirname(dir);
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function printHelp() {
|
|
45
|
+
console.log('');
|
|
46
|
+
console.log(' orchard - Multi-sprint research orchestrator');
|
|
47
|
+
console.log('');
|
|
48
|
+
console.log(' Usage: orchard <command> [options]');
|
|
49
|
+
console.log('');
|
|
50
|
+
console.log(' Commands:');
|
|
51
|
+
for (const [cmd, desc] of Object.entries(COMMANDS)) {
|
|
52
|
+
console.log(` ${cmd.padEnd(12)} ${desc}`);
|
|
53
|
+
}
|
|
54
|
+
console.log('');
|
|
55
|
+
console.log(' Serve options:');
|
|
56
|
+
console.log(' --port 9097 Port for the web server (default: 9097)');
|
|
57
|
+
console.log(' --root <dir> Root directory to scan for sprints');
|
|
58
|
+
console.log('');
|
|
59
|
+
console.log(' Connect:');
|
|
60
|
+
console.log(' orchard connect farmer --url http://localhost:9090');
|
|
61
|
+
console.log('');
|
|
62
|
+
console.log(' Config: orchard.json in project root');
|
|
63
|
+
console.log('');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function main() {
|
|
67
|
+
const args = process.argv.slice(2);
|
|
68
|
+
const command = args[0] || 'help';
|
|
69
|
+
vlog('startup', `command=${command}`, `cwd=${process.cwd()}`);
|
|
70
|
+
|
|
71
|
+
if (command === 'help' || command === '--help' || command === '-h') {
|
|
72
|
+
printHelp();
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!COMMANDS[command]) {
|
|
77
|
+
console.error(`orchard: unknown command: ${command}`);
|
|
78
|
+
console.error(`Run "orchard help" to see available commands.`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Serve command — start the HTTP server (ESM module)
|
|
83
|
+
if (command === 'serve') {
|
|
84
|
+
const serverPath = path.join(__dirname, '..', 'lib', 'server.js');
|
|
85
|
+
const { spawn } = require('node:child_process');
|
|
86
|
+
|
|
87
|
+
// Forward remaining args to the server
|
|
88
|
+
const serverArgs = args.slice(1);
|
|
89
|
+
const child = spawn(process.execPath, [serverPath, ...serverArgs], {
|
|
90
|
+
stdio: 'inherit',
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
child.on('close', (code) => process.exit(code ?? 0));
|
|
94
|
+
child.on('error', (err) => {
|
|
95
|
+
console.error(`orchard: failed to start server: ${err.message}`);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
});
|
|
98
|
+
process.on('SIGTERM', () => child.kill('SIGTERM'));
|
|
99
|
+
process.on('SIGINT', () => child.kill('SIGINT'));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (command === 'connect') {
|
|
104
|
+
const { connect: farmerConnect } = require('../lib/farmer.js');
|
|
105
|
+
const connectArgs = process.argv.slice(process.argv.indexOf('connect') + 1);
|
|
106
|
+
const rootIdx = connectArgs.indexOf('--root');
|
|
107
|
+
let targetDir = process.cwd();
|
|
108
|
+
if (rootIdx !== -1 && connectArgs[rootIdx + 1]) {
|
|
109
|
+
targetDir = path.resolve(connectArgs[rootIdx + 1]);
|
|
110
|
+
}
|
|
111
|
+
await farmerConnect(targetDir, connectArgs);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (command === 'init') {
|
|
116
|
+
const { parseArgs } = require('node:util');
|
|
117
|
+
let rootDir = process.cwd();
|
|
118
|
+
try {
|
|
119
|
+
const { values } = parseArgs({ args: process.argv.slice(3), options: { root: { type: 'string' } }, allowPositionals: true });
|
|
120
|
+
if (values.root) rootDir = path.resolve(values.root);
|
|
121
|
+
} catch (_) { /* ignore parse errors for init */ }
|
|
122
|
+
const configPath = path.join(rootDir, 'orchard.json');
|
|
123
|
+
if (fs.existsSync(configPath)) {
|
|
124
|
+
console.error('orchard: orchard.json already exists');
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
const defaultConfig = { sprints: [], settings: { sync_interval: 'manual' } };
|
|
128
|
+
fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2) + '\n', 'utf8');
|
|
129
|
+
console.log('Initialized orchard.json — add sprints with `orchard plan`');
|
|
130
|
+
process.exit(0);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const root = findOrchardRoot();
|
|
134
|
+
if (!root && command !== 'help' && command !== 'doctor') {
|
|
135
|
+
console.error('orchard: no orchard.json found. Run from a directory with orchard.json or a subdirectory.');
|
|
136
|
+
console.error('');
|
|
137
|
+
console.error('Create one:');
|
|
138
|
+
console.error(' { "sprints": [] }');
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const config = root ? loadConfig(root) : { sprints: [] };
|
|
143
|
+
const jsonMode = args.includes('--json');
|
|
144
|
+
|
|
145
|
+
switch (command) {
|
|
146
|
+
case 'plan': {
|
|
147
|
+
if (jsonMode) {
|
|
148
|
+
const { buildGraph, topoSort, detectCycles } = require('../lib/planner.js');
|
|
149
|
+
const graph = buildGraph(config);
|
|
150
|
+
const order = topoSort(config);
|
|
151
|
+
const cycles = detectCycles(config);
|
|
152
|
+
console.log(JSON.stringify({ graph, order, cycles }, null, 2));
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
const { printDependencyGraph } = require('../lib/planner.js');
|
|
156
|
+
printDependencyGraph(config, root);
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
case 'status': {
|
|
160
|
+
if (jsonMode) {
|
|
161
|
+
const { getStatusData } = require('../lib/tracker.js');
|
|
162
|
+
const data = getStatusData(config, root);
|
|
163
|
+
console.log(JSON.stringify(data, null, 2));
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
const { printStatus } = require('../lib/tracker.js');
|
|
167
|
+
printStatus(config, root);
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
case 'assign': {
|
|
171
|
+
const sprintPath = args[1];
|
|
172
|
+
const person = args[2];
|
|
173
|
+
if (!sprintPath || !person) {
|
|
174
|
+
console.error('orchard: usage: orchard assign <sprint-path> <person>');
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
const { assignSprint } = require('../lib/assignments.js');
|
|
178
|
+
assignSprint(config, root, sprintPath, person);
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case 'sync': {
|
|
182
|
+
const { syncAll } = require('../lib/sync.js');
|
|
183
|
+
syncAll(config, root);
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
case 'dashboard': {
|
|
187
|
+
const { generateDashboard } = require('../lib/dashboard.js');
|
|
188
|
+
const outPath = args[1] || path.join(root, 'orchard-dashboard.html');
|
|
189
|
+
generateDashboard(config, root, outPath);
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
case 'doctor': {
|
|
193
|
+
const { runChecks, printReport } = require('../lib/doctor.js');
|
|
194
|
+
const result = runChecks(root || process.cwd());
|
|
195
|
+
if (jsonMode) {
|
|
196
|
+
console.log(JSON.stringify(result, null, 2));
|
|
197
|
+
} else {
|
|
198
|
+
printReport(result);
|
|
199
|
+
}
|
|
200
|
+
if (!result.ok) process.exit(1);
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
main().catch((err) => {
|
|
207
|
+
console.error(`orchard: ${err.message}`);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Assign a person to a sprint. Updates orchard.json.
|
|
8
|
+
*/
|
|
9
|
+
function assignSprint(config, root, sprintPath, person) {
|
|
10
|
+
const sprint = (config.sprints || []).find((s) => s.path === sprintPath);
|
|
11
|
+
|
|
12
|
+
if (!sprint) {
|
|
13
|
+
console.error(`Sprint not found: ${sprintPath}`);
|
|
14
|
+
console.error('Available sprints:');
|
|
15
|
+
for (const s of config.sprints || []) {
|
|
16
|
+
console.error(` ${s.path}`);
|
|
17
|
+
}
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const prev = sprint.assigned_to;
|
|
22
|
+
sprint.assigned_to = person;
|
|
23
|
+
|
|
24
|
+
const configPath = path.join(root, 'orchard.json');
|
|
25
|
+
const tmp = configPath + '.tmp.' + process.pid;
|
|
26
|
+
fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n');
|
|
27
|
+
fs.renameSync(tmp, configPath);
|
|
28
|
+
|
|
29
|
+
if (prev) {
|
|
30
|
+
console.log(`Reassigned ${path.basename(sprintPath)}: ${prev} -> ${person}`);
|
|
31
|
+
} else {
|
|
32
|
+
console.log(`Assigned ${path.basename(sprintPath)} to ${person}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get workload summary: how many sprints per person.
|
|
38
|
+
*/
|
|
39
|
+
function getWorkload(config) {
|
|
40
|
+
const workload = new Map();
|
|
41
|
+
|
|
42
|
+
for (const sprint of config.sprints || []) {
|
|
43
|
+
const person = sprint.assigned_to || 'unassigned';
|
|
44
|
+
if (!workload.has(person)) {
|
|
45
|
+
workload.set(person, []);
|
|
46
|
+
}
|
|
47
|
+
workload.get(person).push(sprint);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return workload;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Print workload distribution.
|
|
55
|
+
*/
|
|
56
|
+
function printWorkload(config) {
|
|
57
|
+
const workload = getWorkload(config);
|
|
58
|
+
|
|
59
|
+
console.log('');
|
|
60
|
+
console.log(' Workload Distribution');
|
|
61
|
+
console.log(' ' + '-'.repeat(40));
|
|
62
|
+
|
|
63
|
+
for (const [person, sprints] of workload) {
|
|
64
|
+
const active = sprints.filter((s) => s.status !== 'done').length;
|
|
65
|
+
console.log(` ${person}: ${sprints.length} sprints (${active} active)`);
|
|
66
|
+
for (const s of sprints) {
|
|
67
|
+
const status = s.status || 'unknown';
|
|
68
|
+
console.log(` - ${path.basename(s.path)} [${status}]`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log('');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Find overloaded team members (more than maxLoad active sprints).
|
|
77
|
+
*/
|
|
78
|
+
function findOverloaded(config, maxLoad = 3) {
|
|
79
|
+
const workload = getWorkload(config);
|
|
80
|
+
const overloaded = [];
|
|
81
|
+
|
|
82
|
+
for (const [person, sprints] of workload) {
|
|
83
|
+
if (person === 'unassigned') continue;
|
|
84
|
+
const active = sprints.filter((s) => s.status !== 'done').length;
|
|
85
|
+
if (active > maxLoad) {
|
|
86
|
+
overloaded.push({ person, active, sprints });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return overloaded;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
assignSprint,
|
|
95
|
+
getWorkload,
|
|
96
|
+
printWorkload,
|
|
97
|
+
findOverloaded,
|
|
98
|
+
};
|
package/lib/conflicts.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Load claims from a sprint directory.
|
|
8
|
+
*/
|
|
9
|
+
function loadClaims(sprintPath, root) {
|
|
10
|
+
const absPath = path.isAbsolute(sprintPath) ? sprintPath : path.join(root, sprintPath);
|
|
11
|
+
const claimsPath = path.join(absPath, 'claims.json');
|
|
12
|
+
|
|
13
|
+
if (!fs.existsSync(claimsPath)) return [];
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const data = JSON.parse(fs.readFileSync(claimsPath, 'utf8'));
|
|
17
|
+
const claims = Array.isArray(data) ? data : (data.claims || []);
|
|
18
|
+
return claims.map((c) => ({ ...c, _source: sprintPath }));
|
|
19
|
+
} catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Detect potential conflicts between claims across sprints.
|
|
26
|
+
*
|
|
27
|
+
* Conflict heuristics:
|
|
28
|
+
* 1. Same-type claims with contradicting content (recommendations that oppose each other)
|
|
29
|
+
* 2. Constraints that conflict with recommendations from other sprints
|
|
30
|
+
* 3. Estimates with non-overlapping ranges on the same topic
|
|
31
|
+
*
|
|
32
|
+
* Returns array of { type, claimA, claimB, reason }
|
|
33
|
+
*/
|
|
34
|
+
function detectConflicts(config, root) {
|
|
35
|
+
const allClaims = [];
|
|
36
|
+
|
|
37
|
+
for (const sprint of config.sprints || []) {
|
|
38
|
+
const claims = loadClaims(sprint.path, root);
|
|
39
|
+
allClaims.push(...claims);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const conflicts = [];
|
|
43
|
+
|
|
44
|
+
// Group claims by tags for overlap detection
|
|
45
|
+
const byTag = new Map();
|
|
46
|
+
for (const claim of allClaims) {
|
|
47
|
+
for (const tag of claim.tags || []) {
|
|
48
|
+
if (!byTag.has(tag)) byTag.set(tag, []);
|
|
49
|
+
byTag.get(tag).push(claim);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check for cross-sprint conflicts within same tag
|
|
54
|
+
for (const [tag, claims] of byTag) {
|
|
55
|
+
for (let i = 0; i < claims.length; i++) {
|
|
56
|
+
for (let j = i + 1; j < claims.length; j++) {
|
|
57
|
+
const a = claims[i];
|
|
58
|
+
const b = claims[j];
|
|
59
|
+
|
|
60
|
+
// Only flag cross-sprint conflicts
|
|
61
|
+
if (a._source === b._source) continue;
|
|
62
|
+
|
|
63
|
+
// Opposing recommendations
|
|
64
|
+
if (a.type === 'recommendation' && b.type === 'recommendation') {
|
|
65
|
+
if (couldContradict(a.text, b.text)) {
|
|
66
|
+
conflicts.push({
|
|
67
|
+
type: 'opposing-recommendations',
|
|
68
|
+
claimA: a,
|
|
69
|
+
claimB: b,
|
|
70
|
+
tag,
|
|
71
|
+
reason: `Both sprints recommend on "${tag}" but may conflict`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Constraint vs recommendation
|
|
77
|
+
if (
|
|
78
|
+
(a.type === 'constraint' && b.type === 'recommendation') ||
|
|
79
|
+
(a.type === 'recommendation' && b.type === 'constraint')
|
|
80
|
+
) {
|
|
81
|
+
conflicts.push({
|
|
82
|
+
type: 'constraint-recommendation-tension',
|
|
83
|
+
claimA: a,
|
|
84
|
+
claimB: b,
|
|
85
|
+
tag,
|
|
86
|
+
reason: `Constraint from ${a._source} may conflict with recommendation from ${b._source}`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return conflicts;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Simple heuristic: two texts might contradict if they share keywords
|
|
98
|
+
* but use opposing qualifiers. This is intentionally conservative --
|
|
99
|
+
* false positives are better than missed conflicts.
|
|
100
|
+
*/
|
|
101
|
+
function couldContradict(textA, textB) {
|
|
102
|
+
if (!textA || !textB) return false;
|
|
103
|
+
|
|
104
|
+
const negators = ['not', 'no', 'never', 'avoid', 'instead', 'rather', 'without', 'dont', "don't"];
|
|
105
|
+
const aWords = new Set(textA.toLowerCase().split(/\s+/));
|
|
106
|
+
const bWords = new Set(textB.toLowerCase().split(/\s+/));
|
|
107
|
+
|
|
108
|
+
const aNeg = negators.some((n) => aWords.has(n));
|
|
109
|
+
const bNeg = negators.some((n) => bWords.has(n));
|
|
110
|
+
|
|
111
|
+
// One negated, one not -- possible contradiction
|
|
112
|
+
if (aNeg !== bNeg) return true;
|
|
113
|
+
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Print conflict report.
|
|
119
|
+
*/
|
|
120
|
+
function printConflicts(config, root) {
|
|
121
|
+
const conflicts = detectConflicts(config, root);
|
|
122
|
+
|
|
123
|
+
if (conflicts.length === 0) {
|
|
124
|
+
console.log('');
|
|
125
|
+
console.log(' No cross-sprint conflicts detected.');
|
|
126
|
+
console.log('');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.log('');
|
|
131
|
+
console.log(` ${conflicts.length} potential conflict(s) detected`);
|
|
132
|
+
console.log(' ' + '='.repeat(50));
|
|
133
|
+
|
|
134
|
+
for (const c of conflicts) {
|
|
135
|
+
console.log('');
|
|
136
|
+
console.log(` [${c.type}] tag: ${c.tag}`);
|
|
137
|
+
console.log(` Sprint A: ${c.claimA._source} (${c.claimA.id})`);
|
|
138
|
+
console.log(` Sprint B: ${c.claimB._source} (${c.claimB.id})`);
|
|
139
|
+
console.log(` Reason: ${c.reason}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
console.log('');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = {
|
|
146
|
+
loadClaims,
|
|
147
|
+
detectConflicts,
|
|
148
|
+
couldContradict,
|
|
149
|
+
printConflicts,
|
|
150
|
+
};
|