peaks-cli 1.3.8 → 1.4.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/dist/src/cli/commands/core-artifact-commands.js +27 -0
- 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/commands/skill-scope-commands.d.ts +49 -0
- package/dist/src/cli/commands/skill-scope-commands.js +305 -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/program.js +8 -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/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/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 +75 -0
- package/dist/src/services/skill-scope/detect.js +480 -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 +176 -0
- package/dist/src/services/skill-scope/types.js +74 -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/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 +3 -2
- package/schemas/doctor-report.schema.json +2 -2
- package/skills/peaks-qa/SKILL.md +103 -507
- 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-perf-test-plan.md +67 -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-security-test-plan.md +73 -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 +83 -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 +42 -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,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks workflow plan read` — slice 025 (Security + Perf Plan/Result split).
|
|
3
|
+
*
|
|
4
|
+
* Returns the envelope `{ exists, path, hash, refreshedAt }` for the
|
|
5
|
+
* session-scoped security-test-plan or perf-baseline plan. The hash is
|
|
6
|
+
* computed on the **normalized** body (sections sorted, blank lines
|
|
7
|
+
* collapsed) so it is independent of cosmetic re-ordering; mtime is
|
|
8
|
+
* surfaced as ISO-8601.
|
|
9
|
+
*
|
|
10
|
+
* Back-compat: when the BACK_COMPAT_FLAG env var is "1" and the
|
|
11
|
+
* legacy path (`.peaks/<planFile>` at the project root) exists but
|
|
12
|
+
* the canonical session path does not, the reader falls back to the
|
|
13
|
+
* legacy path and reports `source: "legacy"`.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
17
|
+
import { join, sep } from 'node:path';
|
|
18
|
+
import { fail, ok } from '../../shared/result.js';
|
|
19
|
+
import { getSessionDir } from '../session/getSessionDir.js';
|
|
20
|
+
/** Back-compat env-var. When set to "1", fall back to legacy paths. */
|
|
21
|
+
export const BACK_COMPAT_FLAG = 'PEAKS_PLAN_LEGACY_FALLBACK';
|
|
22
|
+
/** F-1 (slice 025 security): canonical session-id shape. */
|
|
23
|
+
export const SESSION_ID_PATTERN = /^\d{4}-\d{2}-\d{2}-[a-z][a-z0-9-]*[a-z0-9]$/;
|
|
24
|
+
const PLAN_FILE = {
|
|
25
|
+
security: 'security-test-plan.md',
|
|
26
|
+
perf: 'perf-baseline.md'
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Normalize a markdown body for hashing. Sections sorted, blank lines
|
|
30
|
+
* collapsed, leading/trailing whitespace stripped. Hash is sha256[0:12].
|
|
31
|
+
*/
|
|
32
|
+
export function normalizePlanBody(body) {
|
|
33
|
+
const lines = body
|
|
34
|
+
.split(/\r?\n/)
|
|
35
|
+
.map((line) => line.trim())
|
|
36
|
+
.filter((line) => line.length > 0);
|
|
37
|
+
return lines.sort().join('\n');
|
|
38
|
+
}
|
|
39
|
+
/** Compute the deterministic plan hash on a normalized body. */
|
|
40
|
+
export function hashNormalizedBody(body) {
|
|
41
|
+
const normalized = normalizePlanBody(body);
|
|
42
|
+
return createHash('sha256').update(normalized, 'utf8').digest('hex').slice(0, 12);
|
|
43
|
+
}
|
|
44
|
+
function canonicalPath(args) {
|
|
45
|
+
return join(getSessionDir(args.projectRoot, args.sessionId), 'qa', PLAN_FILE[args.type]);
|
|
46
|
+
}
|
|
47
|
+
function legacyPath(args) {
|
|
48
|
+
return join(args.projectRoot, '.peaks', PLAN_FILE[args.type]);
|
|
49
|
+
}
|
|
50
|
+
/** Build the data envelope for a path that exists. */
|
|
51
|
+
function buildData(args) {
|
|
52
|
+
const stats = statSync(args.path);
|
|
53
|
+
return {
|
|
54
|
+
type: args.type,
|
|
55
|
+
exists: true,
|
|
56
|
+
path: args.path,
|
|
57
|
+
hash: hashNormalizedBody(readFileSync(args.path, 'utf8')),
|
|
58
|
+
refreshedAt: stats.mtime.toISOString(),
|
|
59
|
+
source: args.source
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* F-2 (slice 025 security): resolve symlinks and confirm the real path
|
|
64
|
+
* still lives under the expected base directory. A canonical path
|
|
65
|
+
* may itself be a symlink (or a directory in the path chain may be),
|
|
66
|
+
* which would let a malicious or accidental symlink escape the
|
|
67
|
+
* `.peaks/_runtime/<sessionId>/` containment. We reject anything
|
|
68
|
+
* whose real path falls outside the expected base.
|
|
69
|
+
*
|
|
70
|
+
* The caller passes the expected base:
|
|
71
|
+
* - session dir for canonical reads (`.peaks/_runtime/<sid>/qa/...`)
|
|
72
|
+
* - project root for legacy back-compat reads (`.peaks/<planFile>`)
|
|
73
|
+
*/
|
|
74
|
+
function assertContained(args) {
|
|
75
|
+
let real;
|
|
76
|
+
try {
|
|
77
|
+
real = realpathSync(args.path);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// If realpath fails (e.g. broken symlink), treat as escape — never
|
|
81
|
+
// return "ok" without a verified real path.
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
code: 'SYMLINK_ESCAPE',
|
|
85
|
+
message: `resolved path escapes base directory: cannot resolve ${args.path}`
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const expectedPrefix = join(args.expectedBase, sep);
|
|
89
|
+
if (!real.startsWith(expectedPrefix)) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
code: 'SYMLINK_ESCAPE',
|
|
93
|
+
message: `resolved path escapes base directory: ${real} is not under ${expectedPrefix}`
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return { ok: true, real };
|
|
97
|
+
}
|
|
98
|
+
export function readPlan(args) {
|
|
99
|
+
// F-1 (slice 025 security): reject path-traversal payloads before any
|
|
100
|
+
// filesystem call. The CLI also validates, but the service is the
|
|
101
|
+
// authoritative gate — every caller (CLI, skill, integration test)
|
|
102
|
+
// benefits from the same rejection shape.
|
|
103
|
+
if (!SESSION_ID_PATTERN.test(args.sessionId)) {
|
|
104
|
+
return fail('workflow.plan.read', 'INVALID_SESSION_ID', 'session id must match YYYY-MM-DD-slug pattern', {
|
|
105
|
+
type: args.type,
|
|
106
|
+
exists: false,
|
|
107
|
+
path: '',
|
|
108
|
+
hash: null,
|
|
109
|
+
refreshedAt: null,
|
|
110
|
+
source: 'missing'
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const canonical = canonicalPath({ projectRoot: args.project, sessionId: args.sessionId, type: args.type });
|
|
114
|
+
if (existsSync(canonical)) {
|
|
115
|
+
const guard = assertContained({
|
|
116
|
+
expectedBase: getSessionDir(args.project, args.sessionId),
|
|
117
|
+
path: canonical
|
|
118
|
+
});
|
|
119
|
+
if (!guard.ok) {
|
|
120
|
+
return fail('workflow.plan.read', guard.code, guard.message, {
|
|
121
|
+
type: args.type,
|
|
122
|
+
exists: false,
|
|
123
|
+
path: canonical,
|
|
124
|
+
hash: null,
|
|
125
|
+
refreshedAt: null,
|
|
126
|
+
source: 'missing'
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return ok('workflow.plan.read', buildData({ type: args.type, path: canonical, source: 'canonical' }));
|
|
130
|
+
}
|
|
131
|
+
const legacy = legacyPath({ projectRoot: args.project, type: args.type });
|
|
132
|
+
const backCompatEnabled = process.env[BACK_COMPAT_FLAG] === '1';
|
|
133
|
+
if (backCompatEnabled && existsSync(legacy)) {
|
|
134
|
+
const guard = assertContained({
|
|
135
|
+
expectedBase: args.project,
|
|
136
|
+
path: legacy
|
|
137
|
+
});
|
|
138
|
+
if (!guard.ok) {
|
|
139
|
+
return fail('workflow.plan.read', guard.code, guard.message, {
|
|
140
|
+
type: args.type,
|
|
141
|
+
exists: false,
|
|
142
|
+
path: legacy,
|
|
143
|
+
hash: null,
|
|
144
|
+
refreshedAt: null,
|
|
145
|
+
source: 'missing'
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return ok('workflow.plan.read', buildData({ type: args.type, path: legacy, source: 'legacy' }));
|
|
149
|
+
}
|
|
150
|
+
return ok('workflow.plan.read', {
|
|
151
|
+
type: args.type,
|
|
152
|
+
exists: false,
|
|
153
|
+
path: canonical,
|
|
154
|
+
hash: null,
|
|
155
|
+
refreshedAt: null,
|
|
156
|
+
source: 'missing'
|
|
157
|
+
});
|
|
158
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type ResultEnvelope } from '../../shared/result.js';
|
|
2
|
+
import { normalizePlanBody, type PlanType } from './plan-reader.js';
|
|
3
|
+
export interface RefreshPlanArgs {
|
|
4
|
+
readonly type: PlanType;
|
|
5
|
+
readonly project: string;
|
|
6
|
+
readonly sessionId: string;
|
|
7
|
+
/** When true, write the plan to disk. When false, return the would-be body + hash only. */
|
|
8
|
+
readonly apply: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface RefreshPlanData {
|
|
11
|
+
readonly type: PlanType;
|
|
12
|
+
readonly writtenFiles: string[];
|
|
13
|
+
/** When `apply=false`, the would-be write targets. */
|
|
14
|
+
readonly wouldWrite: string[];
|
|
15
|
+
readonly hash: string;
|
|
16
|
+
readonly refreshedAt: string;
|
|
17
|
+
readonly dryRun: boolean;
|
|
18
|
+
/** The deterministic body (post-normalization). Always surfaced so tests
|
|
19
|
+
* can assert byte-equality across runs. */
|
|
20
|
+
readonly bodyPreview: string;
|
|
21
|
+
}
|
|
22
|
+
/** Build the security-test-plan body deterministically. */
|
|
23
|
+
export declare function buildSecurityPlanBody(projectRoot: string): string;
|
|
24
|
+
/** Build the perf-baseline body deterministically. */
|
|
25
|
+
export declare function buildPerfPlanBody(projectRoot: string): string;
|
|
26
|
+
export declare function refreshPlan(args: RefreshPlanArgs): ResultEnvelope<RefreshPlanData>;
|
|
27
|
+
export { normalizePlanBody };
|
|
28
|
+
export declare function renderPlanBody(args: {
|
|
29
|
+
type: PlanType;
|
|
30
|
+
project: string;
|
|
31
|
+
}): string;
|
|
32
|
+
export declare function hashBody(body: string): string;
|
|
@@ -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>;
|