pan-wizard 2.8.1
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 +772 -0
- package/agents/pan-debugger.md +1246 -0
- package/agents/pan-document_code.md +965 -0
- package/agents/pan-executor.md +469 -0
- package/agents/pan-integration-checker.md +443 -0
- package/agents/pan-phase-researcher.md +572 -0
- package/agents/pan-plan-checker.md +763 -0
- package/agents/pan-planner.md +1297 -0
- package/agents/pan-project-researcher.md +647 -0
- package/agents/pan-research-synthesizer.md +239 -0
- package/agents/pan-reviewer.md +112 -0
- package/agents/pan-roadmapper.md +642 -0
- package/agents/pan-verifier.md +672 -0
- package/assets/pan-logo-2000-transparent.svg +30 -0
- package/assets/pan-logo-2000.svg +43 -0
- package/assets/terminal.svg +119 -0
- package/bin/install-lib.cjs +616 -0
- package/bin/install.js +1936 -0
- package/commands/pan/add-phase.md +44 -0
- package/commands/pan/assumptions.md +47 -0
- package/commands/pan/audit-deployment.md +378 -0
- package/commands/pan/debug.md +168 -0
- package/commands/pan/discord.md +19 -0
- package/commands/pan/discuss-phase.md +84 -0
- package/commands/pan/exec-phase.md +45 -0
- package/commands/pan/focus-auto.md +323 -0
- package/commands/pan/focus-design.md +816 -0
- package/commands/pan/focus-exec.md +316 -0
- package/commands/pan/focus-plan.md +101 -0
- package/commands/pan/focus-scan.md +272 -0
- package/commands/pan/focus-sync.md +104 -0
- package/commands/pan/health.md +23 -0
- package/commands/pan/help.md +23 -0
- package/commands/pan/insert-phase.md +33 -0
- package/commands/pan/map-codebase.md +72 -0
- package/commands/pan/milestone-audit.md +37 -0
- package/commands/pan/milestone-cleanup.md +19 -0
- package/commands/pan/milestone-done.md +137 -0
- package/commands/pan/milestone-gaps.md +35 -0
- package/commands/pan/milestone-new.md +45 -0
- package/commands/pan/new-project.md +43 -0
- package/commands/pan/patches.md +110 -0
- package/commands/pan/pause.md +39 -0
- package/commands/pan/phase-budget.md +23 -0
- package/commands/pan/phase-tests.md +42 -0
- package/commands/pan/plan-phase.md +46 -0
- package/commands/pan/profile.md +36 -0
- package/commands/pan/progress.md +25 -0
- package/commands/pan/quick.md +42 -0
- package/commands/pan/remove-phase.md +32 -0
- package/commands/pan/research-phase.md +190 -0
- package/commands/pan/resume.md +41 -0
- package/commands/pan/retro.md +33 -0
- package/commands/pan/settings.md +37 -0
- package/commands/pan/todo-add.md +48 -0
- package/commands/pan/todo-check.md +46 -0
- package/commands/pan/update.md +38 -0
- package/commands/pan/verify-phase.md +39 -0
- package/hooks/dist/pan-check-update.js +62 -0
- package/hooks/dist/pan-context-monitor.js +122 -0
- package/hooks/dist/pan-statusline.js +108 -0
- package/package.json +66 -0
- package/pan-wizard-core/bin/lib/codebase.cjs +746 -0
- package/pan-wizard-core/bin/lib/commands.cjs +1435 -0
- package/pan-wizard-core/bin/lib/config.cjs +611 -0
- package/pan-wizard-core/bin/lib/constants.cjs +696 -0
- package/pan-wizard-core/bin/lib/context-budget.cjs +150 -0
- package/pan-wizard-core/bin/lib/core.cjs +650 -0
- package/pan-wizard-core/bin/lib/focus.cjs +900 -0
- package/pan-wizard-core/bin/lib/frontmatter.cjs +442 -0
- package/pan-wizard-core/bin/lib/init.cjs +881 -0
- package/pan-wizard-core/bin/lib/milestone.cjs +276 -0
- package/pan-wizard-core/bin/lib/phase.cjs +1212 -0
- package/pan-wizard-core/bin/lib/roadmap.cjs +470 -0
- package/pan-wizard-core/bin/lib/state.cjs +1029 -0
- package/pan-wizard-core/bin/lib/template.cjs +314 -0
- package/pan-wizard-core/bin/lib/utils.cjs +171 -0
- package/pan-wizard-core/bin/lib/verify.cjs +1808 -0
- package/pan-wizard-core/bin/pan-tools.cjs +773 -0
- package/pan-wizard-core/references/checkpoints.md +776 -0
- package/pan-wizard-core/references/continuation-format.md +249 -0
- package/pan-wizard-core/references/decimal-phase-calculation.md +65 -0
- package/pan-wizard-core/references/git-integration.md +248 -0
- package/pan-wizard-core/references/git-planning-commit.md +38 -0
- package/pan-wizard-core/references/model-profile-resolution.md +34 -0
- package/pan-wizard-core/references/model-profiles.md +111 -0
- package/pan-wizard-core/references/phase-argument-parsing.md +61 -0
- package/pan-wizard-core/references/planning-config.md +196 -0
- package/pan-wizard-core/references/questioning.md +145 -0
- package/pan-wizard-core/references/tdd.md +263 -0
- package/pan-wizard-core/references/ui-brand.md +160 -0
- package/pan-wizard-core/references/verification-patterns.md +612 -0
- package/pan-wizard-core/templates/codebase/architecture.md +283 -0
- package/pan-wizard-core/templates/codebase/best-practices.md +133 -0
- package/pan-wizard-core/templates/codebase/concerns.md +325 -0
- package/pan-wizard-core/templates/codebase/conventions.md +307 -0
- package/pan-wizard-core/templates/codebase/integrations.md +305 -0
- package/pan-wizard-core/templates/codebase/relationships.md +124 -0
- package/pan-wizard-core/templates/codebase/stack.md +199 -0
- package/pan-wizard-core/templates/codebase/structure.md +298 -0
- package/pan-wizard-core/templates/codebase/testing.md +480 -0
- package/pan-wizard-core/templates/config.json +37 -0
- package/pan-wizard-core/templates/context.md +283 -0
- package/pan-wizard-core/templates/continue-here.md +78 -0
- package/pan-wizard-core/templates/debug-subagent-prompt.md +91 -0
- package/pan-wizard-core/templates/debug.md +164 -0
- package/pan-wizard-core/templates/discovery.md +146 -0
- package/pan-wizard-core/templates/milestone-archive.md +123 -0
- package/pan-wizard-core/templates/milestone.md +115 -0
- package/pan-wizard-core/templates/phase-prompt.md +593 -0
- package/pan-wizard-core/templates/planner-subagent-prompt.md +117 -0
- package/pan-wizard-core/templates/project.md +184 -0
- package/pan-wizard-core/templates/requirements.md +231 -0
- package/pan-wizard-core/templates/research-project/architecture.md +204 -0
- package/pan-wizard-core/templates/research-project/features.md +147 -0
- package/pan-wizard-core/templates/research-project/pitfalls.md +200 -0
- package/pan-wizard-core/templates/research-project/stack.md +120 -0
- package/pan-wizard-core/templates/research-project/summary.md +170 -0
- package/pan-wizard-core/templates/research.md +552 -0
- package/pan-wizard-core/templates/retrospective.md +54 -0
- package/pan-wizard-core/templates/roadmap.md +202 -0
- package/pan-wizard-core/templates/standards.md +24 -0
- package/pan-wizard-core/templates/state.md +176 -0
- package/pan-wizard-core/templates/summary-complex.md +59 -0
- package/pan-wizard-core/templates/summary-minimal.md +41 -0
- package/pan-wizard-core/templates/summary-standard.md +49 -0
- package/pan-wizard-core/templates/summary.md +249 -0
- package/pan-wizard-core/templates/uat.md +247 -0
- package/pan-wizard-core/templates/user-setup.md +311 -0
- package/pan-wizard-core/templates/validation.md +76 -0
- package/pan-wizard-core/templates/verification-report.md +322 -0
- package/pan-wizard-core/workflows/add-phase.md +111 -0
- package/pan-wizard-core/workflows/assumptions.md +178 -0
- package/pan-wizard-core/workflows/diagnose-issues.md +219 -0
- package/pan-wizard-core/workflows/discuss-phase.md +542 -0
- package/pan-wizard-core/workflows/exec-phase.md +572 -0
- package/pan-wizard-core/workflows/execute-plan.md +448 -0
- package/pan-wizard-core/workflows/health.md +156 -0
- package/pan-wizard-core/workflows/help.md +431 -0
- package/pan-wizard-core/workflows/insert-phase.md +129 -0
- package/pan-wizard-core/workflows/map-codebase.md +401 -0
- package/pan-wizard-core/workflows/milestone-audit.md +297 -0
- package/pan-wizard-core/workflows/milestone-cleanup.md +152 -0
- package/pan-wizard-core/workflows/milestone-gaps.md +274 -0
- package/pan-wizard-core/workflows/milestone-new.md +382 -0
- package/pan-wizard-core/workflows/new-project.md +1178 -0
- package/pan-wizard-core/workflows/pause.md +122 -0
- package/pan-wizard-core/workflows/phase-tests.md +388 -0
- package/pan-wizard-core/workflows/plan-phase.md +569 -0
- package/pan-wizard-core/workflows/profile.md +115 -0
- package/pan-wizard-core/workflows/progress.md +381 -0
- package/pan-wizard-core/workflows/quick.md +453 -0
- package/pan-wizard-core/workflows/remove-phase.md +154 -0
- package/pan-wizard-core/workflows/research-phase.md +73 -0
- package/pan-wizard-core/workflows/resume-project.md +306 -0
- package/pan-wizard-core/workflows/retro.md +121 -0
- package/pan-wizard-core/workflows/settings.md +213 -0
- package/pan-wizard-core/workflows/todo-add.md +157 -0
- package/pan-wizard-core/workflows/todo-check.md +176 -0
- package/pan-wizard-core/workflows/transition.md +544 -0
- package/pan-wizard-core/workflows/update.md +219 -0
- package/pan-wizard-core/workflows/verify-phase.md +301 -0
- package/scripts/build-hooks.js +43 -0
|
@@ -0,0 +1,1435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commands -- Standalone utility commands
|
|
3
|
+
*/
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { safeReadFile, loadConfig, isGitIgnored, isGitRepo, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, resolveModelInternal, MODEL_PROFILES, output, error, findPhaseInternal, scanPendingTodos, toPosix } = require('./core.cjs');
|
|
7
|
+
const { extractFrontmatter } = require('./frontmatter.cjs');
|
|
8
|
+
const { PLANNING_DIR, PHASES_DIR, MILESTONES_DIR, QUICK_DIR, STATE_FILE, ROADMAP_FILE, PROJECT_FILE, PATTERNS_FILE, SESSION_HISTORY_FILE, LEARNINGS_FILE, CONTEXT_SUFFIX, UAT_SUFFIX, VERIFICATION_SUFFIX, isPlanFile, isSummaryFile, ARCHIVE_DIR_RE, PHASE_DIR_RE, CONTEXT_WINDOW, WARNING_THRESHOLD, CRITICAL_THRESHOLD, VALID_COMMIT_TYPES, DEFAULT_SENSITIVE_PATTERNS } = require('./constants.cjs');
|
|
9
|
+
const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles } = require('./utils.cjs');
|
|
10
|
+
const { estimateTokens } = require('./context-budget.cjs');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generate a URL-safe slug from text by lowercasing and replacing non-alphanumeric chars.
|
|
14
|
+
* @param {string} text - Text to convert to a slug
|
|
15
|
+
* @param {boolean} raw - If true, output raw slug string instead of JSON
|
|
16
|
+
* @returns {void}
|
|
17
|
+
*/
|
|
18
|
+
function cmdGenerateSlug(text, raw) {
|
|
19
|
+
if (!text) {
|
|
20
|
+
error('text required for slug generation');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Delegate to core's shared slug generator to avoid duplicate logic
|
|
24
|
+
const slug = generateSlugInternal(text);
|
|
25
|
+
|
|
26
|
+
const result = { slug };
|
|
27
|
+
output(result, raw, slug);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Output the current timestamp in the specified format (date, filename, or full ISO).
|
|
32
|
+
* @param {string} format - Output format: "date", "filename", or "full" (default)
|
|
33
|
+
* @param {boolean} raw - If true, output raw timestamp string instead of JSON
|
|
34
|
+
* @returns {void}
|
|
35
|
+
*/
|
|
36
|
+
function cmdCurrentTimestamp(format, raw) {
|
|
37
|
+
const now = new Date();
|
|
38
|
+
let result;
|
|
39
|
+
|
|
40
|
+
switch (format) {
|
|
41
|
+
case 'date':
|
|
42
|
+
result = now.toISOString().split('T')[0];
|
|
43
|
+
break;
|
|
44
|
+
case 'filename':
|
|
45
|
+
result = now.toISOString().replace(/:/g, '-').replace(/\..+/, '');
|
|
46
|
+
break;
|
|
47
|
+
case 'full':
|
|
48
|
+
default:
|
|
49
|
+
result = now.toISOString();
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
output({ timestamp: result }, raw, result);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* List pending todo items with optional area filtering.
|
|
58
|
+
* @param {string} cwd - Working directory path
|
|
59
|
+
* @param {string} area - Optional area filter (e.g., "general", "security")
|
|
60
|
+
* @param {boolean} raw - If true, output raw count string instead of JSON
|
|
61
|
+
* @returns {void}
|
|
62
|
+
*/
|
|
63
|
+
function cmdListTodos(cwd, area, raw) {
|
|
64
|
+
const result = scanPendingTodos(cwd, area);
|
|
65
|
+
output(result, raw, result.count.toString());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check if a file or directory exists and report its type.
|
|
70
|
+
* @param {string} cwd - Working directory path
|
|
71
|
+
* @param {string} targetPath - Path to verify (absolute or relative to cwd)
|
|
72
|
+
* @param {boolean} raw - If true, output "true" or "false" instead of JSON
|
|
73
|
+
* @returns {void}
|
|
74
|
+
*/
|
|
75
|
+
function cmdVerifyPathExists(cwd, targetPath, raw) {
|
|
76
|
+
if (!targetPath) {
|
|
77
|
+
error('path required for verification');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const stats = fs.statSync(fullPath);
|
|
84
|
+
const type = stats.isDirectory() ? 'directory' : stats.isFile() ? 'file' : 'other';
|
|
85
|
+
const result = { exists: true, type };
|
|
86
|
+
output(result, raw, 'true');
|
|
87
|
+
} catch {
|
|
88
|
+
// Path does not exist or is inaccessible
|
|
89
|
+
const result = { exists: false, type: null };
|
|
90
|
+
output(result, raw, 'false');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- History digest helper functions ----------------------------------------
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Scan all phase directories (archived + current) and read summary frontmatter.
|
|
98
|
+
* Returns an array of { phaseNum, dirName, frontmatter } objects for each summary found.
|
|
99
|
+
*
|
|
100
|
+
* Algorithm overview:
|
|
101
|
+
* 1. Collect archived phase dirs from milestone archives (oldest milestones first)
|
|
102
|
+
* 2. Collect current phase dirs from .planning/phases/
|
|
103
|
+
* 3. For each directory, read all *-summary.md files and extract frontmatter
|
|
104
|
+
*
|
|
105
|
+
* @param {string} cwd - Working directory path
|
|
106
|
+
* @returns {{ allPhaseDirs: Array, summaries: Array<{phaseNum: string, dirName: string, frontmatter: Object}> }}
|
|
107
|
+
*/
|
|
108
|
+
function collectPhaseSummaries(cwd) {
|
|
109
|
+
const phasesDir = phasesPath(cwd);
|
|
110
|
+
|
|
111
|
+
// Collect all phase directories: archived + current
|
|
112
|
+
const allPhaseDirs = [];
|
|
113
|
+
|
|
114
|
+
// Add archived phases first (oldest milestones first)
|
|
115
|
+
const archived = getArchivedPhaseDirs(cwd);
|
|
116
|
+
for (const archiveEntry of archived) {
|
|
117
|
+
allPhaseDirs.push({ name: archiveEntry.name, fullPath: archiveEntry.fullPath, milestone: archiveEntry.milestone });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Add current phases
|
|
121
|
+
try {
|
|
122
|
+
const currentDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
|
|
123
|
+
.filter(entry => entry.isDirectory())
|
|
124
|
+
.map(entry => entry.name)
|
|
125
|
+
.sort();
|
|
126
|
+
for (const dirName of currentDirs) {
|
|
127
|
+
allPhaseDirs.push({ name: dirName, fullPath: path.join(phasesDir, dirName), milestone: null });
|
|
128
|
+
}
|
|
129
|
+
} catch { /* phases dir missing or unreadable */ }
|
|
130
|
+
|
|
131
|
+
const summaries = [];
|
|
132
|
+
|
|
133
|
+
for (const { name: dirName, fullPath: dirPath } of allPhaseDirs) {
|
|
134
|
+
let summaryFiles;
|
|
135
|
+
try {
|
|
136
|
+
summaryFiles = fs.readdirSync(dirPath).filter(filename => isSummaryFile(filename));
|
|
137
|
+
} catch { continue; }
|
|
138
|
+
|
|
139
|
+
for (const summaryFile of summaryFiles) {
|
|
140
|
+
try {
|
|
141
|
+
const content = fs.readFileSync(path.join(dirPath, summaryFile), 'utf-8');
|
|
142
|
+
const frontmatter = extractFrontmatter(content);
|
|
143
|
+
const phaseNum = frontmatter.phase || dirName.split('-')[0];
|
|
144
|
+
|
|
145
|
+
summaries.push({ phaseNum, dirName, frontmatter });
|
|
146
|
+
} catch {
|
|
147
|
+
// Skip malformed summary files (broken YAML, unreadable)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { allPhaseDirs, summaries };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Aggregate tech stack entries from all summary frontmatters into a unified Set.
|
|
157
|
+
*
|
|
158
|
+
* Tech stack merging logic:
|
|
159
|
+
* - Each summary may have a `tech-stack.added` array in its frontmatter
|
|
160
|
+
* - Array entries can be plain strings ("prisma") or objects with a `name` field ({name: "prisma"})
|
|
161
|
+
* - All entries are collected into a Set to deduplicate across phases
|
|
162
|
+
*
|
|
163
|
+
* @param {Array<{phaseNum: string, dirName: string, frontmatter: Object}>} summaries
|
|
164
|
+
* @returns {Set<string>} Deduplicated set of technology names
|
|
165
|
+
*/
|
|
166
|
+
function mergeTechStack(summaries) {
|
|
167
|
+
const techStack = new Set();
|
|
168
|
+
|
|
169
|
+
for (const { frontmatter } of summaries) {
|
|
170
|
+
if (frontmatter['tech-stack'] && frontmatter['tech-stack'].added) {
|
|
171
|
+
frontmatter['tech-stack'].added.forEach(techEntry =>
|
|
172
|
+
techStack.add(typeof techEntry === 'string' ? techEntry : techEntry.name)
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return techStack;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Assemble the final digest result from collected summaries.
|
|
182
|
+
* Merges provides, affects, patterns, and decisions per phase, then converts Sets to Arrays.
|
|
183
|
+
*
|
|
184
|
+
* @param {Array<{phaseNum: string, dirName: string, frontmatter: Object}>} summaries
|
|
185
|
+
* @param {Set<string>} techStack - Unified tech stack
|
|
186
|
+
* @returns {{ phases: Object, decisions: Array, tech_stack: string[] }}
|
|
187
|
+
*/
|
|
188
|
+
function buildDigest(summaries, techStack) {
|
|
189
|
+
const phases = {};
|
|
190
|
+
const decisions = [];
|
|
191
|
+
|
|
192
|
+
for (const { phaseNum, dirName, frontmatter } of summaries) {
|
|
193
|
+
if (!phases[phaseNum]) {
|
|
194
|
+
phases[phaseNum] = {
|
|
195
|
+
name: frontmatter.name || dirName.split('-').slice(1).join(' ') || 'Unknown',
|
|
196
|
+
provides: new Set(),
|
|
197
|
+
affects: new Set(),
|
|
198
|
+
patterns: new Set(),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Merge provides from either nested dependency-graph or flat provides field
|
|
203
|
+
if (frontmatter['dependency-graph'] && Array.isArray(frontmatter['dependency-graph'].provides)) {
|
|
204
|
+
frontmatter['dependency-graph'].provides.forEach(item => phases[phaseNum].provides.add(item));
|
|
205
|
+
} else if (Array.isArray(frontmatter.provides)) {
|
|
206
|
+
frontmatter.provides.forEach(item => phases[phaseNum].provides.add(item));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Merge affects from nested dependency-graph
|
|
210
|
+
if (frontmatter['dependency-graph'] && Array.isArray(frontmatter['dependency-graph'].affects)) {
|
|
211
|
+
frontmatter['dependency-graph'].affects.forEach(item => phases[phaseNum].affects.add(item));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Merge established patterns
|
|
215
|
+
if (Array.isArray(frontmatter['patterns-established'])) {
|
|
216
|
+
frontmatter['patterns-established'].forEach(item => phases[phaseNum].patterns.add(item));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Merge key decisions with phase attribution
|
|
220
|
+
if (Array.isArray(frontmatter['key-decisions'])) {
|
|
221
|
+
frontmatter['key-decisions'].forEach(decision => {
|
|
222
|
+
decisions.push({ phase: phaseNum, decision });
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Convert Sets to Arrays for JSON serialization
|
|
228
|
+
Object.keys(phases).forEach(phaseKey => {
|
|
229
|
+
phases[phaseKey].provides = [...phases[phaseKey].provides];
|
|
230
|
+
phases[phaseKey].affects = [...phases[phaseKey].affects];
|
|
231
|
+
phases[phaseKey].patterns = [...phases[phaseKey].patterns];
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
return { phases, decisions, tech_stack: [...techStack] };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Build a digest of project history from all summary.md files: decisions, tech stack, patterns.
|
|
239
|
+
*
|
|
240
|
+
* History digest algorithm overview:
|
|
241
|
+
* 1. collectPhaseSummaries() scans archived + current phase dirs for summary.md frontmatter
|
|
242
|
+
* 2. mergeTechStack() aggregates tech-stack.added arrays into a deduplicated Set
|
|
243
|
+
* 3. buildDigest() merges provides/affects/patterns/decisions per phase, converts Sets to Arrays
|
|
244
|
+
*
|
|
245
|
+
* @param {string} cwd - Working directory path
|
|
246
|
+
* @param {boolean} raw - If true, output raw value instead of JSON
|
|
247
|
+
* @returns {void}
|
|
248
|
+
*/
|
|
249
|
+
function cmdHistoryDigest(cwd, raw) {
|
|
250
|
+
const digest = { phases: {}, decisions: [], tech_stack: new Set() };
|
|
251
|
+
|
|
252
|
+
const { allPhaseDirs, summaries } = collectPhaseSummaries(cwd);
|
|
253
|
+
|
|
254
|
+
if (allPhaseDirs.length === 0) {
|
|
255
|
+
digest.tech_stack = [];
|
|
256
|
+
output(digest, raw);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const techStack = mergeTechStack(summaries);
|
|
262
|
+
const result = buildDigest(summaries, techStack);
|
|
263
|
+
|
|
264
|
+
output(result, raw);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
error('Failed to generate history digest: ' + err.message);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Resolve the model name for an agent type based on the configured model profile.
|
|
272
|
+
* @param {string} cwd - Working directory path
|
|
273
|
+
* @param {string} agentType - Agent type identifier (e.g., "pan-executor", "pan-planner")
|
|
274
|
+
* @param {boolean} raw - If true, output raw model name instead of JSON
|
|
275
|
+
* @returns {void}
|
|
276
|
+
*/
|
|
277
|
+
function cmdResolveModel(cwd, agentType, raw) {
|
|
278
|
+
if (!agentType) {
|
|
279
|
+
error('agent-type required');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const config = loadConfig(cwd);
|
|
283
|
+
const profile = config.model_profile || 'balanced';
|
|
284
|
+
|
|
285
|
+
const agentModels = MODEL_PROFILES[agentType];
|
|
286
|
+
if (!agentModels) {
|
|
287
|
+
const result = { model: 'sonnet', profile, unknown_agent: true };
|
|
288
|
+
output(result, raw, 'sonnet');
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const resolved = agentModels[profile] || agentModels['balanced'] || 'sonnet';
|
|
293
|
+
const model = resolved === 'opus' ? 'inherit' : resolved;
|
|
294
|
+
const result = { model, profile };
|
|
295
|
+
output(result, raw, model);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Stage and commit planning files to git, respecting commit_docs config and gitignore.
|
|
301
|
+
* @param {string} cwd - Working directory path
|
|
302
|
+
* @param {string} message - Commit message
|
|
303
|
+
* @param {string[]} files - Files to stage (defaults to .planning/)
|
|
304
|
+
* @param {boolean} raw - If true, output raw commit hash instead of JSON
|
|
305
|
+
* @param {boolean} amend - If true, amend the previous commit instead of creating new
|
|
306
|
+
* @param {Object} [opts] - Additional options
|
|
307
|
+
* @param {string} [opts.type] - Conventional commit type (feat, fix, docs, test, refactor, chore)
|
|
308
|
+
* @param {boolean} [opts.force] - Skip deleted-file safety check
|
|
309
|
+
* @returns {void}
|
|
310
|
+
*/
|
|
311
|
+
/**
|
|
312
|
+
* Run commit safety checks (deleted files, sensitive patterns).
|
|
313
|
+
* @param {string} cwd - Working directory
|
|
314
|
+
* @param {Object} config - Loaded config
|
|
315
|
+
* @param {boolean} force - If true, allow deleted files
|
|
316
|
+
* @returns {{blocked: boolean, reason?: string, safetyChecks: Object, hint?: string}}
|
|
317
|
+
*/
|
|
318
|
+
function runCommitSafetyChecks(cwd, config, force) {
|
|
319
|
+
const safetyChecks = { deleted_files: [], sensitive_files_blocked: [] };
|
|
320
|
+
const safetyEnabled = config.commit && config.commit.safety_checks !== false;
|
|
321
|
+
if (!safetyEnabled) return { blocked: false, safetyChecks };
|
|
322
|
+
|
|
323
|
+
// Check for deleted files
|
|
324
|
+
const statusResult = execGit(cwd, ['status', '--porcelain']);
|
|
325
|
+
if (statusResult.exitCode === 0 && statusResult.stdout) {
|
|
326
|
+
for (const line of statusResult.stdout.split('\n').filter(Boolean)) {
|
|
327
|
+
if (line.startsWith(' D') || line.startsWith('D ') || line.startsWith('D')) {
|
|
328
|
+
const fileName = line.slice(3).trim();
|
|
329
|
+
if (fileName) safetyChecks.deleted_files.push(fileName);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (safetyChecks.deleted_files.length > 0 && !force) {
|
|
334
|
+
return { blocked: true, reason: 'deleted_files_detected', safetyChecks, hint: 'Use --force to confirm deletion, or unstage the files' };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Check for sensitive files in staging
|
|
338
|
+
const stagedResult = execGit(cwd, ['diff', '--cached', '--name-only']);
|
|
339
|
+
if (stagedResult.exitCode === 0 && stagedResult.stdout) {
|
|
340
|
+
const patterns = (config.commit && Array.isArray(config.commit.sensitive_patterns))
|
|
341
|
+
? config.commit.sensitive_patterns
|
|
342
|
+
: DEFAULT_SENSITIVE_PATTERNS;
|
|
343
|
+
if (patterns.length > 0) {
|
|
344
|
+
const stagedFiles = stagedResult.stdout.split('\n').filter(Boolean);
|
|
345
|
+
const regexes = patterns.map(p => { try { return new RegExp(p, 'i'); } catch { return null; } }).filter(Boolean);
|
|
346
|
+
for (const f of stagedFiles) {
|
|
347
|
+
if (regexes.some(re => re.test(f))) safetyChecks.sensitive_files_blocked.push(f);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (safetyChecks.sensitive_files_blocked.length > 0) {
|
|
352
|
+
return { blocked: true, reason: 'sensitive_file_detected', safetyChecks, hint: 'Remove sensitive files from staging before committing' };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return { blocked: false, safetyChecks };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function cmdCommit(cwd, message, files, raw, amend, opts) {
|
|
359
|
+
const commitType = opts && opts.type;
|
|
360
|
+
const force = opts && opts.force;
|
|
361
|
+
|
|
362
|
+
if (!isGitRepo(cwd)) {
|
|
363
|
+
output({ committed: false, hash: null, reason: 'not_a_git_repo', hint: 'Run git init to initialize a repository' }, raw, 'not a git repo');
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (commitType && !VALID_COMMIT_TYPES.includes(commitType)) {
|
|
368
|
+
error('Invalid commit type: ' + commitType + '. Valid: ' + VALID_COMMIT_TYPES.join(', '));
|
|
369
|
+
}
|
|
370
|
+
if (!message && !amend) {
|
|
371
|
+
error('commit message required');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const config = loadConfig(cwd);
|
|
375
|
+
|
|
376
|
+
if (!config.commit_docs) {
|
|
377
|
+
output({ committed: false, hash: null, reason: 'skipped_commit_docs_false' }, raw, 'skipped');
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (isGitIgnored(cwd, PLANNING_DIR)) {
|
|
381
|
+
output({ committed: false, hash: null, reason: 'skipped_gitignored' }, raw, 'skipped');
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Stage files
|
|
386
|
+
const filesToStage = files && files.length > 0 ? files : [PLANNING_DIR + '/'];
|
|
387
|
+
for (const file of filesToStage) execGit(cwd, ['add', file]);
|
|
388
|
+
|
|
389
|
+
// Safety checks
|
|
390
|
+
const safety = runCommitSafetyChecks(cwd, config, force);
|
|
391
|
+
if (safety.blocked) {
|
|
392
|
+
output({ committed: false, hash: null, reason: safety.reason, safety_checks: safety.safetyChecks, hint: safety.hint }, raw, 'blocked');
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Build final message with optional conventional commit type prefix
|
|
397
|
+
const finalMessage = (commitType && message) ? commitType + ': ' + message : message;
|
|
398
|
+
|
|
399
|
+
// Commit
|
|
400
|
+
const commitArgs = amend ? ['commit', '--amend', '--no-edit'] : ['commit', '-m', finalMessage];
|
|
401
|
+
const commitResult = execGit(cwd, commitArgs);
|
|
402
|
+
if (commitResult.exitCode !== 0) {
|
|
403
|
+
if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
|
|
404
|
+
output({ committed: false, hash: null, reason: 'nothing_to_commit' }, raw, 'nothing');
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
output({ committed: false, hash: null, reason: 'commit_failed', error: commitResult.stderr }, raw, 'failed');
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const hashResult = execGit(cwd, ['rev-parse', '--short', 'HEAD']);
|
|
412
|
+
const hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
|
|
413
|
+
output({ committed: true, hash, reason: 'committed', type: commitType || null, safety_checks: safety.safetyChecks }, raw, hash || 'committed');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Extract structured data from a summary.md frontmatter with optional field filtering.
|
|
418
|
+
* @param {string} cwd - Working directory path
|
|
419
|
+
* @param {string} summaryPath - Relative path to the summary.md file
|
|
420
|
+
* @param {string[]} fields - Optional list of fields to extract (returns all if empty)
|
|
421
|
+
* @param {boolean} raw - If true, output raw value instead of JSON
|
|
422
|
+
* @returns {void}
|
|
423
|
+
*/
|
|
424
|
+
function cmdSummaryExtract(cwd, summaryPath, fields, raw) {
|
|
425
|
+
if (!summaryPath) {
|
|
426
|
+
error('summary-path required for summary-extract');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const fullPath = path.join(cwd, summaryPath);
|
|
430
|
+
|
|
431
|
+
let content;
|
|
432
|
+
try {
|
|
433
|
+
content = fs.readFileSync(fullPath, 'utf-8');
|
|
434
|
+
} catch {
|
|
435
|
+
output({ error: 'File not found', path: summaryPath }, raw);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const frontmatter = extractFrontmatter(content);
|
|
439
|
+
|
|
440
|
+
// Parse key-decisions into structured format
|
|
441
|
+
const parseDecisions = (decisionsList) => {
|
|
442
|
+
if (!decisionsList || !Array.isArray(decisionsList)) return [];
|
|
443
|
+
return decisionsList.map(entry => {
|
|
444
|
+
const colonIdx = entry.indexOf(':');
|
|
445
|
+
if (colonIdx > 0) {
|
|
446
|
+
return {
|
|
447
|
+
summary: entry.substring(0, colonIdx).trim(),
|
|
448
|
+
rationale: entry.substring(colonIdx + 1).trim(),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
return { summary: entry, rationale: null };
|
|
452
|
+
});
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
// Build full result
|
|
456
|
+
const fullResult = {
|
|
457
|
+
path: summaryPath,
|
|
458
|
+
one_liner: frontmatter['one-liner'] || null,
|
|
459
|
+
key_files: frontmatter['key-files'] || [],
|
|
460
|
+
tech_added: (frontmatter['tech-stack'] && frontmatter['tech-stack'].added) || [],
|
|
461
|
+
patterns: frontmatter['patterns-established'] || [],
|
|
462
|
+
decisions: parseDecisions(frontmatter['key-decisions']),
|
|
463
|
+
requirements_completed: frontmatter['requirements-completed'] || [],
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
// If fields specified, filter to only those fields
|
|
467
|
+
if (fields && fields.length > 0) {
|
|
468
|
+
const filtered = { path: summaryPath };
|
|
469
|
+
for (const field of fields) {
|
|
470
|
+
if (fullResult[field] !== undefined) {
|
|
471
|
+
filtered[field] = fullResult[field];
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
output(filtered, raw);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
output(fullResult, raw);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Search the web using Brave Search API and return structured results.
|
|
483
|
+
*
|
|
484
|
+
* Web search API call structure:
|
|
485
|
+
* - Endpoint: https://api.search.brave.com/res/v1/web/search
|
|
486
|
+
* - Auth: X-Subscription-Token header with BRAVE_API_KEY
|
|
487
|
+
* - Params: q (query), count (limit), country, search_lang, text_decorations, freshness (optional)
|
|
488
|
+
* - Response: data.web.results[] with title, url, description, age fields
|
|
489
|
+
*
|
|
490
|
+
* @param {string} query - Search query string
|
|
491
|
+
* @param {Object} options - Search options (limit, freshness)
|
|
492
|
+
* @param {boolean} raw - If true, output raw formatted results instead of JSON
|
|
493
|
+
* @returns {Promise<void>}
|
|
494
|
+
*/
|
|
495
|
+
async function cmdWebsearch(query, options, raw) {
|
|
496
|
+
const apiKey = process.env.BRAVE_API_KEY;
|
|
497
|
+
|
|
498
|
+
if (!apiKey) {
|
|
499
|
+
// No key = silent skip, agent falls back to built-in WebSearch
|
|
500
|
+
output({ available: false, reason: 'BRAVE_API_KEY not set' }, raw, '');
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (!query) {
|
|
505
|
+
output({ available: false, error: 'Query required' }, raw, '');
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const params = new URLSearchParams({
|
|
510
|
+
q: query,
|
|
511
|
+
count: String(options.limit || 10),
|
|
512
|
+
country: 'us',
|
|
513
|
+
search_lang: 'en',
|
|
514
|
+
text_decorations: 'false'
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
if (options.freshness) {
|
|
518
|
+
params.set('freshness', options.freshness);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
try {
|
|
522
|
+
const response = await fetch(
|
|
523
|
+
`https://api.search.brave.com/res/v1/web/search?${params}`,
|
|
524
|
+
{
|
|
525
|
+
headers: {
|
|
526
|
+
'Accept': 'application/json',
|
|
527
|
+
'X-Subscription-Token': apiKey
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
if (!response.ok) {
|
|
533
|
+
output({ available: false, error: `API error: ${response.status}` }, raw, '');
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const data = await response.json();
|
|
538
|
+
|
|
539
|
+
const results = (data.web?.results || []).map(item => ({
|
|
540
|
+
title: item.title,
|
|
541
|
+
url: item.url,
|
|
542
|
+
description: item.description,
|
|
543
|
+
age: item.age || null
|
|
544
|
+
}));
|
|
545
|
+
|
|
546
|
+
output({
|
|
547
|
+
available: true,
|
|
548
|
+
query,
|
|
549
|
+
count: results.length,
|
|
550
|
+
results
|
|
551
|
+
}, raw, results.map(item => `${item.title}\n${item.url}\n${item.description}`).join('\n\n'));
|
|
552
|
+
} catch (err) {
|
|
553
|
+
// Network error, DNS failure, or JSON parse error from Brave API
|
|
554
|
+
output({ available: false, error: err.message }, raw, '');
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Render a text progress bar of the given width.
|
|
560
|
+
* @param {number} percent - Completion percentage (0-100)
|
|
561
|
+
* @param {number} width - Bar width in characters
|
|
562
|
+
* @returns {string} Progress bar string like "████░░░░░░"
|
|
563
|
+
*/
|
|
564
|
+
function renderProgressBar(percent, width) {
|
|
565
|
+
const filled = Math.round((percent / 100) * width);
|
|
566
|
+
return '\u2588'.repeat(filled) + '\u2591'.repeat(width - filled);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Render milestone progress as a table, progress bar, or JSON from disk phase data.
|
|
571
|
+
* @param {string} cwd - Working directory path
|
|
572
|
+
* @param {string} format - Output format: "table", "bar", or default JSON
|
|
573
|
+
* @param {boolean} raw - If true, output raw rendered text instead of JSON
|
|
574
|
+
* @returns {void}
|
|
575
|
+
*/
|
|
576
|
+
function cmdProgressRender(cwd, format, raw) {
|
|
577
|
+
const phasesDir = phasesPath(cwd);
|
|
578
|
+
const roadmapPath = path.join(cwd, PLANNING_DIR, ROADMAP_FILE);
|
|
579
|
+
const milestone = getMilestoneInfo(cwd);
|
|
580
|
+
|
|
581
|
+
const phases = [];
|
|
582
|
+
let totalPlans = 0;
|
|
583
|
+
let totalSummaries = 0;
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
587
|
+
const dirs = entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort((a, b) => comparePhaseNum(a, b));
|
|
588
|
+
|
|
589
|
+
for (const dirName of dirs) {
|
|
590
|
+
const dirMatch = dirName.match(/^(\d+(?:\.\d+)*)-?(.*)/);
|
|
591
|
+
const phaseNum = dirMatch ? dirMatch[1] : dirName;
|
|
592
|
+
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2].replace(/-/g, ' ') : '';
|
|
593
|
+
let phaseFiles;
|
|
594
|
+
try {
|
|
595
|
+
phaseFiles = fs.readdirSync(path.join(phasesDir, dirName));
|
|
596
|
+
} catch {
|
|
597
|
+
phases.push({ number: phaseNum, name: phaseName, plans: 0, summaries: 0, status: 'Error' });
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
const planCount = phaseFiles.filter(filename => isPlanFile(filename)).length;
|
|
601
|
+
const summaryCount = phaseFiles.filter(filename => isSummaryFile(filename)).length;
|
|
602
|
+
|
|
603
|
+
totalPlans += planCount;
|
|
604
|
+
totalSummaries += summaryCount;
|
|
605
|
+
|
|
606
|
+
let status;
|
|
607
|
+
if (planCount === 0) status = 'Pending';
|
|
608
|
+
else if (summaryCount >= planCount) status = 'Complete';
|
|
609
|
+
else if (summaryCount > 0) status = 'In Progress';
|
|
610
|
+
else status = 'Planned';
|
|
611
|
+
|
|
612
|
+
phases.push({ number: phaseNum, name: phaseName, plans: planCount, summaries: summaryCount, status });
|
|
613
|
+
}
|
|
614
|
+
} catch {
|
|
615
|
+
// Phases directory does not exist or is unreadable; return empty progress
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const percent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0;
|
|
619
|
+
|
|
620
|
+
if (format === 'health') {
|
|
621
|
+
renderHealthReport(cwd, { phasesDir, phases, totalPlans, totalSummaries, percent }, raw);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (format === 'table') {
|
|
626
|
+
// Render markdown table
|
|
627
|
+
const progressBar = renderProgressBar(percent, 10);
|
|
628
|
+
let rendered = `# ${milestone.version} ${milestone.name}\n\n`;
|
|
629
|
+
rendered += `**Progress:** [${progressBar}] ${totalSummaries}/${totalPlans} plans (${percent}%)\n\n`;
|
|
630
|
+
rendered += '| Phase | Name | Plans | Status |\n';
|
|
631
|
+
rendered += '|-------|------|-------|--------|\n';
|
|
632
|
+
for (const phase of phases) {
|
|
633
|
+
rendered += `| ${phase.number} | ${phase.name} | ${phase.summaries}/${phase.plans} | ${phase.status} |\n`;
|
|
634
|
+
}
|
|
635
|
+
output({ rendered }, raw, rendered);
|
|
636
|
+
} else if (format === 'bar') {
|
|
637
|
+
const progressBar = renderProgressBar(percent, 20);
|
|
638
|
+
const text = `[${progressBar}] ${totalSummaries}/${totalPlans} plans (${percent}%)`;
|
|
639
|
+
output({ bar: text, percent, completed: totalSummaries, total: totalPlans }, raw, text);
|
|
640
|
+
} else {
|
|
641
|
+
// JSON format
|
|
642
|
+
output({
|
|
643
|
+
milestone_version: milestone.version,
|
|
644
|
+
milestone_name: milestone.name,
|
|
645
|
+
phases,
|
|
646
|
+
total_plans: totalPlans,
|
|
647
|
+
total_summaries: totalSummaries,
|
|
648
|
+
percent,
|
|
649
|
+
}, raw);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Compute and output a composite health score from progress, context budget, and staleness.
|
|
655
|
+
*/
|
|
656
|
+
function renderHealthReport(cwd, { phasesDir, phases, totalPlans, totalSummaries, percent }, raw) {
|
|
657
|
+
const stateContent = safeReadFile(path.join(cwd, PLANNING_DIR, STATE_FILE));
|
|
658
|
+
const roadmapContent = safeReadFile(path.join(cwd, PLANNING_DIR, ROADMAP_FILE));
|
|
659
|
+
const projectContent = safeReadFile(path.join(cwd, PLANNING_DIR, PROJECT_FILE));
|
|
660
|
+
|
|
661
|
+
const stateTokens = estimateTokens(stateContent);
|
|
662
|
+
const roadmapTokens = estimateTokens(roadmapContent);
|
|
663
|
+
const projectTokens = estimateTokens(projectContent);
|
|
664
|
+
let planTokens = 0;
|
|
665
|
+
|
|
666
|
+
let phaseDirEntries;
|
|
667
|
+
try { phaseDirEntries = fs.readdirSync(phasesDir); } catch { phaseDirEntries = []; }
|
|
668
|
+
|
|
669
|
+
for (const phase of phases) {
|
|
670
|
+
const match = phaseDirEntries.find(d => d.startsWith(phase.number + '-') || d === phase.number);
|
|
671
|
+
if (!match) continue;
|
|
672
|
+
const phaseDir = path.join(phasesDir, match);
|
|
673
|
+
try {
|
|
674
|
+
const files = fs.readdirSync(phaseDir);
|
|
675
|
+
for (const f of files) {
|
|
676
|
+
if (isPlanFile(f)) planTokens += estimateTokens(safeReadFile(path.join(phaseDir, f)));
|
|
677
|
+
}
|
|
678
|
+
} catch { /* skip */ }
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const totalTokens = stateTokens + roadmapTokens + projectTokens + planTokens;
|
|
682
|
+
const utilization = totalTokens / CONTEXT_WINDOW;
|
|
683
|
+
|
|
684
|
+
const progressScore = percent;
|
|
685
|
+
const contextScore = utilization >= CRITICAL_THRESHOLD ? 20 : utilization >= WARNING_THRESHOLD ? 60 : 100;
|
|
686
|
+
const stalePhasesCount = phases.filter(p => p.status === 'Planned' && p.plans > 0 && p.summaries === 0).length;
|
|
687
|
+
const stalenessScore = phases.length > 0 ? Math.max(0, 100 - (stalePhasesCount / phases.length) * 100) : 100;
|
|
688
|
+
|
|
689
|
+
const composite = Math.round((progressScore + contextScore + stalenessScore) / 3);
|
|
690
|
+
let grade;
|
|
691
|
+
if (composite >= 80) grade = 'A';
|
|
692
|
+
else if (composite >= 60) grade = 'B';
|
|
693
|
+
else if (composite >= 40) grade = 'C';
|
|
694
|
+
else grade = 'D';
|
|
695
|
+
|
|
696
|
+
// Read error patterns count
|
|
697
|
+
const patternsCount = readErrorPatterns(cwd).length;
|
|
698
|
+
|
|
699
|
+
// Read session history count
|
|
700
|
+
let sessionCount = 0;
|
|
701
|
+
try {
|
|
702
|
+
const sessionContent = fs.readFileSync(path.join(cwd, PLANNING_DIR, SESSION_HISTORY_FILE), 'utf-8');
|
|
703
|
+
sessionCount = (sessionContent.match(/^### Session — /gm) || []).length;
|
|
704
|
+
} catch { /* file doesn't exist */ }
|
|
705
|
+
|
|
706
|
+
const healthResult = {
|
|
707
|
+
grade,
|
|
708
|
+
composite,
|
|
709
|
+
progress: { score: progressScore, completed: totalSummaries, total: totalPlans },
|
|
710
|
+
context: { score: contextScore, utilization: Math.round(utilization * 1000) / 1000, tokens: totalTokens },
|
|
711
|
+
staleness: { score: Math.round(stalenessScore), stalePlans: stalePhasesCount, totalPhases: phases.length },
|
|
712
|
+
patterns_count: patternsCount,
|
|
713
|
+
session_count: sessionCount,
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
if (raw) {
|
|
717
|
+
const lines = [
|
|
718
|
+
`Project Health: ${grade} (${composite}/100)`,
|
|
719
|
+
``,
|
|
720
|
+
`Progress: ${progressScore}% (${totalSummaries}/${totalPlans} plans complete)`,
|
|
721
|
+
`Context: ${contextScore}/100 (${(utilization * 100).toFixed(1)}% utilization)`,
|
|
722
|
+
`Staleness: ${Math.round(stalenessScore)}/100 (${stalePhasesCount} stale phases)`,
|
|
723
|
+
];
|
|
724
|
+
output(healthResult, true, lines.join('\n'));
|
|
725
|
+
} else {
|
|
726
|
+
output(healthResult, false);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Move a todo from pending to completed, adding a completion timestamp.
|
|
732
|
+
* @param {string} cwd - Working directory path
|
|
733
|
+
* @param {string} filename - Filename of the todo in the pending directory
|
|
734
|
+
* @param {boolean} raw - If true, output raw value instead of JSON
|
|
735
|
+
* @returns {void}
|
|
736
|
+
*/
|
|
737
|
+
function cmdTodoComplete(cwd, filename, raw) {
|
|
738
|
+
if (!filename) {
|
|
739
|
+
error('filename required for todo complete');
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const pendingDir = path.join(cwd, PLANNING_DIR, 'todos', 'pending');
|
|
743
|
+
const completedDir = path.join(cwd, PLANNING_DIR, 'todos', 'completed');
|
|
744
|
+
const sourcePath = path.join(pendingDir, filename);
|
|
745
|
+
|
|
746
|
+
let content;
|
|
747
|
+
try {
|
|
748
|
+
content = fs.readFileSync(sourcePath, 'utf-8');
|
|
749
|
+
} catch {
|
|
750
|
+
error(`Todo not found: ${filename}`);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Ensure completed directory exists
|
|
754
|
+
try {
|
|
755
|
+
fs.mkdirSync(completedDir, { recursive: true });
|
|
756
|
+
} catch (e) {
|
|
757
|
+
error(`Failed to create completed directory: ${e.message}`);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const today = new Date().toISOString().split('T')[0];
|
|
761
|
+
content = `completed: ${today}\n` + content;
|
|
762
|
+
|
|
763
|
+
try {
|
|
764
|
+
fs.writeFileSync(path.join(completedDir, filename), content, 'utf-8');
|
|
765
|
+
} catch (e) {
|
|
766
|
+
error(`Failed to write completed todo: ${e.message}`);
|
|
767
|
+
}
|
|
768
|
+
try {
|
|
769
|
+
fs.unlinkSync(sourcePath);
|
|
770
|
+
} catch (e) {
|
|
771
|
+
error(`Failed to remove pending todo: ${e.message}`);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
output({ completed: true, file: filename, date: today }, raw, 'completed');
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Scaffold a planning artifact file (context, uat, verification, or phase-dir).
|
|
779
|
+
* @param {string} cwd - Working directory path
|
|
780
|
+
* @param {string} type - Scaffold type: "context", "uat", "verification", or "phase-dir"
|
|
781
|
+
* @param {Object} options - Options (phase, name)
|
|
782
|
+
* @param {boolean} raw - If true, output raw path instead of JSON
|
|
783
|
+
* @returns {void}
|
|
784
|
+
*/
|
|
785
|
+
function cmdScaffold(cwd, type, options, raw) {
|
|
786
|
+
const { phase, name } = options;
|
|
787
|
+
const padded = phase ? normalizePhaseName(phase) : '00';
|
|
788
|
+
const today = new Date().toISOString().split('T')[0];
|
|
789
|
+
|
|
790
|
+
// Find phase directory
|
|
791
|
+
const phaseInfo = phase ? findPhaseInternal(cwd, phase) : null;
|
|
792
|
+
const phaseDir = phaseInfo ? path.join(cwd, phaseInfo.directory) : null;
|
|
793
|
+
|
|
794
|
+
if (phase && !phaseDir && type !== 'phase-dir') {
|
|
795
|
+
error(`Phase ${phase} directory not found`);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
let filePath, content;
|
|
799
|
+
|
|
800
|
+
switch (type) {
|
|
801
|
+
case 'context': {
|
|
802
|
+
filePath = path.join(phaseDir, `${padded}${CONTEXT_SUFFIX}`);
|
|
803
|
+
content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || 'Unnamed'}"\ncreated: ${today}\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || 'Unnamed'} -- Context\n\n## Decisions\n\n_Decisions will be captured during /pan:discuss-phase ${phase}_\n\n## Discretion Areas\n\n_Areas where the executor can use judgment_\n\n## Deferred Ideas\n\n_Ideas to consider later_\n`;
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
case 'uat': {
|
|
807
|
+
filePath = path.join(phaseDir, `${padded}${UAT_SUFFIX}`);
|
|
808
|
+
content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || 'Unnamed'}"\ncreated: ${today}\nstatus: pending\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || 'Unnamed'} -- User Acceptance Testing\n\n## Test Results\n\n| # | Test | Status | Notes |\n|---|------|--------|-------|\n\n## Summary\n\n_Pending UAT_\n`;
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
case 'verification': {
|
|
812
|
+
filePath = path.join(phaseDir, `${padded}${VERIFICATION_SUFFIX}`);
|
|
813
|
+
content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || 'Unnamed'}"\ncreated: ${today}\nstatus: pending\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || 'Unnamed'} -- Verification\n\n## Goal-Backward Verification\n\n**Phase Goal:** [From roadmap.md]\n\n## Checks\n\n| # | Requirement | Status | Evidence |\n|---|------------|--------|----------|\n\n## Result\n\n_Pending verification_\n`;
|
|
814
|
+
break;
|
|
815
|
+
}
|
|
816
|
+
case 'phase-dir': {
|
|
817
|
+
if (!phase || !name) {
|
|
818
|
+
error('phase and name required for phase-dir scaffold');
|
|
819
|
+
}
|
|
820
|
+
const slug = generateSlugInternal(name);
|
|
821
|
+
const dirName = `${padded}-${slug}`;
|
|
822
|
+
const phasesParent = path.join(cwd, PLANNING_DIR, PHASES_DIR);
|
|
823
|
+
try {
|
|
824
|
+
fs.mkdirSync(phasesParent, { recursive: true });
|
|
825
|
+
fs.mkdirSync(path.join(phasesParent, dirName), { recursive: true });
|
|
826
|
+
} catch (e) {
|
|
827
|
+
error(`Failed to create phase directory: ${e.message}`);
|
|
828
|
+
}
|
|
829
|
+
output({ created: true, directory: `${PLANNING_DIR}/${PHASES_DIR}/${dirName}` }, raw, `${PLANNING_DIR}/${PHASES_DIR}/${dirName}`);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
default:
|
|
833
|
+
error(`Unknown scaffold type: ${type}. Available: context, uat, verification, phase-dir`);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const relPath = toPosix(path.relative(cwd, filePath));
|
|
837
|
+
try {
|
|
838
|
+
fs.writeFileSync(filePath, content, { encoding: 'utf-8', flag: 'wx' });
|
|
839
|
+
} catch (e) {
|
|
840
|
+
if (e.code === 'EEXIST') {
|
|
841
|
+
output({ created: false, reason: 'already_exists', path: relPath }, raw, 'exists');
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
error(`Failed to write scaffold: ${e.message}`);
|
|
845
|
+
}
|
|
846
|
+
output({ created: true, path: relPath }, raw, relPath);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Create a git rollback snapshot tag before execution.
|
|
851
|
+
* @param {string} cwd - Working directory path
|
|
852
|
+
* @param {string} phase - Phase identifier (e.g., "05" or "05.1")
|
|
853
|
+
* @param {boolean} raw - If true, output raw tag name instead of JSON
|
|
854
|
+
* @returns {void}
|
|
855
|
+
*/
|
|
856
|
+
function cmdRollbackSnapshot(cwd, phase, raw) {
|
|
857
|
+
if (!phase) {
|
|
858
|
+
error('phase required for rollback-snapshot');
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Sanitize phase for tag name (replace dots with dashes)
|
|
862
|
+
const sanitizedPhase = String(phase).replace(/\./g, '-');
|
|
863
|
+
const now = new Date();
|
|
864
|
+
const timestamp = now.toISOString().replace(/[-:]/g, '').replace(/\..+/, '');
|
|
865
|
+
let tagName = `pan-rollback-${sanitizedPhase}-${timestamp}`;
|
|
866
|
+
|
|
867
|
+
// Get current HEAD hash
|
|
868
|
+
const headResult = execGit(cwd, ['rev-parse', '--short', 'HEAD']);
|
|
869
|
+
if (headResult.exitCode !== 0) {
|
|
870
|
+
const result = { tag: null, hash: null, phase, warning: 'Not a git repository or no commits' };
|
|
871
|
+
output(result, raw, '');
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const hash = headResult.stdout;
|
|
875
|
+
|
|
876
|
+
// Create tag
|
|
877
|
+
let tagResult = execGit(cwd, ['tag', tagName]);
|
|
878
|
+
if (tagResult.exitCode !== 0) {
|
|
879
|
+
// Tag might already exist — try with suffix
|
|
880
|
+
tagName = tagName + '-1';
|
|
881
|
+
tagResult = execGit(cwd, ['tag', tagName]);
|
|
882
|
+
if (tagResult.exitCode !== 0) {
|
|
883
|
+
const result = { tag: null, hash, phase, warning: 'Failed to create tag: ' + tagResult.stderr };
|
|
884
|
+
output(result, raw, '');
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const result = { tag: tagName, hash, phase };
|
|
890
|
+
output(result, raw, tagName);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Create a single commit summarizing batch results (for orchestrators like focus-exec).
|
|
895
|
+
* Only commits .planning/ metadata.
|
|
896
|
+
* @param {string} cwd - Working directory
|
|
897
|
+
* @param {Array<{title: string}>} items - Completed batch items
|
|
898
|
+
* @param {boolean} raw - Raw output mode
|
|
899
|
+
* @returns {void}
|
|
900
|
+
*/
|
|
901
|
+
function cmdBatchCommit(cwd, items, raw) {
|
|
902
|
+
if (!isGitRepo(cwd)) {
|
|
903
|
+
output({ committed: false, reason: 'not_a_git_repo' }, raw, 'not a git repo');
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
907
|
+
output({ committed: false, reason: 'no_items' }, raw, 'no items');
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const config = loadConfig(cwd);
|
|
912
|
+
if (!config.commit_docs) {
|
|
913
|
+
output({ committed: false, reason: 'skipped_commit_docs_false' }, raw, 'skipped');
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Stage .planning/ only
|
|
918
|
+
execGit(cwd, ['add', PLANNING_DIR + '/']);
|
|
919
|
+
|
|
920
|
+
// Check if there's anything to commit
|
|
921
|
+
const statusResult = execGit(cwd, ['diff', '--cached', '--name-only']);
|
|
922
|
+
if (statusResult.exitCode !== 0 || !statusResult.stdout) {
|
|
923
|
+
output({ committed: false, reason: 'nothing_to_commit' }, raw, 'nothing');
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const titles = items.map(i => '- ' + (i.title || 'untitled')).join('\n');
|
|
928
|
+
const message = 'docs: focus-exec batch — ' + items.length + ' items completed\n\n' + titles;
|
|
929
|
+
const commitResult = execGit(cwd, ['commit', '-m', message]);
|
|
930
|
+
if (commitResult.exitCode !== 0) {
|
|
931
|
+
output({ committed: false, reason: 'commit_failed', error: commitResult.stderr }, raw, 'failed');
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const hashResult = execGit(cwd, ['rev-parse', '--short', 'HEAD']);
|
|
936
|
+
const hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
|
|
937
|
+
output({ committed: true, hash, reason: 'committed', items_count: items.length }, raw, hash || 'committed');
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Check if all modified files are markdown (.md) — used to skip test verification.
|
|
942
|
+
* @param {string[]} files - List of file paths
|
|
943
|
+
* @returns {boolean} true if ALL files are .md (or list is empty)
|
|
944
|
+
*/
|
|
945
|
+
function shouldSkipTests(files) {
|
|
946
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
947
|
+
return true;
|
|
948
|
+
}
|
|
949
|
+
return files.every(f => /\.md$/i.test(f));
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Read error patterns from .planning/patterns.md.
|
|
954
|
+
* Parses PAT-NNN entries into structured objects.
|
|
955
|
+
* @param {string} cwd - Working directory path
|
|
956
|
+
* @returns {Array<{id: string, title: string, wrong: string, right: string, context: string|null, date: string|null}>}
|
|
957
|
+
*/
|
|
958
|
+
function readErrorPatterns(cwd) {
|
|
959
|
+
const filePath = path.join(cwd, PLANNING_DIR, PATTERNS_FILE);
|
|
960
|
+
let content;
|
|
961
|
+
try {
|
|
962
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
963
|
+
} catch {
|
|
964
|
+
return [];
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
if (!content || !content.trim()) {
|
|
968
|
+
return [];
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const patterns = [];
|
|
972
|
+
// Split on PAT-NNN headers
|
|
973
|
+
const sections = content.split(/^### (PAT-\d+):\s*/m);
|
|
974
|
+
// sections[0] = preamble, then alternating [id, body, id, body, ...]
|
|
975
|
+
for (let i = 1; i < sections.length; i += 2) {
|
|
976
|
+
const id = sections[i];
|
|
977
|
+
const body = sections[i + 1] || '';
|
|
978
|
+
|
|
979
|
+
// Title is the first line of the body
|
|
980
|
+
const lines = body.split('\n');
|
|
981
|
+
const title = lines[0] ? lines[0].trim() : '';
|
|
982
|
+
const rest = lines.slice(1).join('\n');
|
|
983
|
+
|
|
984
|
+
const wrongMatch = rest.match(/\*\*Wrong:\*\*\s*(.+)/);
|
|
985
|
+
const rightMatch = rest.match(/\*\*Right:\*\*\s*(.+)/);
|
|
986
|
+
const contextMatch = rest.match(/\*\*Context:\*\*\s*(.+)/);
|
|
987
|
+
const dateMatch = rest.match(/\*\*Date:\*\*\s*(.+)/);
|
|
988
|
+
|
|
989
|
+
// Skip entries missing required fields
|
|
990
|
+
if (!wrongMatch || !rightMatch) continue;
|
|
991
|
+
|
|
992
|
+
patterns.push({
|
|
993
|
+
id,
|
|
994
|
+
title,
|
|
995
|
+
wrong: wrongMatch ? wrongMatch[1].trim() : null,
|
|
996
|
+
right: rightMatch ? rightMatch[1].trim() : null,
|
|
997
|
+
context: contextMatch ? contextMatch[1].trim() : null,
|
|
998
|
+
date: dateMatch ? dateMatch[1].trim() : null,
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
return patterns;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Append a new error pattern entry to .planning/patterns.md.
|
|
1007
|
+
* Auto-increments the PAT-NNN ID. Creates file if missing.
|
|
1008
|
+
* @param {string} cwd - Working directory path
|
|
1009
|
+
* @param {Object} pattern - Pattern to append
|
|
1010
|
+
* @param {string} pattern.wrong - What went wrong
|
|
1011
|
+
* @param {string} pattern.right - What is correct
|
|
1012
|
+
* @param {string} [pattern.title] - Short title
|
|
1013
|
+
* @param {string} [pattern.context] - Additional context
|
|
1014
|
+
* @param {string} [pattern.date] - Date string (defaults to today)
|
|
1015
|
+
* @returns {{ id: string } | { error: string }}
|
|
1016
|
+
*/
|
|
1017
|
+
function appendErrorPattern(cwd, pattern) {
|
|
1018
|
+
if (!pattern || !pattern.wrong || !pattern.right) {
|
|
1019
|
+
return { error: "Pattern requires 'wrong' and 'right' fields" };
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const filePath = path.join(cwd, PLANNING_DIR, PATTERNS_FILE);
|
|
1023
|
+
const existing = readErrorPatterns(cwd);
|
|
1024
|
+
|
|
1025
|
+
// Determine next ID
|
|
1026
|
+
let maxNum = 0;
|
|
1027
|
+
for (const p of existing) {
|
|
1028
|
+
const m = p.id.match(/PAT-(\d+)/);
|
|
1029
|
+
if (m) maxNum = Math.max(maxNum, parseInt(m[1], 10));
|
|
1030
|
+
}
|
|
1031
|
+
const nextId = `PAT-${String(maxNum + 1).padStart(3, '0')}`;
|
|
1032
|
+
|
|
1033
|
+
const date = pattern.date || new Date().toISOString().split('T')[0];
|
|
1034
|
+
const title = pattern.title || 'Untitled';
|
|
1035
|
+
|
|
1036
|
+
const entry = [
|
|
1037
|
+
'',
|
|
1038
|
+
`### ${nextId}: ${title}`,
|
|
1039
|
+
`**Wrong:** ${pattern.wrong}`,
|
|
1040
|
+
`**Right:** ${pattern.right}`,
|
|
1041
|
+
pattern.context ? `**Context:** ${pattern.context}` : null,
|
|
1042
|
+
`**Date:** ${date}`,
|
|
1043
|
+
'',
|
|
1044
|
+
].filter(line => line !== null).join('\n');
|
|
1045
|
+
|
|
1046
|
+
try {
|
|
1047
|
+
let existingContent = '';
|
|
1048
|
+
try {
|
|
1049
|
+
existingContent = fs.readFileSync(filePath, 'utf-8');
|
|
1050
|
+
} catch {
|
|
1051
|
+
// File doesn't exist — create with header
|
|
1052
|
+
existingContent = '# Error Patterns\n';
|
|
1053
|
+
}
|
|
1054
|
+
fs.writeFileSync(filePath, existingContent.trimEnd() + '\n' + entry, 'utf-8');
|
|
1055
|
+
return { id: nextId };
|
|
1056
|
+
} catch (e) {
|
|
1057
|
+
return { error: `Failed to write pattern: ${e.message}` };
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* Append a session summary to .planning/session-history.md.
|
|
1063
|
+
* Creates file with header if missing. Keeps last 20 entries.
|
|
1064
|
+
* @param {string} cwd - Working directory path
|
|
1065
|
+
* @param {Object} summary - Session summary
|
|
1066
|
+
* @param {string} summary.phase - Phase identifier
|
|
1067
|
+
* @param {number} [summary.plans_executed] - Plans executed
|
|
1068
|
+
* @param {number} [summary.tests_before] - Test count before
|
|
1069
|
+
* @param {number} [summary.tests_after] - Test count after
|
|
1070
|
+
* @param {string} [summary.key_decisions] - Key decisions made
|
|
1071
|
+
* @param {string} [summary.date] - Date string (defaults to today)
|
|
1072
|
+
* @returns {{ appended: boolean } | { error: string }}
|
|
1073
|
+
*/
|
|
1074
|
+
function appendSessionSummary(cwd, summary) {
|
|
1075
|
+
if (!summary || !summary.phase) {
|
|
1076
|
+
return { error: "Summary requires 'phase' field" };
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const filePath = path.join(cwd, PLANNING_DIR, SESSION_HISTORY_FILE);
|
|
1080
|
+
const date = summary.date || new Date().toISOString().split('T')[0];
|
|
1081
|
+
|
|
1082
|
+
const entry = [
|
|
1083
|
+
`### Session — ${date}`,
|
|
1084
|
+
`- **Phase:** ${summary.phase}`,
|
|
1085
|
+
summary.plans_executed != null ? `- **Plans Executed:** ${summary.plans_executed}` : null,
|
|
1086
|
+
summary.tests_before != null ? `- **Tests Before:** ${summary.tests_before}` : null,
|
|
1087
|
+
summary.tests_after != null ? `- **Tests After:** ${summary.tests_after}` : null,
|
|
1088
|
+
summary.key_decisions ? `- **Key Decisions:** ${summary.key_decisions}` : null,
|
|
1089
|
+
'',
|
|
1090
|
+
].filter(line => line !== null).join('\n');
|
|
1091
|
+
|
|
1092
|
+
try {
|
|
1093
|
+
let content = '';
|
|
1094
|
+
try {
|
|
1095
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
1096
|
+
} catch {
|
|
1097
|
+
content = '# Session History\n\n';
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
content = content.trimEnd() + '\n\n' + entry;
|
|
1101
|
+
|
|
1102
|
+
// Keep last 20 entries — split on session headers, trim oldest
|
|
1103
|
+
const SESSION_HEADER_RE = /^### Session — /m;
|
|
1104
|
+
const parts = content.split(SESSION_HEADER_RE);
|
|
1105
|
+
// parts[0] = header, parts[1..N] = session entries
|
|
1106
|
+
if (parts.length > 21) { // header + 20 entries
|
|
1107
|
+
const header = parts[0];
|
|
1108
|
+
const kept = parts.slice(parts.length - 20);
|
|
1109
|
+
content = header.trimEnd() + '\n\n' + kept.map(p => '### Session — ' + p).join('');
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
1113
|
+
return { appended: true };
|
|
1114
|
+
} catch (e) {
|
|
1115
|
+
return { error: `Failed to write session summary: ${e.message}` };
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// ---- Session Learnings ---------------------------------------------------------
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Parse learnings.md into structured entries.
|
|
1123
|
+
* Each learning has: id, type, title, detail, files (optional), date.
|
|
1124
|
+
* @param {string} content - Raw content of learnings.md
|
|
1125
|
+
* @returns {Array<{id: string, type: string, title: string, detail: string, files: string[], date: string|null}>}
|
|
1126
|
+
*/
|
|
1127
|
+
function parseLearnings(content) {
|
|
1128
|
+
if (!content || !content.trim()) return [];
|
|
1129
|
+
|
|
1130
|
+
const learnings = [];
|
|
1131
|
+
const sections = content.split(/^### (LEARN-\d+):\s*/m);
|
|
1132
|
+
// sections[0] = preamble, then alternating [id, body, ...]
|
|
1133
|
+
for (let i = 1; i < sections.length; i += 2) {
|
|
1134
|
+
const id = sections[i];
|
|
1135
|
+
const body = sections[i + 1] || '';
|
|
1136
|
+
|
|
1137
|
+
const lines = body.split('\n');
|
|
1138
|
+
const title = lines[0] ? lines[0].trim() : '';
|
|
1139
|
+
const rest = lines.slice(1).join('\n');
|
|
1140
|
+
|
|
1141
|
+
const typeMatch = rest.match(/\*\*Type:\*\*\s*(.+)/);
|
|
1142
|
+
const detailMatch = rest.match(/\*\*Detail:\*\*\s*(.+)/);
|
|
1143
|
+
const filesMatch = rest.match(/\*\*Files:\*\*\s*(.+)/);
|
|
1144
|
+
const dateMatch = rest.match(/\*\*Date:\*\*\s*(.+)/);
|
|
1145
|
+
|
|
1146
|
+
learnings.push({
|
|
1147
|
+
id,
|
|
1148
|
+
type: typeMatch ? typeMatch[1].trim() : 'unknown',
|
|
1149
|
+
title,
|
|
1150
|
+
detail: detailMatch ? detailMatch[1].trim() : '',
|
|
1151
|
+
files: filesMatch ? filesMatch[1].trim().split(/,\s*/) : [],
|
|
1152
|
+
date: dateMatch ? dateMatch[1].trim() : null,
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
return learnings;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Format a learning entry as markdown text.
|
|
1161
|
+
* @param {Object} learning - Learning entry
|
|
1162
|
+
* @returns {string}
|
|
1163
|
+
*/
|
|
1164
|
+
function formatLearningEntry(learning) {
|
|
1165
|
+
const lines = [
|
|
1166
|
+
`### ${learning.id}: ${learning.title}`,
|
|
1167
|
+
`**Type:** ${learning.type}`,
|
|
1168
|
+
`**Detail:** ${learning.detail}`,
|
|
1169
|
+
];
|
|
1170
|
+
if (learning.files && learning.files.length > 0) {
|
|
1171
|
+
lines.push(`**Files:** ${learning.files.join(', ')}`);
|
|
1172
|
+
}
|
|
1173
|
+
if (learning.date) {
|
|
1174
|
+
lines.push(`**Date:** ${learning.date}`);
|
|
1175
|
+
}
|
|
1176
|
+
lines.push('');
|
|
1177
|
+
return lines.join('\n');
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Extract learnings from session summaries and error patterns.
|
|
1182
|
+
* Reads session history + error patterns, extracts file co-change patterns
|
|
1183
|
+
* and error resolutions, writes to .planning/learnings.md.
|
|
1184
|
+
* @param {string} cwd - Working directory path
|
|
1185
|
+
* @param {boolean} raw - If true, output raw count instead of JSON
|
|
1186
|
+
* @returns {void}
|
|
1187
|
+
*/
|
|
1188
|
+
function cmdLearningsExtract(cwd, raw) {
|
|
1189
|
+
const learningsPath = path.join(cwd, PLANNING_DIR, LEARNINGS_FILE);
|
|
1190
|
+
const newLearnings = [];
|
|
1191
|
+
const today = new Date().toISOString().split('T')[0];
|
|
1192
|
+
|
|
1193
|
+
// Read existing learnings to get next ID and avoid duplicates
|
|
1194
|
+
let existingContent = '';
|
|
1195
|
+
try { existingContent = fs.readFileSync(learningsPath, 'utf-8'); } catch { /* new file */ }
|
|
1196
|
+
const existing = parseLearnings(existingContent);
|
|
1197
|
+
let maxNum = 0;
|
|
1198
|
+
for (const l of existing) {
|
|
1199
|
+
const m = l.id.match(/LEARN-(\d+)/);
|
|
1200
|
+
if (m) maxNum = Math.max(maxNum, parseInt(m[1], 10));
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Existing detail strings for dedup
|
|
1204
|
+
const existingDetails = new Set(existing.map(l => l.detail));
|
|
1205
|
+
|
|
1206
|
+
// 1. Extract error resolutions from patterns.md
|
|
1207
|
+
const patterns = readErrorPatterns(cwd);
|
|
1208
|
+
for (const pat of patterns) {
|
|
1209
|
+
const detail = `${pat.wrong} -> ${pat.right}`;
|
|
1210
|
+
if (existingDetails.has(detail)) continue;
|
|
1211
|
+
existingDetails.add(detail);
|
|
1212
|
+
maxNum++;
|
|
1213
|
+
newLearnings.push({
|
|
1214
|
+
id: `LEARN-${String(maxNum).padStart(3, '0')}`,
|
|
1215
|
+
type: 'error-resolution',
|
|
1216
|
+
title: pat.title || 'Error pattern',
|
|
1217
|
+
detail,
|
|
1218
|
+
files: [],
|
|
1219
|
+
date: pat.date || today,
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// 2. Extract file co-change patterns from summary frontmatters
|
|
1224
|
+
const { summaries } = collectPhaseSummaries(cwd);
|
|
1225
|
+
const fileCoChanges = new Map(); // file -> Set of co-changed files
|
|
1226
|
+
|
|
1227
|
+
for (const { frontmatter } of summaries) {
|
|
1228
|
+
const keyFiles = Array.isArray(frontmatter['key-files']) ? frontmatter['key-files'] : [];
|
|
1229
|
+
if (keyFiles.length < 2) continue;
|
|
1230
|
+
for (const file of keyFiles) {
|
|
1231
|
+
if (!fileCoChanges.has(file)) fileCoChanges.set(file, new Set());
|
|
1232
|
+
for (const other of keyFiles) {
|
|
1233
|
+
if (other !== file) fileCoChanges.get(file).add(other);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// Emit co-change learnings for files that appear together 2+ times
|
|
1239
|
+
const emittedPairs = new Set();
|
|
1240
|
+
for (const [file, coFiles] of fileCoChanges) {
|
|
1241
|
+
for (const coFile of coFiles) {
|
|
1242
|
+
const pair = [file, coFile].sort().join(' + ');
|
|
1243
|
+
if (emittedPairs.has(pair)) continue;
|
|
1244
|
+
emittedPairs.add(pair);
|
|
1245
|
+
|
|
1246
|
+
// Count co-occurrences
|
|
1247
|
+
let count = 0;
|
|
1248
|
+
for (const { frontmatter } of summaries) {
|
|
1249
|
+
const kf = frontmatter['key-files'] || [];
|
|
1250
|
+
if (kf.includes(file) && kf.includes(coFile)) count++;
|
|
1251
|
+
}
|
|
1252
|
+
if (count < 2) continue;
|
|
1253
|
+
|
|
1254
|
+
const detail = `${file} and ${coFile} changed together ${count} times`;
|
|
1255
|
+
if (existingDetails.has(detail)) continue;
|
|
1256
|
+
existingDetails.add(detail);
|
|
1257
|
+
maxNum++;
|
|
1258
|
+
newLearnings.push({
|
|
1259
|
+
id: `LEARN-${String(maxNum).padStart(3, '0')}`,
|
|
1260
|
+
type: 'co-change',
|
|
1261
|
+
title: `Co-change: ${path.basename(file)} + ${path.basename(coFile)}`,
|
|
1262
|
+
detail,
|
|
1263
|
+
files: [file, coFile],
|
|
1264
|
+
date: today,
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// 3. Extract successful patterns from summaries
|
|
1270
|
+
for (const { frontmatter } of summaries) {
|
|
1271
|
+
const patterns_established = frontmatter['patterns-established'] || [];
|
|
1272
|
+
for (const pattern of patterns_established) {
|
|
1273
|
+
const detail = String(pattern);
|
|
1274
|
+
if (existingDetails.has(detail)) continue;
|
|
1275
|
+
existingDetails.add(detail);
|
|
1276
|
+
maxNum++;
|
|
1277
|
+
newLearnings.push({
|
|
1278
|
+
id: `LEARN-${String(maxNum).padStart(3, '0')}`,
|
|
1279
|
+
type: 'pattern',
|
|
1280
|
+
title: detail.length > 60 ? detail.substring(0, 57) + '...' : detail,
|
|
1281
|
+
detail,
|
|
1282
|
+
files: [],
|
|
1283
|
+
date: today,
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Write new learnings to file
|
|
1289
|
+
if (newLearnings.length > 0) {
|
|
1290
|
+
let content = existingContent;
|
|
1291
|
+
if (!content || !content.trim()) {
|
|
1292
|
+
content = '# Session Learnings\n\n';
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
for (const learning of newLearnings) {
|
|
1296
|
+
content = content.trimEnd() + '\n\n' + formatLearningEntry(learning);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
try {
|
|
1300
|
+
fs.mkdirSync(path.dirname(learningsPath), { recursive: true });
|
|
1301
|
+
fs.writeFileSync(learningsPath, content, 'utf-8');
|
|
1302
|
+
} catch (e) {
|
|
1303
|
+
error('Failed to write learnings: ' + e.message);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
const result = {
|
|
1308
|
+
extracted: newLearnings.length,
|
|
1309
|
+
total: existing.length + newLearnings.length,
|
|
1310
|
+
by_type: {
|
|
1311
|
+
'error-resolution': newLearnings.filter(l => l.type === 'error-resolution').length,
|
|
1312
|
+
'co-change': newLearnings.filter(l => l.type === 'co-change').length,
|
|
1313
|
+
'pattern': newLearnings.filter(l => l.type === 'pattern').length,
|
|
1314
|
+
},
|
|
1315
|
+
};
|
|
1316
|
+
output(result, raw, `Extracted ${newLearnings.length} new learnings (${result.total} total)`);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* List all learnings from .planning/learnings.md.
|
|
1321
|
+
* @param {string} cwd - Working directory path
|
|
1322
|
+
* @param {boolean} raw - If true, output raw formatted list instead of JSON
|
|
1323
|
+
* @returns {void}
|
|
1324
|
+
*/
|
|
1325
|
+
function cmdLearningsList(cwd, raw) {
|
|
1326
|
+
const learningsPath = path.join(cwd, PLANNING_DIR, LEARNINGS_FILE);
|
|
1327
|
+
|
|
1328
|
+
let content;
|
|
1329
|
+
try {
|
|
1330
|
+
content = fs.readFileSync(learningsPath, 'utf-8');
|
|
1331
|
+
} catch {
|
|
1332
|
+
output({ learnings: [], count: 0 }, raw, 'No learnings found');
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
const learnings = parseLearnings(content);
|
|
1337
|
+
const result = {
|
|
1338
|
+
learnings,
|
|
1339
|
+
count: learnings.length,
|
|
1340
|
+
by_type: {},
|
|
1341
|
+
};
|
|
1342
|
+
|
|
1343
|
+
for (const l of learnings) {
|
|
1344
|
+
result.by_type[l.type] = (result.by_type[l.type] || 0) + 1;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
if (raw) {
|
|
1348
|
+
const lines = learnings.map(l => `${l.id} [${l.type}] ${l.title}`);
|
|
1349
|
+
output(result, true, lines.join('\n') || 'No learnings found');
|
|
1350
|
+
} else {
|
|
1351
|
+
output(result, false);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* Prune learnings by age (--days) or by ID (--id).
|
|
1357
|
+
* @param {string} cwd - Working directory path
|
|
1358
|
+
* @param {Object} opts - Prune options
|
|
1359
|
+
* @param {number|null} opts.days - Remove entries older than N days
|
|
1360
|
+
* @param {string|null} opts.id - Remove specific entry by ID
|
|
1361
|
+
* @param {boolean} raw - If true, output raw count instead of JSON
|
|
1362
|
+
* @returns {void}
|
|
1363
|
+
*/
|
|
1364
|
+
function cmdLearningsPrune(cwd, opts, raw) {
|
|
1365
|
+
const learningsPath = path.join(cwd, PLANNING_DIR, LEARNINGS_FILE);
|
|
1366
|
+
|
|
1367
|
+
if (!opts || (opts.days == null && opts.id == null)) {
|
|
1368
|
+
error('Prune requires --days N or --id LEARN-NNN');
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
let content;
|
|
1372
|
+
try {
|
|
1373
|
+
content = fs.readFileSync(learningsPath, 'utf-8');
|
|
1374
|
+
} catch {
|
|
1375
|
+
output({ pruned: 0, remaining: 0 }, raw, 'No learnings file found');
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const learnings = parseLearnings(content);
|
|
1380
|
+
const before = learnings.length;
|
|
1381
|
+
let kept;
|
|
1382
|
+
|
|
1383
|
+
if (opts.id) {
|
|
1384
|
+
kept = learnings.filter(l => l.id !== opts.id);
|
|
1385
|
+
} else if (opts.days != null) {
|
|
1386
|
+
const cutoff = new Date();
|
|
1387
|
+
cutoff.setDate(cutoff.getDate() - opts.days);
|
|
1388
|
+
const cutoffStr = cutoff.toISOString().split('T')[0];
|
|
1389
|
+
kept = learnings.filter(l => !l.date || l.date >= cutoffStr);
|
|
1390
|
+
} else {
|
|
1391
|
+
kept = learnings;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const pruned = before - kept.length;
|
|
1395
|
+
|
|
1396
|
+
// Rewrite file
|
|
1397
|
+
let newContent = '# Session Learnings\n';
|
|
1398
|
+
for (const learning of kept) {
|
|
1399
|
+
newContent += '\n' + formatLearningEntry(learning);
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
try {
|
|
1403
|
+
fs.writeFileSync(learningsPath, newContent, 'utf-8');
|
|
1404
|
+
} catch (e) {
|
|
1405
|
+
error('Failed to write learnings: ' + e.message);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
const result = { pruned, remaining: kept.length };
|
|
1409
|
+
output(result, raw, `Pruned ${pruned} learnings (${kept.length} remaining)`);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
module.exports = {
|
|
1413
|
+
cmdGenerateSlug,
|
|
1414
|
+
cmdCurrentTimestamp,
|
|
1415
|
+
cmdListTodos,
|
|
1416
|
+
cmdVerifyPathExists,
|
|
1417
|
+
cmdHistoryDigest,
|
|
1418
|
+
cmdResolveModel,
|
|
1419
|
+
cmdCommit,
|
|
1420
|
+
cmdSummaryExtract,
|
|
1421
|
+
cmdWebsearch,
|
|
1422
|
+
cmdProgressRender,
|
|
1423
|
+
cmdTodoComplete,
|
|
1424
|
+
cmdScaffold,
|
|
1425
|
+
cmdRollbackSnapshot,
|
|
1426
|
+
cmdBatchCommit,
|
|
1427
|
+
shouldSkipTests,
|
|
1428
|
+
readErrorPatterns,
|
|
1429
|
+
appendErrorPattern,
|
|
1430
|
+
appendSessionSummary,
|
|
1431
|
+
cmdLearningsExtract,
|
|
1432
|
+
cmdLearningsList,
|
|
1433
|
+
cmdLearningsPrune,
|
|
1434
|
+
VALID_COMMIT_TYPES,
|
|
1435
|
+
};
|