context-planning 0.7.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 +454 -0
- package/bin/commands/_helpers.js +53 -0
- package/bin/commands/_usage.js +67 -0
- package/bin/commands/capture.js +46 -0
- package/bin/commands/codebase-status.js +41 -0
- package/bin/commands/complete-milestone.js +57 -0
- package/bin/commands/config.js +70 -0
- package/bin/commands/doctor.js +139 -0
- package/bin/commands/gsd-import.js +90 -0
- package/bin/commands/inbox.js +81 -0
- package/bin/commands/index.js +33 -0
- package/bin/commands/init.js +87 -0
- package/bin/commands/install.js +43 -0
- package/bin/commands/scaffold-codebase.js +53 -0
- package/bin/commands/scaffold-milestone.js +58 -0
- package/bin/commands/scaffold-phase.js +65 -0
- package/bin/commands/status.js +42 -0
- package/bin/commands/statusline.js +108 -0
- package/bin/commands/tick.js +49 -0
- package/bin/commands/version.js +9 -0
- package/bin/commands/worktree.js +218 -0
- package/bin/commands/write-summary.js +54 -0
- package/bin/cp.cmd +2 -0
- package/bin/cp.js +54 -0
- package/commands/cp/capture.md +107 -0
- package/commands/cp/complete-milestone.md +166 -0
- package/commands/cp/execute-phase.md +220 -0
- package/commands/cp/map-codebase.md +211 -0
- package/commands/cp/new-milestone.md +136 -0
- package/commands/cp/new-project.md +132 -0
- package/commands/cp/plan-phase.md +195 -0
- package/commands/cp/progress.md +147 -0
- package/commands/cp/quick.md +104 -0
- package/commands/cp/resume.md +125 -0
- package/commands/cp/write-summary.md +33 -0
- package/docs/MIGRATION-v0.5.md +140 -0
- package/docs/architecture.md +189 -0
- package/docs/superpowers/plans/2026-05-20-v0-7-plan-16-01-design-md-infrastructure.md +1064 -0
- package/docs/superpowers/plans/2026-05-20-v0-7-plan-16-02-review-log-infrastructure.md +418 -0
- package/docs/superpowers/plans/2026-05-20-v0-7-plan-16-03-key-decisions-hard-block.md +295 -0
- package/docs/superpowers/specs/2026-05-20-generic-provider-harness-detection-design.md +380 -0
- package/docs/superpowers/specs/2026-05-20-v0-7-design-capture-design.md +400 -0
- package/docs/writing-providers.md +76 -0
- package/install/aider.js +204 -0
- package/install/claude.js +116 -0
- package/install/common.js +65 -0
- package/install/copilot.js +86 -0
- package/install/cursor.js +120 -0
- package/install/echo-provider.js +50 -0
- package/lib/codebase-mapper.js +169 -0
- package/lib/detect.js +280 -0
- package/lib/frontmatter.js +72 -0
- package/lib/gsd-compat.js +165 -0
- package/lib/import.js +543 -0
- package/lib/inbox.js +226 -0
- package/lib/lifecycle.js +929 -0
- package/lib/merge.js +157 -0
- package/lib/milestone.js +595 -0
- package/lib/paths.js +191 -0
- package/lib/provider.js +168 -0
- package/lib/roadmap.js +134 -0
- package/lib/state.js +99 -0
- package/lib/worktree.js +253 -0
- package/package.json +45 -0
- package/templates/DESIGN.md +78 -0
- package/templates/INBOX.md +13 -0
- package/templates/MILESTONE-CONTEXT.md +40 -0
- package/templates/MILESTONES.md +29 -0
- package/templates/PLAN.md +84 -0
- package/templates/PROJECT.md +43 -0
- package/templates/REVIEW-LOG.md +38 -0
- package/templates/ROADMAP.md +34 -0
- package/templates/STATE.md +78 -0
- package/templates/SUMMARY.md +75 -0
- package/templates/codebase/ARCHITECTURE.md +30 -0
- package/templates/codebase/CONCERNS.md +30 -0
- package/templates/codebase/CONVENTIONS.md +30 -0
- package/templates/codebase/INTEGRATIONS.md +30 -0
- package/templates/codebase/STACK.md +26 -0
- package/templates/codebase/STRUCTURE.md +32 -0
- package/templates/codebase/TESTING.md +39 -0
- package/templates/config.json +173 -0
- package/templates/phase-PLAN.md +32 -0
- package/templates/quick-PLAN.md +24 -0
- package/templates/quick-SUMMARY.md +25 -0
package/lib/paths.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tiny path resolver for the cp plugin. No external deps.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
|
|
10
|
+
function repoRoot() {
|
|
11
|
+
// walk up from cwd until we find .git or a package.json
|
|
12
|
+
let dir = process.cwd();
|
|
13
|
+
for (let i = 0; i < 12; i++) {
|
|
14
|
+
if (
|
|
15
|
+
fs.existsSync(path.join(dir, '.git')) ||
|
|
16
|
+
fs.existsSync(path.join(dir, '.planning'))
|
|
17
|
+
) {
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
const parent = path.dirname(dir);
|
|
21
|
+
if (parent === dir) break;
|
|
22
|
+
dir = parent;
|
|
23
|
+
}
|
|
24
|
+
return process.cwd();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function planningDir(root = repoRoot()) {
|
|
28
|
+
return path.join(root, '.planning');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ensureDir(p) {
|
|
32
|
+
fs.mkdirSync(p, { recursive: true });
|
|
33
|
+
return p;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function pluginRoot() {
|
|
37
|
+
// bin/cp.js -> ../
|
|
38
|
+
return path.resolve(__dirname, '..');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function templatePath(name) {
|
|
42
|
+
return path.join(pluginRoot(), 'templates', name);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readTemplate(name) {
|
|
46
|
+
return fs.readFileSync(templatePath(name), 'utf8');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Format a phase number for use in directory names and filename prefixes.
|
|
51
|
+
* Integer phases get zero-padded to width 2 ("1" -> "01"). Decimal phases
|
|
52
|
+
* keep their natural form ("2.1" stays "2.1"). Matches GSD conventions.
|
|
53
|
+
*/
|
|
54
|
+
function padPhaseNum(num) {
|
|
55
|
+
const s = String(num);
|
|
56
|
+
if (/^\d+$/.test(s)) return s.padStart(2, '0');
|
|
57
|
+
return s; // decimal like "2.1"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function padPlanNum(num) {
|
|
61
|
+
return String(num).padStart(2, '0');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Slugify a phase name for the directory portion of phases/XX-name. */
|
|
65
|
+
function slugifyPhase(name) {
|
|
66
|
+
return String(name)
|
|
67
|
+
.toLowerCase()
|
|
68
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
69
|
+
.replace(/^-+|-+$/g, '')
|
|
70
|
+
.slice(0, 40) || 'phase';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** "01-foundation" given phase 1, name "Foundation". */
|
|
74
|
+
function phaseDirName(phaseNum, phaseName) {
|
|
75
|
+
return `${padPhaseNum(phaseNum)}-${slugifyPhase(phaseName)}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Full path to a phase directory. */
|
|
79
|
+
function phaseDir(phaseNum, phaseName, root = repoRoot()) {
|
|
80
|
+
return path.join(planningDir(root), 'phases', phaseDirName(phaseNum, phaseName));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Filename prefix "01-02" for phase 1, plan 2. */
|
|
84
|
+
function phasePlanPrefix(phaseNum, planNum) {
|
|
85
|
+
return `${padPhaseNum(phaseNum)}-${padPlanNum(planNum)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Full path to a phase plan file: .planning/phases/01-foundation/01-02-PLAN.md */
|
|
89
|
+
function planFile(phaseNum, phaseName, planNum, root = repoRoot()) {
|
|
90
|
+
return path.join(
|
|
91
|
+
phaseDir(phaseNum, phaseName, root),
|
|
92
|
+
`${phasePlanPrefix(phaseNum, planNum)}-PLAN.md`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Full path to a phase summary file. */
|
|
97
|
+
function summaryFile(phaseNum, phaseName, planNum, root = repoRoot()) {
|
|
98
|
+
return path.join(
|
|
99
|
+
phaseDir(phaseNum, phaseName, root),
|
|
100
|
+
`${phasePlanPrefix(phaseNum, planNum)}-SUMMARY.md`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Full path to a phase DESIGN.md file. Resolves the phase dir on disk first
|
|
105
|
+
* so callers can pass just the phase number. Returns null if no phase dir. */
|
|
106
|
+
function designFile(phaseNumOrSlug, root = repoRoot()) {
|
|
107
|
+
const dir = findPhaseDir(phaseNumOrSlug, root);
|
|
108
|
+
if (!dir) return null;
|
|
109
|
+
return path.join(dir, 'DESIGN.md');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Full path to a phase REVIEW-LOG.md file. Returns null if no phase dir. */
|
|
113
|
+
function reviewLogFile(phaseNumOrSlug, root = repoRoot()) {
|
|
114
|
+
const dir = findPhaseDir(phaseNumOrSlug, root);
|
|
115
|
+
if (!dir) return null;
|
|
116
|
+
return path.join(dir, 'REVIEW-LOG.md');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Slugify a milestone name (e.g. "v0.7 Design Capture" -> "v0-7-design-capture"). */
|
|
120
|
+
function milestoneSlug(name) {
|
|
121
|
+
return String(name)
|
|
122
|
+
.toLowerCase()
|
|
123
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
124
|
+
.replace(/^-+|-+$/g, '')
|
|
125
|
+
.slice(0, 60) || 'milestone';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Full path to a milestone directory: .planning/milestones/<slug>/ */
|
|
129
|
+
function milestoneDir(milestoneName, root = repoRoot()) {
|
|
130
|
+
return path.join(planningDir(root), 'milestones', milestoneSlug(milestoneName));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Full path to a milestone DESIGN.md file. */
|
|
134
|
+
function milestoneDesignFile(milestoneName, root = repoRoot()) {
|
|
135
|
+
return path.join(milestoneDir(milestoneName, root), 'DESIGN.md');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Locate the existing phase dir for a number, scanning the filesystem.
|
|
139
|
+
* Accepts either a phase number (e.g. `'3'`, `3`, `'3.1'`) or a full slug
|
|
140
|
+
* (e.g. `'03-sharing'`). Returns the absolute path, or null if not found. */
|
|
141
|
+
function findPhaseDir(phaseNumOrSlug, root = repoRoot()) {
|
|
142
|
+
const phasesRoot = path.join(planningDir(root), 'phases');
|
|
143
|
+
if (!fs.existsSync(phasesRoot)) return null;
|
|
144
|
+
const input = String(phaseNumOrSlug);
|
|
145
|
+
const entries = fs.readdirSync(phasesRoot);
|
|
146
|
+
|
|
147
|
+
// Exact dir-name match first (slug case).
|
|
148
|
+
if (entries.includes(input)) return path.join(phasesRoot, input);
|
|
149
|
+
|
|
150
|
+
// Otherwise treat input as a phase number and match by "NN-" prefix.
|
|
151
|
+
const prefix = padPhaseNum(input) + '-';
|
|
152
|
+
const hit = entries.find((n) => n.startsWith(prefix));
|
|
153
|
+
return hit ? path.join(phasesRoot, hit) : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Quick task dir: .planning/quick/YYYYMMDD-slug/ */
|
|
157
|
+
function quickDirName(slug, date = new Date()) {
|
|
158
|
+
const y = date.getFullYear();
|
|
159
|
+
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
160
|
+
const d = String(date.getDate()).padStart(2, '0');
|
|
161
|
+
return `${y}${m}${d}-${slug}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function quickDir(slug, root = repoRoot(), date = new Date()) {
|
|
165
|
+
return path.join(planningDir(root), 'quick', quickDirName(slug, date));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = {
|
|
169
|
+
repoRoot,
|
|
170
|
+
planningDir,
|
|
171
|
+
ensureDir,
|
|
172
|
+
pluginRoot,
|
|
173
|
+
templatePath,
|
|
174
|
+
readTemplate,
|
|
175
|
+
padPhaseNum,
|
|
176
|
+
padPlanNum,
|
|
177
|
+
slugifyPhase,
|
|
178
|
+
phaseDirName,
|
|
179
|
+
phaseDir,
|
|
180
|
+
phasePlanPrefix,
|
|
181
|
+
planFile,
|
|
182
|
+
summaryFile,
|
|
183
|
+
designFile,
|
|
184
|
+
reviewLogFile,
|
|
185
|
+
milestoneSlug,
|
|
186
|
+
milestoneDir,
|
|
187
|
+
milestoneDesignFile,
|
|
188
|
+
findPhaseDir,
|
|
189
|
+
quickDirName,
|
|
190
|
+
quickDir,
|
|
191
|
+
};
|
package/lib/provider.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Workflow provider abstraction (GSD-compatible).
|
|
5
|
+
*
|
|
6
|
+
* Reads from `.planning/config.json` (the same file GSD uses) and looks for
|
|
7
|
+
* cp-specific settings under the top-level `cp` key. GSD ignores unknown
|
|
8
|
+
* top-level keys, so both tools share the same config file safely.
|
|
9
|
+
*
|
|
10
|
+
* If `config.json` doesn't exist, we materialise it from the template (which
|
|
11
|
+
* includes both GSD's defaults AND the `cp` block).
|
|
12
|
+
*
|
|
13
|
+
* If `config.json` exists but lacks the `cp` block (i.e., pure-GSD project),
|
|
14
|
+
* we merge in the default `cp` block on first read and save it back —
|
|
15
|
+
* preserving every existing GSD key untouched.
|
|
16
|
+
*
|
|
17
|
+
* Detection logic lives in lib/detect.js (v0.5). This module re-exports
|
|
18
|
+
* the detection functions for back-compat.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const os = require('os');
|
|
24
|
+
const { planningDir, repoRoot, readTemplate } = require('./paths');
|
|
25
|
+
const detect = require('./detect');
|
|
26
|
+
const { mergeCpDefaults } = require('./merge');
|
|
27
|
+
|
|
28
|
+
const CONFIG_NAME = 'config.json';
|
|
29
|
+
|
|
30
|
+
function configPath(root = repoRoot()) {
|
|
31
|
+
return path.join(planningDir(root), CONFIG_NAME);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function loadDefaults() {
|
|
35
|
+
return JSON.parse(readTemplate('config.json'));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Return the parsed config, ensuring a `cp` block exists. Writes back if merged. */
|
|
39
|
+
function loadConfig(root = repoRoot()) {
|
|
40
|
+
const p = configPath(root);
|
|
41
|
+
const defaults = loadDefaults();
|
|
42
|
+
|
|
43
|
+
if (!fs.existsSync(p)) {
|
|
44
|
+
// No config at all — caller hasn't run `cp init`. Return defaults in-memory;
|
|
45
|
+
// do NOT write the file from here (init owns that).
|
|
46
|
+
return defaults;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let raw;
|
|
50
|
+
try {
|
|
51
|
+
raw = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
52
|
+
} catch (e) {
|
|
53
|
+
throw new Error(`Failed to parse ${p}: ${e.message}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!raw.cp) {
|
|
57
|
+
// Pure-GSD config (or hand-rolled). Merge in the cp defaults non-destructively.
|
|
58
|
+
raw.cp = defaults.cp;
|
|
59
|
+
fs.writeFileSync(p, JSON.stringify(raw, null, 2) + '\n');
|
|
60
|
+
return raw;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// v0.5 auto-heal: additive merge of new upstream defaults into existing cp block
|
|
64
|
+
try {
|
|
65
|
+
const merged = mergeCpDefaults(raw, defaults);
|
|
66
|
+
if (merged.changed) {
|
|
67
|
+
fs.writeFileSync(p, JSON.stringify(merged.cfg, null, 2) + '\n');
|
|
68
|
+
process.stderr.write(`cp: refreshed .planning/config.json with ${merged.summary}\n`);
|
|
69
|
+
}
|
|
70
|
+
return merged.cfg;
|
|
71
|
+
} catch (e) {
|
|
72
|
+
// Merge failure — return unmerged config, don't block the command
|
|
73
|
+
process.stderr.write(`cp: config merge warning: ${e.message}\n`);
|
|
74
|
+
return raw;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function saveConfig(cfg, root = repoRoot()) {
|
|
79
|
+
const p = configPath(root);
|
|
80
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
81
|
+
fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n');
|
|
82
|
+
return p;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Convenience: read a value from cfg.cp.<path> with safe defaulting. */
|
|
86
|
+
function cpGet(cfg, dotted, fallback) {
|
|
87
|
+
const v = dotted.split('.').reduce((o, k) => (o == null ? o : o[k]), cfg.cp || {});
|
|
88
|
+
return v === undefined ? fallback : v;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Set cfg.cp.<path> = value. Creates nested objects as needed. */
|
|
92
|
+
function cpSet(cfg, dotted, value) {
|
|
93
|
+
cfg.cp = cfg.cp || {};
|
|
94
|
+
const keys = dotted.split('.');
|
|
95
|
+
let cur = cfg.cp;
|
|
96
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
97
|
+
if (cur[keys[i]] == null || typeof cur[keys[i]] !== 'object') cur[keys[i]] = {};
|
|
98
|
+
cur = cur[keys[i]];
|
|
99
|
+
}
|
|
100
|
+
cur[keys[keys.length - 1]] = value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Back-compat wrapper: adapts detectProviderAtAnyHarness to the old
|
|
105
|
+
* detectProvider return shape { name, installed, evidence?, reason? }.
|
|
106
|
+
* New callers should use detect.detectProviderAtAnyHarness directly.
|
|
107
|
+
*/
|
|
108
|
+
function detectProvider(cfg, name) {
|
|
109
|
+
const result = detect.detectProviderAtAnyHarness(cfg, name);
|
|
110
|
+
// Old shape didn't have via/source — strip them for strict back-compat
|
|
111
|
+
// but the superset is harmless since callers only read known keys.
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve a role -> { name, installed, skill, fallback?, primaryMissing? }.
|
|
117
|
+
*/
|
|
118
|
+
function resolveSkill(role, root = repoRoot()) {
|
|
119
|
+
const cfg = loadConfig(root);
|
|
120
|
+
const configured = cpGet(cfg, 'workflow_provider', 'superpowers');
|
|
121
|
+
const fallback = cpGet(cfg, 'behavior.fall_back_to_manual_if_provider_missing', true);
|
|
122
|
+
const providers = (cfg.cp && cfg.cp.providers) || {};
|
|
123
|
+
|
|
124
|
+
const tryProvider = (name) => {
|
|
125
|
+
const det = detect.detectProviderAtAnyHarness(cfg, name);
|
|
126
|
+
const skills = (providers[name] && providers[name].skills) || {};
|
|
127
|
+
return { name, installed: det.installed, skill: skills[role] || null };
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const primary = tryProvider(configured);
|
|
131
|
+
if (primary.installed) {
|
|
132
|
+
return { ...primary, fallback: false };
|
|
133
|
+
}
|
|
134
|
+
if (fallback) {
|
|
135
|
+
const manual = tryProvider('manual');
|
|
136
|
+
return { ...manual, fallback: true, primaryMissing: configured };
|
|
137
|
+
}
|
|
138
|
+
return { ...primary, fallback: false };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Resolve the inline manual prompt for a role. Returns the prompt string, or
|
|
143
|
+
* null if the manual provider has no prompt for this role.
|
|
144
|
+
*/
|
|
145
|
+
function resolvePrompt(role, root = repoRoot()) {
|
|
146
|
+
const cfg = loadConfig(root);
|
|
147
|
+
const manual = (cfg.cp && cfg.cp.providers && cfg.cp.providers.manual) || {};
|
|
148
|
+
const prompts = manual.prompts || {};
|
|
149
|
+
return typeof prompts[role] === 'string' ? prompts[role] : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = {
|
|
153
|
+
CONFIG_NAME,
|
|
154
|
+
configPath,
|
|
155
|
+
loadDefaults,
|
|
156
|
+
loadConfig,
|
|
157
|
+
saveConfig,
|
|
158
|
+
cpGet,
|
|
159
|
+
cpSet,
|
|
160
|
+
// Back-compat re-exports from detect.js
|
|
161
|
+
existsAnywhere: detect.existsAnywhere,
|
|
162
|
+
detectProvider,
|
|
163
|
+
resolveSkill,
|
|
164
|
+
resolvePrompt,
|
|
165
|
+
// New v0.5 detection (re-exported for convenience)
|
|
166
|
+
detectProviderAtAnyHarness: detect.detectProviderAtAnyHarness,
|
|
167
|
+
detectAllInstalled: detect.detectAllInstalled,
|
|
168
|
+
};
|
package/lib/roadmap.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ROADMAP.md helpers — minimal regex-based operations. Avoids a full markdown
|
|
5
|
+
* parser; just enough to:
|
|
6
|
+
* - List phases ({ num, name, status, plans: [{ id, desc, done }] })
|
|
7
|
+
* - Toggle a plan checkbox
|
|
8
|
+
* - Recompute the Progress table row for a phase
|
|
9
|
+
* - Add a new milestone block
|
|
10
|
+
* - Add a new phase block
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
|
|
15
|
+
function read(roadmapPath) {
|
|
16
|
+
return fs.readFileSync(roadmapPath, 'utf8');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function write(roadmapPath, content) {
|
|
20
|
+
fs.writeFileSync(roadmapPath, content);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Find phase sections. Phase headings look like:
|
|
25
|
+
* ### Phase 1: Foundation
|
|
26
|
+
* ### Phase 2.1: Hotfix (INSERTED)
|
|
27
|
+
*/
|
|
28
|
+
function listPhases(content) {
|
|
29
|
+
const phases = [];
|
|
30
|
+
const headingRe = /^###\s+Phase\s+([\d.]+):\s+(.+?)\s*(?:\(INSERTED\))?\s*$/gm;
|
|
31
|
+
let match;
|
|
32
|
+
const matches = [];
|
|
33
|
+
while ((match = headingRe.exec(content)) !== null) {
|
|
34
|
+
matches.push({ num: match[1], name: match[2].trim(), index: match.index });
|
|
35
|
+
}
|
|
36
|
+
for (let i = 0; i < matches.length; i++) {
|
|
37
|
+
const start = matches[i].index;
|
|
38
|
+
const end = i + 1 < matches.length ? matches[i + 1].index : content.length;
|
|
39
|
+
const block = content.slice(start, end);
|
|
40
|
+
const plans = [];
|
|
41
|
+
const planRe = /^\s*-\s+\[([ xX])\]\s+([\d.]+-\d+):\s*(.*)$/gm;
|
|
42
|
+
let p;
|
|
43
|
+
while ((p = planRe.exec(block)) !== null) {
|
|
44
|
+
plans.push({
|
|
45
|
+
id: p[2],
|
|
46
|
+
desc: p[3].trim(),
|
|
47
|
+
done: p[1].toLowerCase() === 'x',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
phases.push({
|
|
51
|
+
num: matches[i].num,
|
|
52
|
+
name: matches[i].name,
|
|
53
|
+
plans,
|
|
54
|
+
blockStart: start,
|
|
55
|
+
blockEnd: end,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return phases;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Toggle a plan checkbox by ID (e.g., "01-02"). Returns updated content.
|
|
63
|
+
*/
|
|
64
|
+
function setPlanDone(content, planId, done = true) {
|
|
65
|
+
const re = new RegExp(
|
|
66
|
+
`(^\\s*-\\s+\\[)([ xX])(\\]\\s+${escapeRegex(planId)}:)`,
|
|
67
|
+
'm'
|
|
68
|
+
);
|
|
69
|
+
return content.replace(re, (_m, a, _b, c) => `${a}${done ? 'x' : ' '}${c}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Append a new phase block. Caller supplies the full markdown for the section
|
|
74
|
+
* (starting with `### Phase N: ...`).
|
|
75
|
+
*/
|
|
76
|
+
function appendPhaseBlock(content, phaseBlock) {
|
|
77
|
+
// Insert before the "## Progress" section if present, else at end.
|
|
78
|
+
const progressIdx = content.indexOf('\n## Progress');
|
|
79
|
+
if (progressIdx === -1) {
|
|
80
|
+
return content.trimEnd() + '\n\n' + phaseBlock.trim() + '\n';
|
|
81
|
+
}
|
|
82
|
+
const before = content.slice(0, progressIdx).trimEnd();
|
|
83
|
+
const after = content.slice(progressIdx);
|
|
84
|
+
return before + '\n\n' + phaseBlock.trim() + '\n' + after;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Recompute the Progress table. Replaces every existing row with fresh ones
|
|
89
|
+
* derived from `listPhases`. Idempotent.
|
|
90
|
+
*/
|
|
91
|
+
function rebuildProgressTable(content, milestoneByPhase = {}) {
|
|
92
|
+
const phases = listPhases(content);
|
|
93
|
+
const header =
|
|
94
|
+
'| Phase | Milestone | Plans Complete | Status | Completed |\n' +
|
|
95
|
+
'|-------|-----------|----------------|--------|-----------|\n';
|
|
96
|
+
const rows = phases
|
|
97
|
+
.map((p) => {
|
|
98
|
+
const total = p.plans.length;
|
|
99
|
+
const done = p.plans.filter((x) => x.done).length;
|
|
100
|
+
const status =
|
|
101
|
+
total === 0
|
|
102
|
+
? 'Planned'
|
|
103
|
+
: done === 0
|
|
104
|
+
? 'Not started'
|
|
105
|
+
: done < total
|
|
106
|
+
? 'In progress'
|
|
107
|
+
: 'Complete';
|
|
108
|
+
const completed = status === 'Complete' ? new Date().toISOString().slice(0, 10) : '-';
|
|
109
|
+
const ms = milestoneByPhase[p.num] || '-';
|
|
110
|
+
return `| ${p.num}. ${p.name} | ${ms} | ${done}/${total} | ${status} | ${completed} |`;
|
|
111
|
+
})
|
|
112
|
+
.join('\n');
|
|
113
|
+
|
|
114
|
+
const table = header + rows + '\n';
|
|
115
|
+
|
|
116
|
+
const sectionRe = /(## Progress[\s\S]*?)(\n(?=## )|$)/;
|
|
117
|
+
if (sectionRe.test(content)) {
|
|
118
|
+
return content.replace(sectionRe, `## Progress\n\n${table}$2`);
|
|
119
|
+
}
|
|
120
|
+
return content.trimEnd() + '\n\n## Progress\n\n' + table;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function escapeRegex(s) {
|
|
124
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = {
|
|
128
|
+
read,
|
|
129
|
+
write,
|
|
130
|
+
listPhases,
|
|
131
|
+
setPlanDone,
|
|
132
|
+
appendPhaseBlock,
|
|
133
|
+
rebuildProgressTable,
|
|
134
|
+
};
|
package/lib/state.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* STATE.md helpers. STATE.md is intentionally tiny (<100 lines). We only need:
|
|
5
|
+
* - read / write
|
|
6
|
+
* - update "Current Position" block
|
|
7
|
+
* - update "Session Continuity" block
|
|
8
|
+
* - update progress bar
|
|
9
|
+
* - append to "Recent Decisions"
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
|
|
14
|
+
function read(p) {
|
|
15
|
+
return fs.readFileSync(p, 'utf8');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function write(p, content) {
|
|
19
|
+
fs.writeFileSync(p, content);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function progressBar(percent) {
|
|
23
|
+
const pct = Math.max(0, Math.min(100, Math.round(percent)));
|
|
24
|
+
const filled = Math.round(pct / 10);
|
|
25
|
+
return '[' + '█'.repeat(filled) + '░'.repeat(10 - filled) + '] ' + pct + '%';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Replace a labeled line inside a section. Lines have the shape:
|
|
30
|
+
* `Label: value`
|
|
31
|
+
*/
|
|
32
|
+
function replaceLineInSection(content, sectionHeading, label, newValue) {
|
|
33
|
+
const sectionRe = new RegExp(
|
|
34
|
+
`(^${escapeRegex(sectionHeading)}\\s*\\n[\\s\\S]*?)(^${escapeRegex(
|
|
35
|
+
label
|
|
36
|
+
)}:[^\\n]*$)`,
|
|
37
|
+
'm'
|
|
38
|
+
);
|
|
39
|
+
const replaced = content.replace(
|
|
40
|
+
sectionRe,
|
|
41
|
+
(_m, pre) => `${pre}${label}: ${newValue}`
|
|
42
|
+
);
|
|
43
|
+
return replaced;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function updatePosition(content, { phase, plan, status, lastActivity, date }) {
|
|
47
|
+
let next = content;
|
|
48
|
+
if (phase !== undefined)
|
|
49
|
+
next = replaceLineInSection(next, '## Current Position', 'Phase', phase);
|
|
50
|
+
if (plan !== undefined)
|
|
51
|
+
next = replaceLineInSection(next, '## Current Position', 'Plan', plan);
|
|
52
|
+
if (status !== undefined)
|
|
53
|
+
next = replaceLineInSection(next, '## Current Position', 'Status', status);
|
|
54
|
+
if (lastActivity !== undefined)
|
|
55
|
+
next = replaceLineInSection(
|
|
56
|
+
next,
|
|
57
|
+
'## Current Position',
|
|
58
|
+
'Last activity',
|
|
59
|
+
`${date || new Date().toISOString().slice(0, 10)} — ${lastActivity}`
|
|
60
|
+
);
|
|
61
|
+
return next;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function updateProgressBar(content, percent) {
|
|
65
|
+
const bar = progressBar(percent);
|
|
66
|
+
return content.replace(/^Progress:\s+.*$/m, `Progress: ${bar}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function updateSessionContinuity(content, { date, stoppedAt, resumeFile }) {
|
|
70
|
+
let next = content;
|
|
71
|
+
if (date !== undefined)
|
|
72
|
+
next = replaceLineInSection(next, '## Session Continuity', 'Last session', date);
|
|
73
|
+
if (stoppedAt !== undefined)
|
|
74
|
+
next = replaceLineInSection(next, '## Session Continuity', 'Stopped at', stoppedAt);
|
|
75
|
+
if (resumeFile !== undefined)
|
|
76
|
+
next = replaceLineInSection(next, '## Session Continuity', 'Resume file', resumeFile);
|
|
77
|
+
return next;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function appendRecentDecision(content, decision) {
|
|
81
|
+
// Insert as the FIRST bullet under "### Recent Decisions"
|
|
82
|
+
const re = /(### Recent Decisions\s*\n+(?:<!--[^>]*?-->\s*\n*)?)/;
|
|
83
|
+
if (!re.test(content)) return content;
|
|
84
|
+
return content.replace(re, (m) => `${m}- ${decision}\n`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function escapeRegex(s) {
|
|
88
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
read,
|
|
93
|
+
write,
|
|
94
|
+
progressBar,
|
|
95
|
+
updatePosition,
|
|
96
|
+
updateProgressBar,
|
|
97
|
+
updateSessionContinuity,
|
|
98
|
+
appendRecentDecision,
|
|
99
|
+
};
|