peaks-cli 1.2.8 → 1.3.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 +12 -0
- package/dist/src/cli/commands/project-commands.js +1 -1
- package/dist/src/cli/commands/scan-commands.js +22 -0
- package/dist/src/cli/commands/workspace-commands.js +59 -1
- package/dist/src/services/memory/project-memory-service.d.ts +1 -1
- package/dist/src/services/memory/project-memory-service.js +52 -23
- package/dist/src/services/sc/sc-service.d.ts +52 -1
- package/dist/src/services/sc/sc-service.js +266 -17
- package/dist/src/services/scan/libraries-service.d.ts +24 -0
- package/dist/src/services/scan/libraries-service.js +419 -0
- package/dist/src/services/scan/libraries-types.d.ts +59 -0
- package/dist/src/services/scan/libraries-types.js +9 -0
- package/dist/src/services/session/session-manager.d.ts +7 -5
- package/dist/src/services/session/session-manager.js +48 -14
- package/dist/src/services/skills/skill-presence-service.js +102 -68
- package/dist/src/services/skills/skill-runbook-service.js +36 -2
- package/dist/src/services/skills/skill-statusline-service.js +13 -7
- package/dist/src/services/workflow/autonomous-resume-writer.js +7 -7
- package/dist/src/services/workspace/reconcile-service.d.ts +119 -0
- package/dist/src/services/workspace/reconcile-service.js +464 -0
- package/dist/src/services/workspace/reconcile-types.d.ts +93 -0
- package/dist/src/services/workspace/reconcile-types.js +13 -0
- package/dist/src/shared/change-id.d.ts +30 -0
- package/dist/src/shared/change-id.js +40 -6
- package/dist/src/shared/paths.d.ts +1 -1
- package/dist/src/shared/paths.js +2 -1
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +4 -1
- package/schemas/library-breaking-changes.data.json +141 -0
- package/schemas/library-breaking-changes.meta.json +6 -0
- package/schemas/library-breaking-changes.schema.json +50 -0
- package/skills/peaks-qa/SKILL.md +12 -0
- package/skills/peaks-rd/SKILL.md +145 -2
- package/skills/peaks-solo/SKILL.md +93 -319
- package/skills/peaks-solo/references/runbook.md +168 -0
- package/skills/peaks-solo/references/workflow-gates-and-types.md +177 -0
- package/skills/peaks-solo-resume/SKILL.md +81 -0
- package/skills/peaks-solo-status/SKILL.md +120 -0
- package/skills/peaks-solo-test/SKILL.md +84 -0
- package/skills/peaks-txt/SKILL.md +8 -5
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { join, sep } from 'node:path';
|
|
3
|
+
import { isDirectory, pathExists, readText } from '../../shared/fs.js';
|
|
4
|
+
const SCOPES = [
|
|
5
|
+
'dependencies',
|
|
6
|
+
'devDependencies',
|
|
7
|
+
'peerDependencies',
|
|
8
|
+
'optionalDependencies'
|
|
9
|
+
];
|
|
10
|
+
const SCOPE_ORDER = {
|
|
11
|
+
dependencies: 0,
|
|
12
|
+
devDependencies: 1,
|
|
13
|
+
peerDependencies: 2,
|
|
14
|
+
optionalDependencies: 3
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Parse the major version from a semver-ish spec.
|
|
18
|
+
*
|
|
19
|
+
* Handles the common shapes:
|
|
20
|
+
* "^5.18.0" → 5
|
|
21
|
+
* "~1.2.3" → 1
|
|
22
|
+
* "1.2.3" → 1
|
|
23
|
+
* ">=1.0.0" → 1
|
|
24
|
+
* "5" → 5
|
|
25
|
+
* "5.x" → 5
|
|
26
|
+
*
|
|
27
|
+
* Returns null for non-semver specs that the LLM should not assume a
|
|
28
|
+
* major for:
|
|
29
|
+
* "workspace:*" → null
|
|
30
|
+
* "file:../..." → null
|
|
31
|
+
* "git+https..." → null
|
|
32
|
+
* "npm:@scope/x@1" → 1 (alias spec, we extract what we can)
|
|
33
|
+
*/
|
|
34
|
+
export function parseMajorVersion(spec) {
|
|
35
|
+
// Strip npm alias prefix "npm:" and take whatever comes after the LAST "@"
|
|
36
|
+
// (since alias specs are "npm:<name>@<version>")
|
|
37
|
+
let versionPart = spec;
|
|
38
|
+
if (versionPart.startsWith('npm:')) {
|
|
39
|
+
const atIdx = versionPart.lastIndexOf('@');
|
|
40
|
+
versionPart = atIdx >= 0 ? versionPart.slice(atIdx + 1) : versionPart.slice(4);
|
|
41
|
+
}
|
|
42
|
+
// Strip semver range operators (caret, tilde, comparison ops, exact)
|
|
43
|
+
versionPart = versionPart.replace(/^[\^~>=<]+\s*/, '').trim();
|
|
44
|
+
// Take the first numeric component
|
|
45
|
+
const match = /^(\d+)/.exec(versionPart);
|
|
46
|
+
if (!match)
|
|
47
|
+
return null;
|
|
48
|
+
const n = Number(match[1]);
|
|
49
|
+
return Number.isSafeInteger(n) ? n : null;
|
|
50
|
+
}
|
|
51
|
+
async function readPackageJson(packageJsonPath) {
|
|
52
|
+
if (!(await pathExists(packageJsonPath))) {
|
|
53
|
+
return { exists: false, record: null };
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const raw = await readText(packageJsonPath);
|
|
57
|
+
const record = JSON.parse(raw);
|
|
58
|
+
return { exists: true, record };
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
return { exists: true, record: null, error: error.message };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Hand-rolled YAML parser for `pnpm-workspace.yaml`.
|
|
66
|
+
*
|
|
67
|
+
* Only consumes the top-level `packages:` list. Ignores all other top-level
|
|
68
|
+
* keys (`allowBuilds:`, `catalog:`, etc.) and indented blocks. Returns
|
|
69
|
+
* `null` when the file is present but does not contain a `packages:`
|
|
70
|
+
* list — callers should fall through to the next detection source.
|
|
71
|
+
*
|
|
72
|
+
* Supports the common shapes:
|
|
73
|
+
* packages:
|
|
74
|
+
* - 'packages/*'
|
|
75
|
+
* - "apps/web"
|
|
76
|
+
* - tools
|
|
77
|
+
* - packages/hermes-agent/*
|
|
78
|
+
*/
|
|
79
|
+
function parsePnpmWorkspaceYaml(content) {
|
|
80
|
+
const lines = content.split(/\r?\n/);
|
|
81
|
+
let inPackagesList = false;
|
|
82
|
+
let packagesIndent = -1;
|
|
83
|
+
const result = [];
|
|
84
|
+
for (const rawLine of lines) {
|
|
85
|
+
// Strip comments (anything after # not inside quotes — close enough for our use)
|
|
86
|
+
const commentIdx = rawLine.indexOf('#');
|
|
87
|
+
const line = (commentIdx >= 0 ? rawLine.slice(0, commentIdx) : rawLine).replace(/\s+$/, '');
|
|
88
|
+
if (line.length === 0)
|
|
89
|
+
continue;
|
|
90
|
+
if (!inPackagesList) {
|
|
91
|
+
// Looking for the top-level `packages:` key.
|
|
92
|
+
const match = /^packages:\s*$/.exec(line);
|
|
93
|
+
if (match) {
|
|
94
|
+
inPackagesList = true;
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
// Inside the packages list. Items must be more indented than the key.
|
|
99
|
+
const indent = line.match(/^\s*/)?.[0].length ?? 0;
|
|
100
|
+
if (packagesIndent < 0) {
|
|
101
|
+
// First non-empty line after the key — this sets the indent.
|
|
102
|
+
if (indent === 0) {
|
|
103
|
+
// `packages:` was the last meaningful line; an unindented line
|
|
104
|
+
// means the list is empty or the format is unsupported.
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
packagesIndent = indent;
|
|
108
|
+
}
|
|
109
|
+
else if (indent < packagesIndent) {
|
|
110
|
+
// Outdented — the list ended.
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
else if (indent > packagesIndent) {
|
|
114
|
+
// Nested list (e.g. `packages: [{ ... }]`) — unsupported; stop.
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
// Expect `- <value>` or `- '<value>'` or `- "<value>"`.
|
|
118
|
+
const itemMatch = /^\s*-\s+(['"]?)(.+?)\1\s*$/.exec(line);
|
|
119
|
+
if (itemMatch && itemMatch[2] !== undefined) {
|
|
120
|
+
result.push(itemMatch[2]);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
// Not a list item; the block ended.
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
function extractNpmWorkspaces(value) {
|
|
130
|
+
if (Array.isArray(value))
|
|
131
|
+
return value;
|
|
132
|
+
if (value && typeof value === 'object' && Array.isArray(value.packages)) {
|
|
133
|
+
return value.packages;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Discover workspace `package.json` paths from the supported monorepo
|
|
139
|
+
* manifest sources. Returns the list of absolute paths plus which
|
|
140
|
+
* detection source won. When no source is present, returns an empty
|
|
141
|
+
* list with `source: null` (caller should fall through to single-package
|
|
142
|
+
* behavior).
|
|
143
|
+
*/
|
|
144
|
+
async function discoverWorkspacePackageJsons(projectRoot, warnings) {
|
|
145
|
+
// 1. pnpm-workspace.yaml
|
|
146
|
+
const pnpmWsPath = join(projectRoot, 'pnpm-workspace.yaml');
|
|
147
|
+
if (await pathExists(pnpmWsPath)) {
|
|
148
|
+
try {
|
|
149
|
+
const yaml = await readText(pnpmWsPath);
|
|
150
|
+
const globs = parsePnpmWorkspaceYaml(yaml);
|
|
151
|
+
if (globs && globs.length > 0) {
|
|
152
|
+
const paths = await expandWorkspaceGlobs(projectRoot, globs, warnings);
|
|
153
|
+
return { paths, source: 'pnpm-workspace' };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
warnings.push(`pnpm-workspace.yaml present but unreadable: ${error.message}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// 2. package.json `workspaces` field (npm or yarn classic).
|
|
161
|
+
const rootPkgPath = join(projectRoot, 'package.json');
|
|
162
|
+
if (await pathExists(rootPkgPath)) {
|
|
163
|
+
const { record } = await readPackageJson(rootPkgPath);
|
|
164
|
+
if (record) {
|
|
165
|
+
const globs = extractNpmWorkspaces(record.workspaces);
|
|
166
|
+
if (globs && globs.length > 0) {
|
|
167
|
+
const paths = await expandWorkspaceGlobs(projectRoot, globs, warnings);
|
|
168
|
+
return { paths, source: 'package-json-workspaces' };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// 3. lerna.json `packages` field.
|
|
173
|
+
const lernaPath = join(projectRoot, 'lerna.json');
|
|
174
|
+
if (await pathExists(lernaPath)) {
|
|
175
|
+
try {
|
|
176
|
+
const lernaRaw = await readText(lernaPath);
|
|
177
|
+
const lerna = JSON.parse(lernaRaw);
|
|
178
|
+
if (Array.isArray(lerna.packages) && lerna.packages.every((p) => typeof p === 'string')) {
|
|
179
|
+
const paths = await expandWorkspaceGlobs(projectRoot, lerna.packages, warnings);
|
|
180
|
+
return { paths, source: 'lerna' };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
warnings.push(`lerna.json present but unreadable: ${error.message}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { paths: [], source: null };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Resolve a list of workspace globs against the project tree.
|
|
191
|
+
*
|
|
192
|
+
* Supports shapes with up to two segments of `*` wildcards:
|
|
193
|
+
* - `packages/*`
|
|
194
|
+
* - `packages/hermes-agent/*`
|
|
195
|
+
* - `apps/web` (literal, no wildcard)
|
|
196
|
+
*
|
|
197
|
+
* Deeper patterns (more than one `*` segment) are silently skipped and a
|
|
198
|
+
* warning is emitted. This is by design (no new dependency, narrow scope).
|
|
199
|
+
*/
|
|
200
|
+
async function expandWorkspaceGlobs(projectRoot, globs, warnings) {
|
|
201
|
+
const found = new Set();
|
|
202
|
+
for (const glob of globs) {
|
|
203
|
+
const segments = glob.split('/').filter((s) => s.length > 0);
|
|
204
|
+
const wildcardCount = segments.filter((s) => s === '*').length;
|
|
205
|
+
if (wildcardCount > 1) {
|
|
206
|
+
warnings.push(`workspace glob "${glob}" has more than one wildcard; skipped (unsupported).`);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (segments.length === 0)
|
|
210
|
+
continue;
|
|
211
|
+
if (wildcardCount === 0) {
|
|
212
|
+
// Literal path — treat the last segment as a directory containing package.json.
|
|
213
|
+
const literalDir = join(projectRoot, ...segments);
|
|
214
|
+
if (await isDirectory(literalDir)) {
|
|
215
|
+
const candidate = join(literalDir, 'package.json');
|
|
216
|
+
if (await pathExists(candidate)) {
|
|
217
|
+
found.add(candidate);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
// Exactly one wildcard — it must be the LAST segment.
|
|
223
|
+
const wildcardIdx = segments.indexOf('*');
|
|
224
|
+
if (wildcardIdx !== segments.length - 1) {
|
|
225
|
+
warnings.push(`workspace glob "${glob}" has non-trailing wildcard; skipped.`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const parentSegments = segments.slice(0, wildcardIdx);
|
|
229
|
+
const parentDir = join(projectRoot, ...parentSegments);
|
|
230
|
+
if (!(await isDirectory(parentDir)))
|
|
231
|
+
continue;
|
|
232
|
+
let entries;
|
|
233
|
+
try {
|
|
234
|
+
entries = await readdir(parentDir, { withFileTypes: true });
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
warnings.push(`workspace glob "${glob}": could not read ${parentDir}: ${error.message}`);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
for (const entry of entries) {
|
|
241
|
+
if (!entry.isDirectory())
|
|
242
|
+
continue;
|
|
243
|
+
const childPkg = join(parentDir, entry.name, 'package.json');
|
|
244
|
+
if (await pathExists(childPkg)) {
|
|
245
|
+
found.add(childPkg);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return Array.from(found).sort();
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Parse a single `package.json` record into `LibraryEntry` rows.
|
|
253
|
+
* Returns the entries and the per-scope tallies for that one package.
|
|
254
|
+
*/
|
|
255
|
+
function extractEntriesFromPackageJson(record) {
|
|
256
|
+
const byScope = {
|
|
257
|
+
dependencies: 0,
|
|
258
|
+
devDependencies: 0,
|
|
259
|
+
peerDependencies: 0,
|
|
260
|
+
optionalDependencies: 0
|
|
261
|
+
};
|
|
262
|
+
const entries = [];
|
|
263
|
+
for (const scope of SCOPES) {
|
|
264
|
+
const deps = record[scope];
|
|
265
|
+
if (!deps)
|
|
266
|
+
continue;
|
|
267
|
+
for (const [name, version] of Object.entries(deps)) {
|
|
268
|
+
entries.push({
|
|
269
|
+
name,
|
|
270
|
+
version,
|
|
271
|
+
major: parseMajorVersion(version),
|
|
272
|
+
scope,
|
|
273
|
+
ecosystem: 'npm'
|
|
274
|
+
});
|
|
275
|
+
byScope[scope] += 1;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return { entries, byScope };
|
|
279
|
+
}
|
|
280
|
+
export async function scanLibraries(options) {
|
|
281
|
+
const { projectRoot } = options;
|
|
282
|
+
const warnings = [];
|
|
283
|
+
const libraries = [];
|
|
284
|
+
const byScope = {
|
|
285
|
+
dependencies: 0,
|
|
286
|
+
devDependencies: 0,
|
|
287
|
+
peerDependencies: 0,
|
|
288
|
+
optionalDependencies: 0
|
|
289
|
+
};
|
|
290
|
+
const workspaces = [];
|
|
291
|
+
// 1. Discover monorepo workspaces (if any). Discovery is independent of
|
|
292
|
+
// whether the root `package.json` exists; we still attempt it.
|
|
293
|
+
const { paths: workspacePkgPaths, source } = await discoverWorkspacePackageJsons(projectRoot, warnings);
|
|
294
|
+
const isMonorepo = source !== null && workspacePkgPaths.length > 0;
|
|
295
|
+
if (isMonorepo) {
|
|
296
|
+
// Monorepo mode: scan the root + every discovered workspace package.json.
|
|
297
|
+
const rootPkgPath = join(projectRoot, 'package.json');
|
|
298
|
+
const allPaths = [];
|
|
299
|
+
if (await pathExists(rootPkgPath)) {
|
|
300
|
+
allPaths.push(rootPkgPath);
|
|
301
|
+
}
|
|
302
|
+
// Dedupe: the root might also be matched by a glob (rare, but possible).
|
|
303
|
+
for (const p of workspacePkgPaths) {
|
|
304
|
+
if (!allPaths.includes(p))
|
|
305
|
+
allPaths.push(p);
|
|
306
|
+
}
|
|
307
|
+
// Recursive descent: for each discovered workspace dir, also pick up
|
|
308
|
+
// any nested `package.json` one level deeper. This matches the pnpm
|
|
309
|
+
// convention where a workspace package (e.g. `hermes-agent`) can be
|
|
310
|
+
// a container for sub-workspaces (e.g. `hermes-agent/ui-tui`). The
|
|
311
|
+
// outer pnpm-workspace.yaml often only declares the top-level glob,
|
|
312
|
+
// so a literal-glob-only scan would miss the nested packages.
|
|
313
|
+
const expanded = new Set(allPaths);
|
|
314
|
+
for (const pkgPath of allPaths) {
|
|
315
|
+
const dir = pkgPath.replace(/[\\/]package\.json$/, '');
|
|
316
|
+
if (!(await isDirectory(dir)))
|
|
317
|
+
continue;
|
|
318
|
+
let entries;
|
|
319
|
+
try {
|
|
320
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
for (const entry of entries) {
|
|
326
|
+
if (!entry.isDirectory())
|
|
327
|
+
continue;
|
|
328
|
+
const nestedPkg = join(dir, entry.name, 'package.json');
|
|
329
|
+
if (await pathExists(nestedPkg)) {
|
|
330
|
+
expanded.add(nestedPkg);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
allPaths.length = 0;
|
|
335
|
+
allPaths.push(...Array.from(expanded).sort());
|
|
336
|
+
for (const pkgPath of allPaths) {
|
|
337
|
+
const { exists, record, error } = await readPackageJson(pkgPath);
|
|
338
|
+
if (!exists)
|
|
339
|
+
continue;
|
|
340
|
+
if (record === null) {
|
|
341
|
+
warnings.push(`${normalizePathForDisplay(pkgPath)} is not valid JSON: ${error ?? 'unknown parse error'}`);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const { entries, byScope: pkgByScope } = extractEntriesFromPackageJson(record);
|
|
345
|
+
libraries.push(...entries);
|
|
346
|
+
for (const scope of SCOPES) {
|
|
347
|
+
byScope[scope] += pkgByScope[scope];
|
|
348
|
+
}
|
|
349
|
+
// For per-workspace `workspaces[]`, only list non-root paths; the
|
|
350
|
+
// root is implicit and lives at `projectRoot`.
|
|
351
|
+
const isRoot = pkgPath === rootPkgPath;
|
|
352
|
+
if (!isRoot) {
|
|
353
|
+
const entry = {
|
|
354
|
+
path: pkgPath,
|
|
355
|
+
count: entries.length
|
|
356
|
+
};
|
|
357
|
+
if (record.name !== undefined)
|
|
358
|
+
entry.name = record.name;
|
|
359
|
+
if (record.version !== undefined)
|
|
360
|
+
entry.version = record.version;
|
|
361
|
+
workspaces.push(entry);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
// Single-package mode (today's behavior). Preserved byte-for-byte
|
|
367
|
+
// apart from the new additive `workspaces: []` field.
|
|
368
|
+
const { exists, record, error } = await readPackageJson(join(projectRoot, 'package.json'));
|
|
369
|
+
if (!exists) {
|
|
370
|
+
warnings.push('package.json not found; nothing to scan.');
|
|
371
|
+
return {
|
|
372
|
+
projectRoot,
|
|
373
|
+
libraries,
|
|
374
|
+
totalCount: 0,
|
|
375
|
+
byScope,
|
|
376
|
+
workspaces: [],
|
|
377
|
+
scannedAt: new Date().toISOString(),
|
|
378
|
+
warnings
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
if (record === null) {
|
|
382
|
+
warnings.push(`package.json is not valid JSON: ${error ?? 'unknown parse error'}`);
|
|
383
|
+
return {
|
|
384
|
+
projectRoot,
|
|
385
|
+
libraries,
|
|
386
|
+
totalCount: 0,
|
|
387
|
+
byScope,
|
|
388
|
+
workspaces: [],
|
|
389
|
+
scannedAt: new Date().toISOString(),
|
|
390
|
+
warnings
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
const { entries, byScope: pkgByScope } = extractEntriesFromPackageJson(record);
|
|
394
|
+
libraries.push(...entries);
|
|
395
|
+
for (const scope of SCOPES) {
|
|
396
|
+
byScope[scope] += pkgByScope[scope];
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// Sort: by name (alphabetical), then by scope order (deps first, then dev, peer, optional)
|
|
400
|
+
libraries.sort((a, b) => {
|
|
401
|
+
if (a.name !== b.name)
|
|
402
|
+
return a.name.localeCompare(b.name);
|
|
403
|
+
return SCOPE_ORDER[a.scope] - SCOPE_ORDER[b.scope];
|
|
404
|
+
});
|
|
405
|
+
return {
|
|
406
|
+
projectRoot,
|
|
407
|
+
libraries,
|
|
408
|
+
totalCount: libraries.length,
|
|
409
|
+
byScope,
|
|
410
|
+
workspaces,
|
|
411
|
+
scannedAt: new Date().toISOString(),
|
|
412
|
+
warnings
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
function normalizePathForDisplay(p) {
|
|
416
|
+
// Surface posix-style separators in warnings for consistency with the
|
|
417
|
+
// existing 'package.json is not valid JSON' message shape.
|
|
418
|
+
return p.split(sep).join('/');
|
|
419
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library version scan types.
|
|
3
|
+
* Joins the existing scan family (archetype, existing-system, type-sanity,
|
|
4
|
+
* acceptance-coverage, diff-vs-scope, file-size) but is intentionally scoped
|
|
5
|
+
* to library version enumeration only. Does NOT extend scan-types.ts —
|
|
6
|
+
* each scan service ships its own types to keep the scan-types module
|
|
7
|
+
* focused on the archetype + existing-system pair (see src/services/scan/scan-types.ts).
|
|
8
|
+
*/
|
|
9
|
+
export type Ecosystem = 'npm';
|
|
10
|
+
export type DependencyScope = 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies';
|
|
11
|
+
export type LibraryEntry = {
|
|
12
|
+
/** npm package name (e.g. "antd", "@mui/material", "react-router-dom"). */
|
|
13
|
+
name: string;
|
|
14
|
+
/** Raw version spec as written in package.json (e.g. "^5.18.0", "workspace:*", "git+https://..."). */
|
|
15
|
+
version: string;
|
|
16
|
+
/**
|
|
17
|
+
* Parsed major version, or null when the spec is non-semver (e.g.
|
|
18
|
+
* "workspace:*", "file:../local", "git+https://..."). The LLM should
|
|
19
|
+
* treat null as "cannot determine major; consult breaking-changes table
|
|
20
|
+
* by other signals (e.g. lockfile or import statement shape)".
|
|
21
|
+
*/
|
|
22
|
+
major: number | null;
|
|
23
|
+
scope: DependencyScope;
|
|
24
|
+
ecosystem: Ecosystem;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Per-workspace provenance for monorepo scans.
|
|
28
|
+
*
|
|
29
|
+
* `path` is the absolute path of the `package.json` that contributed
|
|
30
|
+
* libraries. `count` is the number of `LibraryEntry` rows produced by
|
|
31
|
+
* reading that single `package.json` (i.e. NOT the aggregate across
|
|
32
|
+
* the whole monorepo — use `LibraryReport.totalCount` for the aggregate).
|
|
33
|
+
*
|
|
34
|
+
* `name` and `version` are the workspace's own `name` / `version` from
|
|
35
|
+
* its `package.json`, when present. They are optional because some
|
|
36
|
+
* workspace `package.json` files omit them.
|
|
37
|
+
*/
|
|
38
|
+
export type WorkspaceEntry = {
|
|
39
|
+
path: string;
|
|
40
|
+
count: number;
|
|
41
|
+
name?: string;
|
|
42
|
+
version?: string;
|
|
43
|
+
};
|
|
44
|
+
export type LibraryReport = {
|
|
45
|
+
projectRoot: string;
|
|
46
|
+
libraries: LibraryEntry[];
|
|
47
|
+
totalCount: number;
|
|
48
|
+
byScope: Record<DependencyScope, number>;
|
|
49
|
+
/**
|
|
50
|
+
* Per-workspace provenance for monorepo (pnpm / npm / yarn workspaces,
|
|
51
|
+
* lerna) projects. Empty for single-package projects so the field is
|
|
52
|
+
* always present (additive; consumers can rely on the shape).
|
|
53
|
+
*/
|
|
54
|
+
workspaces: WorkspaceEntry[];
|
|
55
|
+
/** ISO timestamp at scan time. */
|
|
56
|
+
scannedAt: string;
|
|
57
|
+
/** Soft signals — e.g. "package.json not found" or "package.json is not valid JSON". */
|
|
58
|
+
warnings: string[];
|
|
59
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library version scan types.
|
|
3
|
+
* Joins the existing scan family (archetype, existing-system, type-sanity,
|
|
4
|
+
* acceptance-coverage, diff-vs-scope, file-size) but is intentionally scoped
|
|
5
|
+
* to library version enumeration only. Does NOT extend scan-types.ts —
|
|
6
|
+
* each scan service ships its own types to keep the scan-types module
|
|
7
|
+
* focused on the archetype + existing-system pair (see src/services/scan/scan-types.ts).
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
@@ -33,11 +33,13 @@ export type SessionMeta = {
|
|
|
33
33
|
outerSessionId?: string;
|
|
34
34
|
};
|
|
35
35
|
/**
|
|
36
|
-
* Drop the project-level session binding
|
|
37
|
-
* so the next `ensureSession()` call
|
|
38
|
-
* session id. The on-disk session directory
|
|
39
|
-
* rotating does NOT delete the user's data, it
|
|
40
|
-
* project from that session.
|
|
36
|
+
* Drop the project-level session binding at the canonical
|
|
37
|
+
* `.peaks/_runtime/session.json` so the next `ensureSession()` call
|
|
38
|
+
* auto-generates a fresh session id. The on-disk session directory
|
|
39
|
+
* is left intact — rotating does NOT delete the user's data, it
|
|
40
|
+
* just unbinds the project from that session. Also drops the legacy
|
|
41
|
+
* `.peaks/.session.json` if present so a stale read from another
|
|
42
|
+
* tool cannot re-bind the project after rotation.
|
|
41
43
|
*
|
|
42
44
|
* Returns the id of the session that was unbound, or `null` if
|
|
43
45
|
* no binding was present. The caller is expected to do something
|
|
@@ -6,11 +6,20 @@
|
|
|
6
6
|
* Each session gets a unique directory under .peaks/ with incrementing numbered files.
|
|
7
7
|
*/
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
9
|
-
import { join, resolve } from 'node:path';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
10
|
import { randomBytes } from 'node:crypto';
|
|
11
11
|
import { initWorkspace } from '../workspace/workspace-service.js';
|
|
12
|
-
|
|
12
|
+
// As of slice 2026-06-05-peaks-runtime-layer the project-level session
|
|
13
|
+
// binding lives under `.peaks/_runtime/session.json`. The legacy
|
|
14
|
+
// `.peaks/.session.json` path is preserved as a read-only fallback for one
|
|
15
|
+
// minor release so older CLI versions (or trees that have not been migrated
|
|
16
|
+
// by `peaks workspace reconcile`) keep working without a forced re-init.
|
|
17
|
+
const SESSION_FILE = join('_runtime', 'session.json');
|
|
18
|
+
const LEGACY_SESSION_FILE = '.session.json';
|
|
13
19
|
const META_FILE = 'session.json';
|
|
20
|
+
function getLegacySessionFilePath(projectRoot) {
|
|
21
|
+
return join(projectRoot, '.peaks', LEGACY_SESSION_FILE);
|
|
22
|
+
}
|
|
14
23
|
/**
|
|
15
24
|
* Canonicalize a project root path. Returns the realpath
|
|
16
25
|
* (resolving all symlinks — important on macOS where `/var`
|
|
@@ -68,7 +77,9 @@ function generateSessionId() {
|
|
|
68
77
|
return `${date}-session-${random}`;
|
|
69
78
|
}
|
|
70
79
|
/**
|
|
71
|
-
* Get the path to the session file for a project.
|
|
80
|
+
* Get the path to the session file for a project. The canonical home is
|
|
81
|
+
* `.peaks/_runtime/session.json`; the legacy `.peaks/.session.json` is
|
|
82
|
+
* read-only fallback (see `readSessionFile`).
|
|
72
83
|
*/
|
|
73
84
|
function getSessionFilePath(projectRoot) {
|
|
74
85
|
return join(projectRoot, '.peaks', SESSION_FILE);
|
|
@@ -92,10 +103,15 @@ function getSessionFilePath(projectRoot) {
|
|
|
92
103
|
*/
|
|
93
104
|
function readSessionFile(projectRoot) {
|
|
94
105
|
const sessionFile = getSessionFilePath(projectRoot);
|
|
95
|
-
|
|
106
|
+
const legacyFile = getLegacySessionFilePath(projectRoot);
|
|
107
|
+
// Back-compat window: prefer the new canonical path; fall back to the
|
|
108
|
+
// legacy `.peaks/.session.json` so older CLI versions or pre-migration
|
|
109
|
+
// trees keep working. When both exist, the new path wins.
|
|
110
|
+
const pathToRead = existsSync(sessionFile) ? sessionFile : legacyFile;
|
|
111
|
+
if (!existsSync(pathToRead))
|
|
96
112
|
return null;
|
|
97
113
|
try {
|
|
98
|
-
const data = JSON.parse(readFileSync(
|
|
114
|
+
const data = JSON.parse(readFileSync(pathToRead, 'utf8'));
|
|
99
115
|
if (data.sessionId && data.projectRoot === projectRoot) {
|
|
100
116
|
return data;
|
|
101
117
|
}
|
|
@@ -116,10 +132,14 @@ function readSessionFile(projectRoot) {
|
|
|
116
132
|
*/
|
|
117
133
|
function readSessionFileCanonical(projectRoot) {
|
|
118
134
|
const sessionFile = getSessionFilePath(projectRoot);
|
|
119
|
-
|
|
135
|
+
const legacyFile = getLegacySessionFilePath(projectRoot);
|
|
136
|
+
// Back-compat window: prefer the new canonical path; fall back to the
|
|
137
|
+
// legacy `.peaks/.session.json` for one minor release.
|
|
138
|
+
const pathToRead = existsSync(sessionFile) ? sessionFile : legacyFile;
|
|
139
|
+
if (!existsSync(pathToRead))
|
|
120
140
|
return null;
|
|
121
141
|
try {
|
|
122
|
-
const data = JSON.parse(readFileSync(
|
|
142
|
+
const data = JSON.parse(readFileSync(pathToRead, 'utf8'));
|
|
123
143
|
const storedRaw = typeof data.projectRoot === 'string' ? data.projectRoot : null;
|
|
124
144
|
if (data.sessionId &&
|
|
125
145
|
storedRaw !== null &&
|
|
@@ -133,22 +153,27 @@ function readSessionFileCanonical(projectRoot) {
|
|
|
133
153
|
}
|
|
134
154
|
}
|
|
135
155
|
/**
|
|
136
|
-
* Write session info to disk
|
|
156
|
+
* Write session info to disk at the canonical new path
|
|
157
|
+
* `.peaks/_runtime/session.json`. The `.peaks/_runtime/` directory is
|
|
158
|
+
* created on demand. The legacy `.peaks/.session.json` is NOT written by
|
|
159
|
+
* this slice; it is only read for back-compat.
|
|
137
160
|
*/
|
|
138
161
|
function writeSessionFile(projectRoot, info) {
|
|
139
162
|
const sessionFile = getSessionFilePath(projectRoot);
|
|
140
|
-
const dir =
|
|
163
|
+
const dir = dirname(sessionFile);
|
|
141
164
|
if (!existsSync(dir)) {
|
|
142
165
|
mkdirSync(dir, { recursive: true });
|
|
143
166
|
}
|
|
144
167
|
writeFileSync(sessionFile, JSON.stringify(info, null, 2), 'utf8');
|
|
145
168
|
}
|
|
146
169
|
/**
|
|
147
|
-
* Drop the project-level session binding
|
|
148
|
-
* so the next `ensureSession()` call
|
|
149
|
-
* session id. The on-disk session directory
|
|
150
|
-
* rotating does NOT delete the user's data, it
|
|
151
|
-
* project from that session.
|
|
170
|
+
* Drop the project-level session binding at the canonical
|
|
171
|
+
* `.peaks/_runtime/session.json` so the next `ensureSession()` call
|
|
172
|
+
* auto-generates a fresh session id. The on-disk session directory
|
|
173
|
+
* is left intact — rotating does NOT delete the user's data, it
|
|
174
|
+
* just unbinds the project from that session. Also drops the legacy
|
|
175
|
+
* `.peaks/.session.json` if present so a stale read from another
|
|
176
|
+
* tool cannot re-bind the project after rotation.
|
|
152
177
|
*
|
|
153
178
|
* Returns the id of the session that was unbound, or `null` if
|
|
154
179
|
* no binding was present. The caller is expected to do something
|
|
@@ -164,6 +189,15 @@ export function rotateSessionBinding(projectRoot) {
|
|
|
164
189
|
if (existsSync(sessionFile)) {
|
|
165
190
|
unlinkSync(sessionFile);
|
|
166
191
|
}
|
|
192
|
+
const legacyFile = getLegacySessionFilePath(projectRoot);
|
|
193
|
+
if (existsSync(legacyFile)) {
|
|
194
|
+
try {
|
|
195
|
+
unlinkSync(legacyFile);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// best-effort: a stale legacy binding is not blocking
|
|
199
|
+
}
|
|
200
|
+
}
|
|
167
201
|
return previous.sessionId;
|
|
168
202
|
}
|
|
169
203
|
/**
|