roadmapsmith 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/src/match.js ADDED
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ const { similarityScore, slugify, tokenize, uniqueBy } = require('./utils');
4
+ const { PHASE_ORDER } = require('./model');
5
+
6
+ function canonicalSignature(text) {
7
+ const tokens = uniqueBy(tokenize(text), (token) => token).slice(0, 8);
8
+ return tokens.join('-') || slugify(text);
9
+ }
10
+
11
+ function inferPriorityWeight(priority) {
12
+ const normalized = String(priority || '').toUpperCase();
13
+ if (normalized === 'P0') return 0;
14
+ if (normalized === 'P1') return 1;
15
+ if (normalized === 'P2') return 2;
16
+ return 3;
17
+ }
18
+
19
+ function findBestTaskMatch(candidate, existingTasks, minScore = 0.55) {
20
+ const direct = existingTasks.find((task) => task.id === candidate.id);
21
+ if (direct) {
22
+ return { task: direct, score: 1 };
23
+ }
24
+
25
+ let best = null;
26
+ for (const task of existingTasks) {
27
+ const score = similarityScore(candidate.text, task.text);
28
+ if (score < minScore) {
29
+ continue;
30
+ }
31
+ if (!best || score > best.score) {
32
+ best = { task, score };
33
+ }
34
+ }
35
+
36
+ return best;
37
+ }
38
+
39
+ function dedupeTasks(tasks) {
40
+ const grouped = new Map();
41
+
42
+ for (const task of tasks) {
43
+ const signature = canonicalSignature(task.text);
44
+ if (!grouped.has(signature)) {
45
+ grouped.set(signature, task);
46
+ continue;
47
+ }
48
+
49
+ const current = grouped.get(signature);
50
+ const candidate = task;
51
+
52
+ if (current.checked !== candidate.checked) {
53
+ grouped.set(signature, candidate.checked ? candidate : current);
54
+ continue;
55
+ }
56
+
57
+ const currentWeight = inferPriorityWeight(current.priority);
58
+ const candidateWeight = inferPriorityWeight(candidate.priority);
59
+ if (candidateWeight < currentWeight) {
60
+ grouped.set(signature, candidate);
61
+ continue;
62
+ }
63
+
64
+ if (candidateWeight === currentWeight && candidate.text.length < current.text.length) {
65
+ grouped.set(signature, candidate);
66
+ }
67
+ }
68
+
69
+ return Array.from(grouped.values()).sort((left, right) => {
70
+ const leftPhase = PHASE_ORDER.indexOf(left.phase);
71
+ const rightPhase = PHASE_ORDER.indexOf(right.phase);
72
+ if (leftPhase !== rightPhase) {
73
+ return leftPhase - rightPhase;
74
+ }
75
+ if (left.priority !== right.priority) {
76
+ return inferPriorityWeight(left.priority) - inferPriorityWeight(right.priority);
77
+ }
78
+ return left.text.localeCompare(right.text);
79
+ });
80
+ }
81
+
82
+ module.exports = {
83
+ canonicalSignature,
84
+ dedupeTasks,
85
+ findBestTaskMatch
86
+ };
package/src/model.js ADDED
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+
3
+ const PHASE_ORDER = ['P0', 'P1', 'P2'];
4
+
5
+ function phaseWeight(phase) {
6
+ const idx = PHASE_ORDER.indexOf(phase);
7
+ return idx >= 0 ? idx : PHASE_ORDER.length;
8
+ }
9
+
10
+ function createRoadmapModel(input) {
11
+ return {
12
+ northStar: input.northStar,
13
+ currentState: input.currentState,
14
+ phases: input.phases,
15
+ milestones: input.milestones,
16
+ commandBreakdown: input.commandBreakdown,
17
+ exitCriteria: input.exitCriteria,
18
+ risks: input.risks,
19
+ antiGoals: input.antiGoals,
20
+ customSections: input.customSections || []
21
+ };
22
+ }
23
+
24
+ module.exports = {
25
+ PHASE_ORDER,
26
+ createRoadmapModel,
27
+ phaseWeight
28
+ };
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ const { slugify } = require('../utils');
4
+
5
+ const TASK_LINE_RE = /^(\s*)- \[( |x|X)\] (.*?)(?:\s*<!--\s*rs:task=([a-z0-9-]+)\s*-->)?\s*$/;
6
+ const WARNING_RE = /^\s*-\s+⚠️ attempted but validation failed:\s*(.+?)\s*$/;
7
+ const HEADING_RE = /^#{2,3}\s+(.*)$/;
8
+
9
+ const MANAGED_START = '<!-- rs:managed:start -->';
10
+ const MANAGED_END = '<!-- rs:managed:end -->';
11
+
12
+ function parseRoadmap(content) {
13
+ const lines = String(content || '').split(/\r?\n/);
14
+ const tasks = [];
15
+ let section = '';
16
+
17
+ for (let index = 0; index < lines.length; index += 1) {
18
+ const line = lines[index];
19
+ const headingMatch = line.match(HEADING_RE);
20
+ if (headingMatch) {
21
+ section = headingMatch[1].trim();
22
+ }
23
+
24
+ const taskMatch = line.match(TASK_LINE_RE);
25
+ if (!taskMatch) {
26
+ continue;
27
+ }
28
+
29
+ const indent = taskMatch[1] || '';
30
+ const checked = taskMatch[2].toLowerCase() === 'x';
31
+ const text = taskMatch[3].trim();
32
+ const markerId = taskMatch[4] || null;
33
+
34
+ let warningLineIndex = null;
35
+ let warningText = null;
36
+ if (index + 1 < lines.length) {
37
+ const nextLine = lines[index + 1];
38
+ const warningMatch = nextLine.match(WARNING_RE);
39
+ if (warningMatch) {
40
+ warningLineIndex = index + 1;
41
+ warningText = warningMatch[1].trim();
42
+ }
43
+ }
44
+
45
+ const id = markerId || slugify(text);
46
+ tasks.push({
47
+ id,
48
+ text,
49
+ checked,
50
+ lineIndex: index,
51
+ warningLineIndex,
52
+ warningText,
53
+ markerId,
54
+ indent,
55
+ section
56
+ });
57
+ }
58
+
59
+ return {
60
+ lines,
61
+ tasks
62
+ };
63
+ }
64
+
65
+ function findManagedRange(lines) {
66
+ let start = -1;
67
+ let end = -1;
68
+
69
+ for (let i = 0; i < lines.length; i += 1) {
70
+ if (lines[i].trim() === MANAGED_START) {
71
+ start = i;
72
+ continue;
73
+ }
74
+ if (lines[i].trim() === MANAGED_END) {
75
+ end = i;
76
+ break;
77
+ }
78
+ }
79
+
80
+ if (start >= 0 && end >= 0 && start < end) {
81
+ return { start, end };
82
+ }
83
+ return null;
84
+ }
85
+
86
+ function upsertManagedBlock(existingContent, managedBody) {
87
+ const existing = String(existingContent || '');
88
+ const lines = existing.split(/\r?\n/);
89
+ const range = findManagedRange(lines);
90
+ const bodyLines = managedBody.split(/\r?\n/);
91
+
92
+ if (!range) {
93
+ if (existing.trim().length === 0) {
94
+ return [MANAGED_START, ...bodyLines, MANAGED_END].join('\n');
95
+ }
96
+ return `${existing.replace(/\s+$/, '')}\n\n${MANAGED_START}\n${managedBody}\n${MANAGED_END}`;
97
+ }
98
+
99
+ const prefix = lines.slice(0, range.start + 1);
100
+ const suffix = lines.slice(range.end);
101
+ return [...prefix, ...bodyLines, ...suffix].join('\n');
102
+ }
103
+
104
+ module.exports = {
105
+ MANAGED_END,
106
+ MANAGED_START,
107
+ parseRoadmap,
108
+ upsertManagedBlock
109
+ };
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ const { parseRoadmap } = require('../parser');
4
+ const { ensureTrailingNewline } = require('../utils');
5
+
6
+ function setChecklistState(line, checked) {
7
+ return line.replace(/- \[( |x|X)\]/, `- [${checked ? 'x' : ' '}]`);
8
+ }
9
+
10
+ function formatWarning(indent, reason) {
11
+ return `${indent} - ⚠️ attempted but validation failed: ${reason}`;
12
+ }
13
+
14
+ function applySync(content, parsedTasks, results) {
15
+ const parsed = parseRoadmap(content);
16
+ const lines = [...parsed.lines];
17
+ const tasks = parsedTasks || parsed.tasks;
18
+
19
+ let offset = 0;
20
+ for (const task of tasks) {
21
+ const result = results[task.id];
22
+ if (!result) {
23
+ continue;
24
+ }
25
+
26
+ const lineIndex = task.lineIndex + offset;
27
+ if (lineIndex < 0 || lineIndex >= lines.length) {
28
+ continue;
29
+ }
30
+
31
+ lines[lineIndex] = setChecklistState(lines[lineIndex], result.passed);
32
+
33
+ const reason = result.reasons.join('; ');
34
+ const warningText = formatWarning(task.indent || '', reason || 'validation failed');
35
+ const hasWarning = task.warningLineIndex != null;
36
+ const warningIndex = hasWarning ? task.warningLineIndex + offset : null;
37
+
38
+ if (result.passed || !result.attempted) {
39
+ if (warningIndex != null && warningIndex >= 0 && warningIndex < lines.length) {
40
+ lines.splice(warningIndex, 1);
41
+ offset -= 1;
42
+ }
43
+ continue;
44
+ }
45
+
46
+ if (warningIndex != null && warningIndex >= 0 && warningIndex < lines.length) {
47
+ lines[warningIndex] = warningText;
48
+ } else {
49
+ lines.splice(lineIndex + 1, 0, warningText);
50
+ offset += 1;
51
+ }
52
+ }
53
+
54
+ return ensureTrailingNewline(lines.join('\n'));
55
+ }
56
+
57
+ module.exports = {
58
+ applySync
59
+ };
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function readTemplate(name) {
7
+ const absolutePath = path.resolve(__dirname, '..', '..', 'templates', name);
8
+ return fs.readFileSync(absolutePath, 'utf8');
9
+ }
10
+
11
+ function replaceTokens(content, replacements) {
12
+ let result = content;
13
+ for (const [key, value] of Object.entries(replacements || {})) {
14
+ const token = `{{${key}}}`;
15
+ result = result.split(token).join(String(value));
16
+ }
17
+ return result;
18
+ }
19
+
20
+ function renderRoadmapTemplate(replacements = {}) {
21
+ return replaceTokens(readTemplate('roadmap.template.md'), replacements);
22
+ }
23
+
24
+ function renderAgentsTemplate(replacements = {}) {
25
+ return replaceTokens(readTemplate('agents.template.md'), replacements);
26
+ }
27
+
28
+ module.exports = {
29
+ renderAgentsTemplate,
30
+ renderRoadmapTemplate
31
+ };
package/src/utils.js ADDED
@@ -0,0 +1,143 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+
5
+ const STOP_WORDS = new Set([
6
+ 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'in', 'into', 'is', 'it', 'of', 'on', 'or', 'that',
7
+ 'the', 'to', 'with', 'this', 'these', 'those', 'via', 'per', 'task', 'tasks', 'phase', 'priority'
8
+ ]);
9
+
10
+ function toPosix(input) {
11
+ return input.split(path.sep).join('/');
12
+ }
13
+
14
+ function slugify(text) {
15
+ return String(text || '')
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9]+/g, '-')
18
+ .replace(/^-+|-+$/g, '')
19
+ .replace(/-{2,}/g, '-') || 'task';
20
+ }
21
+
22
+ function normalizeText(text) {
23
+ return String(text || '')
24
+ .toLowerCase()
25
+ .replace(/[`*_~#>\[\](){}.!?,:;"']/g, ' ')
26
+ .replace(/\s+/g, ' ')
27
+ .trim();
28
+ }
29
+
30
+ function tokenize(text) {
31
+ return normalizeText(text)
32
+ .split(' ')
33
+ .filter(Boolean)
34
+ .filter((token) => !STOP_WORDS.has(token));
35
+ }
36
+
37
+ function uniqueBy(items, keyFn) {
38
+ const seen = new Set();
39
+ const result = [];
40
+ for (const item of items) {
41
+ const key = keyFn(item);
42
+ if (seen.has(key)) {
43
+ continue;
44
+ }
45
+ seen.add(key);
46
+ result.push(item);
47
+ }
48
+ return result;
49
+ }
50
+
51
+ function similarityScore(left, right) {
52
+ const leftTokens = new Set(tokenize(left));
53
+ const rightTokens = new Set(tokenize(right));
54
+ if (leftTokens.size === 0 || rightTokens.size === 0) {
55
+ return 0;
56
+ }
57
+
58
+ let shared = 0;
59
+ for (const token of leftTokens) {
60
+ if (rightTokens.has(token)) {
61
+ shared += 1;
62
+ }
63
+ }
64
+
65
+ const union = new Set([...leftTokens, ...rightTokens]);
66
+ return shared / union.size;
67
+ }
68
+
69
+ function ensureTrailingNewline(text) {
70
+ return text.endsWith('\n') ? text : `${text}\n`;
71
+ }
72
+
73
+ function escapeRegExp(value) {
74
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
75
+ }
76
+
77
+ function parseArgv(argv) {
78
+ const flags = {};
79
+ const positionals = [];
80
+
81
+ for (let i = 0; i < argv.length; i += 1) {
82
+ const current = argv[i];
83
+ if (!current.startsWith('-')) {
84
+ positionals.push(current);
85
+ continue;
86
+ }
87
+
88
+ if (current.startsWith('--')) {
89
+ const withoutPrefix = current.slice(2);
90
+ const eqIndex = withoutPrefix.indexOf('=');
91
+ let key;
92
+ let value;
93
+
94
+ if (eqIndex >= 0) {
95
+ key = withoutPrefix.slice(0, eqIndex);
96
+ value = withoutPrefix.slice(eqIndex + 1);
97
+ } else {
98
+ key = withoutPrefix;
99
+ const next = argv[i + 1];
100
+ if (next && !next.startsWith('-')) {
101
+ value = next;
102
+ i += 1;
103
+ } else {
104
+ value = true;
105
+ }
106
+ }
107
+
108
+ if (Object.prototype.hasOwnProperty.call(flags, key)) {
109
+ if (Array.isArray(flags[key])) {
110
+ flags[key].push(value);
111
+ } else {
112
+ flags[key] = [flags[key], value];
113
+ }
114
+ } else {
115
+ flags[key] = value;
116
+ }
117
+ continue;
118
+ }
119
+
120
+ const short = current.slice(1);
121
+ flags[short] = true;
122
+ }
123
+
124
+ const command = positionals.length > 0 ? positionals[0] : null;
125
+ return {
126
+ command,
127
+ args: positionals.slice(1),
128
+ flags,
129
+ positionals
130
+ };
131
+ }
132
+
133
+ module.exports = {
134
+ escapeRegExp,
135
+ ensureTrailingNewline,
136
+ normalizeText,
137
+ parseArgv,
138
+ similarityScore,
139
+ slugify,
140
+ toPosix,
141
+ tokenize,
142
+ uniqueBy
143
+ };