@pugi/cli 0.1.0-beta.92 → 0.1.0-beta.94
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/dist/commands/retro.js +210 -0
- package/dist/core/diagnostics/probes/sandbox.js +65 -33
- package/dist/core/engine/native-pugi.js +185 -11
- package/dist/core/engine/prompts.js +1 -1
- package/dist/core/engine/tool-bridge.js +35 -0
- package/dist/core/engine/verification-patterns.js +195 -0
- package/dist/core/mcp/orchestrator-config.js +192 -0
- package/dist/core/mcp/orchestrator-tools.js +147 -3
- package/dist/core/pugi-gitignore.js +52 -0
- package/dist/core/repl/engine-bridge.js +199 -0
- package/dist/core/repl/session.js +395 -6
- package/dist/core/repl/tool-route.js +382 -0
- package/dist/core/retro/git-collector.js +251 -0
- package/dist/core/retro/health-card.js +25 -0
- package/dist/core/retro/metrics.js +342 -0
- package/dist/core/retro/narrative.js +249 -0
- package/dist/core/retro/plane-collector.js +274 -0
- package/dist/core/retro/pr-issue-link.js +65 -0
- package/dist/core/retro/types.js +16 -0
- package/dist/core/sandboxing/adapter.js +29 -0
- package/dist/core/sandboxing/index.js +49 -0
- package/dist/core/sandboxing/none.js +19 -0
- package/dist/core/sandboxing/seatbelt.js +183 -0
- package/dist/core/session.js +27 -0
- package/dist/core/settings.js +22 -0
- package/dist/runtime/cli.js +167 -33
- package/dist/runtime/commands/compact.js +1 -1
- package/dist/runtime/commands/config.js +1 -1
- package/dist/runtime/commands/mcp.js +64 -8
- package/dist/runtime/commands/memory.js +1 -1
- package/dist/runtime/deprecation-warning.js +69 -0
- package/dist/runtime/headless.js +8 -3
- package/dist/runtime/stream-renderer.js +195 -0
- package/dist/runtime/version.js +1 -1
- package/dist/skills/bundled/remember.js +2 -2
- package/dist/tui/agent-tree.js +11 -0
- package/dist/tui/ask-user-question-chips.js +1 -1
- package/dist/tui/multi-file-diff-approval.js +3 -3
- package/dist/tui/repl-render.js +42 -0
- package/package.json +2 -2
- package/test/scenarios/identity.scenario.txt +0 -1
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metric aggregator for `pugi retro`.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions over a RawCommit[] window — no I/O, no git calls.
|
|
5
|
+
* Same input shape goes through the same code path whether we are
|
|
6
|
+
* computing the current window or the prior compare window.
|
|
7
|
+
*
|
|
8
|
+
* All ratio fields default to 0 when undefined (never NaN). The
|
|
9
|
+
* renderer never has to guard against NaN.
|
|
10
|
+
*/
|
|
11
|
+
/** Session detection threshold — `pugi retro` mirrors the gstack-retro
|
|
12
|
+
* convention of 45 minutes between consecutive commits as the session
|
|
13
|
+
* boundary. Sessions are per-author so cross-author commits in the
|
|
14
|
+
* same time window do NOT merge into one session.
|
|
15
|
+
*/
|
|
16
|
+
const SESSION_GAP_MS = 45 * 60 * 1000;
|
|
17
|
+
/** Conventional-commits subject pattern: `<type>(scope)!?: subject`. */
|
|
18
|
+
const CONVENTIONAL_RE = /^(feat|fix|refactor|test|chore|docs)(\([^)]+\))?!?:/i;
|
|
19
|
+
const COMMIT_TYPES = ['feat', 'fix', 'refactor', 'test', 'chore', 'docs', 'other'];
|
|
20
|
+
function emptyCommitTypeTable() {
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const t of COMMIT_TYPES)
|
|
23
|
+
out[t] = 0;
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
export function classifyCommitType(subject, files) {
|
|
27
|
+
const match = CONVENTIONAL_RE.exec(subject);
|
|
28
|
+
if (match) {
|
|
29
|
+
const tag = (match[1] ?? '').toLowerCase();
|
|
30
|
+
if (COMMIT_TYPES.includes(tag))
|
|
31
|
+
return tag;
|
|
32
|
+
}
|
|
33
|
+
// Heuristic fallback: a commit that only touches test files is test.
|
|
34
|
+
// A commit that only touches docs is docs. Otherwise "other".
|
|
35
|
+
if (files.length > 0) {
|
|
36
|
+
const allTest = files.every((f) => /(^|\/)(test|tests|spec|__tests__)\//i.test(f) || /\.(spec|test)\./i.test(f));
|
|
37
|
+
if (allTest)
|
|
38
|
+
return 'test';
|
|
39
|
+
const allDocs = files.every((f) => /\.(md|mdx|rst|txt)$/i.test(f) || /(^|\/)docs?\//i.test(f));
|
|
40
|
+
if (allDocs)
|
|
41
|
+
return 'docs';
|
|
42
|
+
}
|
|
43
|
+
return 'other';
|
|
44
|
+
}
|
|
45
|
+
/** Top-level directory of a file path. Returns `<root>` for files at
|
|
46
|
+
* the repo root so the focus-score reporter has a non-empty bucket.
|
|
47
|
+
*/
|
|
48
|
+
export function topLevelDir(path) {
|
|
49
|
+
const idx = path.indexOf('/');
|
|
50
|
+
if (idx === -1)
|
|
51
|
+
return '<root>';
|
|
52
|
+
return path.slice(0, idx);
|
|
53
|
+
}
|
|
54
|
+
export function aggregateAuthors(commits, currentUserName, currentUserEmail) {
|
|
55
|
+
const byKey = new Map();
|
|
56
|
+
for (const c of commits) {
|
|
57
|
+
const key = c.authorEmail || c.author;
|
|
58
|
+
const existing = byKey.get(key);
|
|
59
|
+
if (existing) {
|
|
60
|
+
existing.commits += 1;
|
|
61
|
+
existing.insertions += c.stats.insertions;
|
|
62
|
+
existing.deletions += c.stats.deletions;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const isYou = matchesUser(c, currentUserName, currentUserEmail);
|
|
66
|
+
byKey.set(key, {
|
|
67
|
+
name: c.author,
|
|
68
|
+
email: c.authorEmail,
|
|
69
|
+
commits: 1,
|
|
70
|
+
insertions: c.stats.insertions,
|
|
71
|
+
deletions: c.stats.deletions,
|
|
72
|
+
isYou,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const arr = Array.from(byKey.values());
|
|
77
|
+
// Sort: you first, then by commits desc, then by name asc for stable order.
|
|
78
|
+
arr.sort((a, b) => {
|
|
79
|
+
if (a.isYou !== b.isYou)
|
|
80
|
+
return a.isYou ? -1 : 1;
|
|
81
|
+
if (b.commits !== a.commits)
|
|
82
|
+
return b.commits - a.commits;
|
|
83
|
+
return a.name.localeCompare(b.name);
|
|
84
|
+
});
|
|
85
|
+
return arr;
|
|
86
|
+
}
|
|
87
|
+
function matchesUser(commit, name, email) {
|
|
88
|
+
if (email && commit.authorEmail && commit.authorEmail.toLowerCase() === email.toLowerCase()) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
if (name && commit.author === name)
|
|
92
|
+
return true;
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
export function hotspots(commits, limit = 10) {
|
|
96
|
+
const byPath = new Map();
|
|
97
|
+
for (const c of commits) {
|
|
98
|
+
for (const f of c.files) {
|
|
99
|
+
const existing = byPath.get(f);
|
|
100
|
+
if (existing) {
|
|
101
|
+
existing.touches += 1;
|
|
102
|
+
// numstat doesn't split per-file insertions here; we already
|
|
103
|
+
// rolled the per-commit total. For hotspot LOC we accumulate
|
|
104
|
+
// the per-commit total against every file it touched, which
|
|
105
|
+
// overcounts but preserves the "files touching big commits"
|
|
106
|
+
// ranking signal. Renderer labels this honestly.
|
|
107
|
+
existing.insertions += c.stats.insertions;
|
|
108
|
+
existing.deletions += c.stats.deletions;
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
byPath.set(f, {
|
|
112
|
+
path: f,
|
|
113
|
+
touches: 1,
|
|
114
|
+
insertions: c.stats.insertions,
|
|
115
|
+
deletions: c.stats.deletions,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const arr = Array.from(byPath.values());
|
|
121
|
+
arr.sort((a, b) => {
|
|
122
|
+
if (b.touches !== a.touches)
|
|
123
|
+
return b.touches - a.touches;
|
|
124
|
+
return a.path.localeCompare(b.path);
|
|
125
|
+
});
|
|
126
|
+
return arr.slice(0, limit);
|
|
127
|
+
}
|
|
128
|
+
export function detectSessions(commits) {
|
|
129
|
+
const byAuthor = new Map();
|
|
130
|
+
for (const c of commits) {
|
|
131
|
+
const key = c.authorEmail || c.author;
|
|
132
|
+
const bucket = byAuthor.get(key);
|
|
133
|
+
if (bucket)
|
|
134
|
+
bucket.push(c);
|
|
135
|
+
else
|
|
136
|
+
byAuthor.set(key, [c]);
|
|
137
|
+
}
|
|
138
|
+
const sessions = [];
|
|
139
|
+
for (const [, list] of byAuthor) {
|
|
140
|
+
const sorted = [...list].sort((a, b) => a.epochSeconds - b.epochSeconds);
|
|
141
|
+
let cur;
|
|
142
|
+
for (const c of sorted) {
|
|
143
|
+
if (!cur) {
|
|
144
|
+
cur = { start: c, end: c, commits: 1 };
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const gapMs = (c.epochSeconds - cur.end.epochSeconds) * 1000;
|
|
148
|
+
if (gapMs > SESSION_GAP_MS) {
|
|
149
|
+
sessions.push(toSession(cur));
|
|
150
|
+
cur = { start: c, end: c, commits: 1 };
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
cur.end = c;
|
|
154
|
+
cur.commits += 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (cur)
|
|
158
|
+
sessions.push(toSession(cur));
|
|
159
|
+
}
|
|
160
|
+
sessions.sort((a, b) => a.start.localeCompare(b.start));
|
|
161
|
+
return sessions;
|
|
162
|
+
}
|
|
163
|
+
function toSession(s) {
|
|
164
|
+
const durationMinutes = Math.max(0, Math.round((s.end.epochSeconds - s.start.epochSeconds) / 60));
|
|
165
|
+
return {
|
|
166
|
+
start: s.start.timestamp,
|
|
167
|
+
end: s.end.timestamp,
|
|
168
|
+
commits: s.commits,
|
|
169
|
+
durationMinutes,
|
|
170
|
+
author: s.start.author,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
export function classifySessions(sessions) {
|
|
174
|
+
let deep = 0;
|
|
175
|
+
let medium = 0;
|
|
176
|
+
let micro = 0;
|
|
177
|
+
let totalMinutes = 0;
|
|
178
|
+
for (const s of sessions) {
|
|
179
|
+
totalMinutes += s.durationMinutes;
|
|
180
|
+
if (s.durationMinutes >= 50)
|
|
181
|
+
deep += 1;
|
|
182
|
+
else if (s.durationMinutes >= 20)
|
|
183
|
+
medium += 1;
|
|
184
|
+
else
|
|
185
|
+
micro += 1;
|
|
186
|
+
}
|
|
187
|
+
const averageMinutes = sessions.length === 0 ? 0 : Math.round(totalMinutes / sessions.length);
|
|
188
|
+
return { deep, medium, micro, totalMinutes, averageMinutes };
|
|
189
|
+
}
|
|
190
|
+
export function hourlyHistogram(commits) {
|
|
191
|
+
const buckets = new Array(24).fill(0);
|
|
192
|
+
for (const c of commits) {
|
|
193
|
+
const d = new Date(c.epochSeconds * 1000);
|
|
194
|
+
const hour = d.getHours();
|
|
195
|
+
const bucket = buckets[hour] ?? 0;
|
|
196
|
+
buckets[hour] = bucket + 1;
|
|
197
|
+
}
|
|
198
|
+
return buckets;
|
|
199
|
+
}
|
|
200
|
+
export function countActiveDays(commits) {
|
|
201
|
+
const days = new Set();
|
|
202
|
+
for (const c of commits) {
|
|
203
|
+
const d = new Date(c.epochSeconds * 1000);
|
|
204
|
+
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
|
205
|
+
days.add(key);
|
|
206
|
+
}
|
|
207
|
+
return days.size;
|
|
208
|
+
}
|
|
209
|
+
export function shipOfTheWeek(commits) {
|
|
210
|
+
let best;
|
|
211
|
+
for (const c of commits) {
|
|
212
|
+
const net = c.stats.insertions + c.stats.deletions;
|
|
213
|
+
if (!best || net > best.net) {
|
|
214
|
+
best = {
|
|
215
|
+
sha: c.sha,
|
|
216
|
+
subject: c.subject,
|
|
217
|
+
author: c.author,
|
|
218
|
+
insertions: c.stats.insertions,
|
|
219
|
+
deletions: c.stats.deletions,
|
|
220
|
+
net,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return best;
|
|
225
|
+
}
|
|
226
|
+
export function computeFocusScore(commits) {
|
|
227
|
+
if (commits.length === 0)
|
|
228
|
+
return { topDir: undefined, score: 0 };
|
|
229
|
+
const byDir = new Map();
|
|
230
|
+
for (const c of commits) {
|
|
231
|
+
const dirsTouched = new Set();
|
|
232
|
+
for (const f of c.files)
|
|
233
|
+
dirsTouched.add(topLevelDir(f));
|
|
234
|
+
for (const dir of dirsTouched) {
|
|
235
|
+
byDir.set(dir, (byDir.get(dir) ?? 0) + 1);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
let topDir;
|
|
239
|
+
let topCount = 0;
|
|
240
|
+
for (const [dir, count] of byDir) {
|
|
241
|
+
if (count > topCount) {
|
|
242
|
+
topDir = dir;
|
|
243
|
+
topCount = count;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const score = Math.round((topCount / commits.length) * 100);
|
|
247
|
+
return { topDir, score };
|
|
248
|
+
}
|
|
249
|
+
/** Per-author + per-team consecutive-day streak ending today.
|
|
250
|
+
*
|
|
251
|
+
* The streak is counted backwards from `now` (the window upper bound).
|
|
252
|
+
* If neither the user nor the team shipped today, the streak for that
|
|
253
|
+
* scope is 0 (today is not yet a contribution day). The function is
|
|
254
|
+
* intentionally lenient about timezone — it bucket-keys by the local
|
|
255
|
+
* day boundary so a commit at 23:59 today is the same calendar day as
|
|
256
|
+
* any other commit today.
|
|
257
|
+
*/
|
|
258
|
+
export function computeStreaks(commits, now, currentUserName, currentUserEmail) {
|
|
259
|
+
const personalDays = new Set();
|
|
260
|
+
const teamDays = new Set();
|
|
261
|
+
for (const c of commits) {
|
|
262
|
+
const d = new Date(c.epochSeconds * 1000);
|
|
263
|
+
const key = dayKey(d);
|
|
264
|
+
teamDays.add(key);
|
|
265
|
+
if (matchesUser(c, currentUserName, currentUserEmail)) {
|
|
266
|
+
personalDays.add(key);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
personalDays: walkStreak(personalDays, now),
|
|
271
|
+
teamDays: walkStreak(teamDays, now),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
function dayKey(d) {
|
|
275
|
+
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
|
|
276
|
+
}
|
|
277
|
+
function walkStreak(days, now) {
|
|
278
|
+
let cursor = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
279
|
+
let streak = 0;
|
|
280
|
+
// Guard: we only walk inside the bounded window — the metric is
|
|
281
|
+
// never larger than the window length itself.
|
|
282
|
+
for (let i = 0; i < 366; i += 1) {
|
|
283
|
+
const key = dayKey(cursor);
|
|
284
|
+
if (days.has(key)) {
|
|
285
|
+
streak += 1;
|
|
286
|
+
cursor = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() - 1);
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return streak;
|
|
293
|
+
}
|
|
294
|
+
export function computeMetrics(args) {
|
|
295
|
+
const { window, commits, currentUserName, currentUserEmail } = args;
|
|
296
|
+
const insertions = commits.reduce((acc, c) => acc + c.stats.insertions, 0);
|
|
297
|
+
const deletions = commits.reduce((acc, c) => acc + c.stats.deletions, 0);
|
|
298
|
+
const testInsertions = commits.reduce((acc, c) => acc + c.stats.testInsertions, 0);
|
|
299
|
+
const testDeletions = commits.reduce((acc, c) => acc + c.stats.testDeletions, 0);
|
|
300
|
+
const totalLoc = insertions + deletions;
|
|
301
|
+
const totalTestLoc = testInsertions + testDeletions;
|
|
302
|
+
const testRatio = totalLoc === 0 ? 0 : Math.min(1, totalTestLoc / totalLoc);
|
|
303
|
+
const commitTypes = emptyCommitTypeTable();
|
|
304
|
+
for (const c of commits) {
|
|
305
|
+
const tag = classifyCommitType(c.subject, c.files);
|
|
306
|
+
commitTypes[tag] += 1;
|
|
307
|
+
}
|
|
308
|
+
const sessions = detectSessions(commits);
|
|
309
|
+
const sessionStats = classifySessions(sessions);
|
|
310
|
+
const focus = computeFocusScore(commits);
|
|
311
|
+
const streak = computeStreaks(commits, window.until, currentUserName, currentUserEmail);
|
|
312
|
+
return {
|
|
313
|
+
window: {
|
|
314
|
+
since: window.since.toISOString(),
|
|
315
|
+
until: window.until.toISOString(),
|
|
316
|
+
label: window.label,
|
|
317
|
+
days: window.days,
|
|
318
|
+
},
|
|
319
|
+
branch: { current: args.currentBranch, base: args.baseBranch },
|
|
320
|
+
commits: { total: commits.length, toBaseHead: args.toBaseHeadCount },
|
|
321
|
+
authors: aggregateAuthors(commits, currentUserName, currentUserEmail),
|
|
322
|
+
loc: { insertions, deletions, net: insertions - deletions },
|
|
323
|
+
testLoc: { insertions: testInsertions, deletions: testDeletions, ratio: testRatio },
|
|
324
|
+
activeDays: countActiveDays(commits),
|
|
325
|
+
sessions: {
|
|
326
|
+
total: sessions.length,
|
|
327
|
+
deep: sessionStats.deep,
|
|
328
|
+
medium: sessionStats.medium,
|
|
329
|
+
micro: sessionStats.micro,
|
|
330
|
+
totalMinutes: sessionStats.totalMinutes,
|
|
331
|
+
averageMinutes: sessionStats.averageMinutes,
|
|
332
|
+
detected: sessions,
|
|
333
|
+
},
|
|
334
|
+
hourly: hourlyHistogram(commits),
|
|
335
|
+
commitTypes,
|
|
336
|
+
hotspots: hotspots(commits, 10),
|
|
337
|
+
focus,
|
|
338
|
+
shipOfTheWeek: shipOfTheWeek(commits),
|
|
339
|
+
streak,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
//# sourceMappingURL=metrics.js.map
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown narrative + JSON snapshot writer for `pugi retro`.
|
|
3
|
+
*
|
|
4
|
+
* The markdown body is the operator-facing artifact (committed to
|
|
5
|
+
* `.pugi/retros/` and optionally posted to Plane). The JSON snapshot is
|
|
6
|
+
* the machine-readable mirror for downstream tooling (e.g. a future
|
|
7
|
+
* trend dashboard). Both are written atomically (tmp + rename) so a
|
|
8
|
+
* concurrent reader never sees a half-written file.
|
|
9
|
+
*
|
|
10
|
+
* Output convention:
|
|
11
|
+
* - One header per major section.
|
|
12
|
+
* - Numbers right-aligned in tables when meaningful.
|
|
13
|
+
* - Empty sections collapse to a single italicised hint instead of
|
|
14
|
+
* rendering an empty table.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, mkdirSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
const RETRO_DIR_REL = '.pugi/retros';
|
|
19
|
+
export function ensureRetroDir(root) {
|
|
20
|
+
const dir = join(root, RETRO_DIR_REL);
|
|
21
|
+
mkdirSync(dir, { recursive: true });
|
|
22
|
+
return dir;
|
|
23
|
+
}
|
|
24
|
+
/** Discover the next sequence number for `<date>-<seq>.md` filenames.
|
|
25
|
+
* Multiple retros on the same day get monotonically increasing seq
|
|
26
|
+
* numbers; the first one of a new day starts at 1.
|
|
27
|
+
*/
|
|
28
|
+
export function nextSequence(dir, dateLabel) {
|
|
29
|
+
if (!existsSync(dir))
|
|
30
|
+
return 1;
|
|
31
|
+
let max = 0;
|
|
32
|
+
const re = new RegExp(`^${escapeRegExp(dateLabel)}-(\\d+)\\.md$`);
|
|
33
|
+
for (const name of readdirSync(dir)) {
|
|
34
|
+
const m = re.exec(name);
|
|
35
|
+
if (!m)
|
|
36
|
+
continue;
|
|
37
|
+
const n = Number.parseInt(m[1] ?? '0', 10);
|
|
38
|
+
if (Number.isFinite(n) && n > max)
|
|
39
|
+
max = n;
|
|
40
|
+
}
|
|
41
|
+
return max + 1;
|
|
42
|
+
}
|
|
43
|
+
function escapeRegExp(s) {
|
|
44
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
45
|
+
}
|
|
46
|
+
export function localDateLabel(d) {
|
|
47
|
+
const pad = (n) => n.toString().padStart(2, '0');
|
|
48
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
49
|
+
}
|
|
50
|
+
export function planOutputPaths(root, now) {
|
|
51
|
+
const dir = ensureRetroDir(root);
|
|
52
|
+
const dateLabel = localDateLabel(now);
|
|
53
|
+
const sequence = nextSequence(dir, dateLabel);
|
|
54
|
+
return {
|
|
55
|
+
dir,
|
|
56
|
+
markdownPath: join(dir, `${dateLabel}-${sequence}.md`),
|
|
57
|
+
jsonPath: join(dir, `${dateLabel}-${sequence}.json`),
|
|
58
|
+
sequence,
|
|
59
|
+
dateLabel,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Atomic write — tmp file in the same directory, then rename so a
|
|
63
|
+
* watcher (or a concurrent `pugi retro` run) never sees a partial body.
|
|
64
|
+
*/
|
|
65
|
+
export function atomicWriteFile(path, body) {
|
|
66
|
+
const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
67
|
+
writeFileSync(tmpPath, body, 'utf8');
|
|
68
|
+
renameSync(tmpPath, path);
|
|
69
|
+
}
|
|
70
|
+
export function renderMarkdown(input) {
|
|
71
|
+
const { metrics, plane, compare, generatedAt, sequence } = input;
|
|
72
|
+
const lines = [];
|
|
73
|
+
lines.push(`# Retro ${localDateLabel(generatedAt)} (seq ${sequence})`);
|
|
74
|
+
lines.push('');
|
|
75
|
+
lines.push(`Window: ${metrics.window.label} (${metrics.window.days} day${metrics.window.days === 1 ? '' : 's'})`);
|
|
76
|
+
lines.push(`Branch: \`${metrics.branch.current}\` over \`${metrics.branch.base}\``);
|
|
77
|
+
lines.push(`Generated: ${generatedAt.toISOString()}`);
|
|
78
|
+
lines.push('');
|
|
79
|
+
lines.push('## Summary');
|
|
80
|
+
lines.push('');
|
|
81
|
+
lines.push('| Metric | Value |');
|
|
82
|
+
lines.push('| --- | ---: |');
|
|
83
|
+
lines.push(`| Commits | ${metrics.commits.total} |`);
|
|
84
|
+
lines.push(`| Commits ahead of \`${metrics.branch.base}\` | ${metrics.commits.toBaseHead} |`);
|
|
85
|
+
lines.push(`| Lines added | ${metrics.loc.insertions} |`);
|
|
86
|
+
lines.push(`| Lines removed | ${metrics.loc.deletions} |`);
|
|
87
|
+
lines.push(`| Net LOC | ${metrics.loc.net} |`);
|
|
88
|
+
lines.push(`| Test LOC ratio | ${(metrics.testLoc.ratio * 100).toFixed(1)}% |`);
|
|
89
|
+
lines.push(`| Active days | ${metrics.activeDays} |`);
|
|
90
|
+
lines.push(`| Sessions | ${metrics.sessions.total} (deep ${metrics.sessions.deep}, medium ${metrics.sessions.medium}, micro ${metrics.sessions.micro}) |`);
|
|
91
|
+
lines.push(`| Total session time | ${formatMinutes(metrics.sessions.totalMinutes)} |`);
|
|
92
|
+
lines.push(`| Focus score | ${metrics.focus.score}% on \`${metrics.focus.topDir ?? 'n/a'}\` |`);
|
|
93
|
+
lines.push(`| Personal streak | ${metrics.streak.personalDays} day${metrics.streak.personalDays === 1 ? '' : 's'} |`);
|
|
94
|
+
lines.push(`| Team streak | ${metrics.streak.teamDays} day${metrics.streak.teamDays === 1 ? '' : 's'} |`);
|
|
95
|
+
lines.push('');
|
|
96
|
+
if (compare) {
|
|
97
|
+
lines.push('## Compare vs prior window');
|
|
98
|
+
lines.push('');
|
|
99
|
+
lines.push('| Metric | Current | Prior | Delta |');
|
|
100
|
+
lines.push('| --- | ---: | ---: | ---: |');
|
|
101
|
+
const c = compare.current;
|
|
102
|
+
const p = compare.prior;
|
|
103
|
+
lines.push(`| Commits | ${c.commits.total} | ${p.commits.total} | ${signed(c.commits.total - p.commits.total)} |`);
|
|
104
|
+
lines.push(`| Net LOC | ${c.loc.net} | ${p.loc.net} | ${signed(c.loc.net - p.loc.net)} |`);
|
|
105
|
+
lines.push(`| Active days | ${c.activeDays} | ${p.activeDays} | ${signed(c.activeDays - p.activeDays)} |`);
|
|
106
|
+
lines.push(`| Sessions | ${c.sessions.total} | ${p.sessions.total} | ${signed(c.sessions.total - p.sessions.total)} |`);
|
|
107
|
+
lines.push(`| Test ratio | ${(c.testLoc.ratio * 100).toFixed(1)}% | ${(p.testLoc.ratio * 100).toFixed(1)}% | ${signed(Math.round((c.testLoc.ratio - p.testLoc.ratio) * 100))}pp |`);
|
|
108
|
+
lines.push('');
|
|
109
|
+
}
|
|
110
|
+
lines.push('## Authors');
|
|
111
|
+
lines.push('');
|
|
112
|
+
if (metrics.authors.length === 0) {
|
|
113
|
+
lines.push('_No commits in this window._');
|
|
114
|
+
lines.push('');
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
lines.push('| Author | Commits | + | - |');
|
|
118
|
+
lines.push('| --- | ---: | ---: | ---: |');
|
|
119
|
+
for (const a of metrics.authors) {
|
|
120
|
+
const label = a.isYou ? `You (${a.name})` : a.name;
|
|
121
|
+
lines.push(`| ${label} | ${a.commits} | ${a.insertions} | ${a.deletions} |`);
|
|
122
|
+
}
|
|
123
|
+
lines.push('');
|
|
124
|
+
}
|
|
125
|
+
lines.push('## Commit types');
|
|
126
|
+
lines.push('');
|
|
127
|
+
lines.push('| Type | Count |');
|
|
128
|
+
lines.push('| --- | ---: |');
|
|
129
|
+
for (const [type, count] of Object.entries(metrics.commitTypes)) {
|
|
130
|
+
lines.push(`| ${type} | ${count} |`);
|
|
131
|
+
}
|
|
132
|
+
lines.push('');
|
|
133
|
+
lines.push('## Hourly histogram (local time)');
|
|
134
|
+
lines.push('');
|
|
135
|
+
lines.push('```');
|
|
136
|
+
for (let h = 0; h < 24; h += 1) {
|
|
137
|
+
const count = metrics.hourly[h] ?? 0;
|
|
138
|
+
const bar = '#'.repeat(Math.min(40, count));
|
|
139
|
+
lines.push(`${h.toString().padStart(2, '0')} | ${bar} ${count}`);
|
|
140
|
+
}
|
|
141
|
+
lines.push('```');
|
|
142
|
+
lines.push('');
|
|
143
|
+
lines.push('## Hotspots (top 10 files)');
|
|
144
|
+
lines.push('');
|
|
145
|
+
if (metrics.hotspots.length === 0) {
|
|
146
|
+
lines.push('_No file activity in this window._');
|
|
147
|
+
lines.push('');
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
lines.push('| File | Touches |');
|
|
151
|
+
lines.push('| --- | ---: |');
|
|
152
|
+
for (const h of metrics.hotspots) {
|
|
153
|
+
lines.push(`| \`${h.path}\` | ${h.touches} |`);
|
|
154
|
+
}
|
|
155
|
+
lines.push('');
|
|
156
|
+
}
|
|
157
|
+
if (metrics.shipOfTheWeek) {
|
|
158
|
+
lines.push('## Ship of the week');
|
|
159
|
+
lines.push('');
|
|
160
|
+
lines.push(`\`${metrics.shipOfTheWeek.sha.slice(0, 8)}\` ${metrics.shipOfTheWeek.subject}`);
|
|
161
|
+
lines.push('');
|
|
162
|
+
lines.push(`Author: ${metrics.shipOfTheWeek.author} · +${metrics.shipOfTheWeek.insertions} -${metrics.shipOfTheWeek.deletions}`);
|
|
163
|
+
lines.push('');
|
|
164
|
+
}
|
|
165
|
+
if (plane) {
|
|
166
|
+
lines.push('## Plane');
|
|
167
|
+
lines.push('');
|
|
168
|
+
lines.push(`Closed: ${plane.closedIssues.length} · Created: ${plane.createdIssues.length} · Net: ${signed(plane.sprintVelocity.netDelta)}`);
|
|
169
|
+
lines.push('');
|
|
170
|
+
if (plane.activeCycle) {
|
|
171
|
+
lines.push(`Active cycle: \`${plane.activeCycle.name}\` (${plane.activeCycle.progressPercent}% complete)`);
|
|
172
|
+
lines.push('');
|
|
173
|
+
}
|
|
174
|
+
if (plane.oversizedModules.length > 0) {
|
|
175
|
+
lines.push('### Oversized modules');
|
|
176
|
+
lines.push('');
|
|
177
|
+
lines.push('Modules with more than 60 issues are flagged because the Plane web UI');
|
|
178
|
+
lines.push('starts to break above the 200-issue cap (see Pugi incident 2026-05-30).');
|
|
179
|
+
lines.push('');
|
|
180
|
+
for (const m of plane.oversizedModules) {
|
|
181
|
+
lines.push(`- \`${m.name}\` — ${m.issueCount} issues`);
|
|
182
|
+
}
|
|
183
|
+
lines.push('');
|
|
184
|
+
}
|
|
185
|
+
if (plane.prToIssueLinks.length > 0) {
|
|
186
|
+
lines.push('### PR ↔ issue links');
|
|
187
|
+
lines.push('');
|
|
188
|
+
for (const link of plane.prToIssueLinks) {
|
|
189
|
+
const issueLabel = link.planeIssue ? `${link.issueRef} — ${link.planeIssue.name}` : link.issueRef;
|
|
190
|
+
lines.push(`- \`${link.prSha.slice(0, 8)}\` ${link.prSubject} ↔ ${issueLabel}`);
|
|
191
|
+
}
|
|
192
|
+
lines.push('');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
lines.push('---');
|
|
196
|
+
lines.push('');
|
|
197
|
+
lines.push('_Generated by `pugi retro`._');
|
|
198
|
+
lines.push('');
|
|
199
|
+
return lines.join('\n');
|
|
200
|
+
}
|
|
201
|
+
function formatMinutes(total) {
|
|
202
|
+
if (total < 60)
|
|
203
|
+
return `${total}m`;
|
|
204
|
+
const h = Math.floor(total / 60);
|
|
205
|
+
const m = total % 60;
|
|
206
|
+
return m === 0 ? `${h}h` : `${h}h ${m}m`;
|
|
207
|
+
}
|
|
208
|
+
function signed(n) {
|
|
209
|
+
if (n > 0)
|
|
210
|
+
return `+${n}`;
|
|
211
|
+
return `${n}`;
|
|
212
|
+
}
|
|
213
|
+
export function snapshotFromMetrics(args) {
|
|
214
|
+
return {
|
|
215
|
+
schema: 1,
|
|
216
|
+
generatedAt: args.generatedAt.toISOString(),
|
|
217
|
+
sequence: args.sequence,
|
|
218
|
+
window: args.metrics.window,
|
|
219
|
+
metrics: args.metrics,
|
|
220
|
+
plane: args.plane,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
export function persistRetro(args) {
|
|
224
|
+
const paths = planOutputPaths(args.root, args.now);
|
|
225
|
+
const markdown = renderMarkdown({
|
|
226
|
+
metrics: args.metrics,
|
|
227
|
+
plane: args.plane,
|
|
228
|
+
compare: args.compare,
|
|
229
|
+
generatedAt: args.now,
|
|
230
|
+
sequence: paths.sequence,
|
|
231
|
+
});
|
|
232
|
+
const snapshot = snapshotFromMetrics({
|
|
233
|
+
metrics: args.metrics,
|
|
234
|
+
plane: args.plane,
|
|
235
|
+
generatedAt: args.now,
|
|
236
|
+
sequence: paths.sequence,
|
|
237
|
+
});
|
|
238
|
+
atomicWriteFile(paths.markdownPath, markdown);
|
|
239
|
+
atomicWriteFile(paths.jsonPath, `${JSON.stringify(snapshot, null, 2)}\n`);
|
|
240
|
+
return {
|
|
241
|
+
markdownPath: paths.markdownPath,
|
|
242
|
+
jsonPath: paths.jsonPath,
|
|
243
|
+
sequence: paths.sequence,
|
|
244
|
+
dateLabel: paths.dateLabel,
|
|
245
|
+
markdown,
|
|
246
|
+
snapshot,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
//# sourceMappingURL=narrative.js.map
|