@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 +11 -3
- package/hooks.ts +112 -44
- package/index.ts +1 -1
- package/package.json +5 -2
- package/coexist.ts +0 -114
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
let sessionStartMs = 0;
|
|
18
|
+
export const MILESTONE_SNAPSHOT_TYPE = "unipi-milestone-snapshot";
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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", (
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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(
|
|
154
|
-
path.join(
|
|
155
|
-
path.join(
|
|
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
|
-
|
|
171
|
-
|
|
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(
|
|
190
|
-
path.join(
|
|
191
|
-
path.join(
|
|
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
|
-
*
|
|
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.
|
|
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.
|
|
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
|
-
}
|