@grainulation/wheat 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 +136 -0
- package/bin/wheat.js +193 -0
- package/compiler/detect-sprints.js +319 -0
- package/compiler/generate-manifest.js +280 -0
- package/compiler/wheat-compiler.js +1229 -0
- package/lib/compiler.js +35 -0
- package/lib/connect.js +418 -0
- package/lib/disconnect.js +188 -0
- package/lib/guard.js +151 -0
- package/lib/index.js +14 -0
- package/lib/init.js +457 -0
- package/lib/install-prompt.js +186 -0
- package/lib/quickstart.js +276 -0
- package/lib/serve-mcp.js +509 -0
- package/lib/server.js +391 -0
- package/lib/stats.js +184 -0
- package/lib/status.js +135 -0
- package/lib/update.js +71 -0
- package/package.json +53 -0
- package/public/index.html +1798 -0
- package/templates/claude.md +122 -0
- package/templates/commands/blind-spot.md +47 -0
- package/templates/commands/brief.md +73 -0
- package/templates/commands/calibrate.md +39 -0
- package/templates/commands/challenge.md +72 -0
- package/templates/commands/connect.md +104 -0
- package/templates/commands/evaluate.md +80 -0
- package/templates/commands/feedback.md +60 -0
- package/templates/commands/handoff.md +53 -0
- package/templates/commands/init.md +68 -0
- package/templates/commands/merge.md +51 -0
- package/templates/commands/present.md +52 -0
- package/templates/commands/prototype.md +68 -0
- package/templates/commands/replay.md +61 -0
- package/templates/commands/research.md +73 -0
- package/templates/commands/resolve.md +42 -0
- package/templates/commands/status.md +56 -0
- package/templates/commands/witness.md +79 -0
- package/templates/explainer.html +343 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aid Idrizovic
|
|
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,136 @@
|
|
|
1
|
+
# wheat
|
|
2
|
+
|
|
3
|
+
**You're about to mass-migrate 200 microservices. Slow down.**
|
|
4
|
+
|
|
5
|
+
The migration will take months. It will cost real money. And right now, the decision to move is based on a Slack thread, a blog post, and a gut feeling from someone who left the company.
|
|
6
|
+
|
|
7
|
+
Wheat exists because the most expensive engineering decisions are made with the least structured evidence. Not because people are careless -- because there's no tool that makes structured investigation feel natural.
|
|
8
|
+
|
|
9
|
+
## The idea
|
|
10
|
+
|
|
11
|
+
Wheat is a research sprint framework. You point it at a question -- "Should we migrate to Postgres?", "Is this architecture going to scale?", "Which vendor should we pick?" -- and it helps you build an evidence base before you commit.
|
|
12
|
+
|
|
13
|
+
Every finding becomes a typed, evidence-graded claim. A constraint from your VP is different from a benchmark you ran, and wheat tracks the difference. When two findings contradict each other, the compiler catches it. When you try to ship a recommendation backed by nothing but blog posts, it warns you.
|
|
14
|
+
|
|
15
|
+
The process is intentionally slow. You gather evidence from multiple sources. You grade how much you trust each piece. You challenge your own assumptions. Then -- and only then -- you compile it into a recommendation you can defend.
|
|
16
|
+
|
|
17
|
+
If that sounds like a lot of work: it is. That's the point. The work happens before you commit a team to six months of migration, not after.
|
|
18
|
+
|
|
19
|
+
## See it in 30 seconds
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx @grainulation/wheat quickstart
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Creates a demo sprint with pre-seeded claims, an intentional conflict, compiles everything, and opens a dashboard. You'll see the compiler flag the conflict and block output until it's resolved.
|
|
26
|
+
|
|
27
|
+
## Start a real sprint
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx @grainulation/wheat init
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Wheat asks a few questions -- what you're investigating, who needs the answer, what constraints exist. Then it sets up the sprint in your repo:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
claims.json # Your evidence database
|
|
37
|
+
CLAUDE.md # AI assistant configuration
|
|
38
|
+
.claude/commands/ # 17 research slash commands
|
|
39
|
+
output/ # Where compiled artifacts land
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Open Claude Code and start investigating:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
/research "Postgres migration risks"
|
|
46
|
+
/prototype # build something testable
|
|
47
|
+
/challenge r003 # stress-test a finding
|
|
48
|
+
/blind-spot # what are we missing?
|
|
49
|
+
/brief # compile the decision document
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## How it works
|
|
53
|
+
|
|
54
|
+
Wheat uses a claim-based system inspired by compiler design:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
You investigate --> Claims accumulate --> Compiler validates --> Artifacts compile
|
|
58
|
+
/research claims.json wheat compile /brief, /present
|
|
59
|
+
/prototype (typed, graded) (7-pass pipeline) (backed by evidence)
|
|
60
|
+
/challenge
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Claim types:** constraint, factual, estimate, risk, recommendation, feedback
|
|
64
|
+
|
|
65
|
+
**Evidence tiers:** stated -> web -> documented -> tested -> production
|
|
66
|
+
|
|
67
|
+
The compiler catches conflicts, warns about weak evidence, and blocks output when issues exist. You cannot ship a brief built on unresolved contradictions.
|
|
68
|
+
|
|
69
|
+
## Works in any repo
|
|
70
|
+
|
|
71
|
+
Wheat doesn't care what language you use. It runs via npx and stores sprint data in your repo. Your Scala project, your Python monorepo, your Flutter app -- wheat works the same everywhere.
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# In a Scala repo
|
|
75
|
+
npx @grainulation/wheat init
|
|
76
|
+
|
|
77
|
+
# In a Python repo
|
|
78
|
+
npx @grainulation/wheat init
|
|
79
|
+
|
|
80
|
+
# Compiles anywhere Node 18+ is available
|
|
81
|
+
npx @grainulation/wheat compile --summary
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
No dependencies are added to your project. No `node_modules` pollution. Wheat is a tool you run, not a library you import.
|
|
85
|
+
|
|
86
|
+
## Guard rails
|
|
87
|
+
|
|
88
|
+
Wheat installs two guard mechanisms:
|
|
89
|
+
|
|
90
|
+
1. **Git pre-commit hook** -- prevents committing broken `claims.json`
|
|
91
|
+
2. **Claude Code guard hook** -- prevents generating output artifacts from stale or blocked compilations
|
|
92
|
+
|
|
93
|
+
Both are optional and can be removed. But they exist because the most dangerous moment in a research sprint is when you skip the process.
|
|
94
|
+
|
|
95
|
+
## Commands
|
|
96
|
+
|
|
97
|
+
| Command | What it does |
|
|
98
|
+
|---------|-------------|
|
|
99
|
+
| `/init` | Bootstrap a new research sprint |
|
|
100
|
+
| `/research <topic>` | Deep dive on a topic, creates claims |
|
|
101
|
+
| `/prototype` | Build something testable |
|
|
102
|
+
| `/challenge <id>` | Adversarial stress-test of a claim |
|
|
103
|
+
| `/witness <id> <url>` | External corroboration |
|
|
104
|
+
| `/blind-spot` | Find gaps in your investigation |
|
|
105
|
+
| `/status` | Sprint dashboard |
|
|
106
|
+
| `/brief` | Compile the decision document |
|
|
107
|
+
| `/present` | Generate a stakeholder presentation |
|
|
108
|
+
| `/feedback` | Incorporate stakeholder input |
|
|
109
|
+
| `/resolve` | Adjudicate conflicts between claims |
|
|
110
|
+
| `/replay` | Time-travel through sprint history |
|
|
111
|
+
| `/calibrate` | Score predictions against actual outcomes |
|
|
112
|
+
| `/handoff` | Package sprint for knowledge transfer |
|
|
113
|
+
| `/merge <path>` | Combine findings across sprints |
|
|
114
|
+
| `/connect <type>` | Link external tools (Jira, docs, etc.) |
|
|
115
|
+
|
|
116
|
+
## Documentation
|
|
117
|
+
|
|
118
|
+
- **[Concepts](docs/concepts.md)** -- claims, phases, evidence tiers, the compiler
|
|
119
|
+
- **[Commands](docs/commands.md)** -- every slash command with usage examples
|
|
120
|
+
- **[FAQ](docs/faq.md)** -- setup, data, usage, and troubleshooting
|
|
121
|
+
|
|
122
|
+
## Platform support
|
|
123
|
+
|
|
124
|
+
Wheat runs on macOS, Linux, and Windows. All path handling uses `path.join`/`path.sep` internally, and git commands are invoked via `execFileSync` (no shell). The pre-commit hook requires Git Bash on Windows (bundled with Git for Windows).
|
|
125
|
+
|
|
126
|
+
On Windows, use `npx @grainulation/wheat` directly -- Node 18+ is the only requirement.
|
|
127
|
+
|
|
128
|
+
## Contributing
|
|
129
|
+
|
|
130
|
+
We'd love your help. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
|
131
|
+
|
|
132
|
+
Good first issues are labeled [`good first issue`](https://github.com/grainulation/wheat/labels/good%20first%20issue).
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT
|
package/bin/wheat.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* wheat — CLI entrypoint for the Wheat research sprint framework
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* wheat init Bootstrap a new sprint (conversational)
|
|
7
|
+
* wheat init --question "..." Quick mode (skip conversation)
|
|
8
|
+
* wheat init --headless Non-interactive mode (requires all flags)
|
|
9
|
+
* wheat compile [--summary|--check|--gate] Run the Bran compiler
|
|
10
|
+
* wheat guard Run the PreToolUse guard hook
|
|
11
|
+
* wheat status Quick sprint status
|
|
12
|
+
* wheat stats Local sprint statistics (no phone-home)
|
|
13
|
+
* wheat update Update slash commands in .claude/commands/
|
|
14
|
+
* wheat mcp Start MCP server
|
|
15
|
+
*
|
|
16
|
+
* All operations resolve paths relative to --dir or process.cwd().
|
|
17
|
+
* The package ships framework code; sprint data stays in YOUR repo.
|
|
18
|
+
*
|
|
19
|
+
* Zero npm dependencies.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import path from 'path';
|
|
23
|
+
import { readFileSync } from 'fs';
|
|
24
|
+
import { fileURLToPath } from 'url';
|
|
25
|
+
import { track as trackInstall, maybePrompt as installPrompt } from '../lib/install-prompt.js';
|
|
26
|
+
|
|
27
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
28
|
+
const __dirname = path.dirname(__filename);
|
|
29
|
+
|
|
30
|
+
const VERSION = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
|
|
31
|
+
|
|
32
|
+
// ─── Parse arguments ─────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
const verbose = process.argv.includes('--verbose');
|
|
35
|
+
function vlog(...a) {
|
|
36
|
+
if (!verbose) return;
|
|
37
|
+
const ts = new Date().toISOString();
|
|
38
|
+
process.stderr.write(`[${ts}] wheat: ${a.join(' ')}\n`);
|
|
39
|
+
}
|
|
40
|
+
export { vlog, verbose };
|
|
41
|
+
|
|
42
|
+
const args = process.argv.slice(2);
|
|
43
|
+
const subcommand = args[0];
|
|
44
|
+
|
|
45
|
+
vlog('startup', `subcommand=${subcommand || '(none)'}`, `cwd=${process.cwd()}`);
|
|
46
|
+
|
|
47
|
+
// Extract --dir or --root flag (applies to all subcommands)
|
|
48
|
+
let targetDir = process.cwd();
|
|
49
|
+
const dirIdx = args.indexOf('--dir') !== -1 ? args.indexOf('--dir') : args.indexOf('--root');
|
|
50
|
+
if (dirIdx !== -1 && args[dirIdx + 1]) {
|
|
51
|
+
targetDir = path.resolve(args[dirIdx + 1]);
|
|
52
|
+
args.splice(dirIdx, 2);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Pass remaining args (minus subcommand) to subcommand handlers
|
|
56
|
+
const subArgs = args.slice(1);
|
|
57
|
+
|
|
58
|
+
// ─── Help / Version ──────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
if (!subcommand || subcommand === '--help' || subcommand === '-h') {
|
|
61
|
+
console.log(`wheat v${VERSION} — Research-driven development framework
|
|
62
|
+
|
|
63
|
+
Usage:
|
|
64
|
+
wheat <command> [options]
|
|
65
|
+
|
|
66
|
+
Commands:
|
|
67
|
+
init Bootstrap a new research sprint in this repo
|
|
68
|
+
quickstart Zero-to-dashboard demo sprint (under 90 seconds)
|
|
69
|
+
compile Run the Bran compiler on claims.json
|
|
70
|
+
serve Start the sprint dashboard UI
|
|
71
|
+
connect Connect to external tools (e.g. wheat connect farmer)
|
|
72
|
+
disconnect Remove external tool hooks (e.g. wheat disconnect farmer)
|
|
73
|
+
guard PreToolUse guard hook (used by Claude Code)
|
|
74
|
+
status Quick sprint status check
|
|
75
|
+
stats Local sprint statistics (no phone-home)
|
|
76
|
+
update Copy/update slash commands to .claude/commands/
|
|
77
|
+
mcp Start MCP server
|
|
78
|
+
|
|
79
|
+
Global options:
|
|
80
|
+
--dir <path> Target directory (default: current directory)
|
|
81
|
+
--json Output as JSON (machine-readable)
|
|
82
|
+
--verbose Enable verbose logging to stderr
|
|
83
|
+
--version Show version
|
|
84
|
+
--help Show this help
|
|
85
|
+
|
|
86
|
+
Examples:
|
|
87
|
+
npx @grainulation/wheat quickstart
|
|
88
|
+
npx @grainulation/wheat init
|
|
89
|
+
npx @grainulation/wheat compile --summary
|
|
90
|
+
npx @grainulation/wheat init --question "Should we migrate to Postgres?"
|
|
91
|
+
|
|
92
|
+
Documentation: https://github.com/grainulation/wheat`);
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (subcommand === '--version' || subcommand === '-v') {
|
|
97
|
+
console.log(`wheat v${VERSION}`);
|
|
98
|
+
process.exit(0);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Install prompt tracking ─────────────────────────────────────────────────
|
|
102
|
+
// Track npx usage and maybe suggest installing. Both calls are sync, <5ms,
|
|
103
|
+
// and fail silently. Only fires for real subcommands (not --help/--version).
|
|
104
|
+
|
|
105
|
+
trackInstall(subcommand);
|
|
106
|
+
installPrompt(subcommand);
|
|
107
|
+
|
|
108
|
+
// ─── Dispatch ────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
const commands = {
|
|
111
|
+
init: '../lib/init.js',
|
|
112
|
+
quickstart: '../lib/quickstart.js',
|
|
113
|
+
compile: '../lib/compiler.js',
|
|
114
|
+
guard: '../lib/guard.js',
|
|
115
|
+
status: '../lib/status.js',
|
|
116
|
+
stats: '../lib/stats.js',
|
|
117
|
+
update: '../lib/update.js',
|
|
118
|
+
serve: '../lib/server.js',
|
|
119
|
+
mcp: '../lib/serve-mcp.js',
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// ─── wheat migrate (not yet implemented) ────────────────────────────────────
|
|
123
|
+
if (subcommand === 'migrate') {
|
|
124
|
+
console.error('wheat migrate is not yet available');
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
// Handle "wheat connect <target>" as a compound subcommand
|
|
130
|
+
if (subcommand === 'connect') {
|
|
131
|
+
const target = subArgs[0];
|
|
132
|
+
if (!target || target === '--help' || target === '-h') {
|
|
133
|
+
console.log(`wheat connect — Link external tools
|
|
134
|
+
|
|
135
|
+
Usage:
|
|
136
|
+
wheat connect farmer [options] Connect Farmer permission dashboard
|
|
137
|
+
|
|
138
|
+
Run "wheat connect farmer --help" for options.`);
|
|
139
|
+
process.exit(0);
|
|
140
|
+
}
|
|
141
|
+
if (target === 'farmer') {
|
|
142
|
+
const connectModule = await import(new URL('../lib/connect.js', import.meta.url).href);
|
|
143
|
+
await connectModule.run(targetDir, subArgs.slice(1)).catch(err => {
|
|
144
|
+
console.error(`\nwheat connect farmer failed:`, err.message);
|
|
145
|
+
if (process.env.WHEAT_DEBUG) console.error(err.stack);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
});
|
|
148
|
+
process.exit(0);
|
|
149
|
+
}
|
|
150
|
+
console.error(`wheat: unknown connect target: ${target}\nAvailable: farmer`);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Handle "wheat disconnect <target>" as a compound subcommand
|
|
155
|
+
if (subcommand === 'disconnect') {
|
|
156
|
+
const target = subArgs[0];
|
|
157
|
+
if (!target || target === '--help' || target === '-h') {
|
|
158
|
+
console.log(`wheat disconnect — Remove external tool hooks
|
|
159
|
+
|
|
160
|
+
Usage:
|
|
161
|
+
wheat disconnect farmer [options] Remove Farmer hooks from settings
|
|
162
|
+
|
|
163
|
+
Run "wheat disconnect farmer --help" for options.`);
|
|
164
|
+
process.exit(0);
|
|
165
|
+
}
|
|
166
|
+
if (target === 'farmer') {
|
|
167
|
+
const disconnectModule = await import(new URL('../lib/disconnect.js', import.meta.url).href);
|
|
168
|
+
await disconnectModule.run(targetDir, subArgs.slice(1)).catch(err => {
|
|
169
|
+
console.error(`\nwheat disconnect farmer failed:`, err.message);
|
|
170
|
+
if (process.env.WHEAT_DEBUG) console.error(err.stack);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
});
|
|
173
|
+
process.exit(0);
|
|
174
|
+
}
|
|
175
|
+
console.error(`wheat: unknown disconnect target: ${target}\nAvailable: farmer`);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!commands[subcommand]) {
|
|
180
|
+
console.error(`wheat: unknown command: ${subcommand}\n`);
|
|
181
|
+
console.error('Run "wheat --help" for available commands.');
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Load and run the subcommand module
|
|
186
|
+
vlog('dispatch', `loading module for "${subcommand}"`);
|
|
187
|
+
const modulePath = new URL(commands[subcommand], import.meta.url).href;
|
|
188
|
+
const handler = await import(modulePath);
|
|
189
|
+
handler.run(targetDir, subArgs).catch(err => {
|
|
190
|
+
console.error(`\nwheat ${subcommand} failed:`, err.message);
|
|
191
|
+
if (process.env.WHEAT_DEBUG) console.error(err.stack);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
});
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* detect-sprints.js — Git-based sprint detection without config pointer
|
|
4
|
+
*
|
|
5
|
+
* Scans the repo for sprint indicators (claims.json files) and determines
|
|
6
|
+
* which sprint is "active" using filesystem + git heuristics:
|
|
7
|
+
*
|
|
8
|
+
* 1. Find all claims.json files (root + examples/ subdirs)
|
|
9
|
+
* 2. Read meta.phase — "archived" sprints are inactive
|
|
10
|
+
* 3. Query git log for most recent commit touching each claims.json
|
|
11
|
+
* 4. Rank by: non-archived > most recent git activity > most recent initiated date
|
|
12
|
+
*
|
|
13
|
+
* Returns a list of sprints with status (active/archived/example).
|
|
14
|
+
* Works without any config file — pure filesystem + git.
|
|
15
|
+
*
|
|
16
|
+
* Based on stakeholder feedback f001: config should not duplicate
|
|
17
|
+
* git-derivable state. Supersedes r020/r025 (config-based currentSprint).
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* node detect-sprints.js # Human-readable output
|
|
21
|
+
* node detect-sprints.js --json # Machine-readable JSON
|
|
22
|
+
* node detect-sprints.js --active # Print only the active sprint path
|
|
23
|
+
*
|
|
24
|
+
* Zero npm dependencies (Node built-in only).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import fs from 'fs';
|
|
28
|
+
import path from 'path';
|
|
29
|
+
import { execFileSync } from 'child_process';
|
|
30
|
+
import { fileURLToPath } from 'url';
|
|
31
|
+
|
|
32
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
33
|
+
const __dirname = path.dirname(__filename);
|
|
34
|
+
|
|
35
|
+
let ROOT = __dirname;
|
|
36
|
+
|
|
37
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/** Safely parse JSON from a file path; returns null on failure. */
|
|
40
|
+
function loadJSON(filePath) {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get the ISO timestamp of the most recent git commit touching a file.
|
|
50
|
+
* Returns null if file is untracked or git is unavailable.
|
|
51
|
+
*/
|
|
52
|
+
function lastGitCommitDate(filePath) {
|
|
53
|
+
try {
|
|
54
|
+
const result = execFileSync('git', [
|
|
55
|
+
'log', '-1', '--format=%aI', '--', filePath
|
|
56
|
+
], { cwd: ROOT, timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
57
|
+
const dateStr = result.toString().trim();
|
|
58
|
+
return dateStr || null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Count git commits touching a file (proxy for activity level).
|
|
66
|
+
*/
|
|
67
|
+
function gitCommitCount(filePath) {
|
|
68
|
+
try {
|
|
69
|
+
const result = execFileSync('git', [
|
|
70
|
+
'rev-list', '--count', 'HEAD', '--', filePath
|
|
71
|
+
], { cwd: ROOT, timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
72
|
+
return parseInt(result.toString().trim(), 10) || 0;
|
|
73
|
+
} catch {
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Derive a slug from the sprint's path or question.
|
|
80
|
+
* Root sprint gets slug from first few words of the question.
|
|
81
|
+
*/
|
|
82
|
+
function deriveName(sprintPath, meta) {
|
|
83
|
+
if (sprintPath !== '.') {
|
|
84
|
+
// examples/remote-farmer-sprint -> remote-farmer-sprint
|
|
85
|
+
return path.basename(sprintPath);
|
|
86
|
+
}
|
|
87
|
+
// Root sprint: derive from question
|
|
88
|
+
if (meta?.question) {
|
|
89
|
+
return meta.question
|
|
90
|
+
.toLowerCase()
|
|
91
|
+
.replace(/[^a-z0-9\s]/g, '')
|
|
92
|
+
.split(/\s+/)
|
|
93
|
+
.slice(0, 4)
|
|
94
|
+
.join('-');
|
|
95
|
+
}
|
|
96
|
+
return 'current';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── Scanner ──────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/** Find all sprint roots (directories containing claims.json). */
|
|
102
|
+
function findSprintRoots() {
|
|
103
|
+
const roots = [];
|
|
104
|
+
|
|
105
|
+
// 1. Root-level claims.json (current sprint)
|
|
106
|
+
const rootClaims = path.join(ROOT, 'claims.json');
|
|
107
|
+
if (fs.existsSync(rootClaims)) {
|
|
108
|
+
roots.push({ claimsPath: rootClaims, sprintPath: '.' });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 2. Scan known subdirectories for sprint claims.json files
|
|
112
|
+
// Root claims.json should NOT prevent scanning subdirs
|
|
113
|
+
const scanDirs = ['examples', 'sprints', 'archive'];
|
|
114
|
+
for (const dirName of scanDirs) {
|
|
115
|
+
const dir = path.join(ROOT, dirName);
|
|
116
|
+
if (!fs.existsSync(dir)) continue;
|
|
117
|
+
try {
|
|
118
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
119
|
+
if (!entry.isDirectory()) continue;
|
|
120
|
+
const claimsPath = path.join(dir, entry.name, 'claims.json');
|
|
121
|
+
if (fs.existsSync(claimsPath)) {
|
|
122
|
+
roots.push({
|
|
123
|
+
claimsPath,
|
|
124
|
+
sprintPath: path.join(dirName, entry.name),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch { /* skip if unreadable */ }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return roots;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── Sprint Analysis ──────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Analyze a single sprint root and return structured info.
|
|
138
|
+
* @param {{ claimsPath: string, sprintPath: string }} root
|
|
139
|
+
* @returns {object} Sprint descriptor
|
|
140
|
+
*/
|
|
141
|
+
function analyzeSprint(root) {
|
|
142
|
+
const claims = loadJSON(root.claimsPath);
|
|
143
|
+
if (!claims) return null;
|
|
144
|
+
|
|
145
|
+
const meta = claims.meta || {};
|
|
146
|
+
const claimsList = claims.claims || [];
|
|
147
|
+
|
|
148
|
+
// Git activity signals
|
|
149
|
+
const lastCommit = lastGitCommitDate(root.claimsPath);
|
|
150
|
+
const commitCount = gitCommitCount(root.claimsPath);
|
|
151
|
+
|
|
152
|
+
// Phase-based status inference
|
|
153
|
+
const phase = meta.phase || 'unknown';
|
|
154
|
+
const isArchived = phase === 'archived' || phase === 'complete';
|
|
155
|
+
const isExample = root.sprintPath.startsWith('examples' + path.sep) || root.sprintPath.startsWith('examples/');
|
|
156
|
+
|
|
157
|
+
// Compute status
|
|
158
|
+
let status;
|
|
159
|
+
if (isArchived) {
|
|
160
|
+
status = 'archived';
|
|
161
|
+
} else if (isExample) {
|
|
162
|
+
status = 'example';
|
|
163
|
+
} else {
|
|
164
|
+
status = 'candidate'; // will be resolved to 'active' below
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
name: deriveName(root.sprintPath, meta),
|
|
169
|
+
path: root.sprintPath,
|
|
170
|
+
question: meta.question || '',
|
|
171
|
+
phase,
|
|
172
|
+
initiated: meta.initiated || null,
|
|
173
|
+
last_git_activity: lastCommit,
|
|
174
|
+
git_commit_count: commitCount,
|
|
175
|
+
claims_count: claimsList.length,
|
|
176
|
+
active_claims: claimsList.filter(c => c.status === 'active').length,
|
|
177
|
+
status,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Detect all sprints and determine which is active.
|
|
183
|
+
*
|
|
184
|
+
* Heuristic ranking (highest to lowest priority):
|
|
185
|
+
* 1. Non-archived, non-example sprints (root-level candidates)
|
|
186
|
+
* 2. Most recent git commit touching claims.json
|
|
187
|
+
* 3. Most recent meta.initiated date
|
|
188
|
+
* 4. Highest claim count (tiebreaker)
|
|
189
|
+
*
|
|
190
|
+
* @returns {{ active: object|null, sprints: object[] }}
|
|
191
|
+
*/
|
|
192
|
+
export function detectSprints(rootDir) {
|
|
193
|
+
if (rootDir) ROOT = rootDir;
|
|
194
|
+
const roots = findSprintRoots();
|
|
195
|
+
const sprints = roots.map(analyzeSprint).filter(Boolean);
|
|
196
|
+
|
|
197
|
+
// Separate candidates from archived/examples
|
|
198
|
+
const candidates = sprints.filter(s => s.status === 'candidate');
|
|
199
|
+
const others = sprints.filter(s => s.status !== 'candidate');
|
|
200
|
+
|
|
201
|
+
// Rank candidates by git activity, then initiated date, then claim count
|
|
202
|
+
candidates.sort((a, b) => {
|
|
203
|
+
// Most recent git activity first
|
|
204
|
+
const dateA = a.last_git_activity ? new Date(a.last_git_activity).getTime() : 0;
|
|
205
|
+
const dateB = b.last_git_activity ? new Date(b.last_git_activity).getTime() : 0;
|
|
206
|
+
if (dateB !== dateA) return dateB - dateA;
|
|
207
|
+
|
|
208
|
+
// Most recent initiated date
|
|
209
|
+
const initA = a.initiated ? new Date(a.initiated).getTime() : 0;
|
|
210
|
+
const initB = b.initiated ? new Date(b.initiated).getTime() : 0;
|
|
211
|
+
if (initB !== initA) return initB - initA;
|
|
212
|
+
|
|
213
|
+
// More claims = more active
|
|
214
|
+
return b.claims_count - a.claims_count;
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Top candidate is active
|
|
218
|
+
let active = null;
|
|
219
|
+
if (candidates.length > 0) {
|
|
220
|
+
candidates[0].status = 'active';
|
|
221
|
+
active = candidates[0];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// If no root candidate, check examples — the one with most recent git activity
|
|
225
|
+
if (!active && others.length > 0) {
|
|
226
|
+
const nonArchived = others.filter(s => s.status !== 'archived');
|
|
227
|
+
if (nonArchived.length > 0) {
|
|
228
|
+
nonArchived.sort((a, b) => {
|
|
229
|
+
const dateA = a.last_git_activity ? new Date(a.last_git_activity).getTime() : 0;
|
|
230
|
+
const dateB = b.last_git_activity ? new Date(b.last_git_activity).getTime() : 0;
|
|
231
|
+
return dateB - dateA;
|
|
232
|
+
});
|
|
233
|
+
nonArchived[0].status = 'active';
|
|
234
|
+
active = nonArchived[0];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Combine and sort: active first, then by last_git_activity
|
|
239
|
+
const allSprints = [...candidates, ...others].sort((a, b) => {
|
|
240
|
+
if (a.status === 'active' && b.status !== 'active') return -1;
|
|
241
|
+
if (b.status === 'active' && a.status !== 'active') return 1;
|
|
242
|
+
const dateA = a.last_git_activity ? new Date(a.last_git_activity).getTime() : 0;
|
|
243
|
+
const dateB = b.last_git_activity ? new Date(b.last_git_activity).getTime() : 0;
|
|
244
|
+
return dateB - dateA;
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
return { active, sprints: allSprints };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export { findSprintRoots, analyzeSprint };
|
|
251
|
+
|
|
252
|
+
// ─── CLI (only when run directly, not when imported) ──────────────────────────
|
|
253
|
+
|
|
254
|
+
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
255
|
+
|
|
256
|
+
if (isMain) {
|
|
257
|
+
const args = process.argv.slice(2);
|
|
258
|
+
|
|
259
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
260
|
+
console.log(`detect-sprints.js — Git-based sprint detection (no config required)
|
|
261
|
+
|
|
262
|
+
Usage:
|
|
263
|
+
node detect-sprints.js Human-readable sprint list
|
|
264
|
+
node detect-sprints.js --json Machine-readable JSON output
|
|
265
|
+
node detect-sprints.js --active Print only the active sprint path
|
|
266
|
+
|
|
267
|
+
Detects sprints from claims.json files in the repo. Determines the active
|
|
268
|
+
sprint using git commit history and metadata — no config pointer needed.
|
|
269
|
+
Based on f001: config should not duplicate git-derivable state.`);
|
|
270
|
+
process.exit(0);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const rootIdx = args.indexOf('--root');
|
|
274
|
+
const rootArg = (rootIdx !== -1 && args[rootIdx + 1]) ? path.resolve(args[rootIdx + 1]) : undefined;
|
|
275
|
+
|
|
276
|
+
const t0 = performance.now();
|
|
277
|
+
const result = detectSprints(rootArg);
|
|
278
|
+
const elapsed = (performance.now() - t0).toFixed(1);
|
|
279
|
+
|
|
280
|
+
if (args.includes('--json')) {
|
|
281
|
+
console.log(JSON.stringify(result, null, 2));
|
|
282
|
+
process.exit(0);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (args.includes('--active')) {
|
|
286
|
+
if (result.active) {
|
|
287
|
+
console.log(result.active.path);
|
|
288
|
+
} else {
|
|
289
|
+
console.error('No active sprint detected.');
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
process.exit(0);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Human-readable output
|
|
296
|
+
console.log(`Sprint Detection (${elapsed}ms)`);
|
|
297
|
+
console.log('='.repeat(50));
|
|
298
|
+
console.log(`Found ${result.sprints.length} sprint(s)\n`);
|
|
299
|
+
|
|
300
|
+
for (const sprint of result.sprints) {
|
|
301
|
+
const icon = sprint.status === 'active' ? '>>>' : ' ';
|
|
302
|
+
const statusTag = sprint.status.toUpperCase().padEnd(8);
|
|
303
|
+
console.log(`${icon} [${statusTag}] ${sprint.name}`);
|
|
304
|
+
console.log(` Path: ${sprint.path}`);
|
|
305
|
+
console.log(` Phase: ${sprint.phase}`);
|
|
306
|
+
console.log(` Claims: ${sprint.claims_count} total, ${sprint.active_claims} active`);
|
|
307
|
+
console.log(` Initiated: ${sprint.initiated || 'unknown'}`);
|
|
308
|
+
console.log(` Last git: ${sprint.last_git_activity || 'untracked'}`);
|
|
309
|
+
console.log(` Commits: ${sprint.git_commit_count}`);
|
|
310
|
+
console.log(` Question: ${sprint.question.slice(0, 80)}${sprint.question.length > 80 ? '...' : ''}`);
|
|
311
|
+
console.log();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (result.active) {
|
|
315
|
+
console.log(`Active sprint: ${result.active.path} (${result.active.name})`);
|
|
316
|
+
} else {
|
|
317
|
+
console.log('No active sprint detected.');
|
|
318
|
+
}
|
|
319
|
+
}
|