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.
Files changed (130) hide show
  1. package/dist/src/cli/commands/core-artifact-commands.js +27 -0
  2. package/dist/src/cli/commands/project-commands.js +58 -1
  3. package/dist/src/cli/commands/request-commands.js +93 -3
  4. package/dist/src/cli/commands/retrospective-commands.d.ts +3 -0
  5. package/dist/src/cli/commands/retrospective-commands.js +113 -0
  6. package/dist/src/cli/commands/skill-scope-commands.d.ts +49 -0
  7. package/dist/src/cli/commands/skill-scope-commands.js +305 -0
  8. package/dist/src/cli/commands/workflow-commands.js +1 -1
  9. package/dist/src/cli/commands/workflow-plan-commands.d.ts +39 -0
  10. package/dist/src/cli/commands/workflow-plan-commands.js +163 -0
  11. package/dist/src/cli/program.js +8 -0
  12. package/dist/src/services/doctor/doctor-service.d.ts +40 -0
  13. package/dist/src/services/doctor/doctor-service.js +160 -0
  14. package/dist/src/services/hooks/presence-marker-detector.d.ts +16 -0
  15. package/dist/src/services/hooks/presence-marker-detector.js +105 -0
  16. package/dist/src/services/memory/project-memory-service.d.ts +19 -0
  17. package/dist/src/services/memory/project-memory-service.js +33 -0
  18. package/dist/src/services/retrospective/migrate-from-md.d.ts +37 -0
  19. package/dist/src/services/retrospective/migrate-from-md.js +528 -0
  20. package/dist/src/services/retrospective/retrospective-index.d.ts +37 -0
  21. package/dist/src/services/retrospective/retrospective-index.js +110 -0
  22. package/dist/src/services/retrospective/retrospective-show.d.ts +40 -0
  23. package/dist/src/services/retrospective/retrospective-show.js +109 -0
  24. package/dist/src/services/skill-scope/adapters/_stub-helper.d.ts +39 -0
  25. package/dist/src/services/skill-scope/adapters/_stub-helper.js +98 -0
  26. package/dist/src/services/skill-scope/adapters/claude-code.d.ts +59 -0
  27. package/dist/src/services/skill-scope/adapters/claude-code.js +304 -0
  28. package/dist/src/services/skill-scope/adapters/codex.d.ts +2 -0
  29. package/dist/src/services/skill-scope/adapters/codex.js +12 -0
  30. package/dist/src/services/skill-scope/adapters/cursor.d.ts +2 -0
  31. package/dist/src/services/skill-scope/adapters/cursor.js +13 -0
  32. package/dist/src/services/skill-scope/adapters/qoder.d.ts +2 -0
  33. package/dist/src/services/skill-scope/adapters/qoder.js +13 -0
  34. package/dist/src/services/skill-scope/adapters/tongyi.d.ts +2 -0
  35. package/dist/src/services/skill-scope/adapters/tongyi.js +13 -0
  36. package/dist/src/services/skill-scope/adapters/trae.d.ts +2 -0
  37. package/dist/src/services/skill-scope/adapters/trae.js +12 -0
  38. package/dist/src/services/skill-scope/detect.d.ts +75 -0
  39. package/dist/src/services/skill-scope/detect.js +480 -0
  40. package/dist/src/services/skill-scope/registry.d.ts +41 -0
  41. package/dist/src/services/skill-scope/registry.js +83 -0
  42. package/dist/src/services/skill-scope/source-of-truth.d.ts +44 -0
  43. package/dist/src/services/skill-scope/source-of-truth.js +118 -0
  44. package/dist/src/services/skill-scope/types.d.ts +176 -0
  45. package/dist/src/services/skill-scope/types.js +74 -0
  46. package/dist/src/services/standards/migrate-service.d.ts +63 -0
  47. package/dist/src/services/standards/migrate-service.js +193 -0
  48. package/dist/src/services/standards/project-standards-service.js +1 -23
  49. package/dist/src/services/workflow/artifact-paths.d.ts +59 -0
  50. package/dist/src/services/workflow/artifact-paths.js +127 -0
  51. package/dist/src/services/workflow/pipeline-verify-service.d.ts +6 -0
  52. package/dist/src/services/workflow/pipeline-verify-service.js +49 -4
  53. package/dist/src/services/workflow/plan-reader.d.ts +29 -0
  54. package/dist/src/services/workflow/plan-reader.js +158 -0
  55. package/dist/src/services/workflow/plan-refresher.d.ts +32 -0
  56. package/dist/src/services/workflow/plan-refresher.js +353 -0
  57. package/dist/src/services/workflow/plan-trigger-detector.d.ts +55 -0
  58. package/dist/src/services/workflow/plan-trigger-detector.js +142 -0
  59. package/dist/src/shared/format-md-compact.d.ts +32 -0
  60. package/dist/src/shared/format-md-compact.js +297 -0
  61. package/dist/src/shared/stale-policy.d.ts +67 -0
  62. package/dist/src/shared/stale-policy.js +85 -0
  63. package/dist/src/shared/version.d.ts +1 -1
  64. package/dist/src/shared/version.js +1 -1
  65. package/package.json +3 -2
  66. package/schemas/doctor-report.schema.json +2 -2
  67. package/skills/peaks-qa/SKILL.md +103 -507
  68. package/skills/peaks-qa/references/artifact-per-request.md +7 -79
  69. package/skills/peaks-qa/references/browser-validation-contracts.md +51 -0
  70. package/skills/peaks-qa/references/codegraph-regression-focus.md +5 -0
  71. package/skills/peaks-qa/references/external-capability-guidance.md +9 -0
  72. package/skills/peaks-qa/references/qa-compact-handoff.md +3 -0
  73. package/skills/peaks-qa/references/qa-context-governance.md +24 -0
  74. package/skills/peaks-qa/references/qa-fanout-contract.md +8 -0
  75. package/skills/peaks-qa/references/qa-gstack-integration.md +7 -0
  76. package/skills/peaks-qa/references/qa-local-artifacts.md +3 -0
  77. package/skills/peaks-qa/references/qa-matt-pocock-integration.md +9 -0
  78. package/skills/peaks-qa/references/qa-perf-test-plan.md +67 -0
  79. package/skills/peaks-qa/references/qa-refactor-role.md +3 -0
  80. package/skills/peaks-qa/references/qa-runbook.md +74 -0
  81. package/skills/peaks-qa/references/qa-security-test-plan.md +73 -0
  82. package/skills/peaks-qa/references/qa-skill-presence.md +22 -0
  83. package/skills/peaks-qa/references/qa-standards-preflight.md +8 -0
  84. package/skills/peaks-qa/references/qa-sub-agent-dispatch.md +38 -0
  85. package/skills/peaks-qa/references/qa-transition-gates.md +83 -0
  86. package/skills/peaks-qa/references/requirement-boundary-recheck.md +9 -0
  87. package/skills/peaks-qa/references/test-case-generation.md +27 -0
  88. package/skills/peaks-qa/references/test-report-output.md +14 -0
  89. package/skills/peaks-rd/SKILL.md +85 -612
  90. package/skills/peaks-rd/references/artifact-and-standards-output.md +9 -0
  91. package/skills/peaks-rd/references/artifact-per-request.md +20 -0
  92. package/skills/peaks-rd/references/browser-self-test-contracts.md +29 -0
  93. package/skills/peaks-rd/references/codegraph-project-analysis.md +5 -0
  94. package/skills/peaks-rd/references/compact-handoff.md +3 -0
  95. package/skills/peaks-rd/references/external-references.md +11 -0
  96. package/skills/peaks-rd/references/frontend-project-generation.md +11 -0
  97. package/skills/peaks-rd/references/library-version-awareness.md +30 -0
  98. package/skills/peaks-rd/references/mandatory-perf-baseline.md +42 -0
  99. package/skills/peaks-rd/references/mandatory-tech-doc.md +18 -0
  100. package/skills/peaks-rd/references/matt-pocock-integration.md +11 -0
  101. package/skills/peaks-rd/references/mock-data-placement.md +40 -0
  102. package/skills/peaks-rd/references/parallel-review-fanout.md +81 -0
  103. package/skills/peaks-rd/references/rd-context-governance.md +36 -0
  104. package/skills/peaks-rd/references/rd-gstack-integration.md +16 -0
  105. package/skills/peaks-rd/references/rd-runbook.md +125 -0
  106. package/skills/peaks-rd/references/rd-standards-preflight.md +8 -0
  107. package/skills/peaks-rd/references/rd-sub-agent-dispatch.md +39 -0
  108. package/skills/peaks-rd/references/rd-transition-gates.md +1 -1
  109. package/skills/peaks-rd/references/skill-presence-and-title.md +22 -0
  110. package/skills/peaks-solo/SKILL.md +87 -595
  111. package/skills/peaks-solo/references/anchoring-and-session-info.md +25 -0
  112. package/skills/peaks-solo/references/boundaries.md +21 -0
  113. package/skills/peaks-solo/references/codegraph-orchestration.md +5 -0
  114. package/skills/peaks-solo/references/completion-handoff.md +16 -0
  115. package/skills/peaks-solo/references/context-governance.md +51 -0
  116. package/skills/peaks-solo/references/external-references.md +17 -0
  117. package/skills/peaks-solo/references/frontend-only-mode.md +14 -0
  118. package/skills/peaks-solo/references/gstack-integration.md +7 -0
  119. package/skills/peaks-solo/references/local-artifact-workspace.md +79 -0
  120. package/skills/peaks-solo/references/micro-cycle.md +68 -0
  121. package/skills/peaks-solo/references/mode-selection.md +21 -0
  122. package/skills/peaks-solo/references/openspec-workflow.md +43 -0
  123. package/skills/peaks-solo/references/project-memory-loading.md +17 -0
  124. package/skills/peaks-solo/references/quality-gate-cheatsheet.md +13 -0
  125. package/skills/peaks-solo/references/resume-detection.md +63 -0
  126. package/skills/peaks-solo/references/runbook.md +1 -1
  127. package/skills/peaks-solo/references/skill-presence-and-title.md +31 -0
  128. package/skills/peaks-solo/references/standards-preflight.md +23 -0
  129. package/skills/peaks-solo/references/sub-agent-dispatch.md +46 -0
  130. 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>;