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.
Files changed (164) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +772 -0
  3. package/agents/pan-debugger.md +1246 -0
  4. package/agents/pan-document_code.md +965 -0
  5. package/agents/pan-executor.md +469 -0
  6. package/agents/pan-integration-checker.md +443 -0
  7. package/agents/pan-phase-researcher.md +572 -0
  8. package/agents/pan-plan-checker.md +763 -0
  9. package/agents/pan-planner.md +1297 -0
  10. package/agents/pan-project-researcher.md +647 -0
  11. package/agents/pan-research-synthesizer.md +239 -0
  12. package/agents/pan-reviewer.md +112 -0
  13. package/agents/pan-roadmapper.md +642 -0
  14. package/agents/pan-verifier.md +672 -0
  15. package/assets/pan-logo-2000-transparent.svg +30 -0
  16. package/assets/pan-logo-2000.svg +43 -0
  17. package/assets/terminal.svg +119 -0
  18. package/bin/install-lib.cjs +616 -0
  19. package/bin/install.js +1936 -0
  20. package/commands/pan/add-phase.md +44 -0
  21. package/commands/pan/assumptions.md +47 -0
  22. package/commands/pan/audit-deployment.md +378 -0
  23. package/commands/pan/debug.md +168 -0
  24. package/commands/pan/discord.md +19 -0
  25. package/commands/pan/discuss-phase.md +84 -0
  26. package/commands/pan/exec-phase.md +45 -0
  27. package/commands/pan/focus-auto.md +323 -0
  28. package/commands/pan/focus-design.md +816 -0
  29. package/commands/pan/focus-exec.md +316 -0
  30. package/commands/pan/focus-plan.md +101 -0
  31. package/commands/pan/focus-scan.md +272 -0
  32. package/commands/pan/focus-sync.md +104 -0
  33. package/commands/pan/health.md +23 -0
  34. package/commands/pan/help.md +23 -0
  35. package/commands/pan/insert-phase.md +33 -0
  36. package/commands/pan/map-codebase.md +72 -0
  37. package/commands/pan/milestone-audit.md +37 -0
  38. package/commands/pan/milestone-cleanup.md +19 -0
  39. package/commands/pan/milestone-done.md +137 -0
  40. package/commands/pan/milestone-gaps.md +35 -0
  41. package/commands/pan/milestone-new.md +45 -0
  42. package/commands/pan/new-project.md +43 -0
  43. package/commands/pan/patches.md +110 -0
  44. package/commands/pan/pause.md +39 -0
  45. package/commands/pan/phase-budget.md +23 -0
  46. package/commands/pan/phase-tests.md +42 -0
  47. package/commands/pan/plan-phase.md +46 -0
  48. package/commands/pan/profile.md +36 -0
  49. package/commands/pan/progress.md +25 -0
  50. package/commands/pan/quick.md +42 -0
  51. package/commands/pan/remove-phase.md +32 -0
  52. package/commands/pan/research-phase.md +190 -0
  53. package/commands/pan/resume.md +41 -0
  54. package/commands/pan/retro.md +33 -0
  55. package/commands/pan/settings.md +37 -0
  56. package/commands/pan/todo-add.md +48 -0
  57. package/commands/pan/todo-check.md +46 -0
  58. package/commands/pan/update.md +38 -0
  59. package/commands/pan/verify-phase.md +39 -0
  60. package/hooks/dist/pan-check-update.js +62 -0
  61. package/hooks/dist/pan-context-monitor.js +122 -0
  62. package/hooks/dist/pan-statusline.js +108 -0
  63. package/package.json +66 -0
  64. package/pan-wizard-core/bin/lib/codebase.cjs +746 -0
  65. package/pan-wizard-core/bin/lib/commands.cjs +1435 -0
  66. package/pan-wizard-core/bin/lib/config.cjs +611 -0
  67. package/pan-wizard-core/bin/lib/constants.cjs +696 -0
  68. package/pan-wizard-core/bin/lib/context-budget.cjs +150 -0
  69. package/pan-wizard-core/bin/lib/core.cjs +650 -0
  70. package/pan-wizard-core/bin/lib/focus.cjs +900 -0
  71. package/pan-wizard-core/bin/lib/frontmatter.cjs +442 -0
  72. package/pan-wizard-core/bin/lib/init.cjs +881 -0
  73. package/pan-wizard-core/bin/lib/milestone.cjs +276 -0
  74. package/pan-wizard-core/bin/lib/phase.cjs +1212 -0
  75. package/pan-wizard-core/bin/lib/roadmap.cjs +470 -0
  76. package/pan-wizard-core/bin/lib/state.cjs +1029 -0
  77. package/pan-wizard-core/bin/lib/template.cjs +314 -0
  78. package/pan-wizard-core/bin/lib/utils.cjs +171 -0
  79. package/pan-wizard-core/bin/lib/verify.cjs +1808 -0
  80. package/pan-wizard-core/bin/pan-tools.cjs +773 -0
  81. package/pan-wizard-core/references/checkpoints.md +776 -0
  82. package/pan-wizard-core/references/continuation-format.md +249 -0
  83. package/pan-wizard-core/references/decimal-phase-calculation.md +65 -0
  84. package/pan-wizard-core/references/git-integration.md +248 -0
  85. package/pan-wizard-core/references/git-planning-commit.md +38 -0
  86. package/pan-wizard-core/references/model-profile-resolution.md +34 -0
  87. package/pan-wizard-core/references/model-profiles.md +111 -0
  88. package/pan-wizard-core/references/phase-argument-parsing.md +61 -0
  89. package/pan-wizard-core/references/planning-config.md +196 -0
  90. package/pan-wizard-core/references/questioning.md +145 -0
  91. package/pan-wizard-core/references/tdd.md +263 -0
  92. package/pan-wizard-core/references/ui-brand.md +160 -0
  93. package/pan-wizard-core/references/verification-patterns.md +612 -0
  94. package/pan-wizard-core/templates/codebase/architecture.md +283 -0
  95. package/pan-wizard-core/templates/codebase/best-practices.md +133 -0
  96. package/pan-wizard-core/templates/codebase/concerns.md +325 -0
  97. package/pan-wizard-core/templates/codebase/conventions.md +307 -0
  98. package/pan-wizard-core/templates/codebase/integrations.md +305 -0
  99. package/pan-wizard-core/templates/codebase/relationships.md +124 -0
  100. package/pan-wizard-core/templates/codebase/stack.md +199 -0
  101. package/pan-wizard-core/templates/codebase/structure.md +298 -0
  102. package/pan-wizard-core/templates/codebase/testing.md +480 -0
  103. package/pan-wizard-core/templates/config.json +37 -0
  104. package/pan-wizard-core/templates/context.md +283 -0
  105. package/pan-wizard-core/templates/continue-here.md +78 -0
  106. package/pan-wizard-core/templates/debug-subagent-prompt.md +91 -0
  107. package/pan-wizard-core/templates/debug.md +164 -0
  108. package/pan-wizard-core/templates/discovery.md +146 -0
  109. package/pan-wizard-core/templates/milestone-archive.md +123 -0
  110. package/pan-wizard-core/templates/milestone.md +115 -0
  111. package/pan-wizard-core/templates/phase-prompt.md +593 -0
  112. package/pan-wizard-core/templates/planner-subagent-prompt.md +117 -0
  113. package/pan-wizard-core/templates/project.md +184 -0
  114. package/pan-wizard-core/templates/requirements.md +231 -0
  115. package/pan-wizard-core/templates/research-project/architecture.md +204 -0
  116. package/pan-wizard-core/templates/research-project/features.md +147 -0
  117. package/pan-wizard-core/templates/research-project/pitfalls.md +200 -0
  118. package/pan-wizard-core/templates/research-project/stack.md +120 -0
  119. package/pan-wizard-core/templates/research-project/summary.md +170 -0
  120. package/pan-wizard-core/templates/research.md +552 -0
  121. package/pan-wizard-core/templates/retrospective.md +54 -0
  122. package/pan-wizard-core/templates/roadmap.md +202 -0
  123. package/pan-wizard-core/templates/standards.md +24 -0
  124. package/pan-wizard-core/templates/state.md +176 -0
  125. package/pan-wizard-core/templates/summary-complex.md +59 -0
  126. package/pan-wizard-core/templates/summary-minimal.md +41 -0
  127. package/pan-wizard-core/templates/summary-standard.md +49 -0
  128. package/pan-wizard-core/templates/summary.md +249 -0
  129. package/pan-wizard-core/templates/uat.md +247 -0
  130. package/pan-wizard-core/templates/user-setup.md +311 -0
  131. package/pan-wizard-core/templates/validation.md +76 -0
  132. package/pan-wizard-core/templates/verification-report.md +322 -0
  133. package/pan-wizard-core/workflows/add-phase.md +111 -0
  134. package/pan-wizard-core/workflows/assumptions.md +178 -0
  135. package/pan-wizard-core/workflows/diagnose-issues.md +219 -0
  136. package/pan-wizard-core/workflows/discuss-phase.md +542 -0
  137. package/pan-wizard-core/workflows/exec-phase.md +572 -0
  138. package/pan-wizard-core/workflows/execute-plan.md +448 -0
  139. package/pan-wizard-core/workflows/health.md +156 -0
  140. package/pan-wizard-core/workflows/help.md +431 -0
  141. package/pan-wizard-core/workflows/insert-phase.md +129 -0
  142. package/pan-wizard-core/workflows/map-codebase.md +401 -0
  143. package/pan-wizard-core/workflows/milestone-audit.md +297 -0
  144. package/pan-wizard-core/workflows/milestone-cleanup.md +152 -0
  145. package/pan-wizard-core/workflows/milestone-gaps.md +274 -0
  146. package/pan-wizard-core/workflows/milestone-new.md +382 -0
  147. package/pan-wizard-core/workflows/new-project.md +1178 -0
  148. package/pan-wizard-core/workflows/pause.md +122 -0
  149. package/pan-wizard-core/workflows/phase-tests.md +388 -0
  150. package/pan-wizard-core/workflows/plan-phase.md +569 -0
  151. package/pan-wizard-core/workflows/profile.md +115 -0
  152. package/pan-wizard-core/workflows/progress.md +381 -0
  153. package/pan-wizard-core/workflows/quick.md +453 -0
  154. package/pan-wizard-core/workflows/remove-phase.md +154 -0
  155. package/pan-wizard-core/workflows/research-phase.md +73 -0
  156. package/pan-wizard-core/workflows/resume-project.md +306 -0
  157. package/pan-wizard-core/workflows/retro.md +121 -0
  158. package/pan-wizard-core/workflows/settings.md +213 -0
  159. package/pan-wizard-core/workflows/todo-add.md +157 -0
  160. package/pan-wizard-core/workflows/todo-check.md +176 -0
  161. package/pan-wizard-core/workflows/transition.md +544 -0
  162. package/pan-wizard-core/workflows/update.md +219 -0
  163. package/pan-wizard-core/workflows/verify-phase.md +301 -0
  164. package/scripts/build-hooks.js +43 -0
@@ -0,0 +1,650 @@
1
+ /**
2
+ * Core — Shared utilities, constants, and internal helpers
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+ const { execFileSync } = require('child_process');
9
+ const {
10
+ PLANNING_DIR,
11
+ PHASES_DIR,
12
+ MILESTONES_DIR,
13
+ ROADMAP_FILE,
14
+ MAX_JSON_SIZE,
15
+ PHASE_NUM_RE,
16
+ PHASE_DIR_RE,
17
+ ARCHIVE_DIR_RE,
18
+ isPlanFile,
19
+ isSummaryFile,
20
+ isResearchFile,
21
+ isContextFile,
22
+ isVerificationFile,
23
+ getPlanId,
24
+ getSummaryId,
25
+ MILESTONE_VERSION_RE,
26
+ } = require('./constants.cjs');
27
+
28
+ // ─── Model Profile Table ─────────────────────────────────────────────────────
29
+
30
+ const MODEL_PROFILES = {
31
+ 'pan-planner': { quality: 'opus', balanced: 'opus', budget: 'sonnet' },
32
+ 'pan-roadmapper': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' },
33
+ 'pan-executor': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' },
34
+ 'pan-phase-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' },
35
+ 'pan-project-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' },
36
+ 'pan-research-synthesizer': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' },
37
+ 'pan-debugger': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' },
38
+ 'pan-document_code': { quality: 'opus', balanced: 'haiku', budget: 'haiku' },
39
+ 'pan-verifier': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' },
40
+ 'pan-plan-checker': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' },
41
+ 'pan-integration-checker': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' },
42
+ 'pan-reviewer': { quality: 'opus', balanced: 'haiku', budget: 'haiku' },
43
+ };
44
+
45
+ // ─── Output helpers ───────────────────────────────────────────────────────────
46
+
47
+ /**
48
+ * Write result to stdout and exit. JSON by default, or raw string if --raw flag is set.
49
+ * Large JSON (>50KB) is written to a tmpfile with @file: prefix.
50
+ * @param {Object} result - The result object to serialize as JSON
51
+ * @param {boolean} [raw] - If true and rawValue is provided, output rawValue as plain string
52
+ * @param {string} [rawValue] - Plain string to output when raw mode is active
53
+ */
54
+ function output(result, raw, rawValue) {
55
+ if (raw && rawValue !== undefined) {
56
+ process.stdout.write(String(rawValue));
57
+ } else {
58
+ const json = JSON.stringify(result, null, 2);
59
+ // Large payloads exceed Claude Code's Bash tool buffer (~50KB).
60
+ // Write to tmpfile and output the path prefixed with @file: so callers can detect it.
61
+ if (json.length > MAX_JSON_SIZE) {
62
+ const tmpPath = path.join(os.tmpdir(), `pan-${Date.now()}.json`);
63
+ try {
64
+ fs.writeFileSync(tmpPath, json, 'utf-8');
65
+ process.stdout.write('@file:' + tmpPath);
66
+ } catch {
67
+ // Tmpfile write failed (disk full, permissions) — truncate and write to stdout
68
+ const truncated = json.slice(0, MAX_JSON_SIZE);
69
+ process.stdout.write(truncated);
70
+ }
71
+ } else {
72
+ process.stdout.write(json);
73
+ }
74
+ }
75
+ process.exit(0);
76
+ }
77
+
78
+ /**
79
+ * Write error message to stderr and exit with code 1.
80
+ * @param {string} message - Error message
81
+ */
82
+ function error(message) {
83
+ process.stderr.write('Error: ' + message + '\n');
84
+ process.exit(1);
85
+ }
86
+
87
+ /**
88
+ * Write debug message to stderr when --verbose flag is active.
89
+ * @param {...any} args - Values to log (joined with space)
90
+ */
91
+ function verbose(...args) {
92
+ if (process.env.PAN_VERBOSE === '1') {
93
+ process.stderr.write('[pan-tools] ' + args.join(' ') + '\n');
94
+ }
95
+ }
96
+
97
+ // ─── Path utilities ─────────────────────────────────────────────────────────
98
+
99
+ /** Normalize a relative path to always use forward slashes (POSIX) for JSON output. */
100
+ function toPosix(p) {
101
+ return p.split(path.sep).join('/');
102
+ }
103
+
104
+ // ─── File & Config utilities ──────────────────────────────────────────────────
105
+
106
+ /**
107
+ * Read a file, returning null instead of throwing on failure.
108
+ * @param {string} filePath - Absolute path to the file
109
+ * @returns {string|null} File contents as UTF-8 string, or null if unreadable
110
+ */
111
+ function safeReadFile(filePath) {
112
+ try {
113
+ return fs.readFileSync(filePath, 'utf-8');
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Load project config from .planning/config.json, merging with defaults.
121
+ * Handles nested config sections (planning.*, workflow.*, git.*) and flat keys.
122
+ * @param {string} cwd - Project root directory
123
+ * @returns {Object} Flattened config with keys: model_profile, commit_docs, search_gitignored,
124
+ * branching_strategy, phase_branch_template, milestone_branch_template, research,
125
+ * plan_checker, verifier, parallelization, brave_search
126
+ */
127
+ function loadConfig(cwd) {
128
+ const configPath = path.join(cwd, PLANNING_DIR, 'config.json');
129
+ const defaults = {
130
+ model_profile: 'balanced',
131
+ commit_docs: true,
132
+ search_gitignored: false,
133
+ branching_strategy: 'none',
134
+ phase_branch_template: 'pan/phase-{phase}-{slug}',
135
+ milestone_branch_template: 'pan/{milestone}-{slug}',
136
+ research: true,
137
+ plan_checker: true,
138
+ verifier: true,
139
+ parallelization: true,
140
+ brave_search: false,
141
+ };
142
+
143
+ try {
144
+ const raw = fs.readFileSync(configPath, 'utf-8');
145
+ const parsed = JSON.parse(raw);
146
+
147
+ // get() resolves a config key by checking flat keys first (parsed[key]),
148
+ // then falling back to nested section lookup (parsed[section][field]).
149
+ // This lets users write either { "commit_docs": true } or
150
+ // { "planning": { "commit_docs": true } } in config.json.
151
+ const get = (key, nested) => {
152
+ if (parsed[key] !== undefined) return parsed[key];
153
+ if (nested && parsed[nested.section] && parsed[nested.section][nested.field] !== undefined) {
154
+ return parsed[nested.section][nested.field];
155
+ }
156
+ return undefined;
157
+ };
158
+
159
+ const parallelization = (() => {
160
+ const val = get('parallelization');
161
+ if (typeof val === 'boolean') return val;
162
+ if (typeof val === 'object' && val !== null && 'enabled' in val) return val.enabled;
163
+ return defaults.parallelization;
164
+ })();
165
+
166
+ return {
167
+ model_profile: get('model_profile') ?? defaults.model_profile,
168
+ commit_docs: get('commit_docs', { section: 'planning', field: 'commit_docs' }) ?? defaults.commit_docs,
169
+ search_gitignored: get('search_gitignored', { section: 'planning', field: 'search_gitignored' }) ?? defaults.search_gitignored,
170
+ branching_strategy: get('branching_strategy', { section: 'git', field: 'branching_strategy' }) ?? defaults.branching_strategy,
171
+ phase_branch_template: get('phase_branch_template', { section: 'git', field: 'phase_branch_template' }) ?? defaults.phase_branch_template,
172
+ milestone_branch_template: get('milestone_branch_template', { section: 'git', field: 'milestone_branch_template' }) ?? defaults.milestone_branch_template,
173
+ research: get('research', { section: 'workflow', field: 'research' }) ?? defaults.research,
174
+ plan_checker: get('plan_checker', { section: 'workflow', field: 'plan_check' }) ?? defaults.plan_checker,
175
+ verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier,
176
+ parallelization,
177
+ brave_search: get('brave_search') ?? defaults.brave_search,
178
+ budget: parsed.budget || { default_points: 50, micro_threshold_tasks: 3, micro_threshold_files: 2 },
179
+ commit: parsed.commit || { safety_checks: true, conventional_types: true, sensitive_patterns: ['\\.env$', '\\.pem$', '\\.key$', 'credentials', 'secret', 'password', 'token'] },
180
+ execution: parsed.execution || { default_mode: 'wave_order', rollback_snapshots: true, error_pattern_learning: true },
181
+ focus: parsed.focus || { auto_commit: true },
182
+ };
183
+ } catch { // Config missing or malformed — use defaults
184
+ return {
185
+ ...defaults,
186
+ budget: { default_points: 50, micro_threshold_tasks: 3, micro_threshold_files: 2 },
187
+ commit: { safety_checks: true, conventional_types: true, sensitive_patterns: ['\\.env$', '\\.pem$', '\\.key$', 'credentials', 'secret', 'password', 'token'] },
188
+ execution: { default_mode: 'wave_order', rollback_snapshots: true, error_pattern_learning: true },
189
+ focus: { auto_commit: true },
190
+ };
191
+ }
192
+ }
193
+
194
+ // ─── Git utilities ────────────────────────────────────────────────────────────
195
+
196
+ /**
197
+ * Check if a path is gitignored using `git check-ignore`.
198
+ * @param {string} cwd - Project root directory
199
+ * @param {string} targetPath - Path to check (relative to cwd)
200
+ * @returns {boolean} True if gitignored
201
+ */
202
+ function isGitIgnored(cwd, targetPath) {
203
+ try {
204
+ execFileSync('git', ['check-ignore', '-q', '--', targetPath], {
205
+ cwd,
206
+ stdio: 'pipe',
207
+ });
208
+ return true;
209
+ } catch {
210
+ return false;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Check if the given directory is inside a git repository.
216
+ * @param {string} cwd - Directory to check
217
+ * @returns {boolean}
218
+ */
219
+ function isGitRepo(cwd) {
220
+ try {
221
+ const stdout = execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
222
+ cwd,
223
+ stdio: 'pipe',
224
+ encoding: 'utf-8',
225
+ });
226
+ return stdout.trim() === 'true';
227
+ } catch {
228
+ return false;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Execute a git command safely with proper argument escaping.
234
+ * @param {string} cwd - Working directory for git
235
+ * @param {string[]} args - Git arguments (e.g., ['add', 'file.md'])
236
+ * @returns {{exitCode: number, stdout: string, stderr: string}}
237
+ */
238
+ function execGit(cwd, args) {
239
+ try {
240
+ const stdout = execFileSync('git', args, {
241
+ cwd,
242
+ stdio: 'pipe',
243
+ encoding: 'utf-8',
244
+ });
245
+ return { exitCode: 0, stdout: stdout.trim(), stderr: '' };
246
+ } catch (err) {
247
+ return {
248
+ exitCode: err.status ?? 1,
249
+ stdout: (err.stdout ?? '').toString().trim(),
250
+ stderr: (err.stderr ?? '').toString().trim(),
251
+ };
252
+ }
253
+ }
254
+
255
+ // ─── Phase utilities ──────────────────────────────────────────────────────────
256
+
257
+ function escapeRegex(value) {
258
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
259
+ }
260
+
261
+ /**
262
+ * Normalize a phase identifier to zero-padded format.
263
+ * Examples: "1" → "01", "3A" → "03A", "12.1" → "12.1"
264
+ * @param {string|number} phase - Phase identifier
265
+ * @returns {string} Normalized phase string
266
+ */
267
+ function normalizePhaseName(phase) {
268
+ const match = String(phase).match(PHASE_NUM_RE);
269
+ if (!match) return phase;
270
+ const padded = match[1].padStart(2, '0');
271
+ const letter = match[2] ? match[2].toUpperCase() : '';
272
+ const decimal = match[3] || '';
273
+ return padded + letter + decimal;
274
+ }
275
+
276
+ /**
277
+ * Compare two phase identifiers for sorting. Handles integer, letter-suffix,
278
+ * and multi-level decimal phases (e.g., 1 < 2 < 2A < 2A.1 < 3).
279
+ * @param {string} a - First phase identifier
280
+ * @param {string} b - Second phase identifier
281
+ * @returns {number} Negative if a < b, positive if a > b, 0 if equal
282
+ */
283
+ function comparePhaseNum(a, b) {
284
+ // 3-level comparison for phase identifiers like "12A.1.2":
285
+ // 1. Integer prefix: compare the leading digits (e.g., 3 vs 12)
286
+ // 2. Letter suffix: no letter < A < B (e.g., 12 < 12A < 12B)
287
+ // 3. Decimal segments: segment-by-segment numeric comparison (e.g., 12A.1 < 12A.2)
288
+ const partsA = String(a).match(PHASE_NUM_RE);
289
+ const partsB = String(b).match(PHASE_NUM_RE);
290
+ if (!partsA || !partsB) return String(a).localeCompare(String(b));
291
+ const intDiff = parseInt(partsA[1], 10) - parseInt(partsB[1], 10);
292
+ if (intDiff !== 0) return intDiff;
293
+ // No letter sorts before letter: 12 < 12A < 12B
294
+ const la = (partsA[2] || '').toUpperCase();
295
+ const lb = (partsB[2] || '').toUpperCase();
296
+ if (la !== lb) {
297
+ if (!la) return -1;
298
+ if (!lb) return 1;
299
+ return la < lb ? -1 : 1;
300
+ }
301
+ // Segment-by-segment decimal comparison: 12A < 12A.1 < 12A.1.2 < 12A.2
302
+ const aDecParts = partsA[3] ? partsA[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
303
+ const bDecParts = partsB[3] ? partsB[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
304
+ const maxLen = Math.max(aDecParts.length, bDecParts.length);
305
+ if (aDecParts.length === 0 && bDecParts.length > 0) return -1;
306
+ if (bDecParts.length === 0 && aDecParts.length > 0) return 1;
307
+ for (let i = 0; i < maxLen; i++) {
308
+ const av = Number.isFinite(aDecParts[i]) ? aDecParts[i] : 0;
309
+ const bv = Number.isFinite(bDecParts[i]) ? bDecParts[i] : 0;
310
+ if (av !== bv) return av - bv;
311
+ }
312
+ return 0;
313
+ }
314
+
315
+ // Search a directory of phase folders for one matching the normalized phase number.
316
+ // Lists all subdirectories, finds the first whose name starts with the normalized
317
+ // phase prefix, then inventories its plan/summary/research/context/verification files.
318
+ // completedPlanIds tracks which plans have matching summaries so we can derive
319
+ // incomplete_plans (plans without a corresponding summary).
320
+ function searchPhaseInDir(baseDir, relBase, normalized) {
321
+ try {
322
+ const entries = fs.readdirSync(baseDir, { withFileTypes: true });
323
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
324
+ const match = dirs.find(d => d.startsWith(normalized));
325
+ if (!match) return null;
326
+
327
+ const dirMatch = match.match(PHASE_DIR_RE);
328
+ const phaseNumber = dirMatch ? dirMatch[1] : normalized;
329
+ const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
330
+ const phaseDir = path.join(baseDir, match);
331
+ const phaseFiles = fs.readdirSync(phaseDir);
332
+
333
+ const plans = phaseFiles.filter(isPlanFile).sort();
334
+ const summaries = phaseFiles.filter(isSummaryFile).sort();
335
+ const hasResearch = phaseFiles.some(isResearchFile);
336
+ const hasContext = phaseFiles.some(isContextFile);
337
+ const hasVerification = phaseFiles.some(isVerificationFile);
338
+
339
+ const completedPlanIds = new Set(
340
+ summaries.map(s => getSummaryId(s))
341
+ );
342
+ const incompletePlans = plans.filter(p => {
343
+ return !completedPlanIds.has(getPlanId(p));
344
+ });
345
+
346
+ return {
347
+ found: true,
348
+ directory: toPosix(path.join(relBase, match)),
349
+ phase_number: phaseNumber,
350
+ phase_name: phaseName,
351
+ phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
352
+ plans,
353
+ summaries,
354
+ incomplete_plans: incompletePlans,
355
+ has_research: hasResearch,
356
+ has_context: hasContext,
357
+ has_verification: hasVerification,
358
+ };
359
+ } catch { // Phase directory unreadable
360
+ return null;
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Find a phase directory by number, searching current phases then archived milestones.
366
+ * @param {string} cwd - Project root directory
367
+ * @param {string} phase - Phase identifier (e.g., "1", "03", "2.1")
368
+ * @returns {Object|null} Phase info: { found, directory, phase_number, phase_name, phase_slug,
369
+ * plans, summaries, incomplete_plans, has_research, has_context, has_verification, archived? }
370
+ */
371
+ function findPhaseInternal(cwd, phase) {
372
+ if (!phase) return null;
373
+
374
+ const phasesDir = path.join(cwd, PLANNING_DIR, PHASES_DIR);
375
+ const normalized = normalizePhaseName(phase);
376
+
377
+ // Two-phase search strategy:
378
+ // 1. Search the active phases directory (.planning/phases/) first.
379
+ // 2. If not found, search archived milestone directories (.planning/milestones/v*-phases/)
380
+ // in reverse order (newest archive first) so the most recent match wins.
381
+ const current = searchPhaseInDir(phasesDir, path.join(PLANNING_DIR, PHASES_DIR), normalized);
382
+ if (current) return current;
383
+
384
+ // Search archived milestone phases (newest first)
385
+ const milestonesDir = path.join(cwd, PLANNING_DIR, MILESTONES_DIR);
386
+ try {
387
+ const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
388
+ const archiveDirs = milestoneEntries
389
+ .filter(e => e.isDirectory() && ARCHIVE_DIR_RE.test(e.name))
390
+ .map(e => e.name)
391
+ .sort()
392
+ .reverse();
393
+
394
+ for (const archiveName of archiveDirs) {
395
+ const vm = archiveName.match(/^(v[\d.]+)-phases$/);
396
+ if (!vm) continue;
397
+ const version = vm[1];
398
+ const archivePath = path.join(milestonesDir, archiveName);
399
+ const relBase = path.join(PLANNING_DIR, MILESTONES_DIR, archiveName);
400
+ const result = searchPhaseInDir(archivePath, relBase, normalized);
401
+ if (result) {
402
+ result.archived = version;
403
+ return result;
404
+ }
405
+ }
406
+ } catch (e) { verbose('findPhaseInArchives: milestones directory missing or unreadable:', e.message); }
407
+
408
+ return null;
409
+ }
410
+
411
+ function getArchivedPhaseDirs(cwd) {
412
+ const milestonesDir = path.join(cwd, PLANNING_DIR, MILESTONES_DIR);
413
+ const results = [];
414
+
415
+ try {
416
+ const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
417
+ // Find v*-phases directories, sort newest first
418
+ const phaseDirs = milestoneEntries
419
+ .filter(e => e.isDirectory() && ARCHIVE_DIR_RE.test(e.name))
420
+ .map(e => e.name)
421
+ .sort()
422
+ .reverse();
423
+
424
+ for (const archiveName of phaseDirs) {
425
+ const vm = archiveName.match(/^(v[\d.]+)-phases$/);
426
+ if (!vm) continue;
427
+ const version = vm[1];
428
+ const archivePath = path.join(milestonesDir, archiveName);
429
+ const entries = fs.readdirSync(archivePath, { withFileTypes: true });
430
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
431
+
432
+ for (const dir of dirs) {
433
+ results.push({
434
+ name: dir,
435
+ milestone: version,
436
+ basePath: path.join(PLANNING_DIR, MILESTONES_DIR, archiveName),
437
+ fullPath: path.join(archivePath, dir),
438
+ });
439
+ }
440
+ }
441
+ } catch (e) { verbose('getArchivedPhaseDirs: milestones directory missing or unreadable:', e.message); }
442
+
443
+ return results;
444
+ }
445
+
446
+ // ─── Roadmap & model utilities ────────────────────────────────────────────────
447
+
448
+ /**
449
+ * Extract a phase section from roadmap.md by phase number.
450
+ * @param {string} cwd - Project root directory
451
+ * @param {string|number} phaseNum - Phase number to look up
452
+ * @returns {Object|null} { found, phase_number, phase_name, goal, section } or null
453
+ */
454
+ function getRoadmapPhaseInternal(cwd, phaseNum) {
455
+ if (!phaseNum) return null;
456
+ const roadmapPath = path.join(cwd, PLANNING_DIR, ROADMAP_FILE);
457
+
458
+ try {
459
+ const content = fs.readFileSync(roadmapPath, 'utf-8');
460
+ const escapedPhase = escapeRegex(phaseNum.toString());
461
+ const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i');
462
+ const headerMatch = content.match(phasePattern);
463
+ if (!headerMatch) return null;
464
+
465
+ const phaseName = headerMatch[1].trim();
466
+ const headerIndex = headerMatch.index;
467
+ const restOfContent = content.slice(headerIndex);
468
+ const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i);
469
+ const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length;
470
+ const section = content.slice(headerIndex, sectionEnd).trim();
471
+
472
+ const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i);
473
+ const goal = goalMatch ? goalMatch[1].trim() : null;
474
+
475
+ return {
476
+ found: true,
477
+ phase_number: phaseNum.toString(),
478
+ phase_name: phaseName,
479
+ goal,
480
+ section,
481
+ };
482
+ } catch {
483
+ return null;
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Resolve the model for a given agent type based on profile and overrides.
489
+ * Returns "inherit" for opus-tier to let Claude Code use its configured opus version.
490
+ * @param {string} cwd - Project root directory
491
+ * @param {string} agentType - Agent name (e.g., "pan-planner", "pan-executor")
492
+ * @returns {string} Model identifier: "inherit" (opus), "sonnet", or "haiku"
493
+ */
494
+ function resolveModelInternal(cwd, agentType) {
495
+ const config = loadConfig(cwd);
496
+
497
+ // Check per-agent override first
498
+ const override = config.model_overrides?.[agentType];
499
+ if (override) {
500
+ return override === 'opus' ? 'inherit' : override;
501
+ }
502
+
503
+ // Fall back to profile lookup
504
+ const profile = config.model_profile || 'balanced';
505
+ const agentModels = MODEL_PROFILES[agentType];
506
+ if (!agentModels) return 'sonnet';
507
+ const resolved = agentModels[profile] || agentModels['balanced'] || 'sonnet';
508
+ return resolved === 'opus' ? 'inherit' : resolved;
509
+ }
510
+
511
+ // ─── Misc utilities ───────────────────────────────────────────────────────────
512
+
513
+ function pathExistsInternal(cwd, targetPath) {
514
+ const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath);
515
+ try {
516
+ fs.statSync(fullPath);
517
+ return true;
518
+ } catch {
519
+ return false;
520
+ }
521
+ }
522
+
523
+ /**
524
+ * Convert text to a URL-safe slug (lowercase, hyphens, no special chars).
525
+ * @param {string} text - Input text
526
+ * @returns {string|null} Slug string, or null if text is falsy
527
+ */
528
+ function generateSlugInternal(text) {
529
+ if (!text) return null;
530
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
531
+ }
532
+
533
+ /**
534
+ * Extract current milestone version and name from roadmap.md.
535
+ * @param {string} cwd - Project root directory
536
+ * @returns {{version: string, name: string}} Milestone info (defaults: v1.0, "milestone")
537
+ */
538
+ function getMilestoneInfo(cwd) {
539
+ try {
540
+ const roadmap = fs.readFileSync(path.join(cwd, PLANNING_DIR, ROADMAP_FILE), 'utf-8');
541
+ const versionMatch = roadmap.match(MILESTONE_VERSION_RE);
542
+ const nameMatch = roadmap.match(/## .*v\d+\.\d+[:\s]+([^\n(]+)/);
543
+ return {
544
+ version: versionMatch ? versionMatch[0] : 'v1.0',
545
+ name: nameMatch ? nameMatch[1].trim() : 'milestone',
546
+ };
547
+ } catch {
548
+ return { version: 'v1.0', name: 'milestone' };
549
+ }
550
+ }
551
+
552
+ /**
553
+ * Scan pending todos directory and return matching items.
554
+ * @param {string} cwd - Project root
555
+ * @param {string|null} area - Optional area filter
556
+ * @returns {{ count: number, todos: Array<{file: string, created: string, title: string, area: string, path: string}> }}
557
+ */
558
+ function scanPendingTodos(cwd, area) {
559
+ const pendingDir = path.join(cwd, PLANNING_DIR, 'todos', 'pending');
560
+ let count = 0;
561
+ const todos = [];
562
+
563
+ try {
564
+ const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
565
+ for (const file of files) {
566
+ try {
567
+ const content = fs.readFileSync(path.join(pendingDir, file), 'utf-8');
568
+ const createdMatch = content.match(/^created:\s*(.+)$/m);
569
+ const titleMatch = content.match(/^title:\s*(.+)$/m);
570
+ const areaMatch = content.match(/^area:\s*(.+)$/m);
571
+ const todoArea = areaMatch ? areaMatch[1].trim() : 'general';
572
+
573
+ if (area && todoArea !== area) continue;
574
+
575
+ count++;
576
+ todos.push({
577
+ file,
578
+ created: createdMatch ? createdMatch[1].trim() : 'unknown',
579
+ title: titleMatch ? titleMatch[1].trim() : 'Untitled',
580
+ area: todoArea,
581
+ path: path.join(PLANNING_DIR, 'todos', 'pending', file),
582
+ });
583
+ } catch { /* skip unreadable file */ }
584
+ }
585
+ } catch { /* pending dir does not exist */ }
586
+
587
+ return { count, todos };
588
+ }
589
+
590
+ /**
591
+ * Scan source files for TODO/FIXME/XXX/HACK comments.
592
+ * @param {string} cwd - Project root
593
+ * @returns {{ count: number, items: Array<{file: string, line: number, tag: string, text: string}> }}
594
+ */
595
+ function scanSourceTodos(cwd) {
596
+ const items = [];
597
+ const libDir = path.join(cwd, 'pan-wizard-core', 'bin', 'lib');
598
+ const pattern = /\b(TODO|FIXME|XXX|HACK)\b[:\s]*(.*)/i;
599
+
600
+ let files;
601
+ try {
602
+ files = fs.readdirSync(libDir).filter(f => f.endsWith('.cjs'));
603
+ } catch { return { count: 0, items }; }
604
+
605
+ for (const file of files) {
606
+ try {
607
+ const content = fs.readFileSync(path.join(libDir, file), 'utf-8');
608
+ const lines = content.split('\n');
609
+ for (let i = 0; i < lines.length; i++) {
610
+ const match = lines[i].match(pattern);
611
+ if (match) {
612
+ items.push({
613
+ file: toPosix(path.join('pan-wizard-core', 'bin', 'lib', file)),
614
+ line: i + 1,
615
+ tag: match[1].toUpperCase(),
616
+ text: match[2].trim(),
617
+ });
618
+ }
619
+ }
620
+ } catch { /* skip unreadable file */ }
621
+ }
622
+
623
+ return { count: items.length, items };
624
+ }
625
+
626
+ module.exports = {
627
+ MODEL_PROFILES,
628
+ output,
629
+ error,
630
+ verbose,
631
+ safeReadFile,
632
+ loadConfig,
633
+ isGitIgnored,
634
+ isGitRepo,
635
+ execGit,
636
+ escapeRegex,
637
+ normalizePhaseName,
638
+ comparePhaseNum,
639
+ searchPhaseInDir,
640
+ findPhaseInternal,
641
+ getArchivedPhaseDirs,
642
+ getRoadmapPhaseInternal,
643
+ resolveModelInternal,
644
+ pathExistsInternal,
645
+ generateSlugInternal,
646
+ getMilestoneInfo,
647
+ toPosix,
648
+ scanPendingTodos,
649
+ scanSourceTodos,
650
+ };