@pi-unipi/milestone 2.4.0 → 2.5.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 CHANGED
@@ -15,9 +15,17 @@ Workflow operates at the task level — brainstorm, plan, work, review. Project
15
15
 
16
16
  ### Session Start
17
17
 
18
- On `before_agent_start`, milestone reads `.unipi/docs/MILESTONES.md` and appends a progress summary to the system prompt:
18
+ On `before_agent_start`, milestone reads `.unipi/docs/MILESTONES.md` from `ctx.cwd` and appends a hidden `unipi-milestone-snapshot` custom message to the session:
19
19
 
20
20
  ```
21
+ # UniPi Milestone Snapshot
22
+
23
+ This snapshot supersedes all prior UniPi milestone snapshots; use only this snapshot for milestone status.
24
+
25
+ Workspace: /path/to/project
26
+
27
+ Status: active
28
+
21
29
  ## Project Milestones
22
30
  Overall progress: 5/10 items (50%)
23
31
  Phase 1: Foundation: 3/5 done
@@ -25,11 +33,11 @@ Overall progress: 5/10 items (50%)
25
33
  Current focus: Phase 1: Foundation
26
34
  ```
27
35
 
28
- If MILESTONES.md doesn't exist, no context is injected.
36
+ Snapshots are append-only and hidden from the transcript. They keep the system-prompt prefix stable, persist milestone context in session history, and are deduplicated against the latest milestone custom message in the effective (compaction-aware) LLM context. If milestones disappear while an older active snapshot remains effective, an inactive snapshot is appended to supersede it. A clean workspace with no milestones and no effective snapshot receives no injected message.
29
37
 
30
38
  ### Session End
31
39
 
32
- On `session_shutdown`, milestone scans workflow docs modified during the session. Detects items that changed from `- [ ]` to `- [x]` and auto-updates MILESTONES.md using exact text matching.
40
+ On `session_shutdown`, milestone scans workflow docs modified during the session. It uses the workspace captured from `session_start`'s `ctx.cwd`, detects items that changed from `- [ ]` to `- [x]`, and auto-updates MILESTONES.md using exact text matching.
33
41
 
34
42
  ### Coexist Triggers
35
43
 
package/hooks.ts CHANGED
@@ -1,22 +1,33 @@
1
1
  /**
2
2
  * @pi-unipi/milestone — Lifecycle hooks
3
3
  *
4
- * Session start: inject milestone progress as system context.
4
+ * Agent start: append milestone progress as a hidden, persistent context snapshot.
5
5
  * Session end: auto-sync completed items from workflow docs.
6
6
  */
7
7
 
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
- import { MILESTONE_DIRS, safeMtimeMs, tryRead } from "@pi-unipi/core";
12
- import { parseMilestones, getProgressSummary, updateItemStatus } from "./milestone.js";
10
+ import {
11
+ buildSessionContext,
12
+ type ExtensionAPI,
13
+ type SessionEntry,
14
+ } from "@earendil-works/pi-coding-agent";
15
+ import { MILESTONE_DIRS, UNIPI_EVENTS, safeMtimeMs, tryRead } from "@pi-unipi/core";
16
+ import { getProgressSummary, updateItemStatus } from "./milestone.js";
13
17
 
14
- /** Track when the session started for diffing modified files */
15
- let sessionStartMs = 0;
18
+ export const MILESTONE_SNAPSHOT_TYPE = "unipi-milestone-snapshot";
16
19
 
17
- /**
18
- * Format a progress summary as a context string for the system prompt.
19
- */
20
+ interface MilestoneSnapshotDetails {
21
+ active: boolean;
22
+ workspace: string;
23
+ }
24
+
25
+ interface EffectiveSnapshot {
26
+ content: unknown;
27
+ details?: MilestoneSnapshotDetails;
28
+ }
29
+
30
+ /** Format the active milestone progress included in a snapshot. */
20
31
  function formatMilestoneContext(filePath: string): string | null {
21
32
  const summary = getProgressSummary(filePath);
22
33
  if (summary.totalItems === 0) return null;
@@ -39,23 +50,84 @@ function formatMilestoneContext(filePath: string): string | null {
39
50
  .join("\n");
40
51
  }
41
52
 
53
+ /** Build an append-only snapshot that explicitly invalidates earlier snapshots. */
54
+ function formatMilestoneSnapshot(workspace: string, context: string | null): string {
55
+ return [
56
+ "# UniPi Milestone Snapshot",
57
+ "This snapshot supersedes all prior UniPi milestone snapshots; use only this snapshot for milestone status.",
58
+ `Workspace: ${workspace}`,
59
+ `Status: ${context ? "active" : "inactive"}`,
60
+ context ?? "No milestones are active for this workspace.",
61
+ ].join("\n\n");
62
+ }
63
+
64
+ function latestEffectiveSnapshot(branch: SessionEntry[]): EffectiveSnapshot | undefined {
65
+ const messages = buildSessionContext(branch).messages;
66
+ for (let index = messages.length - 1; index >= 0; index--) {
67
+ const message = messages[index];
68
+ if (message.role === "custom" && message.customType === MILESTONE_SNAPSHOT_TYPE) {
69
+ return {
70
+ content: message.content,
71
+ details: message.details as MilestoneSnapshotDetails | undefined,
72
+ };
73
+ }
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ /** Find state that may have been folded into a compaction summary. */
79
+ function latestHistoricalSnapshot(branch: SessionEntry[]): EffectiveSnapshot | undefined {
80
+ for (let index = branch.length - 1; index >= 0; index--) {
81
+ const entry = branch[index];
82
+ if (entry.type === "custom_message" && entry.customType === MILESTONE_SNAPSHOT_TYPE) {
83
+ return {
84
+ content: entry.content,
85
+ details: entry.details as MilestoneSnapshotDetails | undefined,
86
+ };
87
+ }
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ function isActiveSnapshot(snapshot: EffectiveSnapshot): boolean {
93
+ if (typeof snapshot.details?.active === "boolean") return snapshot.details.active;
94
+ return typeof snapshot.content === "string" && snapshot.content.includes("Status: active");
95
+ }
96
+
42
97
  /**
43
- * Register session start hook injects milestone progress into system context.
98
+ * Register the agent-start hook. Snapshots are hidden from the transcript but
99
+ * persist in the append-only session and therefore keep the system prefix stable.
44
100
  */
45
101
  export function registerSessionStartHook(pi: ExtensionAPI): void {
46
- pi.on("before_agent_start", (event) => {
47
- sessionStartMs = Date.now();
102
+ pi.on("before_agent_start", (_event, ctx) => {
103
+ const workspace = ctx.cwd;
104
+ const milestonesPath = path.join(workspace, MILESTONE_DIRS.MILESTONES);
105
+ const context = formatMilestoneContext(milestonesPath);
106
+ const branch = ctx.sessionManager.getBranch();
107
+ const latest = latestEffectiveSnapshot(branch);
108
+ const historical = latestHistoricalSnapshot(branch);
48
109
 
49
- const cwd = process.cwd();
50
- const milestonesPath = path.join(cwd, MILESTONE_DIRS.MILESTONES);
110
+ // A genuinely clean workspace/session needs no synthetic context. Raw
111
+ // history is checked because compaction may have folded an old active
112
+ // snapshot into summary prose while removing its custom-message identity.
113
+ if (!context && !latest && !historical) return undefined;
51
114
 
52
- const context = formatMilestoneContext(milestonesPath);
53
- if (!context) return undefined;
115
+ const prior = latest ?? historical;
116
+ if (!context && prior && !isActiveSnapshot(prior)) return undefined;
117
+
118
+ const content = formatMilestoneSnapshot(workspace, context);
119
+ if (latest?.content === content) return undefined;
54
120
 
55
- // Append milestone context to the system prompt
56
- const currentPrompt = (event as any).systemPrompt ?? "";
57
121
  return {
58
- systemPrompt: currentPrompt + "\n\n" + context,
122
+ message: {
123
+ customType: MILESTONE_SNAPSHOT_TYPE,
124
+ content,
125
+ display: false,
126
+ details: {
127
+ active: context !== null,
128
+ workspace,
129
+ } satisfies MilestoneSnapshotDetails,
130
+ },
59
131
  };
60
132
  });
61
133
  }
@@ -140,19 +212,21 @@ function scanModifiedDocs(dirs: string[], since: number): string[] {
140
212
  * scans modified docs, and auto-updates MILESTONES.md.
141
213
  */
142
214
  export function registerSessionEndHook(pi: ExtensionAPI): void {
143
- // Store baseline snapshots at session start
215
+ // Capture the session workspace because process.cwd() can change before shutdown.
144
216
  const baselineSnapshots = new Map<string, string>();
217
+ let sessionStartMs = 0;
218
+ let sessionWorkspace: string | null = null;
145
219
 
146
220
  // Capture baselines on session start
147
- pi.on("session_start", () => {
221
+ pi.on("session_start", (_event, ctx) => {
148
222
  sessionStartMs = Date.now();
223
+ sessionWorkspace = ctx.cwd;
149
224
  baselineSnapshots.clear();
150
225
 
151
- const cwd = process.cwd();
152
226
  const scanDirs = [
153
- path.join(cwd, ".unipi/docs/specs"),
154
- path.join(cwd, ".unipi/docs/plans"),
155
- path.join(cwd, ".unipi/docs/quick-work"),
227
+ path.join(sessionWorkspace, ".unipi/docs/specs"),
228
+ path.join(sessionWorkspace, ".unipi/docs/plans"),
229
+ path.join(sessionWorkspace, ".unipi/docs/quick-work"),
156
230
  ];
157
231
 
158
232
  for (const dir of scanDirs) {
@@ -167,28 +241,16 @@ export function registerSessionEndHook(pi: ExtensionAPI): void {
167
241
  }
168
242
  });
169
243
 
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);
244
+ const syncModifiedDocs = () => {
245
+ if (!sessionWorkspace) return;
185
246
 
247
+ const milestonesPath = path.join(sessionWorkspace, MILESTONE_DIRS.MILESTONES);
186
248
  if (!fs.existsSync(milestonesPath)) return;
187
249
 
188
250
  const scanDirs = [
189
- path.join(cwd, ".unipi/docs/specs"),
190
- path.join(cwd, ".unipi/docs/plans"),
191
- path.join(cwd, ".unipi/docs/quick-work"),
251
+ path.join(sessionWorkspace, ".unipi/docs/specs"),
252
+ path.join(sessionWorkspace, ".unipi/docs/plans"),
253
+ path.join(sessionWorkspace, ".unipi/docs/quick-work"),
192
254
  ];
193
255
 
194
256
  const modifiedFiles = scanModifiedDocs(scanDirs, sessionStartMs);
@@ -204,5 +266,11 @@ export function registerSessionEndHook(pi: ExtensionAPI): void {
204
266
  updateItemStatus(milestonesPath, phase, text, true);
205
267
  }
206
268
  }
207
- });
269
+ };
270
+
271
+ // Workflow emits this once its follow-up agent loop drains.
272
+ pi.events.on(UNIPI_EVENTS.WORKFLOW_END, syncModifiedDocs);
273
+
274
+ // Final fallback for changes made outside a workflow or before event wiring.
275
+ pi.on("session_shutdown", syncModifiedDocs);
208
276
  }
package/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * @pi-unipi/milestone — Extension entry point
3
3
  *
4
4
  * Lifecycle layer for project-level goals. Tracks progress via MILESTONES.md,
5
- * injects context on session start, auto-syncs on session end.
5
+ * appends hidden context snapshots before agent turns, and auto-syncs on session end.
6
6
  */
7
7
 
8
8
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@pi-unipi/milestone",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Lifecycle layer for project-level goals — MILESTONES.md tracking, session hooks, auto-sync",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
+ "scripts": {
8
+ "test": "npx tsx --test tests/hooks.test.ts"
9
+ },
7
10
  "license": "MIT",
8
11
  "author": "Neuron Mr White",
9
12
  "repository": {
@@ -29,7 +32,7 @@
29
32
  "access": "public"
30
33
  },
31
34
  "dependencies": {
32
- "@pi-unipi/core": "2.4.0"
35
+ "@pi-unipi/core": "2.5.0"
33
36
  },
34
37
  "peerDependencies": {
35
38
  "@earendil-works/pi-coding-agent": "^0.80.0",
package/coexist.ts DELETED
@@ -1,114 +0,0 @@
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
- // Removed console.log — milestone matches are informational.
53
- // Use /unipi:milestone-update to sync manually.
54
- }
55
- }
56
-
57
- /**
58
- * After plan completes: check if plan tasks map to milestone items.
59
- * Logs matching items for awareness.
60
- */
61
- export function onPlanComplete(planPath: string): void {
62
- const cwd = process.cwd();
63
- const milestonesPath = path.join(cwd, MILESTONE_DIRS.MILESTONES);
64
-
65
- // Silently skip if no MILESTONES.md
66
- if (!tryRead(milestonesPath)) return;
67
-
68
- const planContent = tryRead(planPath);
69
- if (!planContent) return;
70
-
71
- // Extract task names from plan (### Task N — Name pattern)
72
- const planTasks: string[] = [];
73
- for (const line of planContent.split("\n")) {
74
- const match = line.match(/^###\s+Task\s+\d+\s*[—–-]\s*(.+)$/);
75
- if (match) {
76
- planTasks.push(match[1].trim().toLowerCase());
77
- }
78
- }
79
-
80
- if (planTasks.length === 0) return;
81
-
82
- // Check against milestones
83
- const doc = parseMilestones(milestonesPath);
84
- const matched: string[] = [];
85
-
86
- for (const phase of doc.phases) {
87
- for (const item of phase.items) {
88
- const normalized = item.text.toLowerCase().trim();
89
- // Check if any plan task contains the milestone item text or vice versa
90
- for (const task of planTasks) {
91
- if (task.includes(normalized) || normalized.includes(task)) {
92
- matched.push(`"${item.text}" → plan task`);
93
- break;
94
- }
95
- }
96
- }
97
- }
98
-
99
- if (matched.length > 0) {
100
- // Removed console.log — plan-milestone matches are informational.
101
- }
102
- }
103
-
104
- /**
105
- * After consolidate: reference milestone sync that already happened.
106
- */
107
- export function onConsolidate(): void {
108
- const cwd = process.cwd();
109
- const milestonesPath = path.join(cwd, MILESTONE_DIRS.MILESTONES);
110
-
111
- if (!tryRead(milestonesPath)) return;
112
-
113
- // Removed console.log — milestone auto-sync is silent.
114
- }