@pi-unipi/milestone 0.1.2

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 ADDED
@@ -0,0 +1,123 @@
1
+ # @pi-unipi/milestone
2
+
3
+ Lifecycle layer for project-level goals. Track progress across multiple workflow cycles via a `MILESTONES.md` file with automatic context injection and sync.
4
+
5
+ ## Why
6
+
7
+ Workflow operates at the task level — brainstorm, plan, work, review. But project goals are scattered across specs, plans, and quick-work docs. Milestone provides a unified view of "what's left to do" and keeps the agent aligned with project goals across sessions.
8
+
9
+ ## How It Works
10
+
11
+ 1. **MILESTONES.md** — A markdown file with phases and checkbox items that tracks your project goals
12
+ 2. **Session start hook** — Reads milestones and injects progress summary into the system prompt
13
+ 3. **Session end hook** — Scans modified workflow docs, detects completed items, auto-updates milestones
14
+ 4. **Coexist triggers** — Hooks into brainstorm/plan/consolidate to suggest milestone updates
15
+
16
+ ## MILESTONES.md Format
17
+
18
+ ```markdown
19
+ ---
20
+ title: "Project Milestones"
21
+ created: 2026-04-28
22
+ updated: 2026-04-28
23
+ ---
24
+
25
+ # Project Milestones
26
+
27
+ ## Phase 1: Foundation
28
+ > Set up the core infrastructure
29
+
30
+ - [x] Project scaffold and build system
31
+ - [x] Database schema design
32
+ - [ ] Authentication system
33
+ - [ ] API routing layer
34
+
35
+ ## Phase 2: Core Features
36
+ > Build the primary user-facing features
37
+
38
+ - [ ] User dashboard
39
+ - [ ] File upload system
40
+ - [ ] Notification service
41
+ ```
42
+
43
+ ## Skills
44
+
45
+ ### `/unipi:milestone-onboard`
46
+
47
+ Create MILESTONES.md from existing workflow docs. Scans specs, plans, quick-work, debug, fix, and chore docs to group scattered tasks into coherent milestone phases.
48
+
49
+ **Phases:** Explore → Propose → Refine → Write → Report
50
+
51
+ ### `/unipi:milestone-update`
52
+
53
+ Sync MILESTONES.md with completed work. Detects checkbox changes in workflow docs and updates milestone items.
54
+
55
+ **Phases:** Scan → Diff → Resolve → Write → Report
56
+
57
+ ## API Exports
58
+
59
+ ```typescript
60
+ import {
61
+ parseMilestones, // Parse MILESTONES.md → MilestoneDoc
62
+ writeMilestones, // Write MilestoneDoc → MILESTONES.md
63
+ updateItemStatus, // Toggle a checkbox item
64
+ getProgressSummary, // Get progress stats
65
+ } from "@pi-unipi/milestone";
66
+
67
+ import type {
68
+ MilestoneDoc,
69
+ MilestonePhase,
70
+ MilestoneItem,
71
+ ProgressSummary,
72
+ PhaseProgress,
73
+ } from "@pi-unipi/milestone";
74
+ ```
75
+
76
+ ## Lifecycle Hooks
77
+
78
+ ### Session Start
79
+
80
+ On `before_agent_start`, reads `.unipi/docs/MILESTONES.md` and appends a progress summary to the system prompt:
81
+
82
+ ```
83
+ ## Project Milestones
84
+ Overall progress: 5/10 items (50%)
85
+ Phase 1: Foundation: 3/5 done
86
+ Phase 2: Features: 2/5 done
87
+ Current focus: Phase 1: Foundation
88
+ ```
89
+
90
+ If MILESTONES.md doesn't exist, no context is injected.
91
+
92
+ ### Session End
93
+
94
+ On `session_shutdown`, scans workflow docs modified during the session. Detects items that changed from `- [ ]` to `- [x]` and auto-updates MILESTONES.md using exact text matching.
95
+
96
+ Unmatched items are logged as warnings — resolve manually with `/unipi:milestone-update`.
97
+
98
+ ## Coexist Triggers
99
+
100
+ | Trigger | Behavior |
101
+ |---------|----------|
102
+ | After brainstorm | Checks if new spec items map to milestones, logs suggestions |
103
+ | After plan | Maps plan tasks to milestone items, logs coverage |
104
+ | After consolidate | References auto-sync from session shutdown |
105
+
106
+ All triggers are non-blocking and skip gracefully if MILESTONES.md doesn't exist.
107
+
108
+ ## Info Screen
109
+
110
+ Registers a "Milestones" group in the info-screen dashboard showing:
111
+ - **Progress** — completed/total items with percentage
112
+ - **Current Phase** — phase name with per-phase breakdown
113
+ - **Remaining** — items left to complete
114
+
115
+ ## Configuration
116
+
117
+ No configuration needed. Place MILESTONES.md at `.unipi/docs/MILESTONES.md` and the extension handles the rest.
118
+
119
+ ## Dependencies
120
+
121
+ - `@pi-unipi/core` — shared constants and utilities
122
+ - `@mariozechner/pi-coding-agent` — extension API
123
+ - `@mariozechner/pi-tui` — TUI types
package/coexist.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @pi-unipi/milestone — Coexist triggers
3
+ *
4
+ * Hooks into workflow skill completions to offer milestone integration.
5
+ * Non-blocking — if MILESTONES.md doesn't exist, triggers silently skip.
6
+ */
7
+
8
+ import * as path from "node:path";
9
+ import { MILESTONE_DIRS, tryRead } from "@pi-unipi/core";
10
+ import { parseMilestones, updateItemStatus, writeMilestones } from "./milestone.js";
11
+ import type { MilestoneDoc } from "./types.js";
12
+
13
+ /**
14
+ * After brainstorm completes: check if new spec items map to milestones.
15
+ * Offers to mark matching items as planned.
16
+ */
17
+ export function onBrainstormComplete(specPath: string): void {
18
+ const cwd = process.cwd();
19
+ const milestonesPath = path.join(cwd, MILESTONE_DIRS.MILESTONES);
20
+
21
+ // Silently skip if no MILESTONES.md
22
+ if (!tryRead(milestonesPath)) return;
23
+
24
+ const specContent = tryRead(specPath);
25
+ if (!specContent) return;
26
+
27
+ // Extract checklist items from the new spec
28
+ const specItems: string[] = [];
29
+ for (const line of specContent.split("\n")) {
30
+ const match = line.match(/^-\s+\[([ xX])\]\s+(.+)$/);
31
+ if (match) {
32
+ specItems.push(match[2].trim().toLowerCase());
33
+ }
34
+ }
35
+
36
+ if (specItems.length === 0) return;
37
+
38
+ // Check against milestones
39
+ const doc = parseMilestones(milestonesPath);
40
+ const matched: string[] = [];
41
+
42
+ for (const phase of doc.phases) {
43
+ for (const item of phase.items) {
44
+ const normalized = item.text.toLowerCase().trim();
45
+ if (specItems.includes(normalized) && !item.checked) {
46
+ matched.push(`"${item.text}" in ${phase.name}`);
47
+ }
48
+ }
49
+ }
50
+
51
+ if (matched.length > 0) {
52
+ console.log(
53
+ `[milestone] Brainstorm spec contains items that map to milestones: ${matched.join(", ")}`,
54
+ );
55
+ console.log(
56
+ `[milestone] Run /unipi:milestone-update to sync after completing work.`,
57
+ );
58
+ }
59
+ }
60
+
61
+ /**
62
+ * After plan completes: check if plan tasks map to milestone items.
63
+ * Logs matching items for awareness.
64
+ */
65
+ export function onPlanComplete(planPath: string): void {
66
+ const cwd = process.cwd();
67
+ const milestonesPath = path.join(cwd, MILESTONE_DIRS.MILESTONES);
68
+
69
+ // Silently skip if no MILESTONES.md
70
+ if (!tryRead(milestonesPath)) return;
71
+
72
+ const planContent = tryRead(planPath);
73
+ if (!planContent) return;
74
+
75
+ // Extract task names from plan (### Task N — Name pattern)
76
+ const planTasks: string[] = [];
77
+ for (const line of planContent.split("\n")) {
78
+ const match = line.match(/^###\s+Task\s+\d+\s*[—–-]\s*(.+)$/);
79
+ if (match) {
80
+ planTasks.push(match[1].trim().toLowerCase());
81
+ }
82
+ }
83
+
84
+ if (planTasks.length === 0) return;
85
+
86
+ // Check against milestones
87
+ const doc = parseMilestones(milestonesPath);
88
+ const matched: string[] = [];
89
+
90
+ for (const phase of doc.phases) {
91
+ for (const item of phase.items) {
92
+ const normalized = item.text.toLowerCase().trim();
93
+ // Check if any plan task contains the milestone item text or vice versa
94
+ for (const task of planTasks) {
95
+ if (task.includes(normalized) || normalized.includes(task)) {
96
+ matched.push(`"${item.text}" → plan task`);
97
+ break;
98
+ }
99
+ }
100
+ }
101
+ }
102
+
103
+ if (matched.length > 0) {
104
+ console.log(
105
+ `[milestone] Plan covers milestone items: ${matched.join(", ")}`,
106
+ );
107
+ }
108
+ }
109
+
110
+ /**
111
+ * After consolidate: reference milestone sync that already happened.
112
+ */
113
+ export function onConsolidate(): void {
114
+ const cwd = process.cwd();
115
+ const milestonesPath = path.join(cwd, MILESTONE_DIRS.MILESTONES);
116
+
117
+ if (!tryRead(milestonesPath)) return;
118
+
119
+ // Just log that milestone sync was handled by session_shutdown hook
120
+ console.log(
121
+ `[milestone] Milestone progress was auto-synced during session shutdown.`,
122
+ );
123
+ }
package/commands.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @pi-unipi/milestone — Command registration
3
+ *
4
+ * Registers milestone-onboard and milestone-update commands.
5
+ * Follows the same pattern as workflow/commands.ts:
6
+ * loads SKILL.md content and sends it as a user message.
7
+ */
8
+
9
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
10
+ import { UNIPI_PREFIX, MILESTONE_COMMANDS, MILESTONE_DIRS } from "@pi-unipi/core";
11
+ import { parseMilestones } from "./milestone.js";
12
+ import { readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ /** Resolve the skills directory relative to this file */
16
+ const SKILLS_DIR = join(new URL(".", import.meta.url).pathname, "skills");
17
+
18
+ /**
19
+ * Load SKILL.md content for a given skill name.
20
+ */
21
+ function loadSkill(skillName: string): string {
22
+ try {
23
+ return readFileSync(join(SKILLS_DIR, skillName, "SKILL.md"), "utf-8");
24
+ } catch {
25
+ return "";
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Register milestone commands with the extension API.
31
+ */
32
+ export function registerCommands(pi: ExtensionAPI): void {
33
+ // milestone-onboard — create milestones from existing work
34
+ pi.registerCommand(`${UNIPI_PREFIX}${MILESTONE_COMMANDS.ONBOARD}`, {
35
+ description: "Create MILESTONES.md from existing workflow docs — scan, propose, refine, write",
36
+ handler: async (args: string, ctx: any) => {
37
+ const skillContent = loadSkill("milestone-onboard");
38
+
39
+ let message = "Execute the milestone-onboard workflow.";
40
+ if (args?.trim()) {
41
+ message += `\n\nArguments: ${args.trim()}`;
42
+ }
43
+ if (skillContent) {
44
+ message += `\n\n<skill_content>\n${skillContent}\n</skill_content>`;
45
+ }
46
+
47
+ pi.sendUserMessage(message, { deliverAs: "followUp" });
48
+
49
+ if (ctx.hasUI) {
50
+ ctx.ui.notify("Running /unipi:milestone-onboard", "info");
51
+ }
52
+ },
53
+ });
54
+
55
+ // milestone-update — sync milestones with completed work
56
+ pi.registerCommand(`${UNIPI_PREFIX}${MILESTONE_COMMANDS.UPDATE}`, {
57
+ description: "Sync MILESTONES.md with completed work — scan docs, diff checkboxes, auto-update",
58
+ handler: async (args: string, ctx: any) => {
59
+ const skillContent = loadSkill("milestone-update");
60
+
61
+ let message = "Execute the milestone-update workflow.";
62
+ if (args?.trim()) {
63
+ message += `\n\nArguments: ${args.trim()}`;
64
+ }
65
+ if (skillContent) {
66
+ message += `\n\n<skill_content>\n${skillContent}\n</skill_content>`;
67
+ }
68
+
69
+ pi.sendUserMessage(message, { deliverAs: "followUp" });
70
+
71
+ if (ctx.hasUI) {
72
+ ctx.ui.notify("Running /unipi:milestone-update", "info");
73
+ }
74
+ },
75
+ getArgumentCompletions: () => {
76
+ // Suggest phase names from existing MILESTONES.md
77
+ const cwd = process.cwd();
78
+ const milestonesPath = join(cwd, MILESTONE_DIRS.MILESTONES);
79
+ const doc = parseMilestones(milestonesPath);
80
+
81
+ const suggestions = doc.phases.map((phase) => ({
82
+ value: phase.name,
83
+ label: phase.name,
84
+ description: `${phase.items.filter((i) => i.checked).length}/${phase.items.length} done`,
85
+ }));
86
+
87
+ // Add "all" option
88
+ suggestions.unshift({
89
+ value: "all",
90
+ label: "all",
91
+ description: "Update all phases",
92
+ });
93
+
94
+ return suggestions;
95
+ },
96
+ });
97
+ }
package/hooks.ts ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * @pi-unipi/milestone — Lifecycle hooks
3
+ *
4
+ * Session start: inject milestone progress as system context.
5
+ * Session end: auto-sync completed items from workflow docs.
6
+ */
7
+
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
11
+ import { MILESTONE_DIRS, safeMtimeMs, tryRead } from "@pi-unipi/core";
12
+ import { parseMilestones, getProgressSummary, updateItemStatus } from "./milestone.js";
13
+
14
+ /** Track when the session started for diffing modified files */
15
+ let sessionStartMs = 0;
16
+
17
+ /**
18
+ * Format a progress summary as a context string for the system prompt.
19
+ */
20
+ function formatMilestoneContext(filePath: string): string | null {
21
+ const summary = getProgressSummary(filePath);
22
+ if (summary.totalItems === 0) return null;
23
+
24
+ const phaseLines = summary.phases
25
+ .filter((p) => p.total > 0)
26
+ .map((p) => ` ${p.name}: ${p.done}/${p.total} done`);
27
+
28
+ const focus = summary.currentPhase
29
+ ? `Current focus: ${summary.currentPhase}`
30
+ : "";
31
+
32
+ return [
33
+ "## Project Milestones",
34
+ `Overall progress: ${summary.completedItems}/${summary.totalItems} items (${summary.percentComplete}%)`,
35
+ ...phaseLines,
36
+ focus,
37
+ ]
38
+ .filter(Boolean)
39
+ .join("\n");
40
+ }
41
+
42
+ /**
43
+ * Register session start hook — injects milestone progress into system context.
44
+ */
45
+ export function registerSessionStartHook(pi: ExtensionAPI): void {
46
+ pi.on("before_agent_start", (event) => {
47
+ sessionStartMs = Date.now();
48
+
49
+ const cwd = process.cwd();
50
+ const milestonesPath = path.join(cwd, MILESTONE_DIRS.MILESTONES);
51
+
52
+ const context = formatMilestoneContext(milestonesPath);
53
+ if (!context) return undefined;
54
+
55
+ // Append milestone context to the system prompt
56
+ const currentPrompt = (event as any).systemPrompt ?? "";
57
+ return {
58
+ systemPrompt: currentPrompt + "\n\n" + context,
59
+ };
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Extract checkbox items that changed from [ ] to [x] in a file.
65
+ * Compares current state against a baseline snapshot.
66
+ */
67
+ function extractNewCompletions(
68
+ filePath: string,
69
+ baselineContent: string,
70
+ ): Array<{ text: string; phase: string }> {
71
+ const currentContent = tryRead(filePath);
72
+ if (!currentContent) return [];
73
+
74
+ const baselineLines = baselineContent.split("\n");
75
+ const currentLines = currentContent.split("\n");
76
+ const results: Array<{ text: string; phase: string }> = [];
77
+ let currentPhase = "";
78
+
79
+ for (let i = 0; i < currentLines.length; i++) {
80
+ const line = currentLines[i];
81
+
82
+ // Track phase
83
+ const phaseMatch = line.match(/^##\s+(.+)$/);
84
+ if (phaseMatch) {
85
+ currentPhase = phaseMatch[1].trim();
86
+ continue;
87
+ }
88
+
89
+ // Check if this line is a newly checked item
90
+ const currentItemMatch = line.match(/^-\s+\[x\]\s+(.+)$/);
91
+ if (currentItemMatch && currentPhase) {
92
+ // Check if baseline had this as unchecked
93
+ const baselineLine = baselineLines[i] ?? "";
94
+ const baselineItemMatch = baselineLine.match(/^-\s+\[([ xX])\]\s+(.+)$/);
95
+ if (baselineItemMatch && baselineItemMatch[1] === " ") {
96
+ // Same position, was unchecked, now checked
97
+ results.push({ text: currentItemMatch[1].trim(), phase: currentPhase });
98
+ } else if (!baselineItemMatch) {
99
+ // Line didn't exist or wasn't a checkbox — check by text match in same phase
100
+ const text = currentItemMatch[1].trim().toLowerCase();
101
+ const foundUnchecked = baselineLines.some((bl) => {
102
+ const m = bl.match(/^-\s+\[\s\]\s+(.+)$/);
103
+ return m && m[1].trim().toLowerCase() === text;
104
+ });
105
+ if (foundUnchecked) {
106
+ results.push({ text: currentItemMatch[1].trim(), phase: currentPhase });
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ return results;
113
+ }
114
+
115
+ /**
116
+ * Scan workflow docs for files modified since session start.
117
+ */
118
+ function scanModifiedDocs(dirs: string[], since: number): string[] {
119
+ const modified: string[] = [];
120
+
121
+ for (const dir of dirs) {
122
+ if (!fs.existsSync(dir)) continue;
123
+
124
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
125
+ for (const entry of entries) {
126
+ if (!entry.isFile()) continue;
127
+ const filePath = path.join(dir, entry.name);
128
+ const mtime = safeMtimeMs(filePath);
129
+ if (mtime > since) {
130
+ modified.push(filePath);
131
+ }
132
+ }
133
+ }
134
+
135
+ return modified;
136
+ }
137
+
138
+ /**
139
+ * Register session end hook — listens for WORKFLOW_END events,
140
+ * scans modified docs, and auto-updates MILESTONES.md.
141
+ */
142
+ export function registerSessionEndHook(pi: ExtensionAPI): void {
143
+ // Store baseline snapshots at session start
144
+ const baselineSnapshots = new Map<string, string>();
145
+
146
+ // Capture baselines on session start
147
+ pi.on("session_start", () => {
148
+ sessionStartMs = Date.now();
149
+ baselineSnapshots.clear();
150
+
151
+ const cwd = process.cwd();
152
+ const scanDirs = [
153
+ path.join(cwd, ".unipi/docs/specs"),
154
+ path.join(cwd, ".unipi/docs/plans"),
155
+ path.join(cwd, ".unipi/docs/quick-work"),
156
+ ];
157
+
158
+ for (const dir of scanDirs) {
159
+ if (!fs.existsSync(dir)) continue;
160
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
161
+ for (const entry of entries) {
162
+ if (!entry.isFile()) continue;
163
+ const filePath = path.join(dir, entry.name);
164
+ const content = tryRead(filePath);
165
+ if (content) baselineSnapshots.set(filePath, content);
166
+ }
167
+ }
168
+ });
169
+
170
+ // Listen for WORKFLOW_END events
171
+ pi.on("input", (event) => {
172
+ // Check if this is a unipi event emission for WORKFLOW_END
173
+ // The input event fires for tool calls; we need to detect when
174
+ // the workflow ends. We'll use the events system instead.
175
+ return undefined;
176
+ });
177
+
178
+ // Use tool_result to detect workflow end
179
+ // Actually, we should listen for the UNIPI_EVENTS.WORKFLOW_END via pi.events
180
+ // But the ExtensionAPI doesn't expose pi.events.on() directly.
181
+ // Instead, we'll hook into session_shutdown to do a final sync.
182
+ pi.on("session_shutdown", () => {
183
+ const cwd = process.cwd();
184
+ const milestonesPath = path.join(cwd, MILESTONE_DIRS.MILESTONES);
185
+
186
+ if (!fs.existsSync(milestonesPath)) return;
187
+
188
+ const scanDirs = [
189
+ path.join(cwd, ".unipi/docs/specs"),
190
+ path.join(cwd, ".unipi/docs/plans"),
191
+ path.join(cwd, ".unipi/docs/quick-work"),
192
+ ];
193
+
194
+ const modifiedFiles = scanModifiedDocs(scanDirs, sessionStartMs);
195
+
196
+ for (const filePath of modifiedFiles) {
197
+ const baseline = baselineSnapshots.get(filePath);
198
+ if (!baseline) continue;
199
+
200
+ const completions = extractNewCompletions(filePath, baseline);
201
+ for (const { text, phase } of completions) {
202
+ // Try exact match update
203
+ const updated = updateItemStatus(milestonesPath, phase, text, true);
204
+ if (!updated) {
205
+ console.warn(
206
+ `[milestone] Could not auto-update "${text}" in phase "${phase}" — no exact match found`,
207
+ );
208
+ }
209
+ }
210
+ }
211
+ });
212
+ }
package/index.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @pi-unipi/milestone — Extension entry point
3
+ *
4
+ * Lifecycle layer for project-level goals. Tracks progress via MILESTONES.md,
5
+ * injects context on session start, auto-syncs on session end.
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
+ import { MODULES, MILESTONE_COMMANDS, MILESTONE_DIRS, emitEvent, UNIPI_EVENTS } from "@pi-unipi/core";
10
+ import { registerSessionStartHook, registerSessionEndHook } from "./hooks.js";
11
+ import { registerCommands } from "./commands.js";
12
+ import { getProgressSummary } from "./milestone.js";
13
+ import * as path from "node:path";
14
+
15
+ export default function milestoneExtension(pi: ExtensionAPI): void {
16
+ // Register lifecycle hooks
17
+ registerSessionStartHook(pi);
18
+ registerSessionEndHook(pi);
19
+
20
+ // Register commands
21
+ registerCommands(pi);
22
+
23
+ // Register info-screen group
24
+ const globalObj = globalThis as any;
25
+ const registry = globalObj.__unipi_info_registry;
26
+ if (registry) {
27
+ registry.registerGroup({
28
+ id: "milestone",
29
+ name: "Milestones",
30
+ icon: "🎯",
31
+ priority: 40,
32
+ config: {
33
+ showByDefault: true,
34
+ stats: [
35
+ { id: "progress", label: "Progress", show: true },
36
+ { id: "current_phase", label: "Current Phase", show: true },
37
+ { id: "remaining", label: "Remaining", show: true },
38
+ ],
39
+ },
40
+ dataProvider: async () => {
41
+ const cwd = process.cwd();
42
+ const milestonesPath = path.join(cwd, MILESTONE_DIRS.MILESTONES);
43
+ const summary = getProgressSummary(milestonesPath);
44
+
45
+ return {
46
+ progress: {
47
+ value: `${summary.completedItems}/${summary.totalItems}`,
48
+ detail: `${summary.percentComplete}% complete`,
49
+ },
50
+ current_phase: {
51
+ value: summary.currentPhase || "None",
52
+ detail: summary.phases.length > 0
53
+ ? summary.phases.map((p) => `${p.name}: ${p.done}/${p.total}`).join(", ")
54
+ : "No milestones defined",
55
+ },
56
+ remaining: {
57
+ value: String(summary.totalItems - summary.completedItems),
58
+ detail: "items remaining",
59
+ },
60
+ };
61
+ },
62
+ });
63
+ }
64
+
65
+ // Emit module ready event
66
+ emitEvent(pi as any, UNIPI_EVENTS.MODULE_READY, {
67
+ name: MODULES.MILESTONE,
68
+ version: "0.1.0",
69
+ commands: Object.values(MILESTONE_COMMANDS),
70
+ tools: [],
71
+ });
72
+ }
package/milestone.ts ADDED
@@ -0,0 +1,238 @@
1
+ /**
2
+ * @pi-unipi/milestone — MILESTONES.md parser, writer, and updater
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import { ensureDir, tryRead } from "@pi-unipi/core";
8
+ import type { MilestoneDoc, MilestonePhase, MilestoneItem, ProgressSummary } from "./types.js";
9
+
10
+ /** Default empty milestone doc */
11
+ function emptyDoc(filePath: string): MilestoneDoc {
12
+ return {
13
+ title: "Project Milestones",
14
+ created: new Date().toISOString().split("T")[0],
15
+ updated: new Date().toISOString().split("T")[0],
16
+ phases: [],
17
+ filePath,
18
+ };
19
+ }
20
+
21
+ /**
22
+ * Parse a MILESTONES.md file into a MilestoneDoc.
23
+ * Handles missing file (returns empty doc) and malformed input (skips unparseable lines).
24
+ */
25
+ export function parseMilestones(filePath: string): MilestoneDoc {
26
+ const content = tryRead(filePath);
27
+ if (!content) return emptyDoc(filePath);
28
+
29
+ const lines = content.split("\n");
30
+ const doc = emptyDoc(filePath);
31
+ let currentPhase: MilestonePhase | null = null;
32
+ let inFrontmatter = false;
33
+ let frontmatterDone = false;
34
+
35
+ for (let i = 0; i < lines.length; i++) {
36
+ const line = lines[i];
37
+ const lineNum = i + 1; // 1-indexed
38
+
39
+ // Frontmatter parsing
40
+ if (lineNum === 1 && line.trim() === "---") {
41
+ inFrontmatter = true;
42
+ continue;
43
+ }
44
+ if (inFrontmatter) {
45
+ if (line.trim() === "---") {
46
+ inFrontmatter = false;
47
+ frontmatterDone = true;
48
+ continue;
49
+ }
50
+ const match = line.match(/^(\w+):\s*(.+)$/);
51
+ if (match) {
52
+ const [, key, value] = match;
53
+ if (key === "title") doc.title = value.replace(/^["']|["']$/g, "");
54
+ if (key === "created") doc.created = value.trim();
55
+ if (key === "updated") doc.updated = value.trim();
56
+ }
57
+ continue;
58
+ }
59
+
60
+ // Phase header: ## Phase N: Name or ## Name
61
+ const phaseMatch = line.match(/^##\s+(.+)$/);
62
+ if (phaseMatch) {
63
+ currentPhase = {
64
+ name: phaseMatch[1].trim(),
65
+ items: [],
66
+ };
67
+ doc.phases.push(currentPhase);
68
+ continue;
69
+ }
70
+
71
+ // Phase description: > text
72
+ if (currentPhase && line.match(/^>\s*(.*)$/)) {
73
+ const desc = line.replace(/^>\s*/, "").trim();
74
+ if (desc) {
75
+ currentPhase.description = currentPhase.description
76
+ ? `${currentPhase.description} ${desc}`
77
+ : desc;
78
+ }
79
+ continue;
80
+ }
81
+
82
+ // Checkbox item: - [ ] text or - [x] text
83
+ const itemMatch = line.match(/^-\s+\[([ xX])\]\s+(.+)$/);
84
+ if (itemMatch && currentPhase) {
85
+ const checked = itemMatch[1].toLowerCase() === "x";
86
+ const text = itemMatch[2].trim();
87
+ currentPhase.items.push({
88
+ text,
89
+ checked,
90
+ lineNumber: lineNum,
91
+ });
92
+ continue;
93
+ }
94
+ }
95
+
96
+ return doc;
97
+ }
98
+
99
+ /**
100
+ * Write a MilestoneDoc to a MILESTONES.md file.
101
+ * Generates frontmatter, phase headers, descriptions, and checkbox items.
102
+ */
103
+ export function writeMilestones(filePath: string, doc: MilestoneDoc): void {
104
+ const lines: string[] = [];
105
+
106
+ // Frontmatter
107
+ lines.push("---");
108
+ lines.push(`title: "${doc.title}"`);
109
+ lines.push(`created: ${doc.created}`);
110
+ lines.push(`updated: ${doc.updated}`);
111
+ lines.push("---");
112
+ lines.push("");
113
+ lines.push(`# ${doc.title}`);
114
+ lines.push("");
115
+
116
+ // Phases
117
+ for (const phase of doc.phases) {
118
+ lines.push(`## ${phase.name}`);
119
+ if (phase.description) {
120
+ lines.push(`> ${phase.description}`);
121
+ }
122
+ lines.push("");
123
+ for (const item of phase.items) {
124
+ const check = item.checked ? "[x]" : "[ ]";
125
+ lines.push(`- ${check} ${item.text}`);
126
+ }
127
+ lines.push("");
128
+ }
129
+
130
+ ensureDir(filePath);
131
+ // Atomic write: write to temp, rename
132
+ const tmpPath = filePath + ".tmp";
133
+ fs.writeFileSync(tmpPath, lines.join("\n"), "utf-8");
134
+ fs.renameSync(tmpPath, filePath);
135
+ }
136
+
137
+ /**
138
+ * Toggle a checkbox item's status in a MILESTONES.md file.
139
+ * Matches by normalized (lowercase, trimmed) phase name and item text.
140
+ * Returns true if item was found and updated.
141
+ */
142
+ export function updateItemStatus(
143
+ filePath: string,
144
+ phaseName: string,
145
+ itemText: string,
146
+ checked: boolean,
147
+ ): boolean {
148
+ const content = tryRead(filePath);
149
+ if (!content) return false;
150
+
151
+ const lines = content.split("\n");
152
+ const normalizedPhase = phaseName.toLowerCase().trim();
153
+ const normalizedItem = itemText.toLowerCase().trim();
154
+ let currentPhase = "";
155
+ let found = false;
156
+
157
+ for (let i = 0; i < lines.length; i++) {
158
+ const line = lines[i];
159
+
160
+ // Track current phase
161
+ const phaseMatch = line.match(/^##\s+(.+)$/);
162
+ if (phaseMatch) {
163
+ currentPhase = phaseMatch[1].trim().toLowerCase();
164
+ continue;
165
+ }
166
+
167
+ // Match checkbox in the target phase
168
+ if (currentPhase === normalizedPhase) {
169
+ const itemMatch = line.match(/^-\s+\[([ xX])\]\s+(.+)$/);
170
+ if (itemMatch) {
171
+ const lineText = itemMatch[2].trim().toLowerCase();
172
+ if (lineText === normalizedItem) {
173
+ const mark = checked ? "x" : " ";
174
+ lines[i] = `- [${mark}] ${itemMatch[2].trim()}`;
175
+ found = true;
176
+ break;
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ if (found) {
183
+ // Update the 'updated' frontmatter date
184
+ const today = new Date().toISOString().split("T")[0];
185
+ for (let i = 0; i < lines.length; i++) {
186
+ if (lines[i].match(/^updated:\s*/)) {
187
+ lines[i] = `updated: ${today}`;
188
+ break;
189
+ }
190
+ }
191
+
192
+ // Atomic write
193
+ const tmpPath = filePath + ".tmp";
194
+ fs.writeFileSync(tmpPath, lines.join("\n"), "utf-8");
195
+ fs.renameSync(tmpPath, filePath);
196
+ }
197
+
198
+ return found;
199
+ }
200
+
201
+ /**
202
+ * Get a progress summary from a MILESTONES.md file.
203
+ * Returns empty summary if file doesn't exist.
204
+ */
205
+ export function getProgressSummary(filePath: string): ProgressSummary {
206
+ const doc = parseMilestones(filePath);
207
+
208
+ let totalItems = 0;
209
+ let completedItems = 0;
210
+ let currentPhase = "";
211
+ const phases: ProgressSummary["phases"] = [];
212
+
213
+ for (const phase of doc.phases) {
214
+ const done = phase.items.filter((i) => i.checked).length;
215
+ const total = phase.items.length;
216
+ totalItems += total;
217
+ completedItems += done;
218
+
219
+ phases.push({
220
+ name: phase.name,
221
+ done,
222
+ total,
223
+ });
224
+
225
+ // Current phase: first phase with incomplete items
226
+ if (!currentPhase && done < total) {
227
+ currentPhase = phase.name;
228
+ }
229
+ }
230
+
231
+ return {
232
+ totalItems,
233
+ completedItems,
234
+ percentComplete: totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0,
235
+ currentPhase: currentPhase || (doc.phases.length > 0 ? doc.phases[0].name : "None"),
236
+ phases,
237
+ };
238
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@pi-unipi/milestone",
3
+ "version": "0.1.2",
4
+ "description": "Lifecycle layer for project-level goals — MILESTONES.md tracking, session hooks, auto-sync",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Neuron Mr White",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Neuron-Mr-White/unipi.git",
11
+ "directory": "packages/milestone"
12
+ },
13
+ "keywords": [
14
+ "pi-package",
15
+ "pi-extension",
16
+ "pi-coding-agent",
17
+ "unipi",
18
+ "milestone",
19
+ "goals",
20
+ "progress"
21
+ ],
22
+ "files": [
23
+ "*.ts",
24
+ "skills/**/*",
25
+ "README.md"
26
+ ],
27
+ "pi": {
28
+ "extensions": [
29
+ "index.ts"
30
+ ],
31
+ "skills": [
32
+ "skills"
33
+ ]
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "dependencies": {
39
+ "@pi-unipi/core": "*"
40
+ },
41
+ "peerDependencies": {
42
+ "@mariozechner/pi-coding-agent": "*",
43
+ "@mariozechner/pi-tui": "*",
44
+ "@sinclair/typebox": "*"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^25.6.0",
48
+ "typescript": "^6.0.0"
49
+ }
50
+ }
@@ -0,0 +1,114 @@
1
+ ---
2
+ name: milestone-onboard
3
+ description: "Create MILESTONES.md from existing workflow docs — scan, propose, refine, write."
4
+ ---
5
+
6
+ # Onboard Milestones
7
+
8
+ Create a MILESTONES.md file by scanning existing workflow documentation. Groups scattered tasks into coherent milestone phases.
9
+
10
+ ## Boundaries
11
+
12
+ **This skill MAY:** read `.unipi/docs/`, read existing specs/plans/quick-work/debug/fix/chore docs, write `.unipi/docs/MILESTONES.md`.
13
+ **This skill MAY NOT:** modify existing workflow docs, delete files, merge branches.
14
+
15
+ ---
16
+
17
+ ## Phase 1: Explore
18
+
19
+ Scan `.unipi/docs/` for existing workflow documentation. Understand what's been done and what's planned.
20
+
21
+ 1. List all files in `.unipi/docs/{specs,plans,quick-work,debug,fix,chore}/`
22
+ 2. For each file, extract:
23
+ - Checkbox items (`- [ ]` and `- [x]`)
24
+ - Task statuses (`unstarted:`, `in-progress:`, `completed:`)
25
+ - File modification dates
26
+ 3. Categorize findings:
27
+ - **Completed work** — checked items, completed tasks
28
+ - **In progress** — in-progress tasks, partially checked lists
29
+ - **Planned** — unstarted tasks, unchecked items
30
+ 4. Present summary to user:
31
+ > "Found X completed items, Y in-progress, Z planned across N documents."
32
+
33
+ ---
34
+
35
+ ## Phase 2: Propose
36
+
37
+ Group findings into logical milestone phases. Present with rationale.
38
+
39
+ 1. Analyze themes across documents — group related items together
40
+ 2. Suggest 2-5 phases with clear names and descriptions
41
+ 3. For each phase, list proposed items (both done and todo)
42
+ 4. Present proposal:
43
+ > "**Phase 1: Foundation** (3/5 done)
44
+ > - [x] Project scaffold
45
+ > - [x] Core parser
46
+ > - [ ] Type definitions
47
+ > - [ ] Error handling
48
+ > - [ ] Documentation
49
+ >
50
+ > **Phase 2: Features** (0/3 done)
51
+ > - [ ] User dashboard
52
+ > - [ ] File upload
53
+ > - [ ] Notifications
54
+ >
55
+ > Does this grouping look right?"
56
+
57
+ 5. **One question at a time** — ask if phases are correct before proceeding
58
+
59
+ ---
60
+
61
+ ## Phase 3: Refine
62
+
63
+ User approves/adjusts phases. Iterate until satisfied.
64
+
65
+ 1. If user wants changes:
66
+ - **Add phase**: Ask for name and items
67
+ - **Remove phase**: Confirm removal
68
+ - **Move items**: Ask which item, which phase
69
+ - **Rename phase**: Ask for new name
70
+ - **Add items**: Ask for text and target phase
71
+ 2. After each change, show updated proposal
72
+ 3. Continue until user says "looks good" or "write it"
73
+
74
+ ---
75
+
76
+ ## Phase 4: Write
77
+
78
+ Save MILESTONES.md using the milestone parser.
79
+
80
+ 1. Build `MilestoneDoc` from approved phases:
81
+ - `title`: "Project Milestones" (or ask user for custom title)
82
+ - `created`: today's date
83
+ - `updated`: today's date
84
+ - `phases`: approved phases with items
85
+ 2. Call `writeMilestones(".unipi/docs/MILESTONES.md", doc)`
86
+ 3. Verify file was written correctly
87
+
88
+ ---
89
+
90
+ ## Phase 5: Report
91
+
92
+ Show summary and suggest next steps.
93
+
94
+ 1. Display what was written:
95
+ > "✅ MILESTONES.md created with N phases and M items.
96
+ > - Phase 1: Foundation (3/5 done)
97
+ > - Phase 2: Features (0/3 done)
98
+ >
99
+ > **Next steps:**
100
+ > - `/unipi:milestone-update` — sync milestones with completed work
101
+ > - Milestones will auto-inject context on session start
102
+ > - Completed items auto-sync on session end"
103
+
104
+ ---
105
+
106
+ ## Validation Checklist
107
+
108
+ Before completing, verify:
109
+ - [ ] MILESTONES.md exists at `.unipi/docs/MILESTONES.md`
110
+ - [ ] File has valid frontmatter (title, created, updated)
111
+ - [ ] All phases have names
112
+ - [ ] All items have checkbox format (`- [ ]` or `- [x]`)
113
+ - [ ] Previously completed items are marked `[x]`
114
+ - [ ] File parses correctly via `parseMilestones()`
@@ -0,0 +1,109 @@
1
+ ---
2
+ name: milestone-update
3
+ description: "Sync MILESTONES.md with completed work — scan docs, diff checkboxes, auto-update."
4
+ ---
5
+
6
+ # Update Milestones
7
+
8
+ Sync MILESTONES.md with work completed in workflow docs. Detects checkbox changes and updates milestone items.
9
+
10
+ ## Boundaries
11
+
12
+ **This skill MAY:** read `.unipi/docs/`, update `.unipi/docs/MILESTONES.md`, ask user for conflict resolution.
13
+ **This skill MAY NOT:** modify workflow docs, delete files, create new milestones (use `/unipi:milestone-onboard`).
14
+
15
+ ---
16
+
17
+ ## Phase 1: Scan
18
+
19
+ Read all workflow docs modified since last milestone update.
20
+
21
+ 1. Read `.unipi/docs/MILESTONES.md` — if missing, suggest `/unipi:milestone-onboard`
22
+ 2. Record `updated` date from MILESTONES.md frontmatter
23
+ 3. Scan `.unipi/docs/{specs,plans,quick-work}/` for files modified after the `updated` date
24
+ 4. If no modified files found:
25
+ > "No workflow docs have been modified since the last milestone update."
26
+ 5. List modified files and present to user
27
+
28
+ ---
29
+
30
+ ## Phase 2: Diff
31
+
32
+ Compare checkbox states between current docs and baseline.
33
+
34
+ 1. For each modified file:
35
+ - Extract all checkbox items (`- [ ]` and `- [x]`)
36
+ - Extract task statuses from plans (`completed:`)
37
+ 2. Compare against MILESTONES.md items:
38
+ - **Exact match** (normalized): item text matches a milestone item
39
+ - **No match**: item not found in milestones
40
+ 3. Categorize:
41
+ - **Newly completed**: item is `[x]` in doc but `[ ]` in milestones
42
+ - **Already synced**: item matches milestone state
43
+ - **Unmatched**: item not in milestones at all
44
+ 4. Present diff:
45
+ > "**Found 3 changes:**
46
+ > - ✅ `Authentication system` — completed in spec.md (exact match)
47
+ > - ✅ `API routing` — completed in plan.md (exact match)
48
+ > - ⚠️ `New feature idea` — not found in milestones (skipped)"
49
+
50
+ ---
51
+
52
+ ## Phase 3: Resolve
53
+
54
+ Auto-update clear matches, ask user on conflicts.
55
+
56
+ 1. **Exact matches**: Update automatically via `updateItemStatus()`
57
+ 2. **Unmatched completions**: Present to user via `ask_user`:
58
+ > "Found completed items not in milestones:
59
+ > 1. `New feature idea` (from spec.md)
60
+ >
61
+ > What should I do?"
62
+ > - Skip (don't update milestones)
63
+ > - Add to a phase (specify which)
64
+ 3. If user wants to add: ask which phase, then add item and mark as completed
65
+ 4. Log all changes made
66
+
67
+ ---
68
+
69
+ ## Phase 4: Write
70
+
71
+ Apply resolved changes to MILESTONES.md.
72
+
73
+ 1. For each resolved change:
74
+ - Call `updateItemStatus(milestonesPath, phase, text, true)` for completions
75
+ - Or modify doc directly for new items
76
+ 2. Update `updated` frontmatter date to today
77
+ 3. Verify file still parses correctly
78
+
79
+ ---
80
+
81
+ ## Phase 5: Report
82
+
83
+ Show what changed, what was skipped, suggest next steps.
84
+
85
+ 1. Display summary:
86
+ > "✅ **Milestones updated:**
87
+ > - 2 items marked complete
88
+ > - 1 item skipped (not in milestones)
89
+ >
90
+ > **Progress:** 5/10 items (50%)
91
+ > **Current phase:** Phase 1: Foundation (3/5 done)
92
+ >
93
+ > **Next steps:**
94
+ > - Continue with remaining items
95
+ > - `/unipi:milestone-onboard` — restructure milestones"
96
+
97
+ 2. If no changes were made:
98
+ > "No milestone items needed updating. Everything is in sync."
99
+
100
+ ---
101
+
102
+ ## Validation Checklist
103
+
104
+ Before completing, verify:
105
+ - [ ] MILESTONES.md still exists and is valid
106
+ - [ ] All auto-updated items match their source docs
107
+ - [ ] `updated` date reflects today
108
+ - [ ] No items were accidentally unchecked
109
+ - [ ] File parses correctly via `parseMilestones()`
package/types.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @pi-unipi/milestone — Type definitions
3
+ */
4
+
5
+ /** A single item within a milestone phase */
6
+ export interface MilestoneItem {
7
+ /** Item text (without checkbox) */
8
+ text: string;
9
+ /** Whether item is checked off */
10
+ checked: boolean;
11
+ /** Line number in the source file (1-indexed) */
12
+ lineNumber: number;
13
+ }
14
+
15
+ /** A phase grouping milestone items */
16
+ export interface MilestonePhase {
17
+ /** Phase name (e.g., "Phase 1: Foundation") */
18
+ name: string;
19
+ /** Optional description (from `>` blockquote lines) */
20
+ description?: string;
21
+ /** Items in this phase */
22
+ items: MilestoneItem[];
23
+ }
24
+
25
+ /** Parsed representation of a MILESTONES.md file */
26
+ export interface MilestoneDoc {
27
+ /** Document title from frontmatter */
28
+ title: string;
29
+ /** Creation date (ISO string) */
30
+ created: string;
31
+ /** Last update date (ISO string) */
32
+ updated: string;
33
+ /** Ordered list of phases */
34
+ phases: MilestonePhase[];
35
+ /** Source file path */
36
+ filePath: string;
37
+ }
38
+
39
+ /** Per-phase progress */
40
+ export interface PhaseProgress {
41
+ /** Phase name */
42
+ name: string;
43
+ /** Completed items in this phase */
44
+ done: number;
45
+ /** Total items in this phase */
46
+ total: number;
47
+ }
48
+
49
+ /** Progress summary across all phases */
50
+ export interface ProgressSummary {
51
+ /** Total items across all phases */
52
+ totalItems: number;
53
+ /** Completed items across all phases */
54
+ completedItems: number;
55
+ /** Overall completion percentage (0-100) */
56
+ percentComplete: number;
57
+ /** Name of the current phase (first with incomplete items) */
58
+ currentPhase: string;
59
+ /** Per-phase progress */
60
+ phases: PhaseProgress[];
61
+ }