peaks-cli 1.3.8 → 1.3.9
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/src/cli/commands/project-commands.js +58 -1
- package/dist/src/cli/commands/request-commands.js +93 -3
- package/dist/src/cli/commands/retrospective-commands.d.ts +3 -0
- package/dist/src/cli/commands/retrospective-commands.js +113 -0
- package/dist/src/cli/program.js +2 -0
- package/dist/src/services/memory/project-memory-service.d.ts +19 -0
- package/dist/src/services/memory/project-memory-service.js +33 -0
- package/dist/src/services/retrospective/migrate-from-md.d.ts +37 -0
- package/dist/src/services/retrospective/migrate-from-md.js +528 -0
- package/dist/src/services/retrospective/retrospective-index.d.ts +37 -0
- package/dist/src/services/retrospective/retrospective-index.js +110 -0
- package/dist/src/services/retrospective/retrospective-show.d.ts +40 -0
- package/dist/src/services/retrospective/retrospective-show.js +109 -0
- package/dist/src/shared/format-md-compact.d.ts +32 -0
- package/dist/src/shared/format-md-compact.js +297 -0
- package/dist/src/shared/stale-policy.d.ts +67 -0
- package/dist/src/shared/stale-policy.js +85 -0
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +1 -1
- package/skills/peaks-qa/SKILL.md +86 -515
- package/skills/peaks-qa/references/artifact-per-request.md +7 -79
- package/skills/peaks-qa/references/browser-validation-contracts.md +51 -0
- package/skills/peaks-qa/references/codegraph-regression-focus.md +5 -0
- package/skills/peaks-qa/references/external-capability-guidance.md +9 -0
- package/skills/peaks-qa/references/qa-compact-handoff.md +3 -0
- package/skills/peaks-qa/references/qa-context-governance.md +24 -0
- package/skills/peaks-qa/references/qa-fanout-contract.md +8 -0
- package/skills/peaks-qa/references/qa-gstack-integration.md +7 -0
- package/skills/peaks-qa/references/qa-local-artifacts.md +3 -0
- package/skills/peaks-qa/references/qa-matt-pocock-integration.md +9 -0
- package/skills/peaks-qa/references/qa-refactor-role.md +3 -0
- package/skills/peaks-qa/references/qa-runbook.md +74 -0
- package/skills/peaks-qa/references/qa-skill-presence.md +22 -0
- package/skills/peaks-qa/references/qa-standards-preflight.md +8 -0
- package/skills/peaks-qa/references/qa-sub-agent-dispatch.md +38 -0
- package/skills/peaks-qa/references/qa-transition-gates.md +79 -0
- package/skills/peaks-qa/references/requirement-boundary-recheck.md +9 -0
- package/skills/peaks-qa/references/test-case-generation.md +27 -0
- package/skills/peaks-qa/references/test-report-output.md +14 -0
- package/skills/peaks-rd/SKILL.md +85 -612
- package/skills/peaks-rd/references/artifact-and-standards-output.md +9 -0
- package/skills/peaks-rd/references/artifact-per-request.md +20 -0
- package/skills/peaks-rd/references/browser-self-test-contracts.md +29 -0
- package/skills/peaks-rd/references/codegraph-project-analysis.md +5 -0
- package/skills/peaks-rd/references/compact-handoff.md +3 -0
- package/skills/peaks-rd/references/external-references.md +11 -0
- package/skills/peaks-rd/references/frontend-project-generation.md +11 -0
- package/skills/peaks-rd/references/library-version-awareness.md +30 -0
- package/skills/peaks-rd/references/mandatory-perf-baseline.md +40 -0
- package/skills/peaks-rd/references/mandatory-tech-doc.md +18 -0
- package/skills/peaks-rd/references/matt-pocock-integration.md +11 -0
- package/skills/peaks-rd/references/mock-data-placement.md +40 -0
- package/skills/peaks-rd/references/parallel-review-fanout.md +81 -0
- package/skills/peaks-rd/references/rd-context-governance.md +36 -0
- package/skills/peaks-rd/references/rd-gstack-integration.md +16 -0
- package/skills/peaks-rd/references/rd-runbook.md +125 -0
- package/skills/peaks-rd/references/rd-standards-preflight.md +8 -0
- package/skills/peaks-rd/references/rd-sub-agent-dispatch.md +39 -0
- package/skills/peaks-rd/references/rd-transition-gates.md +1 -1
- package/skills/peaks-rd/references/skill-presence-and-title.md +22 -0
- package/skills/peaks-solo/SKILL.md +87 -595
- package/skills/peaks-solo/references/anchoring-and-session-info.md +25 -0
- package/skills/peaks-solo/references/boundaries.md +21 -0
- package/skills/peaks-solo/references/codegraph-orchestration.md +5 -0
- package/skills/peaks-solo/references/completion-handoff.md +16 -0
- package/skills/peaks-solo/references/context-governance.md +51 -0
- package/skills/peaks-solo/references/external-references.md +17 -0
- package/skills/peaks-solo/references/frontend-only-mode.md +14 -0
- package/skills/peaks-solo/references/gstack-integration.md +7 -0
- package/skills/peaks-solo/references/local-artifact-workspace.md +79 -0
- package/skills/peaks-solo/references/micro-cycle.md +68 -0
- package/skills/peaks-solo/references/mode-selection.md +21 -0
- package/skills/peaks-solo/references/openspec-workflow.md +43 -0
- package/skills/peaks-solo/references/project-memory-loading.md +17 -0
- package/skills/peaks-solo/references/quality-gate-cheatsheet.md +13 -0
- package/skills/peaks-solo/references/resume-detection.md +63 -0
- package/skills/peaks-solo/references/runbook.md +1 -1
- package/skills/peaks-solo/references/skill-presence-and-title.md +31 -0
- package/skills/peaks-solo/references/standards-preflight.md +23 -0
- package/skills/peaks-solo/references/sub-agent-dispatch.md +46 -0
- package/skills/peaks-solo/references/swarm-dispatch-contract.md +56 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* migrate-from-md — one-time migration from `.peaks/retrospective/<id>/*.md`
|
|
3
|
+
* per-workflow MD dirs to a single `.peaks/retrospective/index.json` plus a
|
|
4
|
+
* `.peaks/_archive/retrospective-2026-06-09-pre-r3.tar.gz` archive.
|
|
5
|
+
*
|
|
6
|
+
* Slice 023 (R3) G9. Idempotent: re-run is a no-op when `index.json` has
|
|
7
|
+
* 88 entries with matching `updatedAt`.
|
|
8
|
+
*
|
|
9
|
+
* The legacy MDs in this repo use a *bullet-list* metadata format (no YAML
|
|
10
|
+
* frontmatter). The fields we need are extracted from the leading bullet
|
|
11
|
+
* block: `session:`, `rid:`, `type:`, `sliceId:`, plus the first `# Title`
|
|
12
|
+
* heading.
|
|
13
|
+
*/
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { join, relative, resolve } from 'node:path';
|
|
17
|
+
import { loadRetrospectiveIndex } from './retrospective-index.js';
|
|
18
|
+
const ARCHIVE_RELATIVE_PATH = '.peaks/_archive/retrospective-2026-06-09-pre-r3.tar.gz';
|
|
19
|
+
export function migrateRetrospectiveFromMd(options) {
|
|
20
|
+
const projectRoot = resolve(options.projectRoot);
|
|
21
|
+
const sourceDir = join(projectRoot, '.peaks', 'retrospective');
|
|
22
|
+
const indexPath = join(sourceDir, 'index.json');
|
|
23
|
+
const apply = options.apply === true;
|
|
24
|
+
const includeFailed = options.includeFailed === true;
|
|
25
|
+
const expectedEntries = options.expectedEntries ?? 88;
|
|
26
|
+
if (!existsSync(sourceDir)) {
|
|
27
|
+
return {
|
|
28
|
+
apply,
|
|
29
|
+
projectRoot,
|
|
30
|
+
indexPath,
|
|
31
|
+
archivePath: null,
|
|
32
|
+
sourceDir,
|
|
33
|
+
totalLegacyDirs: 0,
|
|
34
|
+
totalLegacyMds: 0,
|
|
35
|
+
parsedEntries: 0,
|
|
36
|
+
failedEntries: [],
|
|
37
|
+
archiveVerified: false,
|
|
38
|
+
status: 'failed',
|
|
39
|
+
warnings: ['retrospective source dir does not exist; nothing to migrate']
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
// 1. Idempotency: re-run is a no-op when the existing index has the
|
|
43
|
+
// expected number of entries AND the on-disk MDs are gone.
|
|
44
|
+
// The `expectedEntries` cap is the *minimum* — when the on-disk tree
|
|
45
|
+
// is empty, the existing index is authoritative. We do NOT require
|
|
46
|
+
// a specific count, only that the tree is empty AND the index has
|
|
47
|
+
// at least `expectedEntries` entries.
|
|
48
|
+
const existing = loadRetrospectiveIndex(projectRoot);
|
|
49
|
+
const legacyDirs = listLegacyDirs(sourceDir);
|
|
50
|
+
const legacyMds = listLegacyMds(sourceDir);
|
|
51
|
+
if (existing.source === 'index.json' && existing.totalCount >= expectedEntries && legacyMds.length === 0 && legacyDirs.length === 0) {
|
|
52
|
+
return {
|
|
53
|
+
apply,
|
|
54
|
+
projectRoot,
|
|
55
|
+
indexPath,
|
|
56
|
+
archivePath: join(projectRoot, ARCHIVE_RELATIVE_PATH),
|
|
57
|
+
sourceDir,
|
|
58
|
+
totalLegacyDirs: legacyDirs.length,
|
|
59
|
+
totalLegacyMds: 0,
|
|
60
|
+
parsedEntries: existing.totalCount,
|
|
61
|
+
failedEntries: [],
|
|
62
|
+
archiveVerified: existsSync(join(projectRoot, ARCHIVE_RELATIVE_PATH)),
|
|
63
|
+
status: 'no-op',
|
|
64
|
+
warnings: []
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// 2. Build the entry list by walking the legacy dirs.
|
|
68
|
+
const entries = [];
|
|
69
|
+
const failed = [];
|
|
70
|
+
for (const dir of legacyDirs) {
|
|
71
|
+
for (const md of listMarkdownFilesInDir(dir)) {
|
|
72
|
+
const result = parseLegacyMd(md, projectRoot);
|
|
73
|
+
if (result.ok) {
|
|
74
|
+
entries.push(result.entry);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
failed.push({ id: result.id, reason: result.reason });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// 3. Decide outcome based on failures. Per R3: malformed MD → skip + warn
|
|
82
|
+
// + log to sidecar, do NOT archive until all parsed. We allow the
|
|
83
|
+
// partial build only when --include-failed is set (skips failures,
|
|
84
|
+
// uses whatever did parse) or when there are zero failures.
|
|
85
|
+
if (failed.length > 0 && !includeFailed) {
|
|
86
|
+
// No archive, no write — return the partial result for inspection.
|
|
87
|
+
return {
|
|
88
|
+
apply,
|
|
89
|
+
projectRoot,
|
|
90
|
+
indexPath,
|
|
91
|
+
archivePath: null,
|
|
92
|
+
sourceDir,
|
|
93
|
+
totalLegacyDirs: legacyDirs.length,
|
|
94
|
+
totalLegacyMds: legacyMds.length,
|
|
95
|
+
parsedEntries: entries.length,
|
|
96
|
+
failedEntries: failed,
|
|
97
|
+
archiveVerified: false,
|
|
98
|
+
status: 'partial',
|
|
99
|
+
warnings: failed.map((f) => `WARN: ${f.id} failed to parse — ${f.reason}`)
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// 4. Build the index. We sort by updatedAt desc for stable ordering.
|
|
103
|
+
const sorted = [...entries].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
104
|
+
const index = {
|
|
105
|
+
version: 1,
|
|
106
|
+
updatedAt: new Date().toISOString(),
|
|
107
|
+
entries: sorted
|
|
108
|
+
};
|
|
109
|
+
if (!apply) {
|
|
110
|
+
return {
|
|
111
|
+
apply,
|
|
112
|
+
projectRoot,
|
|
113
|
+
indexPath,
|
|
114
|
+
archivePath: null,
|
|
115
|
+
sourceDir,
|
|
116
|
+
totalLegacyDirs: legacyDirs.length,
|
|
117
|
+
totalLegacyMds: legacyMds.length,
|
|
118
|
+
parsedEntries: sorted.length,
|
|
119
|
+
failedEntries: failed,
|
|
120
|
+
archiveVerified: false,
|
|
121
|
+
status: 'partial',
|
|
122
|
+
warnings: ['dry-run; no files were written or archived']
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
// 5. Atomic write: write to indexPath + '.tmp' first, then rename.
|
|
126
|
+
mkdirSync(sourceDir, { recursive: true });
|
|
127
|
+
const tmpPath = indexPath + '.tmp';
|
|
128
|
+
writeFileSync(tmpPath, JSON.stringify(index, null, 2), 'utf8');
|
|
129
|
+
renameSync(tmpPath, indexPath);
|
|
130
|
+
// 6. Archive legacy dirs to .peaks/_archive/retrospective-2026-06-09-pre-r3.tar.gz.
|
|
131
|
+
const archivePath = join(projectRoot, ARCHIVE_RELATIVE_PATH);
|
|
132
|
+
mkdirSync(join(projectRoot, '.peaks', '_archive'), { recursive: true });
|
|
133
|
+
const archiveResult = runTar(sourceDir, archivePath);
|
|
134
|
+
if (archiveResult.exitCode !== 0) {
|
|
135
|
+
// Roll back: delete the index we just wrote.
|
|
136
|
+
try {
|
|
137
|
+
writeFileSync(indexPath, JSON.stringify({ version: 1, updatedAt: new Date().toISOString(), entries: [] }, null, 2));
|
|
138
|
+
}
|
|
139
|
+
catch { /* swallow */ }
|
|
140
|
+
return {
|
|
141
|
+
apply,
|
|
142
|
+
projectRoot,
|
|
143
|
+
indexPath,
|
|
144
|
+
archivePath: null,
|
|
145
|
+
sourceDir,
|
|
146
|
+
totalLegacyDirs: legacyDirs.length,
|
|
147
|
+
totalLegacyMds: legacyMds.length,
|
|
148
|
+
parsedEntries: sorted.length,
|
|
149
|
+
failedEntries: failed,
|
|
150
|
+
archiveVerified: false,
|
|
151
|
+
status: 'failed',
|
|
152
|
+
warnings: [`tar failed: ${archiveResult.stderr}`]
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
// 7. Verify archive: tar -tzf the file and ensure all 88 original entries
|
|
156
|
+
// (or whatever count we have) are listed. A missing archive entry
|
|
157
|
+
// means the migration is unsound — roll back.
|
|
158
|
+
const verification = verifyArchive(archivePath, legacyDirs);
|
|
159
|
+
if (!verification.ok) {
|
|
160
|
+
// Roll back.
|
|
161
|
+
try {
|
|
162
|
+
writeFileSync(indexPath, JSON.stringify({ version: 1, updatedAt: new Date().toISOString(), entries: [] }, null, 2));
|
|
163
|
+
}
|
|
164
|
+
catch { /* swallow */ }
|
|
165
|
+
return {
|
|
166
|
+
apply,
|
|
167
|
+
projectRoot,
|
|
168
|
+
indexPath,
|
|
169
|
+
archivePath: null,
|
|
170
|
+
sourceDir,
|
|
171
|
+
totalLegacyDirs: legacyDirs.length,
|
|
172
|
+
totalLegacyMds: legacyMds.length,
|
|
173
|
+
parsedEntries: sorted.length,
|
|
174
|
+
failedEntries: failed,
|
|
175
|
+
archiveVerified: false,
|
|
176
|
+
status: 'failed',
|
|
177
|
+
warnings: [`archive verification failed: ${verification.reason}`]
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
// 8. Delete the legacy dirs from the live tree. We only delete top-level
|
|
181
|
+
// dirs under .peaks/retrospective/ that are not `index.json` (or its
|
|
182
|
+
// tmp). index.json is the canonical live artifact; everything else
|
|
183
|
+
// is the legacy form.
|
|
184
|
+
deleteLegacyDirs(sourceDir, indexPath);
|
|
185
|
+
return {
|
|
186
|
+
apply,
|
|
187
|
+
projectRoot,
|
|
188
|
+
indexPath,
|
|
189
|
+
archivePath,
|
|
190
|
+
sourceDir,
|
|
191
|
+
totalLegacyDirs: legacyDirs.length,
|
|
192
|
+
totalLegacyMds: legacyMds.length,
|
|
193
|
+
parsedEntries: sorted.length,
|
|
194
|
+
failedEntries: failed,
|
|
195
|
+
archiveVerified: true,
|
|
196
|
+
status: 'applied',
|
|
197
|
+
warnings: []
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function listLegacyDirs(sourceDir) {
|
|
201
|
+
if (!existsSync(sourceDir))
|
|
202
|
+
return [];
|
|
203
|
+
const result = [];
|
|
204
|
+
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
|
|
205
|
+
if (!entry.isDirectory())
|
|
206
|
+
continue;
|
|
207
|
+
if (entry.name.startsWith('_'))
|
|
208
|
+
continue;
|
|
209
|
+
result.push(join(sourceDir, entry.name));
|
|
210
|
+
}
|
|
211
|
+
return result.sort();
|
|
212
|
+
}
|
|
213
|
+
function listLegacyMds(sourceDir) {
|
|
214
|
+
if (!existsSync(sourceDir))
|
|
215
|
+
return [];
|
|
216
|
+
const result = [];
|
|
217
|
+
for (const dir of listLegacyDirs(sourceDir)) {
|
|
218
|
+
for (const md of listMarkdownFilesInDir(dir)) {
|
|
219
|
+
result.push(md);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
function listMarkdownFilesInDir(dir) {
|
|
225
|
+
const result = [];
|
|
226
|
+
const stack = [dir];
|
|
227
|
+
while (stack.length > 0) {
|
|
228
|
+
const current = stack.pop();
|
|
229
|
+
if (current === undefined)
|
|
230
|
+
continue;
|
|
231
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
232
|
+
const full = join(current, entry.name);
|
|
233
|
+
if (entry.isDirectory()) {
|
|
234
|
+
stack.push(full);
|
|
235
|
+
}
|
|
236
|
+
else if (entry.isFile() && entry.name.endsWith('.md') && !entry.name.startsWith('_')) {
|
|
237
|
+
result.push(full);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return result.sort();
|
|
242
|
+
}
|
|
243
|
+
function parseLegacyMd(mdPath, projectRoot) {
|
|
244
|
+
let content;
|
|
245
|
+
try {
|
|
246
|
+
content = readFileSync(mdPath, 'utf8');
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
return { ok: false, id: relative(projectRoot, mdPath), reason: 'read-failed: ' + (error instanceof Error ? error.message : String(error)) };
|
|
250
|
+
}
|
|
251
|
+
const lines = content.split(/\r?\n/);
|
|
252
|
+
// First line: # Title
|
|
253
|
+
const titleLine = lines[0] ?? '';
|
|
254
|
+
const titleMatch = titleLine.match(/^#\s+(.*)$/);
|
|
255
|
+
if (titleMatch === null) {
|
|
256
|
+
return { ok: false, id: relative(projectRoot, mdPath), reason: 'missing # Title on first line' };
|
|
257
|
+
}
|
|
258
|
+
const title = titleMatch[1]?.trim() ?? '';
|
|
259
|
+
// Walk leading bullet list for metadata fields. Each line starting
|
|
260
|
+
// with `- key: value` contributes a field. Stop at the first non-bullet
|
|
261
|
+
// non-blank line.
|
|
262
|
+
const fields = new Map();
|
|
263
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
264
|
+
const line = lines[index] ?? '';
|
|
265
|
+
if (line.trim() === '')
|
|
266
|
+
continue;
|
|
267
|
+
const match = line.match(/^-\s*([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
|
|
268
|
+
if (match === null)
|
|
269
|
+
break;
|
|
270
|
+
fields.set(match[1] ?? '', (match[2] ?? '').trim());
|
|
271
|
+
}
|
|
272
|
+
// sessionId: prefer `- session:`; fall back to the parent dir name
|
|
273
|
+
// (e.g. `2026-06-02-grep-strip-meta` slices that don't have a
|
|
274
|
+
// leading bullet list still encode the slice / change id in the
|
|
275
|
+
// path). QA test-cases / test-reports in particular have no bullet
|
|
276
|
+
// header at all.
|
|
277
|
+
let sessionId = fields.get('session') ?? '';
|
|
278
|
+
if (sessionId.length === 0) {
|
|
279
|
+
// Walk up the path: `.peaks/retrospective/<id>/...` — the
|
|
280
|
+
// `<id>` segment is a stable identifier we can use as a fallback
|
|
281
|
+
// for the sessionId when the bullet header is missing.
|
|
282
|
+
const relPath = relative(projectRoot, mdPath).replaceAll('\\', '/');
|
|
283
|
+
const segments = relPath.split('/');
|
|
284
|
+
const retroIdx = segments.indexOf('retrospective');
|
|
285
|
+
if (retroIdx >= 0 && retroIdx + 1 < segments.length) {
|
|
286
|
+
sessionId = segments[retroIdx + 1] ?? '';
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (sessionId.length === 0) {
|
|
290
|
+
return { ok: false, id: relative(projectRoot, mdPath), reason: 'missing `- session:` in leading bullet list and no parent-dir fallback' };
|
|
291
|
+
}
|
|
292
|
+
const rawType = fields.get('type') ?? 'refactor';
|
|
293
|
+
// Some legacy MDs write `- type: feature (foundation A) — ...` with
|
|
294
|
+
// parenthetical commentary. Take the first word; default to `refactor`
|
|
295
|
+
// when nothing else matches.
|
|
296
|
+
const typeWord = rawType.split(/[\s(]/)[0] ?? 'refactor';
|
|
297
|
+
const type = typeWord;
|
|
298
|
+
const VALID_TYPES = ['refactor', 'feature', 'bugfix', 'config', 'docs', 'chore'];
|
|
299
|
+
if (!VALID_TYPES.includes(type)) {
|
|
300
|
+
return { ok: false, id: relative(projectRoot, mdPath), reason: `invalid type: ${typeWord}` };
|
|
301
|
+
}
|
|
302
|
+
const outcome = inferOutcome(content);
|
|
303
|
+
const keyDecisions = extractKeyDecisions(content);
|
|
304
|
+
const lessonsLearned = extractLessonsLearnedCount(content);
|
|
305
|
+
const summary = extractSummary(content, title);
|
|
306
|
+
const sliceId = fields.get('rid') ?? fields.get('sliceId');
|
|
307
|
+
const id = buildEntryId(mdPath, projectRoot, sliceId);
|
|
308
|
+
// updatedAt: prefer file mtime, fall back to a stable extraction.
|
|
309
|
+
const updatedAt = readMtimeIso(mdPath);
|
|
310
|
+
const artifactPaths = [relative(projectRoot, mdPath).replaceAll('\\', '/')];
|
|
311
|
+
const entry = {
|
|
312
|
+
id,
|
|
313
|
+
sessionId,
|
|
314
|
+
type,
|
|
315
|
+
title,
|
|
316
|
+
summary,
|
|
317
|
+
outcome,
|
|
318
|
+
keyDecisions,
|
|
319
|
+
lessonsLearned,
|
|
320
|
+
artifactPaths,
|
|
321
|
+
updatedAt
|
|
322
|
+
};
|
|
323
|
+
return { ok: true, entry };
|
|
324
|
+
}
|
|
325
|
+
function readMtimeIso(filePath) {
|
|
326
|
+
try {
|
|
327
|
+
return statSync(filePath).mtime.toISOString();
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return new Date(0).toISOString();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function inferOutcome(content) {
|
|
334
|
+
// Heuristic: scan first ~2000 chars for outcome signals. Defaults to
|
|
335
|
+
// `in-flight` when nothing is conclusive.
|
|
336
|
+
const head = content.slice(0, 4000).toLowerCase();
|
|
337
|
+
if (/\b(state\s*:\s*shipped|outcome\s*:\s*shipped|shipped|merged|## outcome\s*:\s*shipped)/u.test(head)) {
|
|
338
|
+
return 'shipped';
|
|
339
|
+
}
|
|
340
|
+
if (/\b(blocked|cancelled|canceled|abandoned)/u.test(head)) {
|
|
341
|
+
return 'blocked';
|
|
342
|
+
}
|
|
343
|
+
if (/\b(in[- ]flight|in[- ]progress|wip|ongoing)/u.test(head)) {
|
|
344
|
+
return 'in-flight';
|
|
345
|
+
}
|
|
346
|
+
return 'in-flight';
|
|
347
|
+
}
|
|
348
|
+
function extractKeyDecisions(content) {
|
|
349
|
+
// Look for a `## Key Decisions` or `## AD-` style block; pull the
|
|
350
|
+
// first `**Decision**:` line and similar patterns. Keep entries ≤ 120
|
|
351
|
+
// chars, 1 line each.
|
|
352
|
+
const lines = content.split('\n');
|
|
353
|
+
const decisions = [];
|
|
354
|
+
let inKeyDecisions = false;
|
|
355
|
+
for (const line of lines) {
|
|
356
|
+
if (/^##\s*key\s*decisions/iu.test(line)) {
|
|
357
|
+
inKeyDecisions = true;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (inKeyDecisions && /^##\s+/u.test(line)) {
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
if (!inKeyDecisions)
|
|
364
|
+
continue;
|
|
365
|
+
const decisionMatch = line.match(/^[-*]\s+\*\*decision\*\*\s*:?\s*(.+)$/iu);
|
|
366
|
+
if (decisionMatch) {
|
|
367
|
+
const text = (decisionMatch[1] ?? '').trim();
|
|
368
|
+
if (text.length > 0) {
|
|
369
|
+
decisions.push(truncate(text, 120));
|
|
370
|
+
}
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
const bullet = line.match(/^[-*]\s+(.+)$/u);
|
|
374
|
+
if (bullet) {
|
|
375
|
+
const text = (bullet[1] ?? '').trim();
|
|
376
|
+
// Skip bullets that are clearly not decisions (sub-headings, links).
|
|
377
|
+
if (text.length > 0 && !text.startsWith('[') && !text.startsWith('!')) {
|
|
378
|
+
decisions.push(truncate(text, 120));
|
|
379
|
+
if (decisions.length >= 6)
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return decisions.slice(0, 6);
|
|
385
|
+
}
|
|
386
|
+
function extractLessonsLearnedCount(content) {
|
|
387
|
+
// Count `## Lessons` block bullets; if the heading is absent, return 0.
|
|
388
|
+
const lines = content.split('\n');
|
|
389
|
+
let inLessons = false;
|
|
390
|
+
let count = 0;
|
|
391
|
+
for (const line of lines) {
|
|
392
|
+
if (/^##\s*lessons(\s+learned)?/iu.test(line)) {
|
|
393
|
+
inLessons = true;
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (inLessons && /^##\s+/u.test(line)) {
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
if (inLessons && /^[-*]\s+/.test(line)) {
|
|
400
|
+
count += 1;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return count;
|
|
404
|
+
}
|
|
405
|
+
function extractSummary(content, title) {
|
|
406
|
+
// The summary is the first paragraph after the leading metadata block
|
|
407
|
+
// (and after the first `## Goals` / `## Summary` heading). We prefer a
|
|
408
|
+
// paragraph directly under a `## Summary` heading; fall back to the
|
|
409
|
+
// first non-blank paragraph after the title block.
|
|
410
|
+
const lines = content.split('\n');
|
|
411
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
412
|
+
const line = lines[index] ?? '';
|
|
413
|
+
if (/^##\s*summary/iu.test(line)) {
|
|
414
|
+
let cursor = index + 1;
|
|
415
|
+
while (cursor < lines.length && (lines[cursor] ?? '').trim() === '')
|
|
416
|
+
cursor += 1;
|
|
417
|
+
const collected = [];
|
|
418
|
+
while (cursor < lines.length && (lines[cursor] ?? '').trim() !== '' && !/^##\s+/u.test(lines[cursor] ?? '')) {
|
|
419
|
+
collected.push((lines[cursor] ?? '').trim());
|
|
420
|
+
cursor += 1;
|
|
421
|
+
}
|
|
422
|
+
const text = collected.join(' ').trim();
|
|
423
|
+
if (text.length > 0)
|
|
424
|
+
return truncate(text, 280);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
// Fall back: first paragraph under `## Goals` (most PRDs / RDs have one).
|
|
428
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
429
|
+
const line = lines[index] ?? '';
|
|
430
|
+
if (/^##\s*goals/iu.test(line)) {
|
|
431
|
+
let cursor = index + 1;
|
|
432
|
+
while (cursor < lines.length && (lines[cursor] ?? '').trim() === '')
|
|
433
|
+
cursor += 1;
|
|
434
|
+
const collected = [];
|
|
435
|
+
while (cursor < lines.length && (lines[cursor] ?? '').trim() !== '' && !/^##\s+/u.test(lines[cursor] ?? '')) {
|
|
436
|
+
collected.push((lines[cursor] ?? '').trim());
|
|
437
|
+
cursor += 1;
|
|
438
|
+
}
|
|
439
|
+
const text = collected.join(' ').trim();
|
|
440
|
+
if (text.length > 0)
|
|
441
|
+
return truncate(text, 280);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return truncate(title, 280);
|
|
445
|
+
}
|
|
446
|
+
function truncate(value, max) {
|
|
447
|
+
if (value.length <= max)
|
|
448
|
+
return value;
|
|
449
|
+
return value.slice(0, max - 1) + '…';
|
|
450
|
+
}
|
|
451
|
+
function buildEntryId(mdPath, projectRoot, sliceId) {
|
|
452
|
+
if (sliceId !== undefined && sliceId.length > 0)
|
|
453
|
+
return sliceId;
|
|
454
|
+
// Fall back to the parent dir name relative to the retrospective root.
|
|
455
|
+
// The path is `<projectRoot>/.peaks/retrospective/<id>/.../<file>.md`,
|
|
456
|
+
// so the dir at depth `.peaks/retrospective/<id>` is the slice id.
|
|
457
|
+
const rel = relative(projectRoot, mdPath).replaceAll('\\', '/');
|
|
458
|
+
const segments = rel.split('/');
|
|
459
|
+
// We expect the pattern `.peaks/retrospective/<id>/<rest>`; the
|
|
460
|
+
// `<id>` segment is 3 slots from the end of the path's dir prefix.
|
|
461
|
+
// Simpler: walk segments, find the index right after `retrospective`.
|
|
462
|
+
const retroIdx = segments.indexOf('retrospective');
|
|
463
|
+
if (retroIdx >= 0 && retroIdx + 1 < segments.length - 1) {
|
|
464
|
+
return segments[retroIdx + 1] ?? 'unknown';
|
|
465
|
+
}
|
|
466
|
+
// Last-resort fallback: file stem.
|
|
467
|
+
const last = segments[segments.length - 1] ?? 'unknown';
|
|
468
|
+
return last.replace(/\.md$/, '');
|
|
469
|
+
}
|
|
470
|
+
function runTar(sourceDir, archivePath) {
|
|
471
|
+
// Use the system `tar` to roll up everything in `sourceDir` except
|
|
472
|
+
// `index.json` and `index.json.tmp`. We do this with `--exclude` rather
|
|
473
|
+
// than walking the dir ourselves because tar's snapshot is the same
|
|
474
|
+
// on every platform (Windows ships tar.exe in System32).
|
|
475
|
+
//
|
|
476
|
+
// Windows tar (System32 tar.exe) chokes on absolute paths that contain
|
|
477
|
+
// a `:` drive letter ("Cannot connect to C:"). We work around this by
|
|
478
|
+
// running `tar` with `sourceDir` as the cwd, using `.` as the input,
|
|
479
|
+
// and computing the archive path as a *relative* path that the
|
|
480
|
+
// `spawnSync` cwd resolves.
|
|
481
|
+
const relativeArchive = relative(sourceDir, archivePath);
|
|
482
|
+
const result = spawnSync('tar', [
|
|
483
|
+
'-czf', relativeArchive,
|
|
484
|
+
'--exclude=index.json',
|
|
485
|
+
'--exclude=index.json.tmp',
|
|
486
|
+
'--exclude=index.json.bak',
|
|
487
|
+
'.'
|
|
488
|
+
], { encoding: 'utf8', cwd: sourceDir });
|
|
489
|
+
return {
|
|
490
|
+
exitCode: result.status ?? -1,
|
|
491
|
+
stdout: result.stdout ?? '',
|
|
492
|
+
stderr: result.stderr ?? ''
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
function verifyArchive(archivePath, legacyDirs) {
|
|
496
|
+
if (!existsSync(archivePath))
|
|
497
|
+
return { ok: false, reason: 'archive file missing' };
|
|
498
|
+
// Use cwd-relative path for tar -tzf to avoid the System32 tar.exe
|
|
499
|
+
// "Cannot connect to C:" failure mode.
|
|
500
|
+
const relativeArchive = relative(process.cwd(), archivePath);
|
|
501
|
+
const result = spawnSync('tar', ['-tzf', relativeArchive], { encoding: 'utf8', cwd: process.cwd() });
|
|
502
|
+
if (result.status !== 0)
|
|
503
|
+
return { ok: false, reason: 'tar -tzf failed: ' + (result.stderr ?? '') };
|
|
504
|
+
const listing = (result.stdout ?? '').split('\n');
|
|
505
|
+
for (const dir of legacyDirs) {
|
|
506
|
+
const dirName = dir.split(/[\\/]/).pop() ?? '';
|
|
507
|
+
const found = listing.some((line) => line.includes(dirName + '/') || line.endsWith('/' + dirName));
|
|
508
|
+
if (!found) {
|
|
509
|
+
return { ok: false, reason: `archive is missing legacy dir: ${dirName}` };
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return { ok: true };
|
|
513
|
+
}
|
|
514
|
+
function deleteLegacyDirs(sourceDir, indexPath) {
|
|
515
|
+
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
|
|
516
|
+
const full = join(sourceDir, entry.name);
|
|
517
|
+
if (entry.isDirectory() && !entry.name.startsWith('_')) {
|
|
518
|
+
try {
|
|
519
|
+
rmSync(full, { recursive: true, force: true });
|
|
520
|
+
}
|
|
521
|
+
catch { /* swallow; archive holds the original */ }
|
|
522
|
+
}
|
|
523
|
+
else if (entry.isFile() && full !== indexPath && !entry.name.endsWith('.tmp')) {
|
|
524
|
+
// No-op: legacy MDs are nested inside legacy dirs.
|
|
525
|
+
void indexPath;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* retrospective-index — load `.peaks/retrospective/index.json`, parse to
|
|
3
|
+
* `RetrospectiveEntry[]`, return the index envelope.
|
|
4
|
+
*
|
|
5
|
+
* Slice 023 (R3). Pure read on the hot path: a single `fs.readFile` of
|
|
6
|
+
* the index, no MD-tree fallback. The migration script (G9) is the only
|
|
7
|
+
* writer; this loader is read-only.
|
|
8
|
+
*/
|
|
9
|
+
export type RetrospectiveType = 'refactor' | 'feature' | 'bugfix' | 'config' | 'docs' | 'chore';
|
|
10
|
+
export type RetrospectiveOutcome = 'shipped' | 'blocked' | 'in-flight' | 'cancelled';
|
|
11
|
+
export interface RetrospectiveEntry {
|
|
12
|
+
id: string;
|
|
13
|
+
sessionId: string;
|
|
14
|
+
sliceId?: string;
|
|
15
|
+
type: RetrospectiveType;
|
|
16
|
+
title: string;
|
|
17
|
+
summary: string;
|
|
18
|
+
outcome: RetrospectiveOutcome;
|
|
19
|
+
keyDecisions: string[];
|
|
20
|
+
lessonsLearned: number;
|
|
21
|
+
artifactPaths: string[];
|
|
22
|
+
updatedAt: string;
|
|
23
|
+
}
|
|
24
|
+
export interface RetrospectiveIndex {
|
|
25
|
+
version: 1;
|
|
26
|
+
updatedAt: string;
|
|
27
|
+
entries: RetrospectiveEntry[];
|
|
28
|
+
}
|
|
29
|
+
export interface RetrospectiveIndexResult {
|
|
30
|
+
projectRoot: string;
|
|
31
|
+
indexPath: string;
|
|
32
|
+
entries: RetrospectiveEntry[];
|
|
33
|
+
totalCount: number;
|
|
34
|
+
source: 'index.json' | null;
|
|
35
|
+
warning: string | null;
|
|
36
|
+
}
|
|
37
|
+
export declare function loadRetrospectiveIndex(projectRoot: string): RetrospectiveIndexResult;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* retrospective-index — load `.peaks/retrospective/index.json`, parse to
|
|
3
|
+
* `RetrospectiveEntry[]`, return the index envelope.
|
|
4
|
+
*
|
|
5
|
+
* Slice 023 (R3). Pure read on the hot path: a single `fs.readFile` of
|
|
6
|
+
* the index, no MD-tree fallback. The migration script (G9) is the only
|
|
7
|
+
* writer; this loader is read-only.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { join, resolve } from 'node:path';
|
|
11
|
+
const VALID_TYPES = new Set(['refactor', 'feature', 'bugfix', 'config', 'docs', 'chore']);
|
|
12
|
+
const VALID_OUTCOMES = new Set(['shipped', 'blocked', 'in-flight', 'cancelled']);
|
|
13
|
+
export function loadRetrospectiveIndex(projectRoot) {
|
|
14
|
+
const resolvedRoot = resolve(projectRoot);
|
|
15
|
+
const indexPath = join(resolvedRoot, '.peaks', 'retrospective', 'index.json');
|
|
16
|
+
if (!existsSync(indexPath)) {
|
|
17
|
+
return {
|
|
18
|
+
projectRoot: resolvedRoot,
|
|
19
|
+
indexPath,
|
|
20
|
+
entries: [],
|
|
21
|
+
totalCount: 0,
|
|
22
|
+
source: null,
|
|
23
|
+
warning: 'no retrospective index; run `peaks retrospective migrate --apply` to build one from legacy MDs'
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
let raw;
|
|
27
|
+
try {
|
|
28
|
+
raw = readFileSync(indexPath, 'utf8');
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return {
|
|
32
|
+
projectRoot: resolvedRoot,
|
|
33
|
+
indexPath,
|
|
34
|
+
entries: [],
|
|
35
|
+
totalCount: 0,
|
|
36
|
+
source: null,
|
|
37
|
+
warning: `failed to read retrospective index at ${indexPath}`
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(raw);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return {
|
|
46
|
+
projectRoot: resolvedRoot,
|
|
47
|
+
indexPath,
|
|
48
|
+
entries: [],
|
|
49
|
+
totalCount: 0,
|
|
50
|
+
source: null,
|
|
51
|
+
warning: `retrospective index at ${indexPath} is not valid JSON`
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const entries = extractEntries(parsed);
|
|
55
|
+
const sorted = [...entries].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
56
|
+
return {
|
|
57
|
+
projectRoot: resolvedRoot,
|
|
58
|
+
indexPath,
|
|
59
|
+
entries: sorted,
|
|
60
|
+
totalCount: sorted.length,
|
|
61
|
+
source: 'index.json',
|
|
62
|
+
warning: null
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function extractEntries(parsed) {
|
|
66
|
+
if (parsed === null || typeof parsed !== 'object')
|
|
67
|
+
return [];
|
|
68
|
+
const obj = parsed;
|
|
69
|
+
if (!Array.isArray(obj.entries))
|
|
70
|
+
return [];
|
|
71
|
+
const result = [];
|
|
72
|
+
for (const candidate of obj.entries) {
|
|
73
|
+
if (!isRetrospectiveEntry(candidate))
|
|
74
|
+
continue;
|
|
75
|
+
result.push(candidate);
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
function isRetrospectiveEntry(value) {
|
|
80
|
+
if (value === null || typeof value !== 'object')
|
|
81
|
+
return false;
|
|
82
|
+
const v = value;
|
|
83
|
+
if (typeof v.id !== 'string' || v.id.length === 0)
|
|
84
|
+
return false;
|
|
85
|
+
if (typeof v.sessionId !== 'string')
|
|
86
|
+
return false;
|
|
87
|
+
if (typeof v.type !== 'string' || !VALID_TYPES.has(v.type))
|
|
88
|
+
return false;
|
|
89
|
+
if (typeof v.title !== 'string')
|
|
90
|
+
return false;
|
|
91
|
+
if (typeof v.summary !== 'string')
|
|
92
|
+
return false;
|
|
93
|
+
if (typeof v.outcome !== 'string' || !VALID_OUTCOMES.has(v.outcome))
|
|
94
|
+
return false;
|
|
95
|
+
if (!Array.isArray(v.keyDecisions))
|
|
96
|
+
return false;
|
|
97
|
+
if (!v.keyDecisions.every((decision) => typeof decision === 'string'))
|
|
98
|
+
return false;
|
|
99
|
+
if (typeof v.lessonsLearned !== 'number' || !Number.isInteger(v.lessonsLearned) || v.lessonsLearned < 0)
|
|
100
|
+
return false;
|
|
101
|
+
if (!Array.isArray(v.artifactPaths))
|
|
102
|
+
return false;
|
|
103
|
+
if (!v.artifactPaths.every((p) => typeof p === 'string'))
|
|
104
|
+
return false;
|
|
105
|
+
if (typeof v.updatedAt !== 'string')
|
|
106
|
+
return false;
|
|
107
|
+
if (v.sliceId !== undefined && typeof v.sliceId !== 'string')
|
|
108
|
+
return false;
|
|
109
|
+
return true;
|
|
110
|
+
}
|