@slope-dev/cli 0.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/LICENSE +21 -0
- package/dist/commands/briefing.js +49 -0
- package/dist/commands/card.js +50 -0
- package/dist/commands/classify.js +69 -0
- package/dist/commands/init.js +130 -0
- package/dist/commands/plan.js +53 -0
- package/dist/commands/review.js +46 -0
- package/dist/commands/validate.js +73 -0
- package/dist/config.js +36 -0
- package/dist/index.js +77 -0
- package/dist/loader.js +44 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sam Bryers
|
|
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.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { formatBriefing } from '@slope-dev/core';
|
|
4
|
+
import { loadConfig } from '../config.js';
|
|
5
|
+
import { loadScorecards } from '../loader.js';
|
|
6
|
+
export function briefingCommand(args) {
|
|
7
|
+
const config = loadConfig();
|
|
8
|
+
const cwd = process.cwd();
|
|
9
|
+
const scorecards = loadScorecards(config, cwd);
|
|
10
|
+
// Load common-issues
|
|
11
|
+
let commonIssues;
|
|
12
|
+
try {
|
|
13
|
+
commonIssues = JSON.parse(readFileSync(join(cwd, config.commonIssuesPath), 'utf8'));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
commonIssues = { recurring_patterns: [] };
|
|
17
|
+
}
|
|
18
|
+
// Load last session
|
|
19
|
+
let lastSession;
|
|
20
|
+
try {
|
|
21
|
+
const sessionsData = JSON.parse(readFileSync(join(cwd, config.sessionsPath), 'utf8'));
|
|
22
|
+
const sessions = sessionsData.sessions;
|
|
23
|
+
if (sessions && sessions.length > 0) {
|
|
24
|
+
lastSession = sessions[sessions.length - 1];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch { /* skip */ }
|
|
28
|
+
// Parse args
|
|
29
|
+
const categories = [];
|
|
30
|
+
const keywords = [];
|
|
31
|
+
let includeTraining = true;
|
|
32
|
+
for (const arg of args) {
|
|
33
|
+
if (arg.startsWith('--categories=')) {
|
|
34
|
+
categories.push(...arg.slice('--categories='.length).split(',').map(s => s.trim()).filter(Boolean));
|
|
35
|
+
}
|
|
36
|
+
else if (arg.startsWith('--keywords=')) {
|
|
37
|
+
keywords.push(...arg.slice('--keywords='.length).split(',').map(s => s.trim()).filter(Boolean));
|
|
38
|
+
}
|
|
39
|
+
else if (arg === '--no-training') {
|
|
40
|
+
includeTraining = false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const filter = (categories.length > 0 || keywords.length > 0)
|
|
44
|
+
? { categories: categories.length > 0 ? categories : undefined, keywords: keywords.length > 0 ? keywords : undefined }
|
|
45
|
+
: undefined;
|
|
46
|
+
const output = formatBriefing({ scorecards, commonIssues, lastSession, filter, includeTraining });
|
|
47
|
+
console.log('');
|
|
48
|
+
console.log(output);
|
|
49
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { computeHandicapCard } from '@slope-dev/core';
|
|
2
|
+
import { loadConfig } from '../config.js';
|
|
3
|
+
import { loadScorecards } from '../loader.js';
|
|
4
|
+
export function cardCommand() {
|
|
5
|
+
const config = loadConfig();
|
|
6
|
+
const scorecards = loadScorecards(config);
|
|
7
|
+
if (scorecards.length === 0) {
|
|
8
|
+
console.log('\nNo scorecards found. Run `slope init` to create an example.\n');
|
|
9
|
+
process.exit(0);
|
|
10
|
+
}
|
|
11
|
+
const card = computeHandicapCard(scorecards);
|
|
12
|
+
const pad = (s, w) => String(s).padStart(w);
|
|
13
|
+
const pct = (n) => n.toFixed(1) + '%';
|
|
14
|
+
const minSprint = config.minSprint;
|
|
15
|
+
console.log(`\nSLOPE Handicap Card (${scorecards.length} scorecard${scorecards.length === 1 ? '' : 's'}, Sprint ${minSprint}+)`);
|
|
16
|
+
console.log('\u2501'.repeat(47));
|
|
17
|
+
console.log('');
|
|
18
|
+
console.log(`${'Stat'.padEnd(20)}${'Last 5'.padStart(9)}${'Last 10'.padStart(10)}${'All-time'.padStart(10)}`);
|
|
19
|
+
console.log('\u2500'.repeat(49));
|
|
20
|
+
const rows = [
|
|
21
|
+
['Handicap', w => `+${w.handicap.toFixed(1)}`],
|
|
22
|
+
['Fairways', w => pct(w.fairway_pct)],
|
|
23
|
+
['GIR', w => pct(w.gir_pct)],
|
|
24
|
+
['Avg putts/hole', w => w.avg_putts.toFixed(1)],
|
|
25
|
+
['Penalties/round', w => w.penalties_per_round.toFixed(1)],
|
|
26
|
+
['Mulligans', w => String(w.mulligans)],
|
|
27
|
+
['Gimmes', w => String(w.gimmes)],
|
|
28
|
+
];
|
|
29
|
+
for (const [label, fn] of rows) {
|
|
30
|
+
console.log(`${label.padEnd(20)}${pad(fn(card.last_5), 9)}${pad(fn(card.last_10), 10)}${pad(fn(card.all_time), 10)}`);
|
|
31
|
+
}
|
|
32
|
+
// Miss pattern summary
|
|
33
|
+
const allMiss = card.all_time.miss_pattern;
|
|
34
|
+
const totalMisses = allMiss.long + allMiss.short + allMiss.left + allMiss.right;
|
|
35
|
+
console.log('');
|
|
36
|
+
if (totalMisses === 0) {
|
|
37
|
+
console.log('Miss Pattern: No misses recorded.');
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const dirs = ['long', 'short', 'left', 'right']
|
|
41
|
+
.filter(d => allMiss[d] > 0)
|
|
42
|
+
.map(d => `${d}: ${allMiss[d]}`)
|
|
43
|
+
.join(', ');
|
|
44
|
+
console.log(`Miss Pattern: ${dirs} (${totalMisses} total)`);
|
|
45
|
+
}
|
|
46
|
+
if (scorecards.length < 5) {
|
|
47
|
+
console.log(`\nNote: Only ${scorecards.length} scorecard${scorecards.length === 1 ? '' : 's'} \u2014 windows fill at Sprint ${minSprint + 5 - scorecards.length}.`);
|
|
48
|
+
}
|
|
49
|
+
console.log('');
|
|
50
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { classifyShot } from '@slope-dev/core';
|
|
2
|
+
export function classifyCommand(args) {
|
|
3
|
+
let scope;
|
|
4
|
+
let modified;
|
|
5
|
+
let tests;
|
|
6
|
+
let reverts;
|
|
7
|
+
let hazards;
|
|
8
|
+
for (const arg of args) {
|
|
9
|
+
if (arg.startsWith('--scope='))
|
|
10
|
+
scope = arg.slice('--scope='.length);
|
|
11
|
+
else if (arg.startsWith('--modified='))
|
|
12
|
+
modified = arg.slice('--modified='.length);
|
|
13
|
+
else if (arg.startsWith('--tests='))
|
|
14
|
+
tests = arg.slice('--tests='.length);
|
|
15
|
+
else if (arg.startsWith('--reverts='))
|
|
16
|
+
reverts = arg.slice('--reverts='.length);
|
|
17
|
+
else if (arg.startsWith('--hazards='))
|
|
18
|
+
hazards = arg.slice('--hazards='.length);
|
|
19
|
+
}
|
|
20
|
+
if (!scope || !modified || !tests || reverts == null) {
|
|
21
|
+
console.error('\nUsage: slope classify --scope="a.ts,b.ts" --modified="a.ts,b.ts" --tests=pass|fail|partial --reverts=N [--hazards=N]\n');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
return; // unreachable, helps TS narrow
|
|
24
|
+
}
|
|
25
|
+
const validTests = ['pass', 'fail', 'partial'];
|
|
26
|
+
if (!validTests.includes(tests)) {
|
|
27
|
+
console.error(`\nInvalid --tests value "${tests}". Must be one of: ${validTests.join(', ')}\n`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const scopePaths = scope.split(',').map((s) => s.trim()).filter(Boolean);
|
|
32
|
+
const modifiedFiles = modified.split(',').map((s) => s.trim()).filter(Boolean);
|
|
33
|
+
const revertCount = parseInt(reverts, 10) || 0;
|
|
34
|
+
const hazardCount = parseInt(hazards ?? '0', 10) || 0;
|
|
35
|
+
// Build test_results
|
|
36
|
+
const testResults = [];
|
|
37
|
+
if (tests === 'pass') {
|
|
38
|
+
testResults.push({ suite: 'all', passed: true, first_run: true });
|
|
39
|
+
}
|
|
40
|
+
else if (tests === 'fail') {
|
|
41
|
+
testResults.push({ suite: 'all', passed: false, first_run: true });
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
testResults.push({ suite: 'unit', passed: true, first_run: true });
|
|
45
|
+
testResults.push({ suite: 'integration', passed: false, first_run: true });
|
|
46
|
+
}
|
|
47
|
+
// Build hazards_encountered
|
|
48
|
+
const hazardsEncountered = Array.from({ length: hazardCount }, (_, i) => ({
|
|
49
|
+
type: 'rough',
|
|
50
|
+
description: `Hazard ${i + 1}`,
|
|
51
|
+
}));
|
|
52
|
+
const trace = {
|
|
53
|
+
planned_scope_paths: scopePaths,
|
|
54
|
+
modified_files: modifiedFiles,
|
|
55
|
+
test_results: testResults,
|
|
56
|
+
reverts: revertCount,
|
|
57
|
+
elapsed_minutes: 0,
|
|
58
|
+
hazards_encountered: hazardsEncountered,
|
|
59
|
+
};
|
|
60
|
+
const result = classifyShot(trace);
|
|
61
|
+
console.log('');
|
|
62
|
+
console.log('SHOT CLASSIFICATION');
|
|
63
|
+
console.log('\u2550'.repeat(40));
|
|
64
|
+
console.log(` Result: ${result.result}`);
|
|
65
|
+
console.log(` Miss direction: ${result.miss_direction ?? 'none'}`);
|
|
66
|
+
console.log(` Confidence: ${Math.round(result.confidence * 100)}%`);
|
|
67
|
+
console.log(` Reasoning: ${result.reasoning}`);
|
|
68
|
+
console.log('');
|
|
69
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync, existsSync, cpSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { createConfig } from '../config.js';
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const EXAMPLE_SCORECARD = {
|
|
7
|
+
sprint_number: 1,
|
|
8
|
+
theme: 'Example Sprint',
|
|
9
|
+
par: 3,
|
|
10
|
+
slope: 0,
|
|
11
|
+
score: 3,
|
|
12
|
+
score_label: 'par',
|
|
13
|
+
date: new Date().toISOString().split('T')[0],
|
|
14
|
+
shots: [
|
|
15
|
+
{
|
|
16
|
+
ticket_key: 'S1-1',
|
|
17
|
+
title: 'Set up project',
|
|
18
|
+
club: 'short_iron',
|
|
19
|
+
result: 'green',
|
|
20
|
+
hazards: [],
|
|
21
|
+
notes: 'Clean setup',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
ticket_key: 'S1-2',
|
|
25
|
+
title: 'Add core feature',
|
|
26
|
+
club: 'short_iron',
|
|
27
|
+
result: 'in_the_hole',
|
|
28
|
+
hazards: [],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
ticket_key: 'S1-3',
|
|
32
|
+
title: 'Write tests',
|
|
33
|
+
club: 'wedge',
|
|
34
|
+
result: 'green',
|
|
35
|
+
hazards: [{ type: 'rough', description: 'Flaky test environment' }],
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
conditions: [],
|
|
39
|
+
special_plays: [],
|
|
40
|
+
stats: {
|
|
41
|
+
fairways_hit: 3,
|
|
42
|
+
fairways_total: 3,
|
|
43
|
+
greens_in_regulation: 3,
|
|
44
|
+
greens_total: 3,
|
|
45
|
+
putts: 0,
|
|
46
|
+
penalties: 0,
|
|
47
|
+
hazards_hit: 1,
|
|
48
|
+
miss_directions: { long: 0, short: 0, left: 0, right: 0 },
|
|
49
|
+
},
|
|
50
|
+
yardage_book_updates: [],
|
|
51
|
+
bunker_locations: [],
|
|
52
|
+
course_management_notes: ['This is an example scorecard — replace with your own sprint data.'],
|
|
53
|
+
};
|
|
54
|
+
const EXAMPLE_COMMON_ISSUES = {
|
|
55
|
+
recurring_patterns: [
|
|
56
|
+
{
|
|
57
|
+
id: 1,
|
|
58
|
+
title: 'Example pattern',
|
|
59
|
+
category: 'general',
|
|
60
|
+
sprints_hit: [1],
|
|
61
|
+
gotcha_refs: [],
|
|
62
|
+
description: 'This is an example recurring pattern. Replace with your own.',
|
|
63
|
+
prevention: 'Add your prevention strategy here.',
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
export function initCommand(args) {
|
|
68
|
+
const cwd = process.cwd();
|
|
69
|
+
const claudeCode = args.includes('--claude-code');
|
|
70
|
+
// Create .slope directory and config
|
|
71
|
+
const configPath = createConfig(cwd);
|
|
72
|
+
console.log(` Created ${configPath}`);
|
|
73
|
+
// Create scorecard directory
|
|
74
|
+
const scorecardDir = join(cwd, 'docs', 'retros');
|
|
75
|
+
if (!existsSync(scorecardDir)) {
|
|
76
|
+
mkdirSync(scorecardDir, { recursive: true });
|
|
77
|
+
console.log(` Created ${scorecardDir}/`);
|
|
78
|
+
}
|
|
79
|
+
// Write example scorecard
|
|
80
|
+
const examplePath = join(scorecardDir, 'sprint-1.json');
|
|
81
|
+
if (!existsSync(examplePath)) {
|
|
82
|
+
writeFileSync(examplePath, JSON.stringify(EXAMPLE_SCORECARD, null, 2) + '\n');
|
|
83
|
+
console.log(` Created ${examplePath}`);
|
|
84
|
+
}
|
|
85
|
+
// Write example common-issues.json
|
|
86
|
+
const commonIssuesPath = join(cwd, '.slope', 'common-issues.json');
|
|
87
|
+
if (!existsSync(commonIssuesPath)) {
|
|
88
|
+
writeFileSync(commonIssuesPath, JSON.stringify(EXAMPLE_COMMON_ISSUES, null, 2) + '\n');
|
|
89
|
+
console.log(` Created ${commonIssuesPath}`);
|
|
90
|
+
}
|
|
91
|
+
// Write example sessions.json
|
|
92
|
+
const sessionsPath = join(cwd, '.slope', 'sessions.json');
|
|
93
|
+
if (!existsSync(sessionsPath)) {
|
|
94
|
+
writeFileSync(sessionsPath, JSON.stringify({ sessions: [] }, null, 2) + '\n');
|
|
95
|
+
console.log(` Created ${sessionsPath}`);
|
|
96
|
+
}
|
|
97
|
+
// Claude Code templates
|
|
98
|
+
if (claudeCode) {
|
|
99
|
+
const templatesRoot = join(__dirname, '..', '..', '..', '..', 'templates', 'claude-code');
|
|
100
|
+
const rulesDir = join(cwd, '.claude', 'rules');
|
|
101
|
+
const hooksDir = join(cwd, '.claude', 'hooks');
|
|
102
|
+
mkdirSync(rulesDir, { recursive: true });
|
|
103
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
104
|
+
// Copy rule templates
|
|
105
|
+
const ruleFiles = ['sprint-checklist.md', 'commit-discipline.md', 'review-loop.md'];
|
|
106
|
+
for (const file of ruleFiles) {
|
|
107
|
+
const src = join(templatesRoot, 'rules', file);
|
|
108
|
+
const dest = join(rulesDir, file);
|
|
109
|
+
if (existsSync(src) && !existsSync(dest)) {
|
|
110
|
+
cpSync(src, dest);
|
|
111
|
+
console.log(` Created ${dest}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Copy hook templates
|
|
115
|
+
const hookFiles = ['pre-merge-check.sh'];
|
|
116
|
+
for (const file of hookFiles) {
|
|
117
|
+
const src = join(templatesRoot, 'hooks', file);
|
|
118
|
+
const dest = join(hooksDir, file);
|
|
119
|
+
if (existsSync(src) && !existsSync(dest)) {
|
|
120
|
+
cpSync(src, dest);
|
|
121
|
+
console.log(` Created ${dest}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
console.log('\n Claude Code templates installed to .claude/rules/ and .claude/hooks/');
|
|
125
|
+
}
|
|
126
|
+
console.log('\nSLOPE initialized. Try:');
|
|
127
|
+
console.log(' slope card');
|
|
128
|
+
console.log(' slope validate');
|
|
129
|
+
console.log('');
|
|
130
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { recommendClub, computeHandicapCard, computeDispersion, generateTrainingPlan, hazardBriefing, formatAdvisorReport, } from '@slope-dev/core';
|
|
2
|
+
import { loadConfig } from '../config.js';
|
|
3
|
+
import { loadScorecards } from '../loader.js';
|
|
4
|
+
export function planCommand(args) {
|
|
5
|
+
let complexity;
|
|
6
|
+
const slopeFactors = [];
|
|
7
|
+
const areas = [];
|
|
8
|
+
for (const arg of args) {
|
|
9
|
+
if (arg.startsWith('--complexity=')) {
|
|
10
|
+
complexity = arg.slice('--complexity='.length);
|
|
11
|
+
}
|
|
12
|
+
else if (arg.startsWith('--slope-factors=')) {
|
|
13
|
+
slopeFactors.push(...arg.slice('--slope-factors='.length).split(',').map(s => s.trim()).filter(Boolean));
|
|
14
|
+
}
|
|
15
|
+
else if (arg.startsWith('--areas=')) {
|
|
16
|
+
areas.push(...arg.slice('--areas='.length).split(',').map(s => s.trim()).filter(Boolean));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (!complexity) {
|
|
20
|
+
console.error('\nUsage: slope plan --complexity=<trivial|small|medium|large> [--slope-factors=a,b] [--areas=x,y]\n');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const validComplexities = ['trivial', 'small', 'medium', 'large'];
|
|
25
|
+
if (!validComplexities.includes(complexity)) {
|
|
26
|
+
console.error(`\nInvalid complexity "${complexity}". Must be one of: ${validComplexities.join(', ')}\n`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const config = loadConfig();
|
|
31
|
+
const scorecards = loadScorecards(config);
|
|
32
|
+
// Club recommendation
|
|
33
|
+
const clubRec = recommendClub({
|
|
34
|
+
ticketComplexity: complexity,
|
|
35
|
+
scorecards,
|
|
36
|
+
slopeFactors,
|
|
37
|
+
});
|
|
38
|
+
// Training plan
|
|
39
|
+
let trainingPlan = [];
|
|
40
|
+
if (scorecards.length > 0) {
|
|
41
|
+
const handicap = computeHandicapCard(scorecards);
|
|
42
|
+
const dispersion = computeDispersion(scorecards);
|
|
43
|
+
trainingPlan = generateTrainingPlan({ handicap, dispersion, recentScorecards: scorecards });
|
|
44
|
+
}
|
|
45
|
+
// Hazard warnings
|
|
46
|
+
let hazardWarnings = [];
|
|
47
|
+
if (areas.length > 0 && scorecards.length > 0) {
|
|
48
|
+
hazardWarnings = hazardBriefing({ areas, scorecards });
|
|
49
|
+
}
|
|
50
|
+
const report = formatAdvisorReport({ clubRecommendation: clubRec, trainingPlan, hazardWarnings });
|
|
51
|
+
console.log('');
|
|
52
|
+
console.log(report);
|
|
53
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { formatSprintReview } from '@slope-dev/core';
|
|
4
|
+
import { loadConfig } from '../config.js';
|
|
5
|
+
export function reviewCommand(path, mode) {
|
|
6
|
+
const config = loadConfig();
|
|
7
|
+
const cwd = process.cwd();
|
|
8
|
+
if (!path) {
|
|
9
|
+
// Default to latest scorecard
|
|
10
|
+
const dir = join(cwd, config.scorecardDir);
|
|
11
|
+
const patternParts = config.scorecardPattern.split('*');
|
|
12
|
+
const prefix = patternParts[0] ?? '';
|
|
13
|
+
const suffix = patternParts[1] ?? '';
|
|
14
|
+
const regex = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)${suffix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
|
|
15
|
+
try {
|
|
16
|
+
const files = readdirSync(dir)
|
|
17
|
+
.filter((f) => {
|
|
18
|
+
const m = f.match(regex);
|
|
19
|
+
return m && parseInt(m[1], 10) >= config.minSprint;
|
|
20
|
+
})
|
|
21
|
+
.sort();
|
|
22
|
+
if (files.length === 0) {
|
|
23
|
+
console.log('\nNo scorecards found.\n');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
path = join(dir, files[files.length - 1]);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
console.log('\nScorecard directory not found.\n');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
let raw;
|
|
34
|
+
try {
|
|
35
|
+
raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
console.error(`\nFailed to parse ${path}\n`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
const card = { ...raw, sprint_number: raw.sprint_number ?? raw.sprint };
|
|
42
|
+
const reviewMode = mode === 'plain' ? 'plain' : 'technical';
|
|
43
|
+
const review = formatSprintReview(card, undefined, undefined, reviewMode);
|
|
44
|
+
console.log('');
|
|
45
|
+
console.log(review);
|
|
46
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { validateScorecard } from '@slope-dev/core';
|
|
4
|
+
import { loadConfig } from '../config.js';
|
|
5
|
+
export function validateCommand(path) {
|
|
6
|
+
const config = loadConfig();
|
|
7
|
+
const files = [];
|
|
8
|
+
if (path) {
|
|
9
|
+
files.push(path);
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
// Validate all scorecards matching config
|
|
13
|
+
const dir = join(process.cwd(), config.scorecardDir);
|
|
14
|
+
const patternParts = config.scorecardPattern.split('*');
|
|
15
|
+
const prefix = patternParts[0] ?? '';
|
|
16
|
+
const suffix = patternParts[1] ?? '';
|
|
17
|
+
const regex = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)${suffix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
|
|
18
|
+
try {
|
|
19
|
+
const dirFiles = readdirSync(dir)
|
|
20
|
+
.filter((f) => {
|
|
21
|
+
const m = f.match(regex);
|
|
22
|
+
return m && parseInt(m[1], 10) >= config.minSprint;
|
|
23
|
+
})
|
|
24
|
+
.sort();
|
|
25
|
+
for (const f of dirFiles) {
|
|
26
|
+
files.push(join(dir, f));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Directory doesn't exist
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (files.length === 0) {
|
|
34
|
+
console.log('\nNo scorecards found to validate.\n');
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
let allValid = true;
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
let raw;
|
|
40
|
+
try {
|
|
41
|
+
raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
console.log(`\n\u2717 ${file}: Failed to parse JSON`);
|
|
45
|
+
allValid = false;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const card = { ...raw, sprint_number: raw.sprint_number ?? raw.sprint };
|
|
49
|
+
const result = validateScorecard(card);
|
|
50
|
+
const sprintLabel = card.sprint_number ? `Sprint ${card.sprint_number}` : file;
|
|
51
|
+
if (result.valid && result.warnings.length === 0) {
|
|
52
|
+
console.log(`\u2713 ${sprintLabel}: Valid (no errors, no warnings)`);
|
|
53
|
+
}
|
|
54
|
+
else if (result.valid) {
|
|
55
|
+
console.log(`\u2713 ${sprintLabel}: Valid (${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'})`);
|
|
56
|
+
for (const w of result.warnings) {
|
|
57
|
+
console.log(` \u26A0 [${w.code}] ${w.message}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
console.log(`\u2717 ${sprintLabel}: INVALID (${result.errors.length} error${result.errors.length === 1 ? '' : 's'}, ${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'})`);
|
|
62
|
+
for (const e of result.errors) {
|
|
63
|
+
console.log(` \u2717 [${e.code}] ${e.message}${e.field ? ` (${e.field})` : ''}`);
|
|
64
|
+
}
|
|
65
|
+
for (const w of result.warnings) {
|
|
66
|
+
console.log(` \u26A0 [${w.code}] ${w.message}`);
|
|
67
|
+
}
|
|
68
|
+
allValid = false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
console.log('');
|
|
72
|
+
process.exit(allValid ? 0 : 1);
|
|
73
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const DEFAULT_CONFIG = {
|
|
4
|
+
scorecardDir: 'docs/retros',
|
|
5
|
+
scorecardPattern: 'sprint-*.json',
|
|
6
|
+
minSprint: 1,
|
|
7
|
+
commonIssuesPath: '.slope/common-issues.json',
|
|
8
|
+
sessionsPath: '.slope/sessions.json',
|
|
9
|
+
};
|
|
10
|
+
const CONFIG_DIR = '.slope';
|
|
11
|
+
const CONFIG_FILE = 'config.json';
|
|
12
|
+
export function loadConfig(cwd = process.cwd()) {
|
|
13
|
+
const configPath = join(cwd, CONFIG_DIR, CONFIG_FILE);
|
|
14
|
+
if (!existsSync(configPath)) {
|
|
15
|
+
return { ...DEFAULT_CONFIG };
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const raw = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
19
|
+
return { ...DEFAULT_CONFIG, ...raw };
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return { ...DEFAULT_CONFIG };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function createConfig(cwd = process.cwd()) {
|
|
26
|
+
const dir = join(cwd, CONFIG_DIR);
|
|
27
|
+
if (!existsSync(dir)) {
|
|
28
|
+
mkdirSync(dir, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
const configPath = join(dir, CONFIG_FILE);
|
|
31
|
+
writeFileSync(configPath, JSON.stringify(DEFAULT_CONFIG, null, 2) + '\n');
|
|
32
|
+
return configPath;
|
|
33
|
+
}
|
|
34
|
+
export function resolveConfigPath(config, relativePath, cwd = process.cwd()) {
|
|
35
|
+
return join(cwd, relativePath);
|
|
36
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SLOPE CLI — Sprint Lifecycle & Operational Performance Engine
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* slope init Initialize .slope/ directory
|
|
7
|
+
* slope card Display handicap card
|
|
8
|
+
* slope validate [path] Validate scorecard(s)
|
|
9
|
+
* slope review [path] [--plain] Format sprint review
|
|
10
|
+
* slope briefing [options] Pre-round briefing
|
|
11
|
+
* slope plan --complexity=<level> Pre-shot advisor
|
|
12
|
+
* slope classify --scope=... ... Classify a shot
|
|
13
|
+
*/
|
|
14
|
+
import { initCommand } from './commands/init.js';
|
|
15
|
+
import { cardCommand } from './commands/card.js';
|
|
16
|
+
import { validateCommand } from './commands/validate.js';
|
|
17
|
+
import { reviewCommand } from './commands/review.js';
|
|
18
|
+
import { briefingCommand } from './commands/briefing.js';
|
|
19
|
+
import { planCommand } from './commands/plan.js';
|
|
20
|
+
import { classifyCommand } from './commands/classify.js';
|
|
21
|
+
const subcommand = process.argv[2];
|
|
22
|
+
switch (subcommand) {
|
|
23
|
+
case 'init':
|
|
24
|
+
initCommand(process.argv.slice(3));
|
|
25
|
+
break;
|
|
26
|
+
case 'card':
|
|
27
|
+
cardCommand();
|
|
28
|
+
break;
|
|
29
|
+
case 'validate':
|
|
30
|
+
validateCommand(process.argv[3]);
|
|
31
|
+
break;
|
|
32
|
+
case 'review': {
|
|
33
|
+
const reviewArgs = process.argv.slice(3);
|
|
34
|
+
const plainFlag = reviewArgs.includes('--plain');
|
|
35
|
+
const path = reviewArgs.find((a) => !a.startsWith('--'));
|
|
36
|
+
reviewCommand(path, plainFlag ? 'plain' : undefined);
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
case 'briefing':
|
|
40
|
+
briefingCommand(process.argv.slice(3));
|
|
41
|
+
break;
|
|
42
|
+
case 'plan':
|
|
43
|
+
planCommand(process.argv.slice(3));
|
|
44
|
+
break;
|
|
45
|
+
case 'classify':
|
|
46
|
+
classifyCommand(process.argv.slice(3));
|
|
47
|
+
break;
|
|
48
|
+
default:
|
|
49
|
+
console.log(`
|
|
50
|
+
SLOPE CLI — Sprint Lifecycle & Operational Performance Engine
|
|
51
|
+
|
|
52
|
+
Usage:
|
|
53
|
+
slope init [--claude-code] Initialize .slope/ directory
|
|
54
|
+
slope card Show handicap card
|
|
55
|
+
slope validate [path] Validate scorecard(s)
|
|
56
|
+
slope review [path] [--plain] Format sprint review markdown
|
|
57
|
+
slope briefing [options] Pre-round briefing
|
|
58
|
+
slope plan --complexity=<level> Pre-shot advisor (club + training + hazards)
|
|
59
|
+
slope classify --scope=... ... Classify a shot from execution trace
|
|
60
|
+
|
|
61
|
+
Examples:
|
|
62
|
+
slope init Create .slope/ with config + example scorecard
|
|
63
|
+
slope init --claude-code Also install Claude Code rules + hooks
|
|
64
|
+
slope card Show handicap across all scorecards
|
|
65
|
+
slope validate docs/retros/sprint-1.json Validate a specific scorecard
|
|
66
|
+
slope validate Validate all scorecards
|
|
67
|
+
slope review Review the latest scorecard
|
|
68
|
+
slope review --plain Non-technical sprint review
|
|
69
|
+
slope briefing Full briefing (top 10 recent gotchas)
|
|
70
|
+
slope briefing --categories=testing Filter by category
|
|
71
|
+
slope briefing --keywords=migration Filter by keyword
|
|
72
|
+
slope plan --complexity=medium Club recommendation for medium ticket
|
|
73
|
+
slope plan --complexity=large --areas=db Include hazard warnings for db area
|
|
74
|
+
slope classify --scope="a.ts" --modified="a.ts" --tests=pass --reverts=0
|
|
75
|
+
`);
|
|
76
|
+
process.exit(subcommand ? 1 : 0);
|
|
77
|
+
}
|
package/dist/loader.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Load SLOPE scorecards from the configured directory.
|
|
5
|
+
* Filters by minSprint and normalizes sprint_number.
|
|
6
|
+
*/
|
|
7
|
+
export function loadScorecards(config, cwd = process.cwd()) {
|
|
8
|
+
const dir = join(cwd, config.scorecardDir);
|
|
9
|
+
if (!existsSync(dir)) {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
// Build regex from pattern (e.g. "sprint-*.json" → /^sprint-(\d+)\.json$/)
|
|
13
|
+
const patternParts = config.scorecardPattern.split('*');
|
|
14
|
+
const prefix = patternParts[0] ?? '';
|
|
15
|
+
const suffix = patternParts[1] ?? '';
|
|
16
|
+
const regex = new RegExp(`^${escapeRegex(prefix)}(\\d+)${escapeRegex(suffix)}$`);
|
|
17
|
+
const files = readdirSync(dir)
|
|
18
|
+
.filter((f) => regex.test(f))
|
|
19
|
+
.sort();
|
|
20
|
+
const scorecards = [];
|
|
21
|
+
for (const file of files) {
|
|
22
|
+
const match = file.match(regex);
|
|
23
|
+
if (!match)
|
|
24
|
+
continue;
|
|
25
|
+
const num = parseInt(match[1], 10);
|
|
26
|
+
if (num < config.minSprint)
|
|
27
|
+
continue;
|
|
28
|
+
try {
|
|
29
|
+
const raw = JSON.parse(readFileSync(join(dir, file), 'utf8'));
|
|
30
|
+
const card = {
|
|
31
|
+
...raw,
|
|
32
|
+
sprint_number: raw.sprint_number ?? raw.sprint,
|
|
33
|
+
};
|
|
34
|
+
scorecards.push(card);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
console.error(` Warning: Could not parse ${file}, skipping`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return scorecards;
|
|
41
|
+
}
|
|
42
|
+
function escapeRegex(s) {
|
|
43
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
44
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@slope-dev/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SLOPE CLI — Sprint Lifecycle & Operational Performance Engine",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"slope": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@slope-dev/core": "0.1.0"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@types/node": "^25.3.0",
|
|
14
|
+
"typescript": "^5.7.0"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/srbryers/slope.git",
|
|
26
|
+
"directory": "packages/cli"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"slope",
|
|
30
|
+
"sprint",
|
|
31
|
+
"scoring",
|
|
32
|
+
"cli",
|
|
33
|
+
"retro"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc"
|
|
40
|
+
}
|
|
41
|
+
}
|