peaks-cli 1.3.9 → 1.4.1
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 +53 -0
- package/dist/src/cli/commands/core-artifact-commands.js +27 -0
- package/dist/src/cli/commands/migrate-1-4-1-command.d.ts +11 -0
- package/dist/src/cli/commands/migrate-1-4-1-command.js +34 -0
- package/dist/src/cli/commands/skill-context-stats-command.d.ts +40 -0
- package/dist/src/cli/commands/skill-context-stats-command.js +96 -0
- package/dist/src/cli/commands/skill-scope-commands.d.ts +51 -0
- package/dist/src/cli/commands/skill-scope-commands.js +310 -0
- package/dist/src/cli/commands/workflow-commands.js +1 -1
- package/dist/src/cli/commands/workflow-plan-commands.d.ts +39 -0
- package/dist/src/cli/commands/workflow-plan-commands.js +163 -0
- package/dist/src/cli/commands/workspace-commands.js +8 -0
- package/dist/src/cli/program.js +6 -0
- package/dist/src/services/doctor/doctor-service.d.ts +40 -0
- package/dist/src/services/doctor/doctor-service.js +160 -0
- package/dist/src/services/hooks/presence-marker-detector.d.ts +16 -0
- package/dist/src/services/hooks/presence-marker-detector.js +105 -0
- package/dist/src/services/skill-scope/adapters/_stub-helper.d.ts +39 -0
- package/dist/src/services/skill-scope/adapters/_stub-helper.js +98 -0
- package/dist/src/services/skill-scope/adapters/claude-code.d.ts +59 -0
- package/dist/src/services/skill-scope/adapters/claude-code.js +304 -0
- package/dist/src/services/skill-scope/adapters/codex.d.ts +2 -0
- package/dist/src/services/skill-scope/adapters/codex.js +12 -0
- package/dist/src/services/skill-scope/adapters/cursor.d.ts +2 -0
- package/dist/src/services/skill-scope/adapters/cursor.js +13 -0
- package/dist/src/services/skill-scope/adapters/qoder.d.ts +2 -0
- package/dist/src/services/skill-scope/adapters/qoder.js +13 -0
- package/dist/src/services/skill-scope/adapters/tongyi.d.ts +2 -0
- package/dist/src/services/skill-scope/adapters/tongyi.js +13 -0
- package/dist/src/services/skill-scope/adapters/trae.d.ts +2 -0
- package/dist/src/services/skill-scope/adapters/trae.js +12 -0
- package/dist/src/services/skill-scope/detect.d.ts +81 -0
- package/dist/src/services/skill-scope/detect.js +513 -0
- package/dist/src/services/skill-scope/registry.d.ts +41 -0
- package/dist/src/services/skill-scope/registry.js +83 -0
- package/dist/src/services/skill-scope/source-of-truth.d.ts +44 -0
- package/dist/src/services/skill-scope/source-of-truth.js +118 -0
- package/dist/src/services/skill-scope/types.d.ts +195 -0
- package/dist/src/services/skill-scope/types.js +97 -0
- package/dist/src/services/standards/migrate-service.d.ts +63 -0
- package/dist/src/services/standards/migrate-service.js +193 -0
- package/dist/src/services/standards/project-standards-service.js +1 -23
- package/dist/src/services/workflow/artifact-paths.d.ts +59 -0
- package/dist/src/services/workflow/artifact-paths.js +127 -0
- package/dist/src/services/workflow/pipeline-verify-service.d.ts +6 -0
- package/dist/src/services/workflow/pipeline-verify-service.js +49 -4
- package/dist/src/services/workflow/plan-reader.d.ts +29 -0
- package/dist/src/services/workflow/plan-reader.js +158 -0
- package/dist/src/services/workflow/plan-refresher.d.ts +32 -0
- package/dist/src/services/workflow/plan-refresher.js +353 -0
- package/dist/src/services/workflow/plan-trigger-detector.d.ts +55 -0
- package/dist/src/services/workflow/plan-trigger-detector.js +142 -0
- package/dist/src/services/workspace/migrate-1-4-1-service.d.ts +44 -0
- package/dist/src/services/workspace/migrate-1-4-1-service.js +195 -0
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +3 -2
- package/schemas/doctor-report.schema.json +2 -2
- package/skills/peaks-qa/SKILL.md +25 -0
- package/skills/peaks-qa/references/qa-perf-test-plan.md +67 -0
- package/skills/peaks-qa/references/qa-security-test-plan.md +73 -0
- package/skills/peaks-qa/references/qa-transition-gates.md +13 -9
- package/skills/peaks-rd/SKILL.md +2 -2
- package/skills/peaks-rd/references/mandatory-perf-baseline.md +2 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks workflow plan refresh` — slice 025 (Security + Perf Plan/Result split).
|
|
3
|
+
*
|
|
4
|
+
* Deterministically regenerates a security-test-plan or perf-baseline
|
|
5
|
+
* plan body. Without `--apply`, computes the would-be body + hash but
|
|
6
|
+
* does not write. With `--apply`, atomically writes the file.
|
|
7
|
+
*
|
|
8
|
+
* Determinism: inputs (file list, dependency list) are sorted before
|
|
9
|
+
* being rendered; the body is then `normalizePlanBody`-ed before hashing
|
|
10
|
+
* so re-running with no input change returns the same hash.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
13
|
+
import { join, sep } from 'node:path';
|
|
14
|
+
import { fail, ok } from '../../shared/result.js';
|
|
15
|
+
import { getSessionDir } from '../session/getSessionDir.js';
|
|
16
|
+
import { hashNormalizedBody, normalizePlanBody } from './plan-reader.js';
|
|
17
|
+
const PLAN_FILE = {
|
|
18
|
+
security: 'security-test-plan.md',
|
|
19
|
+
perf: 'perf-baseline.md'
|
|
20
|
+
};
|
|
21
|
+
const SENSITIVE_SERVICE_DIRS = ['auth', 'security', 'secrets', 'payments', 'filesystem'];
|
|
22
|
+
function readPackageJson(projectRoot) {
|
|
23
|
+
const path = join(projectRoot, 'package.json');
|
|
24
|
+
if (!existsSync(path))
|
|
25
|
+
return null;
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function listAuthTsFiles(projectRoot) {
|
|
34
|
+
const out = [];
|
|
35
|
+
const roots = [join(projectRoot, 'src')];
|
|
36
|
+
for (const root of roots) {
|
|
37
|
+
if (!existsSync(root))
|
|
38
|
+
continue;
|
|
39
|
+
const stack = [root];
|
|
40
|
+
while (stack.length > 0) {
|
|
41
|
+
const dir = stack.pop();
|
|
42
|
+
if (dir === undefined)
|
|
43
|
+
continue;
|
|
44
|
+
let entries;
|
|
45
|
+
try {
|
|
46
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist')
|
|
53
|
+
continue;
|
|
54
|
+
const full = join(dir, entry.name);
|
|
55
|
+
if (entry.isDirectory()) {
|
|
56
|
+
stack.push(full);
|
|
57
|
+
}
|
|
58
|
+
else if (entry.isFile() && /auth.*\.ts$|\.ts$/i.test(entry.name) && /auth/i.test(entry.name)) {
|
|
59
|
+
out.push(full);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return [...new Set(out)].sort();
|
|
65
|
+
}
|
|
66
|
+
function listSensitiveServiceFiles(projectRoot) {
|
|
67
|
+
const result = {};
|
|
68
|
+
for (const dir of SENSITIVE_SERVICE_DIRS) {
|
|
69
|
+
const root = join(projectRoot, 'src', 'services', dir);
|
|
70
|
+
if (!existsSync(root)) {
|
|
71
|
+
result[dir] = [];
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const files = [];
|
|
75
|
+
const stack = [root];
|
|
76
|
+
while (stack.length > 0) {
|
|
77
|
+
const cur = stack.pop();
|
|
78
|
+
if (cur === undefined)
|
|
79
|
+
continue;
|
|
80
|
+
let entries;
|
|
81
|
+
try {
|
|
82
|
+
entries = readdirSync(cur, { withFileTypes: true });
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
const full = join(cur, entry.name);
|
|
89
|
+
if (entry.isDirectory()) {
|
|
90
|
+
stack.push(full);
|
|
91
|
+
}
|
|
92
|
+
else if (entry.isFile() && entry.name.endsWith('.ts')) {
|
|
93
|
+
files.push(full);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
result[dir] = files.sort();
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
function listCliCommands(projectRoot) {
|
|
102
|
+
const root = join(projectRoot, 'src', 'cli', 'commands');
|
|
103
|
+
if (!existsSync(root))
|
|
104
|
+
return [];
|
|
105
|
+
try {
|
|
106
|
+
return readdirSync(root, { withFileTypes: true })
|
|
107
|
+
.filter((e) => e.isFile() && e.name.endsWith('-commands.ts'))
|
|
108
|
+
.map((e) => e.name)
|
|
109
|
+
.sort();
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Build the security-test-plan body deterministically. */
|
|
116
|
+
export function buildSecurityPlanBody(projectRoot) {
|
|
117
|
+
const pkg = readPackageJson(projectRoot);
|
|
118
|
+
const deps = pkg?.dependencies ? Object.keys(pkg.dependencies).sort() : [];
|
|
119
|
+
const optDeps = pkg?.optionalDependencies ? Object.keys(pkg.optionalDependencies).sort() : [];
|
|
120
|
+
const sensitive = listSensitiveServiceFiles(projectRoot);
|
|
121
|
+
const authFiles = listAuthTsFiles(projectRoot);
|
|
122
|
+
const sections = [];
|
|
123
|
+
sections.push(`# Security Test Plan (project-level)`);
|
|
124
|
+
sections.push(`Generated: ${new Date('2026-01-01T00:00:00Z').toISOString()}`);
|
|
125
|
+
sections.push(`## Threat Model`);
|
|
126
|
+
sections.push(`Asset inventory: auth boundary, secret storage, external API surface, file system writes.`);
|
|
127
|
+
sections.push(`## Sensitive Service Files`);
|
|
128
|
+
for (const dir of [...SENSITIVE_SERVICE_DIRS].sort()) {
|
|
129
|
+
const files = sensitive[dir] ?? [];
|
|
130
|
+
sections.push(`### ${dir}`);
|
|
131
|
+
if (files.length === 0) {
|
|
132
|
+
sections.push('- (none)');
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
for (const f of files)
|
|
136
|
+
sections.push(`- ${f}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
sections.push(`## Auth Surface (*auth*.ts files repo-wide)`);
|
|
140
|
+
if (authFiles.length === 0) {
|
|
141
|
+
sections.push('- (none)');
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
for (const f of authFiles)
|
|
145
|
+
sections.push(`- ${f}`);
|
|
146
|
+
}
|
|
147
|
+
sections.push(`## Runtime Dependencies`);
|
|
148
|
+
sections.push(`### dependencies`);
|
|
149
|
+
if (deps.length === 0) {
|
|
150
|
+
sections.push('- (none)');
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
for (const d of deps)
|
|
154
|
+
sections.push(`- ${d}`);
|
|
155
|
+
}
|
|
156
|
+
sections.push(`### optionalDependencies`);
|
|
157
|
+
if (optDeps.length === 0) {
|
|
158
|
+
sections.push('- (none)');
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
for (const d of optDeps)
|
|
162
|
+
sections.push(`- ${d}`);
|
|
163
|
+
}
|
|
164
|
+
sections.push(`## Test Matrix`);
|
|
165
|
+
sections.push(`- Auth boundary: covered by peaks-qa per-slice diff scan.`);
|
|
166
|
+
sections.push(`- Secret storage: covered by peaks-qa per-slice diff scan.`);
|
|
167
|
+
sections.push(`- External API surface: covered by peaks-qa per-slice diff scan.`);
|
|
168
|
+
sections.push(`- File system writes: covered by peaks-qa per-slice diff scan.`);
|
|
169
|
+
return sections.join('\n');
|
|
170
|
+
}
|
|
171
|
+
/** Build the perf-baseline body deterministically. */
|
|
172
|
+
export function buildPerfPlanBody(projectRoot) {
|
|
173
|
+
const commands = listCliCommands(projectRoot);
|
|
174
|
+
const sections = [];
|
|
175
|
+
sections.push(`# Performance Baseline (project-level)`);
|
|
176
|
+
sections.push(`Generated: ${new Date('2026-01-01T00:00:00Z').toISOString()}`);
|
|
177
|
+
sections.push(`## CLI Command Inventory`);
|
|
178
|
+
if (commands.length === 0) {
|
|
179
|
+
sections.push('- (none)');
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
for (const c of commands)
|
|
183
|
+
sections.push(`- ${c}`);
|
|
184
|
+
}
|
|
185
|
+
sections.push(`## Routes / Hooks`);
|
|
186
|
+
sections.push(`- All routes are CLI subcommands; no HTTP listeners.`);
|
|
187
|
+
sections.push(`## Baseline Measurements`);
|
|
188
|
+
sections.push(`- TBD: lighthouse / k6 / autocannon — project-local measurement.`);
|
|
189
|
+
sections.push(`## Thresholds`);
|
|
190
|
+
sections.push(`- TBD: per-route threshold (p95 latency / throughput).`);
|
|
191
|
+
return sections.join('\n');
|
|
192
|
+
}
|
|
193
|
+
function planPath(args) {
|
|
194
|
+
return join(getSessionDir(args.projectRoot, args.sessionId), 'qa', PLAN_FILE[args.type]);
|
|
195
|
+
}
|
|
196
|
+
function nowIso() {
|
|
197
|
+
return new Date().toISOString();
|
|
198
|
+
}
|
|
199
|
+
export function refreshPlan(args) {
|
|
200
|
+
const target = planPath({ projectRoot: args.project, sessionId: args.sessionId, type: args.type });
|
|
201
|
+
// The body is a *function* of the project (sorted inputs + normalized
|
|
202
|
+
// output). To make the hash independent of the current wall clock, we
|
|
203
|
+
// always emit the same `Generated:` timestamp; the real `refreshedAt`
|
|
204
|
+
// is reported separately as the envelope field.
|
|
205
|
+
const rawBody = args.type === 'security' ? buildSecurityPlanBody(args.project) : buildPerfPlanBody(args.project);
|
|
206
|
+
const hash = hashNormalizedBody(rawBody);
|
|
207
|
+
const wouldWrite = [target];
|
|
208
|
+
const refreshedAt = nowIso();
|
|
209
|
+
if (!args.apply) {
|
|
210
|
+
return ok('workflow.plan.refresh', {
|
|
211
|
+
type: args.type,
|
|
212
|
+
writtenFiles: [],
|
|
213
|
+
wouldWrite,
|
|
214
|
+
hash,
|
|
215
|
+
refreshedAt,
|
|
216
|
+
dryRun: true,
|
|
217
|
+
bodyPreview: rawBody
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
// F-2 (slice 025 security): if the parent dir chain has a symlink
|
|
221
|
+
// that escapes the session dir, refuse to write. We resolve the
|
|
222
|
+
// parent (the dir we are about to mkdir/write into) and confirm its
|
|
223
|
+
// real path stays under the expected base.
|
|
224
|
+
//
|
|
225
|
+
// Two cases:
|
|
226
|
+
// (a) Some ancestor of the parent already exists on disk. We
|
|
227
|
+
// resolve the deepest existing ancestor to its real path and
|
|
228
|
+
// require that real path to be under `<projectRoot>` (a
|
|
229
|
+
// symlink within the project is fine; an escape to outside the
|
|
230
|
+
// project is not). The new dirs we are about to mkdir are
|
|
231
|
+
// created by us, so once the deepest-existing ancestor is
|
|
232
|
+
// verified, the new sub-dirs inherit containment.
|
|
233
|
+
// (b) No ancestor inside the project exists (a fully fresh write).
|
|
234
|
+
// The mkdir chain starts from the project root, which we
|
|
235
|
+
// already resolved. We require the project root's real path
|
|
236
|
+
// to be under itself (trivially true) and trust the mkdir
|
|
237
|
+
// chain. This avoids the "walked up to /" false positive
|
|
238
|
+
// when the test fixture is a fresh temp dir.
|
|
239
|
+
const projectRoot = args.project;
|
|
240
|
+
let projectRootReal;
|
|
241
|
+
try {
|
|
242
|
+
projectRootReal = realpathSync(projectRoot);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return fail('workflow.plan.refresh', 'SYMLINK_ESCAPE', `cannot resolve project root ${projectRoot}`, {
|
|
246
|
+
type: args.type,
|
|
247
|
+
writtenFiles: [],
|
|
248
|
+
wouldWrite,
|
|
249
|
+
hash,
|
|
250
|
+
refreshedAt,
|
|
251
|
+
dryRun: true,
|
|
252
|
+
bodyPreview: rawBody
|
|
253
|
+
}, ['Inspect the project root for symlinks that escape the filesystem']);
|
|
254
|
+
}
|
|
255
|
+
const parent = join(target, '..');
|
|
256
|
+
// Find the deepest existing ancestor of `parent` that is still
|
|
257
|
+
// inside the project root. We start at the parent itself and walk
|
|
258
|
+
// up, but never past the project root.
|
|
259
|
+
let existingParent = null;
|
|
260
|
+
let cursor = parent;
|
|
261
|
+
// Bound the walk: stop at the project root (inclusive). If the
|
|
262
|
+
// project root itself does not exist, that's an error.
|
|
263
|
+
while (cursor !== projectRoot && cursor !== join(projectRoot, '..') && cursor !== '' && cursor !== sep) {
|
|
264
|
+
if (existsSync(cursor)) {
|
|
265
|
+
existingParent = cursor;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
const next = join(cursor, '..');
|
|
269
|
+
if (next === cursor)
|
|
270
|
+
break;
|
|
271
|
+
cursor = next;
|
|
272
|
+
}
|
|
273
|
+
if (existingParent === null) {
|
|
274
|
+
// No ancestor inside the project root exists. Verify the project
|
|
275
|
+
// root itself resolves, and trust the mkdir chain. The project
|
|
276
|
+
// root's real path IS the deepest verifiable ancestor; if it's
|
|
277
|
+
// a symlink, realpathSync has already collapsed it.
|
|
278
|
+
if (!existsSync(projectRoot)) {
|
|
279
|
+
return fail('workflow.plan.refresh', 'SYMLINK_ESCAPE', `project root does not exist: ${projectRoot}`, {
|
|
280
|
+
type: args.type,
|
|
281
|
+
writtenFiles: [],
|
|
282
|
+
wouldWrite,
|
|
283
|
+
hash,
|
|
284
|
+
refreshedAt,
|
|
285
|
+
dryRun: true,
|
|
286
|
+
bodyPreview: rawBody
|
|
287
|
+
}, ['Inspect the project root — it must exist and be a directory']);
|
|
288
|
+
}
|
|
289
|
+
// Sanity: ensure the project root's real path stays inside its
|
|
290
|
+
// own prefix (always true after realpath). No further check needed.
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
let resolvedParent;
|
|
294
|
+
try {
|
|
295
|
+
resolvedParent = realpathSync(existingParent);
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return fail('workflow.plan.refresh', 'SYMLINK_ESCAPE', `cannot resolve parent directory ${existingParent}`, {
|
|
299
|
+
type: args.type,
|
|
300
|
+
writtenFiles: [],
|
|
301
|
+
wouldWrite,
|
|
302
|
+
hash,
|
|
303
|
+
refreshedAt,
|
|
304
|
+
dryRun: true,
|
|
305
|
+
bodyPreview: rawBody
|
|
306
|
+
}, ['Inspect the parent directory chain for symlinks that escape the session dir']);
|
|
307
|
+
}
|
|
308
|
+
// Resolved parent must stay under the project root. This catches
|
|
309
|
+
// both: (i) a symlink within the project that points outside the
|
|
310
|
+
// project, (ii) a symlink that points inside the project but
|
|
311
|
+
// outside the session dir. The session dir is the eventual
|
|
312
|
+
// destination, so the resolved parent chain must end up there.
|
|
313
|
+
const projectRootPrefix = projectRootReal + sep;
|
|
314
|
+
if (!resolvedParent.startsWith(projectRootPrefix) && resolvedParent !== projectRootReal) {
|
|
315
|
+
return fail('workflow.plan.refresh', 'SYMLINK_ESCAPE', `resolved path escapes project root: ${resolvedParent} is not under ${projectRootReal}`, {
|
|
316
|
+
type: args.type,
|
|
317
|
+
writtenFiles: [],
|
|
318
|
+
wouldWrite,
|
|
319
|
+
hash,
|
|
320
|
+
refreshedAt,
|
|
321
|
+
dryRun: true,
|
|
322
|
+
bodyPreview: rawBody
|
|
323
|
+
}, ['Inspect the parent directory chain for symlinks that escape the project root']);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// Apply: ensure parent dir exists, then write.
|
|
327
|
+
if (!existsSync(parent)) {
|
|
328
|
+
mkdirSync(parent, { recursive: true });
|
|
329
|
+
}
|
|
330
|
+
// If a file already exists, capture its mtime to report `refreshedAt` of
|
|
331
|
+
// the new state. If it doesn't, this is a fresh write.
|
|
332
|
+
writeFileSync(target, rawBody, 'utf8');
|
|
333
|
+
const stats = statSync(target);
|
|
334
|
+
return ok('workflow.plan.refresh', {
|
|
335
|
+
type: args.type,
|
|
336
|
+
writtenFiles: [target],
|
|
337
|
+
wouldWrite: [],
|
|
338
|
+
hash,
|
|
339
|
+
refreshedAt: stats.mtime.toISOString(),
|
|
340
|
+
dryRun: false,
|
|
341
|
+
bodyPreview: rawBody
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
// Re-export for callers that import the normalizer from this module.
|
|
345
|
+
export { normalizePlanBody };
|
|
346
|
+
// Helper for the CLI to use the same body builder.
|
|
347
|
+
export function renderPlanBody(args) {
|
|
348
|
+
return args.type === 'security' ? buildSecurityPlanBody(args.project) : buildPerfPlanBody(args.project);
|
|
349
|
+
}
|
|
350
|
+
// hash helper for tests that want to assert a body against a fixture.
|
|
351
|
+
export function hashBody(body) {
|
|
352
|
+
return hashNormalizedBody(body);
|
|
353
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks workflow plan detect-trigger` — slice 025 (Security + Perf
|
|
3
|
+
* Plan/Result split).
|
|
4
|
+
*
|
|
5
|
+
* Compares the current project state (filesystem + package.json) to the
|
|
6
|
+
* last-refresh fingerprint and returns whether a plan refresh is
|
|
7
|
+
* warranted. Five trigger rules, locked decision 1 excludes
|
|
8
|
+
* devDependencies.
|
|
9
|
+
*
|
|
10
|
+
* The slice's "diff" is supplied as a `SliceDiff` object; when not
|
|
11
|
+
* supplied, the detector scans the project directly (the same scan the
|
|
12
|
+
* refresh plan performs).
|
|
13
|
+
*/
|
|
14
|
+
import { type ResultEnvelope } from '../../shared/result.js';
|
|
15
|
+
export type TriggerReason = 'new-dependency' | 'auth-surface-added' | 'hot-path-added' | 'manual-override' | 'no-change' | 'no-triggering-change';
|
|
16
|
+
/** F-1 (slice 025 security): canonical request-id shape. */
|
|
17
|
+
export declare const REQUEST_ID_PATTERN: RegExp;
|
|
18
|
+
export interface DetectTriggerArgs {
|
|
19
|
+
readonly project: string;
|
|
20
|
+
readonly rid: string;
|
|
21
|
+
readonly sessionId: string;
|
|
22
|
+
/** Optional slice diff — when provided, takes precedence over a fresh
|
|
23
|
+
* filesystem scan. Shape mirrors the `peaks request diff <rid> --json`
|
|
24
|
+
* output's `packageJson` field. */
|
|
25
|
+
readonly diff?: SliceDiff | null;
|
|
26
|
+
/** When true, the caller is the slice workflow with `--refresh` set.
|
|
27
|
+
* Forces triggered=true. Per PRD trigger table. */
|
|
28
|
+
readonly manualOverride?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface SliceDiff {
|
|
31
|
+
readonly packageJson?: {
|
|
32
|
+
readonly dependencies?: {
|
|
33
|
+
readonly added?: readonly string[];
|
|
34
|
+
readonly removed?: readonly string[];
|
|
35
|
+
readonly changed?: readonly string[];
|
|
36
|
+
};
|
|
37
|
+
readonly optionalDependencies?: {
|
|
38
|
+
readonly added?: readonly string[];
|
|
39
|
+
readonly removed?: readonly string[];
|
|
40
|
+
readonly changed?: readonly string[];
|
|
41
|
+
};
|
|
42
|
+
readonly devDependencies?: {
|
|
43
|
+
readonly added?: readonly string[];
|
|
44
|
+
readonly removed?: readonly string[];
|
|
45
|
+
readonly changed?: readonly string[];
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
readonly newFiles?: readonly string[];
|
|
49
|
+
readonly changedFiles?: readonly string[];
|
|
50
|
+
}
|
|
51
|
+
export interface DetectTriggerData {
|
|
52
|
+
readonly triggered: boolean;
|
|
53
|
+
readonly reason: TriggerReason;
|
|
54
|
+
}
|
|
55
|
+
export declare function detectTrigger(args: DetectTriggerArgs): ResultEnvelope<DetectTriggerData>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks workflow plan detect-trigger` — slice 025 (Security + Perf
|
|
3
|
+
* Plan/Result split).
|
|
4
|
+
*
|
|
5
|
+
* Compares the current project state (filesystem + package.json) to the
|
|
6
|
+
* last-refresh fingerprint and returns whether a plan refresh is
|
|
7
|
+
* warranted. Five trigger rules, locked decision 1 excludes
|
|
8
|
+
* devDependencies.
|
|
9
|
+
*
|
|
10
|
+
* The slice's "diff" is supplied as a `SliceDiff` object; when not
|
|
11
|
+
* supplied, the detector scans the project directly (the same scan the
|
|
12
|
+
* refresh plan performs).
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { fail, ok } from '../../shared/result.js';
|
|
17
|
+
/** F-1 (slice 025 security): canonical request-id shape. */
|
|
18
|
+
export const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
19
|
+
const SENSITIVE_SERVICE_DIRS = ['auth', 'security', 'secrets', 'payments', 'filesystem'];
|
|
20
|
+
function readPackageJson(projectRoot) {
|
|
21
|
+
const path = join(projectRoot, 'package.json');
|
|
22
|
+
if (!existsSync(path))
|
|
23
|
+
return null;
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function isAuthFile(path) {
|
|
32
|
+
return /auth.*\.ts$|\.ts$/i.test(path) && /auth/i.test(path);
|
|
33
|
+
}
|
|
34
|
+
function isHotPathFile(path) {
|
|
35
|
+
return /router\.ts$|commands\/.*-commands\.ts$/i.test(path);
|
|
36
|
+
}
|
|
37
|
+
function isSensitiveServiceFile(path) {
|
|
38
|
+
if (!/^src\/services\/(auth|security|secrets|payments|filesystem)\//.test(path))
|
|
39
|
+
return false;
|
|
40
|
+
return /\.ts$/.test(path);
|
|
41
|
+
}
|
|
42
|
+
function freshScan(projectRoot) {
|
|
43
|
+
const pkg = readPackageJson(projectRoot);
|
|
44
|
+
const newFiles = [];
|
|
45
|
+
const root = join(projectRoot, 'src');
|
|
46
|
+
if (existsSync(root)) {
|
|
47
|
+
const stack = [root];
|
|
48
|
+
while (stack.length > 0) {
|
|
49
|
+
const dir = stack.pop();
|
|
50
|
+
if (dir === undefined)
|
|
51
|
+
continue;
|
|
52
|
+
let entries;
|
|
53
|
+
try {
|
|
54
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist')
|
|
61
|
+
continue;
|
|
62
|
+
const full = join(dir, entry.name);
|
|
63
|
+
if (entry.isDirectory()) {
|
|
64
|
+
stack.push(full);
|
|
65
|
+
}
|
|
66
|
+
else if (entry.isFile() && entry.name.endsWith('.ts')) {
|
|
67
|
+
// For the purposes of trigger detection, every TS file is a
|
|
68
|
+
// candidate; the gate logic decides which class it falls into.
|
|
69
|
+
newFiles.push(full);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
packageJson: {
|
|
76
|
+
dependencies: { added: Object.keys(pkg?.dependencies ?? {}), removed: [], changed: [] },
|
|
77
|
+
optionalDependencies: { added: Object.keys(pkg?.optionalDependencies ?? {}), removed: [], changed: [] },
|
|
78
|
+
devDependencies: { added: Object.keys(pkg?.devDependencies ?? {}), removed: [], changed: [] }
|
|
79
|
+
},
|
|
80
|
+
newFiles: newFiles.sort(),
|
|
81
|
+
changedFiles: []
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function anyAddedDeps(diff) {
|
|
85
|
+
const dep = diff.packageJson?.dependencies?.added ?? [];
|
|
86
|
+
const opt = diff.packageJson?.optionalDependencies?.added ?? [];
|
|
87
|
+
return dep.length > 0 || opt.length > 0;
|
|
88
|
+
}
|
|
89
|
+
function findNewAuthFile(diff) {
|
|
90
|
+
for (const f of diff.newFiles ?? []) {
|
|
91
|
+
if (isAuthFile(f))
|
|
92
|
+
return f;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
function findNewSensitiveServiceFile(diff) {
|
|
97
|
+
for (const f of diff.newFiles ?? []) {
|
|
98
|
+
if (isSensitiveServiceFile(f))
|
|
99
|
+
return f;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
function findNewHotPathFile(diff) {
|
|
104
|
+
for (const f of diff.newFiles ?? []) {
|
|
105
|
+
if (isHotPathFile(f))
|
|
106
|
+
return f;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
export function detectTrigger(args) {
|
|
111
|
+
// F-1 (slice 025 security): reject traversal/separator payloads at
|
|
112
|
+
// the service boundary so every caller (CLI, skill, integration test)
|
|
113
|
+
// gets the same rejection shape.
|
|
114
|
+
if (!REQUEST_ID_PATTERN.test(args.rid)) {
|
|
115
|
+
return fail('workflow.plan.detect-trigger', 'INVALID_RID', 'request id must match [A-Za-z0-9][A-Za-z0-9._-]*', {
|
|
116
|
+
triggered: false,
|
|
117
|
+
reason: 'no-triggering-change'
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (args.manualOverride === true) {
|
|
121
|
+
return ok('workflow.plan.detect-trigger', { triggered: true, reason: 'manual-override' });
|
|
122
|
+
}
|
|
123
|
+
const diff = args.diff ?? freshScan(args.project);
|
|
124
|
+
// Rule 1: new top-level dependency in `dependencies` or `optionalDependencies`
|
|
125
|
+
// (devDependencies explicitly excluded per locked decision 1).
|
|
126
|
+
if (anyAddedDeps(diff)) {
|
|
127
|
+
return ok('workflow.plan.detect-trigger', { triggered: true, reason: 'new-dependency' });
|
|
128
|
+
}
|
|
129
|
+
// Rule 2: new file under src/services/{auth,security,secrets,payments,filesystem}/
|
|
130
|
+
if (findNewSensitiveServiceFile(diff) !== null) {
|
|
131
|
+
return ok('workflow.plan.detect-trigger', { triggered: true, reason: 'auth-surface-added' });
|
|
132
|
+
}
|
|
133
|
+
// Rule 3: new *auth*.ts file anywhere in src/
|
|
134
|
+
if (findNewAuthFile(diff) !== null) {
|
|
135
|
+
return ok('workflow.plan.detect-trigger', { triggered: true, reason: 'auth-surface-added' });
|
|
136
|
+
}
|
|
137
|
+
// Rule 4: new endpoint / route registration
|
|
138
|
+
if (findNewHotPathFile(diff) !== null) {
|
|
139
|
+
return ok('workflow.plan.detect-trigger', { triggered: true, reason: 'hot-path-added' });
|
|
140
|
+
}
|
|
141
|
+
return ok('workflow.plan.detect-trigger', { triggered: false, reason: 'no-triggering-change' });
|
|
142
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks workspace migrate-1-4-1` — R004.
|
|
3
|
+
*
|
|
4
|
+
* Cleanup command for projects upgraded from peaks-cli 1.4.1 → 1.4.2.
|
|
5
|
+
*
|
|
6
|
+
* Slice 006 (1.4.0) moved the canonical per-session root to
|
|
7
|
+
* `.peaks/_runtime/<sid>/`. Per-request artifacts (PRD, RD, QA, SC requests)
|
|
8
|
+
* were written to the new root, but per-session artifacts (tech-doc.md,
|
|
9
|
+
* code-review.md, test-cases/<rid>.md, etc.) were kept at the legacy
|
|
10
|
+
* `.peaks/<sid>/<role>/<file>.md` path. The 2-tier fallback in
|
|
11
|
+
* `resolvePrerequisiteAbsolutePathWithFallback` accepts either location, so
|
|
12
|
+
* the functional behavior is correct, but the user's filesystem has visible
|
|
13
|
+
* dual-path duplication ("飘逸" — the user's term for the UX).
|
|
14
|
+
*
|
|
15
|
+
* R004 ships this command to physically move the legacy per-session files
|
|
16
|
+
* into the canonical `_runtime/<sid>/<role>/` location. After this runs,
|
|
17
|
+
* the project has a single canonical tree; the legacy `<sid>/<role>/`
|
|
18
|
+
* directories are removed (only if empty after the move).
|
|
19
|
+
*
|
|
20
|
+
* Default: dry-run. Pass `--apply` to actually move.
|
|
21
|
+
*/
|
|
22
|
+
export declare const PER_SESSION_ARTIFACT_TYPES: readonly ["rd/tech-doc.md", "rd/code-review.md", "rd/security-review.md", "rd/perf-baseline.md", "rd/bug-analysis.md", "qa/security-findings.md", "qa/performance-findings.md"];
|
|
23
|
+
export declare const PER_REQUEST_ARTIFACT_TYPES: readonly ["qa/test-cases/<rid>.md", "qa/test-reports/<rid>.md"];
|
|
24
|
+
export type MigrationPlanEntry = {
|
|
25
|
+
readonly sessionId: string;
|
|
26
|
+
readonly relativePath: string;
|
|
27
|
+
readonly from: string;
|
|
28
|
+
readonly to: string;
|
|
29
|
+
readonly sha256: string;
|
|
30
|
+
readonly reason: 'legacy-only' | 'identical-content-already-canonical' | 'content-mismatch' | 'no-legacy-file';
|
|
31
|
+
};
|
|
32
|
+
export type MigrationResult = {
|
|
33
|
+
readonly plan: ReadonlyArray<MigrationPlanEntry>;
|
|
34
|
+
readonly applied: boolean;
|
|
35
|
+
readonly movedCount: number;
|
|
36
|
+
readonly conflictCount: number;
|
|
37
|
+
readonly deletedEmptyDirs: ReadonlyArray<string>;
|
|
38
|
+
readonly errors: ReadonlyArray<{
|
|
39
|
+
path: string;
|
|
40
|
+
message: string;
|
|
41
|
+
}>;
|
|
42
|
+
};
|
|
43
|
+
export declare function planMigrate1_4_1(projectRoot: string): MigrationResult;
|
|
44
|
+
export declare function applyMigrate1_4_1(projectRoot: string): MigrationResult;
|