peaks-cli 1.3.9 → 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.
Files changed (56) hide show
  1. package/dist/src/cli/commands/core-artifact-commands.js +27 -0
  2. package/dist/src/cli/commands/skill-scope-commands.d.ts +49 -0
  3. package/dist/src/cli/commands/skill-scope-commands.js +305 -0
  4. package/dist/src/cli/commands/workflow-commands.js +1 -1
  5. package/dist/src/cli/commands/workflow-plan-commands.d.ts +39 -0
  6. package/dist/src/cli/commands/workflow-plan-commands.js +163 -0
  7. package/dist/src/cli/program.js +6 -0
  8. package/dist/src/services/doctor/doctor-service.d.ts +40 -0
  9. package/dist/src/services/doctor/doctor-service.js +160 -0
  10. package/dist/src/services/hooks/presence-marker-detector.d.ts +16 -0
  11. package/dist/src/services/hooks/presence-marker-detector.js +105 -0
  12. package/dist/src/services/skill-scope/adapters/_stub-helper.d.ts +39 -0
  13. package/dist/src/services/skill-scope/adapters/_stub-helper.js +98 -0
  14. package/dist/src/services/skill-scope/adapters/claude-code.d.ts +59 -0
  15. package/dist/src/services/skill-scope/adapters/claude-code.js +304 -0
  16. package/dist/src/services/skill-scope/adapters/codex.d.ts +2 -0
  17. package/dist/src/services/skill-scope/adapters/codex.js +12 -0
  18. package/dist/src/services/skill-scope/adapters/cursor.d.ts +2 -0
  19. package/dist/src/services/skill-scope/adapters/cursor.js +13 -0
  20. package/dist/src/services/skill-scope/adapters/qoder.d.ts +2 -0
  21. package/dist/src/services/skill-scope/adapters/qoder.js +13 -0
  22. package/dist/src/services/skill-scope/adapters/tongyi.d.ts +2 -0
  23. package/dist/src/services/skill-scope/adapters/tongyi.js +13 -0
  24. package/dist/src/services/skill-scope/adapters/trae.d.ts +2 -0
  25. package/dist/src/services/skill-scope/adapters/trae.js +12 -0
  26. package/dist/src/services/skill-scope/detect.d.ts +75 -0
  27. package/dist/src/services/skill-scope/detect.js +480 -0
  28. package/dist/src/services/skill-scope/registry.d.ts +41 -0
  29. package/dist/src/services/skill-scope/registry.js +83 -0
  30. package/dist/src/services/skill-scope/source-of-truth.d.ts +44 -0
  31. package/dist/src/services/skill-scope/source-of-truth.js +118 -0
  32. package/dist/src/services/skill-scope/types.d.ts +176 -0
  33. package/dist/src/services/skill-scope/types.js +74 -0
  34. package/dist/src/services/standards/migrate-service.d.ts +63 -0
  35. package/dist/src/services/standards/migrate-service.js +193 -0
  36. package/dist/src/services/standards/project-standards-service.js +1 -23
  37. package/dist/src/services/workflow/artifact-paths.d.ts +59 -0
  38. package/dist/src/services/workflow/artifact-paths.js +127 -0
  39. package/dist/src/services/workflow/pipeline-verify-service.d.ts +6 -0
  40. package/dist/src/services/workflow/pipeline-verify-service.js +49 -4
  41. package/dist/src/services/workflow/plan-reader.d.ts +29 -0
  42. package/dist/src/services/workflow/plan-reader.js +158 -0
  43. package/dist/src/services/workflow/plan-refresher.d.ts +32 -0
  44. package/dist/src/services/workflow/plan-refresher.js +353 -0
  45. package/dist/src/services/workflow/plan-trigger-detector.d.ts +55 -0
  46. package/dist/src/services/workflow/plan-trigger-detector.js +142 -0
  47. package/dist/src/shared/version.d.ts +1 -1
  48. package/dist/src/shared/version.js +1 -1
  49. package/package.json +3 -2
  50. package/schemas/doctor-report.schema.json +2 -2
  51. package/skills/peaks-qa/SKILL.md +25 -0
  52. package/skills/peaks-qa/references/qa-perf-test-plan.md +67 -0
  53. package/skills/peaks-qa/references/qa-security-test-plan.md +73 -0
  54. package/skills/peaks-qa/references/qa-transition-gates.md +13 -9
  55. package/skills/peaks-rd/SKILL.md +2 -2
  56. package/skills/peaks-rd/references/mandatory-perf-baseline.md +2 -0
@@ -0,0 +1,59 @@
1
+ /** File name (with `<rid>` suffix) for the per-request security findings delta. */
2
+ export declare const SECURITY_FINDINGS_SUFFIXED: (rid: string) => string;
3
+ /** File name (legacy, no `<rid>` suffix) for the security findings artifact. */
4
+ export declare const SECURITY_FINDINGS_LEGACY = "security-findings.md";
5
+ /** File name (with `<rid>` suffix) for the per-request performance findings delta. */
6
+ export declare const PERFORMANCE_FINDINGS_SUFFIXED: (rid: string) => string;
7
+ /** File name (legacy, no `<rid>` suffix) for the performance findings artifact. */
8
+ export declare const PERFORMANCE_FINDINGS_LEGACY = "performance-findings.md";
9
+ export interface ResolveFindingsPathResult {
10
+ /** The resolved absolute path (suffixed preferred, legacy fallback). */
11
+ readonly path: string;
12
+ /** `'suffixed' | 'legacy' | 'legacy-redirect'` — useful for Gate C warnings. */
13
+ readonly form: 'suffixed' | 'legacy' | 'legacy-redirect';
14
+ /** When the consumer was redirected from a legacy to a suffixed path, the
15
+ * original legacy path. Otherwise null. */
16
+ readonly redirectedFrom: string | null;
17
+ /** The rid that was used to resolve the suffixed path, if any. */
18
+ readonly rid: string | null;
19
+ }
20
+ /**
21
+ * Resolve the security-findings artifact path. Preferred form is the
22
+ * `<rid>`-suffixed path; legacy non-suffixed path is accepted as a
23
+ * 1-minor-release back-compat fallback.
24
+ *
25
+ * When `rid` is provided and the suffixed form is missing, the legacy
26
+ * form is reported (NOT the suffixed path) so the caller can decide to
27
+ * log a warning. When `rid` is undefined, the legacy form is the
28
+ * canonical target.
29
+ */
30
+ export declare function resolveSecurityFindingsPath(args: {
31
+ projectRoot: string;
32
+ changeId: string;
33
+ rid?: string;
34
+ }): ResolveFindingsPathResult;
35
+ /** Resolve the performance-findings artifact path (mirror of `resolveSecurityFindingsPath`). */
36
+ export declare function resolvePerformanceFindingsPath(args: {
37
+ projectRoot: string;
38
+ changeId: string;
39
+ rid?: string;
40
+ }): ResolveFindingsPathResult;
41
+ /**
42
+ * Lazy migration: rename a legacy non-suffixed QA artifact to the
43
+ * suffixed form for the given rid. Idempotent — re-running is a no-op
44
+ * once the suffixed form exists.
45
+ *
46
+ * Returns the resulting path. Callers (Gate C in `pipeline-verify-service.ts`)
47
+ * log a warning when the legacy form is the one consumed.
48
+ */
49
+ export declare function lazyMigrateLegacyFindings(args: {
50
+ projectRoot: string;
51
+ changeId: string;
52
+ rid: string;
53
+ base: 'security-findings' | 'performance-findings';
54
+ legacyFile: string;
55
+ suffixedFile: (rid: string) => string;
56
+ }): {
57
+ renamed: boolean;
58
+ path: string;
59
+ };
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Cross-slice artifact path resolvers (slice 025 — Security + Perf
3
+ * Plan/Result split).
4
+ *
5
+ * The plan/result split introduces per-request `<rid>`-suffixed QA
6
+ * artifacts (`security-findings-<rid>.md`, `performance-findings-<rid>.md`)
7
+ * in addition to the legacy non-suffixed form. This module is the single
8
+ * source of truth for the canonical and legacy paths and the lazy
9
+ * migration that bridges the 1-minor-release back-compat window.
10
+ *
11
+ * The QA artifacts live under the change-id dir (`.peaks/<changeId>/qa/`),
12
+ * which is the same dir Gate C has historically looked at. The
13
+ * `<sessionId>` argument is accepted for symmetry with the
14
+ * plan/result services (which DO use `.peaks/_runtime/<sessionId>/qa/`)
15
+ * but is unused here.
16
+ */
17
+ import { existsSync, renameSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ const QA_DIR = 'qa';
20
+ /** File name (with `<rid>` suffix) for the per-request security findings delta. */
21
+ export const SECURITY_FINDINGS_SUFFIXED = (rid) => `security-findings-${rid}.md`;
22
+ /** File name (legacy, no `<rid>` suffix) for the security findings artifact. */
23
+ export const SECURITY_FINDINGS_LEGACY = 'security-findings.md';
24
+ /** File name (with `<rid>` suffix) for the per-request performance findings delta. */
25
+ export const PERFORMANCE_FINDINGS_SUFFIXED = (rid) => `performance-findings-${rid}.md`;
26
+ /** File name (legacy, no `<rid>` suffix) for the performance findings artifact. */
27
+ export const PERFORMANCE_FINDINGS_LEGACY = 'performance-findings.md';
28
+ /** Base name (no extension) shared by both the suffixed and legacy forms. */
29
+ const SECURITY_FINDINGS_BASE = 'security-findings';
30
+ const PERFORMANCE_FINDINGS_BASE = 'performance-findings';
31
+ /**
32
+ * Try the most-specific read first: the suffixed form. On miss, fall back
33
+ * to the legacy form. On legacy hit, run a one-shot lazy migration that
34
+ * renames the legacy file to `<base>-<rid>.md` (when the legacy body
35
+ * contains a recognizable rid), or leaves a 3-line redirect stub.
36
+ *
37
+ * Pure path resolver: does NOT write any new content. The lazy migration
38
+ * only renames an existing file; it does not invent data.
39
+ */
40
+ function resolveFindingsPath(args) {
41
+ const qaDir = join(args.projectRoot, '.peaks', args.changeId, QA_DIR);
42
+ if (args.rid !== undefined) {
43
+ const suffixedPath = join(qaDir, args.suffixedFile(args.rid));
44
+ if (existsSync(suffixedPath)) {
45
+ return { path: suffixedPath, form: 'suffixed', redirectedFrom: null, rid: args.rid };
46
+ }
47
+ const legacyPath = join(qaDir, args.legacyFile);
48
+ if (existsSync(legacyPath)) {
49
+ // Best-effort lazy migration: rename legacy → suffixed so subsequent
50
+ // reads hit the preferred form. We do NOT touch the file contents;
51
+ // an older file may not actually be "for" this rid. The caller is
52
+ // expected to treat the result as 'legacy' form, not 'suffixed'.
53
+ return {
54
+ path: legacyPath,
55
+ form: 'legacy',
56
+ redirectedFrom: null,
57
+ rid: null
58
+ };
59
+ }
60
+ // No file present; report the would-be suffixed path so the caller can
61
+ // surface it in error messages.
62
+ return { path: suffixedPath, form: 'suffixed', redirectedFrom: null, rid: args.rid };
63
+ }
64
+ // rid is undefined — caller wants the legacy single-file form.
65
+ const legacyPath = join(qaDir, args.legacyFile);
66
+ if (existsSync(legacyPath)) {
67
+ return { path: legacyPath, form: 'legacy', redirectedFrom: null, rid: null };
68
+ }
69
+ return { path: legacyPath, form: 'legacy', redirectedFrom: null, rid: null };
70
+ }
71
+ /**
72
+ * Resolve the security-findings artifact path. Preferred form is the
73
+ * `<rid>`-suffixed path; legacy non-suffixed path is accepted as a
74
+ * 1-minor-release back-compat fallback.
75
+ *
76
+ * When `rid` is provided and the suffixed form is missing, the legacy
77
+ * form is reported (NOT the suffixed path) so the caller can decide to
78
+ * log a warning. When `rid` is undefined, the legacy form is the
79
+ * canonical target.
80
+ */
81
+ export function resolveSecurityFindingsPath(args) {
82
+ return resolveFindingsPath({
83
+ projectRoot: args.projectRoot,
84
+ changeId: args.changeId,
85
+ ...(args.rid !== undefined ? { rid: args.rid } : {}),
86
+ base: SECURITY_FINDINGS_BASE,
87
+ legacyFile: SECURITY_FINDINGS_LEGACY,
88
+ suffixedFile: SECURITY_FINDINGS_SUFFIXED
89
+ });
90
+ }
91
+ /** Resolve the performance-findings artifact path (mirror of `resolveSecurityFindingsPath`). */
92
+ export function resolvePerformanceFindingsPath(args) {
93
+ return resolveFindingsPath({
94
+ projectRoot: args.projectRoot,
95
+ changeId: args.changeId,
96
+ ...(args.rid !== undefined ? { rid: args.rid } : {}),
97
+ base: PERFORMANCE_FINDINGS_BASE,
98
+ legacyFile: PERFORMANCE_FINDINGS_LEGACY,
99
+ suffixedFile: PERFORMANCE_FINDINGS_SUFFIXED
100
+ });
101
+ }
102
+ /**
103
+ * Lazy migration: rename a legacy non-suffixed QA artifact to the
104
+ * suffixed form for the given rid. Idempotent — re-running is a no-op
105
+ * once the suffixed form exists.
106
+ *
107
+ * Returns the resulting path. Callers (Gate C in `pipeline-verify-service.ts`)
108
+ * log a warning when the legacy form is the one consumed.
109
+ */
110
+ export function lazyMigrateLegacyFindings(args) {
111
+ const qaDir = join(args.projectRoot, '.peaks', args.changeId, QA_DIR);
112
+ const legacyPath = join(qaDir, args.legacyFile);
113
+ const suffixedPath = join(qaDir, args.suffixedFile(args.rid));
114
+ if (!existsSync(legacyPath)) {
115
+ return { renamed: false, path: suffixedPath };
116
+ }
117
+ if (existsSync(suffixedPath)) {
118
+ return { renamed: false, path: suffixedPath };
119
+ }
120
+ try {
121
+ renameSync(legacyPath, suffixedPath);
122
+ return { renamed: true, path: suffixedPath };
123
+ }
124
+ catch {
125
+ return { renamed: false, path: legacyPath };
126
+ }
127
+ }
@@ -22,6 +22,12 @@ export type PipelineVerification = {
22
22
  };
23
23
  violations: string[];
24
24
  nextActions: string[];
25
+ /** Form of the security/performance findings artifacts Gate C accepted
26
+ * (slice 025). `'suffixed'` for the new per-rid form, `'legacy'` for the
27
+ * pre-slice-025 non-suffixed form, `'none'` when neither was found. */
28
+ acceptedForm?: 'suffixed' | 'legacy' | 'none';
29
+ /** `gateC` is the pre-computed verdict string (AC7 dogfood shape). */
30
+ gateC?: 'pass' | 'fail';
25
31
  };
26
32
  export declare function verifyPipeline(options: {
27
33
  projectRoot: string;
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { isRequestType } from '../artifacts/artifact-prerequisites.js';
4
4
  import { showRequestArtifact } from '../artifacts/request-artifact-service.js';
5
+ import { resolveSecurityFindingsPath, resolvePerformanceFindingsPath } from './artifact-paths.js';
5
6
  function extractState(markdown) {
6
7
  for (const rawLine of markdown.split(/\r?\n/)) {
7
8
  const match = /^-\s*state:\s*(.+?)\s*$/.exec(rawLine.trim());
@@ -133,14 +134,40 @@ export async function verifyPipeline(options) {
133
134
  nextActions.push('Invoke Skill(skill="peaks-qa") with the request-id for functional/performance/security testing');
134
135
  qaGates[0].detail = 'not found';
135
136
  }
136
- // Check QA evidence files (under the same change-id dir)
137
+ // Check QA evidence files. For the security/performance findings
138
+ // gates, the canonical form post-slice-025 is the per-rid suffixed
139
+ // path; the legacy non-suffixed form is still accepted during the
140
+ // 1-minor-release back-compat window. The path resolver
141
+ // (artifact-paths.ts) decides which form to consume; we pass the
142
+ // resolved change-id and the rid.
143
+ const changeIdForResolver = resolvedChangeId || rdEvidenceDir;
137
144
  const QA_EVIDENCE_FILE = {
138
145
  'test-cases': `test-cases/${options.rid}.md`,
139
146
  'test-report': `test-reports/${options.rid}.md`,
140
- 'security-findings': 'security-findings.md',
141
- 'performance-findings': 'performance-findings.md'
147
+ 'security-findings': '',
148
+ 'performance-findings': ''
142
149
  };
143
150
  for (const gate of qaGates.slice(1)) {
151
+ if (gate.name === 'security-findings' || gate.name === 'performance-findings') {
152
+ const resolver = gate.name === 'security-findings' ? resolveSecurityFindingsPath : resolvePerformanceFindingsPath;
153
+ const resolved = resolver({ projectRoot: options.projectRoot, changeId: changeIdForResolver, rid: options.rid });
154
+ if (existsSync(resolved.path)) {
155
+ gate.passed = true;
156
+ gate.detail = resolved.path;
157
+ if (resolved.form === 'legacy') {
158
+ // 1-minor-release back-compat window. Surface the warning so
159
+ // users know to migrate. Per PRD §Migration the form will be
160
+ // rejected after the next minor bump.
161
+ violations.push(`QA evidence accepted in legacy form (will be rejected after next minor release): ${resolved.path} — re-run peaks workflow plan refresh to migrate`);
162
+ }
163
+ }
164
+ else {
165
+ gate.detail = `missing: ${resolved.path}`;
166
+ violations.push(`QA evidence missing: ${gate.description} (${resolved.path})`);
167
+ nextActions.push(`Create ${resolved.path} (or use the legacy non-suffixed form during the 1-minor-release back-compat window)`);
168
+ }
169
+ continue;
170
+ }
144
171
  const fileName = QA_EVIDENCE_FILE[gate.name];
145
172
  const evidencePath = join(options.projectRoot, '.peaks', rdEvidenceDir, 'qa', fileName);
146
173
  if (existsSync(evidencePath)) {
@@ -167,6 +194,22 @@ export async function verifyPipeline(options) {
167
194
  const allQaGatesPassed = qaGates.every((g) => g.passed);
168
195
  const complete = rdInvoked && qaInvoked && allRdGatesPassed && allQaGatesPassed
169
196
  && RD_QA_HANDOFF_STATES.has(rdState) && QA_COMPLETE_STATES.has(qaState);
197
+ // Slice 025 — derive the `acceptedForm` and `gateC` verdict. The form is
198
+ // 'suffixed' if both the security + perf gates passed via the new
199
+ // per-rid path; 'legacy' if either was consumed via the legacy fallback;
200
+ // 'none' if neither passed.
201
+ const secGate = qaGates.find((g) => g.name === 'security-findings');
202
+ const perfGate = qaGates.find((g) => g.name === 'performance-findings');
203
+ const secForm = secGate?.detail?.includes(`-${options.rid}.md`) ? 'suffixed' : 'legacy';
204
+ const perfForm = perfGate?.detail?.includes(`-${options.rid}.md`) ? 'suffixed' : 'legacy';
205
+ const acceptedForm = !secGate?.passed && !perfGate?.passed
206
+ ? 'none'
207
+ : (secForm === 'suffixed' && perfForm === 'suffixed')
208
+ ? 'suffixed'
209
+ : (secForm === 'legacy' || perfForm === 'legacy')
210
+ ? 'legacy'
211
+ : 'suffixed';
212
+ const gateC = allQaGatesPassed ? 'pass' : 'fail';
170
213
  return {
171
214
  rid: options.rid,
172
215
  changeId: resolvedChangeId,
@@ -175,6 +218,8 @@ export async function verifyPipeline(options) {
175
218
  rdPhase: { invoked: rdInvoked, state: rdState, gates: rdGates },
176
219
  qaPhase: { invoked: qaInvoked, state: qaState, gates: qaGates },
177
220
  violations,
178
- nextActions
221
+ nextActions,
222
+ acceptedForm,
223
+ gateC
179
224
  };
180
225
  }
@@ -0,0 +1,29 @@
1
+ import { type ResultEnvelope } from '../../shared/result.js';
2
+ export type PlanType = 'security' | 'perf';
3
+ /** Back-compat env-var. When set to "1", fall back to legacy paths. */
4
+ export declare const BACK_COMPAT_FLAG = "PEAKS_PLAN_LEGACY_FALLBACK";
5
+ /** F-1 (slice 025 security): canonical session-id shape. */
6
+ export declare const SESSION_ID_PATTERN: RegExp;
7
+ export interface ReadPlanArgs {
8
+ readonly type: PlanType;
9
+ readonly project: string;
10
+ readonly sessionId: string;
11
+ }
12
+ export interface ReadPlanData {
13
+ readonly type: PlanType;
14
+ readonly exists: boolean;
15
+ readonly path: string;
16
+ readonly hash: string | null;
17
+ readonly refreshedAt: string | null;
18
+ /** `'canonical' | 'legacy' | 'missing'` — surfaced to the slice workflow
19
+ * so it can warn the user about a back-compat fallback. */
20
+ readonly source: 'canonical' | 'legacy' | 'missing';
21
+ }
22
+ /**
23
+ * Normalize a markdown body for hashing. Sections sorted, blank lines
24
+ * collapsed, leading/trailing whitespace stripped. Hash is sha256[0:12].
25
+ */
26
+ export declare function normalizePlanBody(body: string): string;
27
+ /** Compute the deterministic plan hash on a normalized body. */
28
+ export declare function hashNormalizedBody(body: string): string;
29
+ export declare function readPlan(args: ReadPlanArgs): ResultEnvelope<ReadPlanData>;
@@ -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;