pan-wizard 3.8.0 → 3.10.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/README.md +4 -1
- package/agents/pan-conductor.md +1 -2
- package/agents/pan-counterfactual.md +1 -2
- package/agents/pan-debugger.md +1 -2
- package/agents/pan-distiller.md +1 -2
- package/agents/pan-document_code.md +1 -0
- package/agents/pan-executor.md +1 -0
- package/agents/pan-experiment-runner.md +1 -2
- package/agents/pan-hardener.md +1 -2
- package/agents/pan-integration-checker.md +1 -2
- package/agents/pan-knowledge.md +1 -2
- package/agents/pan-meta-reviewer.md +1 -2
- package/agents/pan-optimizer.md +1 -0
- package/agents/pan-phase-researcher.md +1 -0
- package/agents/pan-plan-checker.md +1 -2
- package/agents/pan-planner.md +1 -0
- package/agents/pan-previewer.md +1 -2
- package/agents/pan-project-researcher.md +6 -0
- package/agents/pan-research-synthesizer.md +7 -0
- package/agents/pan-reviewer.md +2 -3
- package/agents/pan-roadmapper.md +1 -0
- package/agents/pan-verifier.md +1 -2
- package/bin/install-lib.cjs +661 -46
- package/bin/install.js +722 -116
- package/commands/pan/experiment.md +2 -0
- package/commands/pan/profile.md +2 -0
- package/hooks/dist/pan-cost-logger.js +22 -7
- package/package.json +5 -4
- package/pan-wizard-core/bin/lib/commands-learnings.cjs +544 -0
- package/pan-wizard-core/bin/lib/commands.cjs +12 -523
- package/pan-wizard-core/bin/lib/core.cjs +69 -0
- package/pan-wizard-core/bin/lib/cost.cjs +62 -8
- package/pan-wizard-core/bin/lib/git.cjs +6 -1
- package/pan-wizard-core/bin/lib/lock.cjs +108 -0
- package/pan-wizard-core/bin/lib/milestone.cjs +3 -2
- package/pan-wizard-core/bin/lib/phase-remove.cjs +392 -0
- package/pan-wizard-core/bin/lib/phase.cjs +4 -369
- package/pan-wizard-core/bin/lib/runner.cjs +5 -0
- package/pan-wizard-core/bin/lib/state.cjs +10 -1
- package/pan-wizard-core/bin/lib/verify-deploy.cjs +181 -0
- package/pan-wizard-core/bin/lib/verify-drift.cjs +255 -0
- package/pan-wizard-core/bin/lib/verify-preflight.cjs +261 -0
- package/pan-wizard-core/bin/lib/verify-retro.cjs +177 -0
- package/pan-wizard-core/bin/lib/verify.cjs +10 -797
- package/pan-wizard-core/bin/pan-tools.cjs +10 -0
- package/pan-wizard-core/workflows/plan-phase.md +11 -0
- package/scripts/build-plugin.js +105 -0
- package/scripts/install-git-hooks.js +64 -0
- package/scripts/release-check.js +13 -2
|
@@ -105,6 +105,8 @@ Spawn the external AI runtime against the experiment folder. **Synchronous** —
|
|
|
105
105
|
|
|
106
106
|
**Runtime support:** claude / codex / gemini / opencode (via `RUNTIME_RUNNERS` adapter map in `runner.cjs`). GitHub Copilot CLI is **unsupported** for the `run` subcommand — no documented headless prompt mode. Copilot users can still scaffold and harvest manually.
|
|
107
107
|
|
|
108
|
+
**Billing note (Claude runtime):** headless `claude -p` runs bill against the **Claude Agent SDK credit pool** — a monthly allotment separate from your interactive subscription limits (Anthropic split the two effective June 15, 2026). Experiment runs do not consume interactive-session quota, but heavy experimentation can exhaust the SDK pool independently. Captured metrics are tagged `billing_pool: "agent_sdk"` so you can reconcile experiment spend separately.
|
|
109
|
+
|
|
108
110
|
### `/pan:experiment status <slug>`
|
|
109
111
|
|
|
110
112
|
Read the current `run-state.json` snapshot. Returns the full state object (`status`, `stop_reason`, `exit_code`, `elapsed_ms`, `events`).
|
package/commands/pan/profile.md
CHANGED
|
@@ -71,4 +71,6 @@ Final tier → provider-native model name
|
|
|
71
71
|
- All rules are additive to the `quality` / `balanced` / `budget` profile you pick here — profile sets the floor, capability hints adjust upward or downward within that floor's band.
|
|
72
72
|
|
|
73
73
|
**Inspecting routing:** use `pan-tools resolve-model <agent> --metadata '{"context_estimate":900000,"needs_thinking":true}'` to see what tier a given hint set resolves to.
|
|
74
|
+
|
|
75
|
+
**Effort dimension (2026-06):** `resolve-model` also returns an `effort` level (`low`/`medium`/`high`/`xhigh`) per agent — the within-model reasoning-depth dial on current models. Profiles modulate it: `budget` steps each agent's base effort down one level (floor `low`); `quality`/`balanced` keep the base. Per-agent override: `.planning/config.json` → `"effort_overrides": { "pan-verifier": "xhigh" }`.
|
|
74
76
|
</tier_decision_tree>
|
|
@@ -37,23 +37,33 @@ function buildCostRecord(data, cwd) {
|
|
|
37
37
|
// doesn't include it in the SubagentStop payload), fall back to reading the
|
|
38
38
|
// transcript_path JSONL and summing usage across the subagent's messages.
|
|
39
39
|
// Same approach as pan-trace-logger.js for consistency.
|
|
40
|
+
//
|
|
41
|
+
// 2026-06: the SubagentStop payload carries no model id either, which left
|
|
42
|
+
// every hook record with model:null and /pan:cost unable to price it. The
|
|
43
|
+
// transcript's assistant messages carry message.model right next to the
|
|
44
|
+
// usage we already read — capture it whenever data.model is absent.
|
|
40
45
|
let inputTokens = extractNumber(data.usage, 'input_tokens');
|
|
41
46
|
let outputTokens = extractNumber(data.usage, 'output_tokens');
|
|
42
47
|
let cacheRead = extractNumber(data.usage, 'cache_read_input_tokens');
|
|
43
48
|
let cacheWrite = extractNumber(data.usage, 'cache_creation_input_tokens');
|
|
44
|
-
|
|
49
|
+
let model = typeof data.model === 'string' && data.model ? data.model : null;
|
|
50
|
+
const needUsage = (inputTokens + outputTokens + cacheRead + cacheWrite) === 0;
|
|
51
|
+
if ((needUsage || !model) && data.transcript_path) {
|
|
45
52
|
const fromTranscript = readUsageFromTranscript(data.transcript_path, data.session_id);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
if (needUsage) {
|
|
54
|
+
inputTokens = fromTranscript.input_tokens;
|
|
55
|
+
outputTokens = fromTranscript.output_tokens;
|
|
56
|
+
cacheRead = fromTranscript.cache_read_input_tokens;
|
|
57
|
+
cacheWrite = fromTranscript.cache_creation_input_tokens;
|
|
58
|
+
}
|
|
59
|
+
if (!model) model = fromTranscript.model;
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
const record = {
|
|
53
63
|
ts: new Date().toISOString(),
|
|
54
64
|
agent: data.agent_type || data.subagent_type || null,
|
|
55
65
|
command: null,
|
|
56
|
-
model
|
|
66
|
+
model,
|
|
57
67
|
tier: null,
|
|
58
68
|
input_tokens: inputTokens,
|
|
59
69
|
output_tokens: outputTokens,
|
|
@@ -85,6 +95,7 @@ function readUsageFromTranscript(transcriptPath, sessionId) {
|
|
|
85
95
|
output_tokens: 0,
|
|
86
96
|
cache_read_input_tokens: 0,
|
|
87
97
|
cache_creation_input_tokens: 0,
|
|
98
|
+
model: null,
|
|
88
99
|
};
|
|
89
100
|
if (!transcriptPath || typeof transcriptPath !== 'string') return totals;
|
|
90
101
|
let raw;
|
|
@@ -94,6 +105,10 @@ function readUsageFromTranscript(transcriptPath, sessionId) {
|
|
|
94
105
|
let entry;
|
|
95
106
|
try { entry = JSON.parse(line); } catch { continue; }
|
|
96
107
|
if (sessionId && entry.session_id && entry.session_id !== sessionId) continue;
|
|
108
|
+
// Assistant messages carry the model id alongside their usage — keep the
|
|
109
|
+
// last one seen (mid-session model switches resolve to the final model).
|
|
110
|
+
const entryModel = entry.message?.model || entry.model || null;
|
|
111
|
+
if (typeof entryModel === 'string' && entryModel) totals.model = entryModel;
|
|
97
112
|
const usage = entry.usage
|
|
98
113
|
|| entry.message?.usage
|
|
99
114
|
|| entry.response?.usage
|
|
@@ -149,4 +164,4 @@ if (require.main === module) {
|
|
|
149
164
|
});
|
|
150
165
|
}
|
|
151
166
|
|
|
152
|
-
module.exports = { buildCostRecord, appendRecord, METRICS_DIR, TOKENS_FILE };
|
|
167
|
+
module.exports = { buildCostRecord, appendRecord, readUsageFromTranscript, METRICS_DIR, TOKENS_FILE };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pan-wizard",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.0",
|
|
4
4
|
"description": "A lightweight workflow automation and context engineering system for Claude Code, OpenCode, Gemini CLI, Codex, and Copilot CLI.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"pan-wizard": "bin/install.js"
|
|
@@ -50,11 +50,11 @@
|
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@playwright/test": "^1.58.2",
|
|
53
|
-
"@vscode/test-electron": "^2.5.2"
|
|
54
|
-
"esbuild": "^0.28.0"
|
|
53
|
+
"@vscode/test-electron": "^2.5.2"
|
|
55
54
|
},
|
|
56
55
|
"scripts": {
|
|
57
56
|
"build:hooks": "node scripts/build-hooks.js",
|
|
57
|
+
"prepare": "node scripts/install-git-hooks.js",
|
|
58
58
|
"release:check": "node scripts/release-check.js",
|
|
59
59
|
"prepublishOnly": "node scripts/release-check.js",
|
|
60
60
|
"test": "node --test tests/*.test.cjs",
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"test:all": "node --test tests/*.test.cjs tests/scenarios/*.test.cjs",
|
|
63
63
|
"test:e2e": "node --test tests/scenarios/*.test.cjs",
|
|
64
64
|
"test:vscode": "npx playwright test --config tests/e2e/playwright.config.mjs",
|
|
65
|
-
"test:watch": "node --test --watch tests/*.test.cjs"
|
|
65
|
+
"test:watch": "node --test --watch tests/*.test.cjs",
|
|
66
|
+
"build:plugin": "node scripts/build-plugin.js"
|
|
66
67
|
}
|
|
67
68
|
}
|
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commands / Learnings — error patterns, session history, and session-learnings
|
|
3
|
+
* extraction (LEARN-NNN lifecycle), plus the shared phase-summary collector.
|
|
4
|
+
* Extracted from commands.cjs (IMPROVEMENT-TODO P2 module decomposition);
|
|
5
|
+
* commands.cjs re-exports the public pieces, so consumers are unaffected.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { getArchivedPhaseDirs, output, error } = require('./core.cjs');
|
|
11
|
+
const { extractFrontmatter } = require('./frontmatter.cjs');
|
|
12
|
+
const { PLANNING_DIR, PATTERNS_FILE, SESSION_HISTORY_FILE, LEARNINGS_FILE, isSummaryFile } = require('./constants.cjs');
|
|
13
|
+
const { phasesPath } = require('./utils.cjs');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Scan all phase directories (archived + current) and read summary frontmatter.
|
|
17
|
+
* Returns an array of { phaseNum, dirName, frontmatter } objects for each summary found.
|
|
18
|
+
*
|
|
19
|
+
* Algorithm overview:
|
|
20
|
+
* 1. Collect archived phase dirs from milestone archives (oldest milestones first)
|
|
21
|
+
* 2. Collect current phase dirs from .planning/phases/
|
|
22
|
+
* 3. For each directory, read all *-summary.md files and extract frontmatter
|
|
23
|
+
*
|
|
24
|
+
* @param {string} cwd - Working directory path
|
|
25
|
+
* @returns {{ allPhaseDirs: Array, summaries: Array<{phaseNum: string, dirName: string, frontmatter: Object}> }}
|
|
26
|
+
*/
|
|
27
|
+
function collectPhaseSummaries(cwd) {
|
|
28
|
+
const phasesDir = phasesPath(cwd);
|
|
29
|
+
|
|
30
|
+
// Collect all phase directories: archived + current
|
|
31
|
+
const allPhaseDirs = [];
|
|
32
|
+
|
|
33
|
+
// Add archived phases first (oldest milestones first)
|
|
34
|
+
const archived = getArchivedPhaseDirs(cwd);
|
|
35
|
+
for (const archiveEntry of archived) {
|
|
36
|
+
allPhaseDirs.push({ name: archiveEntry.name, fullPath: archiveEntry.fullPath, milestone: archiveEntry.milestone });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Add current phases
|
|
40
|
+
try {
|
|
41
|
+
const currentDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
|
|
42
|
+
.filter(entry => entry.isDirectory())
|
|
43
|
+
.map(entry => entry.name)
|
|
44
|
+
.sort();
|
|
45
|
+
for (const dirName of currentDirs) {
|
|
46
|
+
allPhaseDirs.push({ name: dirName, fullPath: path.join(phasesDir, dirName), milestone: null });
|
|
47
|
+
}
|
|
48
|
+
} catch { /* phases dir missing or unreadable */ }
|
|
49
|
+
|
|
50
|
+
const summaries = [];
|
|
51
|
+
|
|
52
|
+
for (const { name: dirName, fullPath: dirPath } of allPhaseDirs) {
|
|
53
|
+
let summaryFiles;
|
|
54
|
+
try {
|
|
55
|
+
summaryFiles = fs.readdirSync(dirPath).filter(filename => isSummaryFile(filename));
|
|
56
|
+
} catch { continue; }
|
|
57
|
+
|
|
58
|
+
for (const summaryFile of summaryFiles) {
|
|
59
|
+
try {
|
|
60
|
+
const content = fs.readFileSync(path.join(dirPath, summaryFile), 'utf-8');
|
|
61
|
+
const frontmatter = extractFrontmatter(content);
|
|
62
|
+
const phaseNum = frontmatter.phase || dirName.split('-')[0];
|
|
63
|
+
|
|
64
|
+
summaries.push({ phaseNum, dirName, frontmatter });
|
|
65
|
+
} catch {
|
|
66
|
+
// Skip malformed summary files (broken YAML, unreadable)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { allPhaseDirs, summaries };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read error patterns from .planning/patterns.md.
|
|
76
|
+
* Parses PAT-NNN entries into structured objects.
|
|
77
|
+
* @param {string} cwd - Working directory path
|
|
78
|
+
* @returns {Array<{id: string, title: string, wrong: string, right: string, context: string|null, date: string|null}>}
|
|
79
|
+
*/
|
|
80
|
+
function readErrorPatterns(cwd) {
|
|
81
|
+
const filePath = path.join(cwd, PLANNING_DIR, PATTERNS_FILE);
|
|
82
|
+
let content;
|
|
83
|
+
try {
|
|
84
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
85
|
+
} catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!content || !content.trim()) {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const patterns = [];
|
|
94
|
+
// Split on PAT-NNN headers
|
|
95
|
+
const sections = content.split(/^### (PAT-\d+):\s*/m);
|
|
96
|
+
// sections[0] = preamble, then alternating [id, body, id, body, ...]
|
|
97
|
+
for (let i = 1; i < sections.length; i += 2) {
|
|
98
|
+
const id = sections[i];
|
|
99
|
+
const body = sections[i + 1] || '';
|
|
100
|
+
|
|
101
|
+
// Title is the first line of the body
|
|
102
|
+
const lines = body.split('\n');
|
|
103
|
+
const title = lines[0] ? lines[0].trim() : '';
|
|
104
|
+
const rest = lines.slice(1).join('\n');
|
|
105
|
+
|
|
106
|
+
const wrongMatch = rest.match(/\*\*Wrong:\*\*\s*(.+)/);
|
|
107
|
+
const rightMatch = rest.match(/\*\*Right:\*\*\s*(.+)/);
|
|
108
|
+
const contextMatch = rest.match(/\*\*Context:\*\*\s*(.+)/);
|
|
109
|
+
const dateMatch = rest.match(/\*\*Date:\*\*\s*(.+)/);
|
|
110
|
+
|
|
111
|
+
// Skip entries missing required fields
|
|
112
|
+
if (!wrongMatch || !rightMatch) continue;
|
|
113
|
+
|
|
114
|
+
patterns.push({
|
|
115
|
+
id,
|
|
116
|
+
title,
|
|
117
|
+
wrong: wrongMatch ? wrongMatch[1].trim() : null,
|
|
118
|
+
right: rightMatch ? rightMatch[1].trim() : null,
|
|
119
|
+
context: contextMatch ? contextMatch[1].trim() : null,
|
|
120
|
+
date: dateMatch ? dateMatch[1].trim() : null,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return patterns;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Append a new error pattern entry to .planning/patterns.md.
|
|
129
|
+
* Auto-increments the PAT-NNN ID. Creates file if missing.
|
|
130
|
+
* @param {string} cwd - Working directory path
|
|
131
|
+
* @param {Object} pattern - Pattern to append
|
|
132
|
+
* @param {string} pattern.wrong - What went wrong
|
|
133
|
+
* @param {string} pattern.right - What is correct
|
|
134
|
+
* @param {string} [pattern.title] - Short title
|
|
135
|
+
* @param {string} [pattern.context] - Additional context
|
|
136
|
+
* @param {string} [pattern.date] - Date string (defaults to today)
|
|
137
|
+
* @returns {{ id: string } | { error: string }}
|
|
138
|
+
*/
|
|
139
|
+
function appendErrorPattern(cwd, pattern) {
|
|
140
|
+
if (!pattern || !pattern.wrong || !pattern.right) {
|
|
141
|
+
return { error: "Pattern requires 'wrong' and 'right' fields" };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const filePath = path.join(cwd, PLANNING_DIR, PATTERNS_FILE);
|
|
145
|
+
const existing = readErrorPatterns(cwd);
|
|
146
|
+
|
|
147
|
+
// Determine next ID
|
|
148
|
+
let maxNum = 0;
|
|
149
|
+
for (const p of existing) {
|
|
150
|
+
const m = p.id.match(/PAT-(\d+)/);
|
|
151
|
+
if (m) maxNum = Math.max(maxNum, parseInt(m[1], 10));
|
|
152
|
+
}
|
|
153
|
+
const nextId = `PAT-${String(maxNum + 1).padStart(3, '0')}`;
|
|
154
|
+
|
|
155
|
+
const date = pattern.date || new Date().toISOString().split('T')[0];
|
|
156
|
+
const title = pattern.title || 'Untitled';
|
|
157
|
+
|
|
158
|
+
const entry = [
|
|
159
|
+
'',
|
|
160
|
+
`### ${nextId}: ${title}`,
|
|
161
|
+
`**Wrong:** ${pattern.wrong}`,
|
|
162
|
+
`**Right:** ${pattern.right}`,
|
|
163
|
+
pattern.context ? `**Context:** ${pattern.context}` : null,
|
|
164
|
+
`**Date:** ${date}`,
|
|
165
|
+
'',
|
|
166
|
+
].filter(line => line !== null).join('\n');
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
let existingContent = '';
|
|
170
|
+
try {
|
|
171
|
+
existingContent = fs.readFileSync(filePath, 'utf-8');
|
|
172
|
+
} catch {
|
|
173
|
+
// File doesn't exist — create with header
|
|
174
|
+
existingContent = '# Error Patterns\n';
|
|
175
|
+
}
|
|
176
|
+
fs.writeFileSync(filePath, existingContent.trimEnd() + '\n' + entry, 'utf-8');
|
|
177
|
+
return { id: nextId };
|
|
178
|
+
} catch (e) {
|
|
179
|
+
return { error: `Failed to write pattern: ${e.message}` };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Append a session summary to .planning/session-history.md.
|
|
185
|
+
* Creates file with header if missing. Keeps last 20 entries.
|
|
186
|
+
* @param {string} cwd - Working directory path
|
|
187
|
+
* @param {Object} summary - Session summary
|
|
188
|
+
* @param {string} summary.phase - Phase identifier
|
|
189
|
+
* @param {number} [summary.plans_executed] - Plans executed
|
|
190
|
+
* @param {number} [summary.tests_before] - Test count before
|
|
191
|
+
* @param {number} [summary.tests_after] - Test count after
|
|
192
|
+
* @param {string} [summary.key_decisions] - Key decisions made
|
|
193
|
+
* @param {string} [summary.date] - Date string (defaults to today)
|
|
194
|
+
* @returns {{ appended: boolean } | { error: string }}
|
|
195
|
+
*/
|
|
196
|
+
function appendSessionSummary(cwd, summary) {
|
|
197
|
+
if (!summary || !summary.phase) {
|
|
198
|
+
return { error: "Summary requires 'phase' field" };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const filePath = path.join(cwd, PLANNING_DIR, SESSION_HISTORY_FILE);
|
|
202
|
+
const date = summary.date || new Date().toISOString().split('T')[0];
|
|
203
|
+
|
|
204
|
+
const entry = [
|
|
205
|
+
`### Session — ${date}`,
|
|
206
|
+
`- **Phase:** ${summary.phase}`,
|
|
207
|
+
summary.plans_executed != null ? `- **Plans Executed:** ${summary.plans_executed}` : null,
|
|
208
|
+
summary.tests_before != null ? `- **Tests Before:** ${summary.tests_before}` : null,
|
|
209
|
+
summary.tests_after != null ? `- **Tests After:** ${summary.tests_after}` : null,
|
|
210
|
+
summary.key_decisions ? `- **Key Decisions:** ${summary.key_decisions}` : null,
|
|
211
|
+
'',
|
|
212
|
+
].filter(line => line !== null).join('\n');
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
let content = '';
|
|
216
|
+
try {
|
|
217
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
218
|
+
} catch {
|
|
219
|
+
content = '# Session History\n\n';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
content = content.trimEnd() + '\n\n' + entry;
|
|
223
|
+
|
|
224
|
+
// Keep last 20 entries — split on session headers, trim oldest
|
|
225
|
+
const SESSION_HEADER_RE = /^### Session — /m;
|
|
226
|
+
const parts = content.split(SESSION_HEADER_RE);
|
|
227
|
+
// parts[0] = header, parts[1..N] = session entries
|
|
228
|
+
if (parts.length > 21) { // header + 20 entries
|
|
229
|
+
const header = parts[0];
|
|
230
|
+
const kept = parts.slice(parts.length - 20);
|
|
231
|
+
content = header.trimEnd() + '\n\n' + kept.map(p => '### Session — ' + p).join('');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
235
|
+
return { appended: true };
|
|
236
|
+
} catch (e) {
|
|
237
|
+
return { error: `Failed to write session summary: ${e.message}` };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---- Session Learnings ---------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Parse learnings.md into structured entries.
|
|
245
|
+
* Each learning has: id, type, title, detail, files (optional), date.
|
|
246
|
+
* @param {string} content - Raw content of learnings.md
|
|
247
|
+
* @returns {Array<{id: string, type: string, title: string, detail: string, files: string[], date: string|null}>}
|
|
248
|
+
*/
|
|
249
|
+
function parseLearnings(content) {
|
|
250
|
+
if (!content || !content.trim()) return [];
|
|
251
|
+
|
|
252
|
+
const learnings = [];
|
|
253
|
+
const sections = content.split(/^### (LEARN-\d+):\s*/m);
|
|
254
|
+
// sections[0] = preamble, then alternating [id, body, ...]
|
|
255
|
+
for (let i = 1; i < sections.length; i += 2) {
|
|
256
|
+
const id = sections[i];
|
|
257
|
+
const body = sections[i + 1] || '';
|
|
258
|
+
|
|
259
|
+
const lines = body.split('\n');
|
|
260
|
+
const title = lines[0] ? lines[0].trim() : '';
|
|
261
|
+
const rest = lines.slice(1).join('\n');
|
|
262
|
+
|
|
263
|
+
const typeMatch = rest.match(/\*\*Type:\*\*\s*(.+)/);
|
|
264
|
+
const detailMatch = rest.match(/\*\*Detail:\*\*\s*(.+)/);
|
|
265
|
+
const filesMatch = rest.match(/\*\*Files:\*\*\s*(.+)/);
|
|
266
|
+
const dateMatch = rest.match(/\*\*Date:\*\*\s*(.+)/);
|
|
267
|
+
|
|
268
|
+
learnings.push({
|
|
269
|
+
id,
|
|
270
|
+
type: typeMatch ? typeMatch[1].trim() : 'unknown',
|
|
271
|
+
title,
|
|
272
|
+
detail: detailMatch ? detailMatch[1].trim() : '',
|
|
273
|
+
files: filesMatch ? filesMatch[1].trim().split(/,\s*/) : [],
|
|
274
|
+
date: dateMatch ? dateMatch[1].trim() : null,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return learnings;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Format a learning entry as markdown text.
|
|
283
|
+
* @param {Object} learning - Learning entry
|
|
284
|
+
* @returns {string}
|
|
285
|
+
*/
|
|
286
|
+
function formatLearningEntry(learning) {
|
|
287
|
+
const lines = [
|
|
288
|
+
`### ${learning.id}: ${learning.title}`,
|
|
289
|
+
`**Type:** ${learning.type}`,
|
|
290
|
+
`**Detail:** ${learning.detail}`,
|
|
291
|
+
];
|
|
292
|
+
if (learning.files && learning.files.length > 0) {
|
|
293
|
+
lines.push(`**Files:** ${learning.files.join(', ')}`);
|
|
294
|
+
}
|
|
295
|
+
if (learning.date) {
|
|
296
|
+
lines.push(`**Date:** ${learning.date}`);
|
|
297
|
+
}
|
|
298
|
+
lines.push('');
|
|
299
|
+
return lines.join('\n');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Extract learnings from session summaries and error patterns.
|
|
304
|
+
* Reads session history + error patterns, extracts file co-change patterns
|
|
305
|
+
* and error resolutions, writes to .planning/learnings.md.
|
|
306
|
+
* @param {string} cwd - Working directory path
|
|
307
|
+
* @param {boolean} raw - If true, output raw count instead of JSON
|
|
308
|
+
* @returns {void}
|
|
309
|
+
*/
|
|
310
|
+
function cmdLearningsExtract(cwd, raw) {
|
|
311
|
+
const learningsPath = path.join(cwd, PLANNING_DIR, LEARNINGS_FILE);
|
|
312
|
+
const newLearnings = [];
|
|
313
|
+
const today = new Date().toISOString().split('T')[0];
|
|
314
|
+
|
|
315
|
+
// Read existing learnings to get next ID and avoid duplicates
|
|
316
|
+
let existingContent = '';
|
|
317
|
+
try { existingContent = fs.readFileSync(learningsPath, 'utf-8'); } catch { /* new file */ }
|
|
318
|
+
const existing = parseLearnings(existingContent);
|
|
319
|
+
let maxNum = 0;
|
|
320
|
+
for (const l of existing) {
|
|
321
|
+
const m = l.id.match(/LEARN-(\d+)/);
|
|
322
|
+
if (m) maxNum = Math.max(maxNum, parseInt(m[1], 10));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Existing detail strings for dedup
|
|
326
|
+
const existingDetails = new Set(existing.map(l => l.detail));
|
|
327
|
+
|
|
328
|
+
// 1. Extract error resolutions from patterns.md
|
|
329
|
+
const patterns = readErrorPatterns(cwd);
|
|
330
|
+
for (const pat of patterns) {
|
|
331
|
+
const detail = `${pat.wrong} -> ${pat.right}`;
|
|
332
|
+
if (existingDetails.has(detail)) continue;
|
|
333
|
+
existingDetails.add(detail);
|
|
334
|
+
maxNum++;
|
|
335
|
+
newLearnings.push({
|
|
336
|
+
id: `LEARN-${String(maxNum).padStart(3, '0')}`,
|
|
337
|
+
type: 'error-resolution',
|
|
338
|
+
title: pat.title || 'Error pattern',
|
|
339
|
+
detail,
|
|
340
|
+
files: [],
|
|
341
|
+
date: pat.date || today,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 2. Extract file co-change patterns from summary frontmatters
|
|
346
|
+
const { summaries } = collectPhaseSummaries(cwd);
|
|
347
|
+
const fileCoChanges = new Map(); // file -> Set of co-changed files
|
|
348
|
+
|
|
349
|
+
for (const { frontmatter } of summaries) {
|
|
350
|
+
const keyFiles = Array.isArray(frontmatter['key-files']) ? frontmatter['key-files'] : [];
|
|
351
|
+
if (keyFiles.length < 2) continue;
|
|
352
|
+
for (const file of keyFiles) {
|
|
353
|
+
if (!fileCoChanges.has(file)) fileCoChanges.set(file, new Set());
|
|
354
|
+
for (const other of keyFiles) {
|
|
355
|
+
if (other !== file) fileCoChanges.get(file).add(other);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Emit co-change learnings for files that appear together 2+ times
|
|
361
|
+
const emittedPairs = new Set();
|
|
362
|
+
for (const [file, coFiles] of fileCoChanges) {
|
|
363
|
+
for (const coFile of coFiles) {
|
|
364
|
+
const pair = [file, coFile].sort().join(' + ');
|
|
365
|
+
if (emittedPairs.has(pair)) continue;
|
|
366
|
+
emittedPairs.add(pair);
|
|
367
|
+
|
|
368
|
+
// Count co-occurrences
|
|
369
|
+
let count = 0;
|
|
370
|
+
for (const { frontmatter } of summaries) {
|
|
371
|
+
const kf = frontmatter['key-files'] || [];
|
|
372
|
+
if (kf.includes(file) && kf.includes(coFile)) count++;
|
|
373
|
+
}
|
|
374
|
+
if (count < 2) continue;
|
|
375
|
+
|
|
376
|
+
const detail = `${file} and ${coFile} changed together ${count} times`;
|
|
377
|
+
if (existingDetails.has(detail)) continue;
|
|
378
|
+
existingDetails.add(detail);
|
|
379
|
+
maxNum++;
|
|
380
|
+
newLearnings.push({
|
|
381
|
+
id: `LEARN-${String(maxNum).padStart(3, '0')}`,
|
|
382
|
+
type: 'co-change',
|
|
383
|
+
title: `Co-change: ${path.basename(file)} + ${path.basename(coFile)}`,
|
|
384
|
+
detail,
|
|
385
|
+
files: [file, coFile],
|
|
386
|
+
date: today,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// 3. Extract successful patterns from summaries
|
|
392
|
+
for (const { frontmatter } of summaries) {
|
|
393
|
+
const patterns_established = frontmatter['patterns-established'] || [];
|
|
394
|
+
for (const pattern of patterns_established) {
|
|
395
|
+
const detail = String(pattern);
|
|
396
|
+
if (existingDetails.has(detail)) continue;
|
|
397
|
+
existingDetails.add(detail);
|
|
398
|
+
maxNum++;
|
|
399
|
+
newLearnings.push({
|
|
400
|
+
id: `LEARN-${String(maxNum).padStart(3, '0')}`,
|
|
401
|
+
type: 'pattern',
|
|
402
|
+
title: detail.length > 60 ? detail.substring(0, 57) + '...' : detail,
|
|
403
|
+
detail,
|
|
404
|
+
files: [],
|
|
405
|
+
date: today,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Write new learnings to file
|
|
411
|
+
if (newLearnings.length > 0) {
|
|
412
|
+
let content = existingContent;
|
|
413
|
+
if (!content || !content.trim()) {
|
|
414
|
+
content = '# Session Learnings\n\n';
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
for (const learning of newLearnings) {
|
|
418
|
+
content = content.trimEnd() + '\n\n' + formatLearningEntry(learning);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
fs.mkdirSync(path.dirname(learningsPath), { recursive: true });
|
|
423
|
+
fs.writeFileSync(learningsPath, content, 'utf-8');
|
|
424
|
+
} catch (e) {
|
|
425
|
+
error('Failed to write learnings: ' + e.message);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const result = {
|
|
430
|
+
extracted: newLearnings.length,
|
|
431
|
+
total: existing.length + newLearnings.length,
|
|
432
|
+
by_type: {
|
|
433
|
+
'error-resolution': newLearnings.filter(l => l.type === 'error-resolution').length,
|
|
434
|
+
'co-change': newLearnings.filter(l => l.type === 'co-change').length,
|
|
435
|
+
'pattern': newLearnings.filter(l => l.type === 'pattern').length,
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
output(result, raw, `Extracted ${newLearnings.length} new learnings (${result.total} total)`);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* List all learnings from .planning/learnings.md.
|
|
443
|
+
* @param {string} cwd - Working directory path
|
|
444
|
+
* @param {boolean} raw - If true, output raw formatted list instead of JSON
|
|
445
|
+
* @returns {void}
|
|
446
|
+
*/
|
|
447
|
+
function cmdLearningsList(cwd, raw) {
|
|
448
|
+
const learningsPath = path.join(cwd, PLANNING_DIR, LEARNINGS_FILE);
|
|
449
|
+
|
|
450
|
+
let content;
|
|
451
|
+
try {
|
|
452
|
+
content = fs.readFileSync(learningsPath, 'utf-8');
|
|
453
|
+
} catch {
|
|
454
|
+
output({ learnings: [], count: 0 }, raw, 'No learnings found');
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const learnings = parseLearnings(content);
|
|
459
|
+
const result = {
|
|
460
|
+
learnings,
|
|
461
|
+
count: learnings.length,
|
|
462
|
+
by_type: {},
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
for (const l of learnings) {
|
|
466
|
+
result.by_type[l.type] = (result.by_type[l.type] || 0) + 1;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (raw) {
|
|
470
|
+
const lines = learnings.map(l => `${l.id} [${l.type}] ${l.title}`);
|
|
471
|
+
output(result, true, lines.join('\n') || 'No learnings found');
|
|
472
|
+
} else {
|
|
473
|
+
output(result, false);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Prune learnings by age (--days) or by ID (--id).
|
|
479
|
+
* @param {string} cwd - Working directory path
|
|
480
|
+
* @param {Object} opts - Prune options
|
|
481
|
+
* @param {number|null} opts.days - Remove entries older than N days
|
|
482
|
+
* @param {string|null} opts.id - Remove specific entry by ID
|
|
483
|
+
* @param {boolean} raw - If true, output raw count instead of JSON
|
|
484
|
+
* @returns {void}
|
|
485
|
+
*/
|
|
486
|
+
function cmdLearningsPrune(cwd, opts, raw) {
|
|
487
|
+
const learningsPath = path.join(cwd, PLANNING_DIR, LEARNINGS_FILE);
|
|
488
|
+
|
|
489
|
+
if (!opts || (opts.days == null && opts.id == null)) {
|
|
490
|
+
error('Prune requires --days N or --id LEARN-NNN');
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
let content;
|
|
494
|
+
try {
|
|
495
|
+
content = fs.readFileSync(learningsPath, 'utf-8');
|
|
496
|
+
} catch {
|
|
497
|
+
output({ pruned: 0, remaining: 0 }, raw, 'No learnings file found');
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const learnings = parseLearnings(content);
|
|
502
|
+
const before = learnings.length;
|
|
503
|
+
let kept;
|
|
504
|
+
|
|
505
|
+
if (opts.id) {
|
|
506
|
+
kept = learnings.filter(l => l.id !== opts.id);
|
|
507
|
+
} else if (opts.days != null) {
|
|
508
|
+
const cutoff = new Date();
|
|
509
|
+
cutoff.setDate(cutoff.getDate() - opts.days);
|
|
510
|
+
const cutoffStr = cutoff.toISOString().split('T')[0];
|
|
511
|
+
kept = learnings.filter(l => !l.date || l.date >= cutoffStr);
|
|
512
|
+
} else {
|
|
513
|
+
kept = learnings;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const pruned = before - kept.length;
|
|
517
|
+
|
|
518
|
+
// Rewrite file
|
|
519
|
+
let newContent = '# Session Learnings\n';
|
|
520
|
+
for (const learning of kept) {
|
|
521
|
+
newContent += '\n' + formatLearningEntry(learning);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
fs.writeFileSync(learningsPath, newContent, 'utf-8');
|
|
526
|
+
} catch (e) {
|
|
527
|
+
error('Failed to write learnings: ' + e.message);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const result = { pruned, remaining: kept.length };
|
|
531
|
+
output(result, raw, `Pruned ${pruned} learnings (${kept.length} remaining)`);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
module.exports = {
|
|
535
|
+
collectPhaseSummaries,
|
|
536
|
+
readErrorPatterns,
|
|
537
|
+
appendErrorPattern,
|
|
538
|
+
appendSessionSummary,
|
|
539
|
+
parseLearnings,
|
|
540
|
+
formatLearningEntry,
|
|
541
|
+
cmdLearningsExtract,
|
|
542
|
+
cmdLearningsList,
|
|
543
|
+
cmdLearningsPrune,
|
|
544
|
+
};
|