peaks-cli 1.0.13 → 1.0.15

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 (35) hide show
  1. package/dist/src/cli/commands/core-artifact-commands.js +25 -0
  2. package/dist/src/cli/commands/project-commands.js +5 -0
  3. package/dist/src/cli/commands/request-commands.js +1 -1
  4. package/dist/src/cli/commands/workflow-commands.js +38 -0
  5. package/dist/src/services/artifacts/request-artifact-service.d.ts +2 -2
  6. package/dist/src/services/artifacts/request-artifact-service.js +60 -5
  7. package/dist/src/services/codegraph/codegraph-process-runner.d.ts +2 -0
  8. package/dist/src/services/codegraph/codegraph-process-runner.js +93 -0
  9. package/dist/src/services/codegraph/codegraph-service.js +2 -98
  10. package/dist/src/services/config/config-safety.d.ts +14 -0
  11. package/dist/src/services/config/config-safety.js +275 -0
  12. package/dist/src/services/config/config-service.d.ts +1 -1
  13. package/dist/src/services/config/config-service.js +5 -274
  14. package/dist/src/services/dashboard/project-dashboard-service.d.ts +11 -0
  15. package/dist/src/services/dashboard/project-dashboard-service.js +21 -2
  16. package/dist/src/services/doctor/doctor-service.d.ts +3 -0
  17. package/dist/src/services/doctor/doctor-service.js +58 -0
  18. package/dist/src/services/mcp/mcp-scan-service.js +1 -1
  19. package/dist/src/services/skills/skill-presence-service.d.ts +10 -0
  20. package/dist/src/services/skills/skill-presence-service.js +54 -0
  21. package/dist/src/services/skills/skill-runbook-service.js +1 -1
  22. package/dist/src/services/workflow/autonomous-resume-writer.d.ts +16 -0
  23. package/dist/src/services/workflow/autonomous-resume-writer.js +156 -0
  24. package/dist/src/services/workflow/workflow-autonomous-service.js +7 -13
  25. package/dist/src/shared/version.d.ts +1 -1
  26. package/dist/src/shared/version.js +1 -1
  27. package/package.json +1 -1
  28. package/schemas/doctor-report.schema.json +2 -2
  29. package/skills/peaks-prd/SKILL.md +12 -0
  30. package/skills/peaks-qa/SKILL.md +12 -0
  31. package/skills/peaks-rd/SKILL.md +12 -0
  32. package/skills/peaks-sc/SKILL.md +12 -0
  33. package/skills/peaks-solo/SKILL.md +14 -0
  34. package/skills/peaks-txt/SKILL.md +22 -0
  35. package/skills/peaks-ui/SKILL.md +12 -0
@@ -0,0 +1,275 @@
1
+ import { closeSync, constants, existsSync, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { dirname, isAbsolute, relative, resolve } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ export function getUserConfigPath() {
6
+ return resolve(homedir(), '.peaks', 'config.json');
7
+ }
8
+ export function isInsidePath(childPath, parentPath) {
9
+ const relativePath = relative(parentPath, childPath);
10
+ return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath));
11
+ }
12
+ function isSafeProjectConfigMarker(projectRoot) {
13
+ const peaksPath = resolve(projectRoot, '.peaks');
14
+ const markerPath = resolve(peaksPath, 'config.json');
15
+ try {
16
+ const projectRootReal = realpathSync(projectRoot);
17
+ const peaksStats = lstatSync(peaksPath);
18
+ const peaksReal = realpathSync(peaksPath);
19
+ const markerStats = lstatSync(markerPath);
20
+ if (!peaksStats.isDirectory() || peaksStats.isSymbolicLink())
21
+ return false;
22
+ if (!markerStats.isFile() || markerStats.isSymbolicLink() || markerStats.nlink !== 1)
23
+ return false;
24
+ const markerReal = realpathSync(markerPath);
25
+ if (!isInsidePath(peaksReal, projectRootReal))
26
+ return false;
27
+ if (!isInsidePath(markerReal, projectRootReal))
28
+ return false;
29
+ return isInsidePath(markerReal, peaksReal);
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ function normalizeBoundaryPath(path) {
36
+ const resolved = resolve(path);
37
+ let realPath = resolved;
38
+ try {
39
+ realPath = existsSync(resolved) ? realpathSync.native(resolved) : resolved;
40
+ }
41
+ catch {
42
+ realPath = resolved;
43
+ }
44
+ return process.platform === 'win32' || process.platform === 'darwin' ? realPath.toLowerCase() : realPath;
45
+ }
46
+ function getHomeBoundaryPaths() {
47
+ return new Set([homedir(), process.env.HOME, process.env.USERPROFILE].filter((path) => typeof path === 'string' && path.length > 0).map(normalizeBoundaryPath));
48
+ }
49
+ export function findProjectRoot(startPath) {
50
+ const homeBoundaryPaths = getHomeBoundaryPaths();
51
+ let current = resolve(startPath);
52
+ let parent = dirname(current);
53
+ while (current !== parent && !homeBoundaryPaths.has(normalizeBoundaryPath(current))) {
54
+ if (existsSync(resolve(current, '.peaks', 'config.json')) && isSafeProjectConfigMarker(current)) {
55
+ return current;
56
+ }
57
+ parent = current;
58
+ current = dirname(parent);
59
+ }
60
+ return null;
61
+ }
62
+ export function resolveProjectRootForConfig(startPath) {
63
+ const start = resolve(startPath);
64
+ const homeBoundaryPaths = getHomeBoundaryPaths();
65
+ let current = start;
66
+ let parent = dirname(current);
67
+ while (current !== parent && !homeBoundaryPaths.has(normalizeBoundaryPath(current))) {
68
+ if (existsSync(resolve(current, '.peaks', 'config.json')) && isSafeProjectConfigMarker(current)) {
69
+ return current;
70
+ }
71
+ if (existsSync(resolve(current, 'package.json')) || existsSync(resolve(current, '.git'))) {
72
+ return current;
73
+ }
74
+ parent = current;
75
+ current = dirname(parent);
76
+ }
77
+ return start;
78
+ }
79
+ export function getProjectConfigPath(projectRoot) {
80
+ if (!projectRoot)
81
+ return null;
82
+ if (!isSafeProjectConfigMarker(projectRoot))
83
+ return null;
84
+ return resolve(projectRoot, '.peaks', 'config.json');
85
+ }
86
+ export function getProjectBootstrapConfigPath(projectRoot) {
87
+ const projectRootPath = resolve(projectRoot);
88
+ const peaksPath = resolve(projectRootPath, '.peaks');
89
+ const configPath = resolve(peaksPath, 'config.json');
90
+ if (!isInsidePath(configPath, projectRootPath)) {
91
+ throw new Error('Project config path must stay inside the project root');
92
+ }
93
+ if (!existsSync(peaksPath)) {
94
+ mkdirSync(peaksPath, { recursive: true });
95
+ }
96
+ validateProjectBootstrapConfigPath(projectRootPath, peaksPath, configPath);
97
+ return configPath;
98
+ }
99
+ function validateProjectBootstrapConfigPath(projectRootPath, peaksPath, configPath) {
100
+ const projectRootReal = realpathSync(projectRootPath);
101
+ const peaksStats = lstatSync(peaksPath);
102
+ const peaksReal = realpathSync(peaksPath);
103
+ if (!peaksStats.isDirectory() || peaksStats.isSymbolicLink() || peaksReal !== resolve(projectRootReal, '.peaks')) {
104
+ throw new Error('Project config path must stay inside the project root');
105
+ }
106
+ try {
107
+ const markerStats = lstatSync(configPath);
108
+ if (!markerStats.isFile() || markerStats.isSymbolicLink()) {
109
+ throw new Error('Project config path must stay inside the project root');
110
+ }
111
+ if (markerStats.nlink !== 1) {
112
+ throw new Error('Config path must not be hardlinked');
113
+ }
114
+ const markerReal = realpathSync(configPath);
115
+ if (!isInsidePath(markerReal, projectRootReal) || !isInsidePath(markerReal, peaksReal)) {
116
+ throw new Error('Project config path must stay inside the project root');
117
+ }
118
+ }
119
+ catch (error) {
120
+ if (error.code !== 'ENOENT') {
121
+ throw error;
122
+ }
123
+ }
124
+ }
125
+ export function validateProjectBootstrapConfigPathForWrite(projectRoot, configPath) {
126
+ const projectRootPath = resolve(projectRoot);
127
+ validateProjectBootstrapConfigPath(projectRootPath, resolve(projectRootPath, '.peaks'), configPath);
128
+ }
129
+ export function validateUserConfigPathForWrite(configPath) {
130
+ const userRoot = resolve(homedir());
131
+ const peaksPath = resolve(userRoot, '.peaks');
132
+ const userRootReal = realpathSync(userRoot);
133
+ const peaksStats = lstatSync(peaksPath);
134
+ const peaksReal = realpathSync(peaksPath);
135
+ if (!peaksStats.isDirectory() || peaksStats.isSymbolicLink() || peaksReal !== resolve(userRootReal, '.peaks')) {
136
+ throw new Error('User config path must stay inside the user root');
137
+ }
138
+ try {
139
+ const markerStats = lstatSync(configPath);
140
+ if (!markerStats.isFile() || markerStats.isSymbolicLink()) {
141
+ throw new Error('User config path must stay inside the user root');
142
+ }
143
+ if (markerStats.nlink !== 1) {
144
+ throw new Error('Config path must not be hardlinked');
145
+ }
146
+ const markerReal = realpathSync(configPath);
147
+ if (!isInsidePath(markerReal, userRootReal) || !isInsidePath(markerReal, peaksReal)) {
148
+ throw new Error('User config path must stay inside the user root');
149
+ }
150
+ }
151
+ catch (error) {
152
+ if (error.code !== 'ENOENT') {
153
+ throw error;
154
+ }
155
+ }
156
+ }
157
+ export function validateArtifactWorkspaceRoot(artifactRoot, workspaceRoot) {
158
+ const artifactStats = lstatSync(artifactRoot);
159
+ if (!artifactStats.isDirectory() || artifactStats.isSymbolicLink()) {
160
+ throw new Error('Artifact workspace marker must stay inside the artifact workspace');
161
+ }
162
+ const artifactRootReal = realpathSync(artifactRoot);
163
+ const workspaceRootReal = realpathSync(workspaceRoot);
164
+ if (isInsidePath(artifactRootReal, workspaceRootReal)) {
165
+ throw new Error('Artifact workspace must stay outside the project root');
166
+ }
167
+ }
168
+ export function validateArtifactWorkspaceMarkerPath(artifactRoot, peaksPath, markerPath) {
169
+ const artifactStats = lstatSync(artifactRoot);
170
+ if (!artifactStats.isDirectory() || artifactStats.isSymbolicLink()) {
171
+ throw new Error('Artifact workspace marker must stay inside the artifact workspace');
172
+ }
173
+ const artifactRootReal = realpathSync(artifactRoot);
174
+ const peaksStats = lstatSync(peaksPath);
175
+ const peaksReal = realpathSync(peaksPath);
176
+ if (!peaksStats.isDirectory() || peaksStats.isSymbolicLink() || peaksReal !== resolve(artifactRootReal, '.peaks')) {
177
+ throw new Error('Artifact workspace marker must stay inside the artifact workspace');
178
+ }
179
+ try {
180
+ const markerStats = lstatSync(markerPath);
181
+ if (!markerStats.isFile() || markerStats.isSymbolicLink()) {
182
+ throw new Error('Artifact workspace marker must stay inside the artifact workspace');
183
+ }
184
+ if (markerStats.nlink !== 1) {
185
+ throw new Error('Config path must not be hardlinked');
186
+ }
187
+ const markerReal = realpathSync(markerPath);
188
+ if (!isInsidePath(markerReal, artifactRootReal) || !isInsidePath(markerReal, peaksReal)) {
189
+ throw new Error('Artifact workspace marker must stay inside the artifact workspace');
190
+ }
191
+ }
192
+ catch (error) {
193
+ if (error.code !== 'ENOENT') {
194
+ throw error;
195
+ }
196
+ }
197
+ }
198
+ function validateOpenConfigFile(fd, tempPath, errorMessage) {
199
+ const fdStats = fstatSync(fd);
200
+ const pathStats = lstatSync(tempPath);
201
+ if (!fdStats.isFile() || !pathStats.isFile() || fdStats.dev !== pathStats.dev || fdStats.ino !== pathStats.ino) {
202
+ throw new Error(errorMessage);
203
+ }
204
+ if (fdStats.nlink !== 1 || pathStats.nlink !== 1) {
205
+ throw new Error('Config path must not be hardlinked');
206
+ }
207
+ }
208
+ function getSafeTempOpenFlags() {
209
+ const baseFlags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL;
210
+ return typeof constants.O_NOFOLLOW === 'number' ? baseFlags | constants.O_NOFOLLOW : baseFlags;
211
+ }
212
+ function getSafeReadOpenFlags() {
213
+ return typeof constants.O_NOFOLLOW === 'number' ? constants.O_RDONLY | constants.O_NOFOLLOW : constants.O_RDONLY;
214
+ }
215
+ export function readConfigFileSafely(configPath, errorMessage) {
216
+ const fd = openSync(configPath, getSafeReadOpenFlags());
217
+ try {
218
+ validateOpenConfigFile(fd, configPath, errorMessage);
219
+ return readFileSync(fd, 'utf-8');
220
+ }
221
+ finally {
222
+ closeSync(fd);
223
+ }
224
+ }
225
+ export function writeConfigFileSafely(configPath, content, validateBeforeWrite, errorMessage) {
226
+ validateBeforeWrite();
227
+ const tempPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
228
+ let fd = openSync(tempPath, getSafeTempOpenFlags(), 0o600);
229
+ let renamed = false;
230
+ let closeError;
231
+ try {
232
+ validateOpenConfigFile(fd, tempPath, errorMessage);
233
+ fchmodSync(fd, 0o600);
234
+ writeFileSync(fd, content, 'utf-8');
235
+ const writeFd = fd;
236
+ fd = null;
237
+ closeSync(writeFd);
238
+ validateBeforeWrite();
239
+ const readFd = openSync(tempPath, getSafeReadOpenFlags());
240
+ try {
241
+ validateOpenConfigFile(readFd, tempPath, errorMessage);
242
+ }
243
+ finally {
244
+ closeSync(readFd);
245
+ }
246
+ renameSync(tempPath, configPath);
247
+ renamed = true;
248
+ }
249
+ finally {
250
+ if (fd !== null) {
251
+ try {
252
+ closeSync(fd);
253
+ }
254
+ catch (error) {
255
+ closeError = error;
256
+ }
257
+ }
258
+ try {
259
+ if (!renamed && existsSync(tempPath)) {
260
+ unlinkSync(tempPath);
261
+ }
262
+ }
263
+ finally {
264
+ if (closeError) {
265
+ throw closeError;
266
+ }
267
+ }
268
+ }
269
+ }
270
+ export function writeProjectConfigFile(projectRoot, configPath, content) {
271
+ writeConfigFileSafely(configPath, content, () => validateProjectBootstrapConfigPathForWrite(projectRoot, configPath), 'Project config path must stay inside the project root');
272
+ }
273
+ export function writeUserConfigFile(configPath, content) {
274
+ writeConfigFileSafely(configPath, content, () => validateUserConfigPathForWrite(configPath), 'User config path must stay inside the user root');
275
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ConfigGetOptions, ConfigLayer, ConfigSetOptions, MiniMaxProviderConfig, PeaksConfig, TokenRef, WorkspaceConfig } from './config-types.js';
2
- export declare function resolveProjectRootForConfig(startPath: string): string;
2
+ export { resolveProjectRootForConfig } from './config-safety.js';
3
3
  export declare function isConfigLayer(value: string): value is ConfigLayer;
4
4
  export declare function isSensitiveConfigPath(path: string): boolean;
5
5
  export declare function containsSensitiveConfigValue(value: unknown): boolean;
@@ -1,280 +1,11 @@
1
- import { closeSync, constants, existsSync, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
- import { randomUUID } from 'node:crypto';
3
- import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
1
+ import { existsSync, mkdirSync } from 'node:fs';
4
2
  import { homedir } from 'node:os';
3
+ import { basename, dirname, isAbsolute, resolve } from 'node:path';
5
4
  import { DEFAULT_CONFIG } from './config-types.js';
6
5
  import { stablePath } from '../../shared/path-utils.js';
7
- function getUserConfigPath() {
8
- return resolve(homedir(), '.peaks', 'config.json');
9
- }
10
- function isInsidePath(childPath, parentPath) {
11
- const relativePath = relative(parentPath, childPath);
12
- return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath));
13
- }
14
- function isSafeProjectConfigMarker(projectRoot) {
15
- const peaksPath = resolve(projectRoot, '.peaks');
16
- const markerPath = resolve(peaksPath, 'config.json');
17
- try {
18
- const projectRootReal = realpathSync(projectRoot);
19
- const peaksStats = lstatSync(peaksPath);
20
- const peaksReal = realpathSync(peaksPath);
21
- const markerStats = lstatSync(markerPath);
22
- if (!peaksStats.isDirectory() || peaksStats.isSymbolicLink())
23
- return false;
24
- if (!markerStats.isFile() || markerStats.isSymbolicLink() || markerStats.nlink !== 1)
25
- return false;
26
- const markerReal = realpathSync(markerPath);
27
- if (!isInsidePath(peaksReal, projectRootReal))
28
- return false;
29
- if (!isInsidePath(markerReal, projectRootReal))
30
- return false;
31
- return isInsidePath(markerReal, peaksReal);
32
- }
33
- catch {
34
- return false;
35
- }
36
- }
37
- function normalizeBoundaryPath(path) {
38
- const resolved = resolve(path);
39
- let realPath = resolved;
40
- try {
41
- realPath = existsSync(resolved) ? realpathSync.native(resolved) : resolved;
42
- }
43
- catch {
44
- realPath = resolved;
45
- }
46
- return process.platform === 'win32' || process.platform === 'darwin' ? realPath.toLowerCase() : realPath;
47
- }
48
- function getHomeBoundaryPaths() {
49
- return new Set([homedir(), process.env.HOME, process.env.USERPROFILE].filter((path) => typeof path === 'string' && path.length > 0).map(normalizeBoundaryPath));
50
- }
51
- function findProjectRoot(startPath) {
52
- const homeBoundaryPaths = getHomeBoundaryPaths();
53
- let current = resolve(startPath);
54
- let parent = dirname(current);
55
- while (current !== parent && !homeBoundaryPaths.has(normalizeBoundaryPath(current))) {
56
- if (existsSync(resolve(current, '.peaks', 'config.json')) && isSafeProjectConfigMarker(current)) {
57
- return current;
58
- }
59
- parent = current;
60
- current = dirname(parent);
61
- }
62
- return null;
63
- }
64
- export function resolveProjectRootForConfig(startPath) {
65
- const start = resolve(startPath);
66
- const homeBoundaryPaths = getHomeBoundaryPaths();
67
- let current = start;
68
- let parent = dirname(current);
69
- while (current !== parent && !homeBoundaryPaths.has(normalizeBoundaryPath(current))) {
70
- if (existsSync(resolve(current, '.peaks', 'config.json')) && isSafeProjectConfigMarker(current)) {
71
- return current;
72
- }
73
- if (existsSync(resolve(current, 'package.json')) || existsSync(resolve(current, '.git'))) {
74
- return current;
75
- }
76
- parent = current;
77
- current = dirname(parent);
78
- }
79
- return start;
80
- }
81
- function getProjectConfigPath(projectRoot) {
82
- if (!projectRoot)
83
- return null;
84
- if (!isSafeProjectConfigMarker(projectRoot))
85
- return null;
86
- return resolve(projectRoot, '.peaks', 'config.json');
87
- }
88
- function getProjectBootstrapConfigPath(projectRoot) {
89
- const projectRootPath = resolve(projectRoot);
90
- const peaksPath = resolve(projectRootPath, '.peaks');
91
- const configPath = resolve(peaksPath, 'config.json');
92
- if (!isInsidePath(configPath, projectRootPath)) {
93
- throw new Error('Project config path must stay inside the project root');
94
- }
95
- if (!existsSync(peaksPath)) {
96
- mkdirSync(peaksPath, { recursive: true });
97
- }
98
- validateProjectBootstrapConfigPath(projectRootPath, peaksPath, configPath);
99
- return configPath;
100
- }
101
- function validateProjectBootstrapConfigPath(projectRootPath, peaksPath, configPath) {
102
- const projectRootReal = realpathSync(projectRootPath);
103
- const peaksStats = lstatSync(peaksPath);
104
- const peaksReal = realpathSync(peaksPath);
105
- if (!peaksStats.isDirectory() || peaksStats.isSymbolicLink() || peaksReal !== resolve(projectRootReal, '.peaks')) {
106
- throw new Error('Project config path must stay inside the project root');
107
- }
108
- try {
109
- const markerStats = lstatSync(configPath);
110
- if (!markerStats.isFile() || markerStats.isSymbolicLink()) {
111
- throw new Error('Project config path must stay inside the project root');
112
- }
113
- if (markerStats.nlink !== 1) {
114
- throw new Error('Config path must not be hardlinked');
115
- }
116
- const markerReal = realpathSync(configPath);
117
- if (!isInsidePath(markerReal, projectRootReal) || !isInsidePath(markerReal, peaksReal)) {
118
- throw new Error('Project config path must stay inside the project root');
119
- }
120
- }
121
- catch (error) {
122
- if (error.code !== 'ENOENT') {
123
- throw error;
124
- }
125
- }
126
- }
127
- function validateProjectBootstrapConfigPathForWrite(projectRoot, configPath) {
128
- const projectRootPath = resolve(projectRoot);
129
- validateProjectBootstrapConfigPath(projectRootPath, resolve(projectRootPath, '.peaks'), configPath);
130
- }
131
- function validateUserConfigPathForWrite(configPath) {
132
- const userRoot = resolve(homedir());
133
- const peaksPath = resolve(userRoot, '.peaks');
134
- const userRootReal = realpathSync(userRoot);
135
- const peaksStats = lstatSync(peaksPath);
136
- const peaksReal = realpathSync(peaksPath);
137
- if (!peaksStats.isDirectory() || peaksStats.isSymbolicLink() || peaksReal !== resolve(userRootReal, '.peaks')) {
138
- throw new Error('User config path must stay inside the user root');
139
- }
140
- try {
141
- const markerStats = lstatSync(configPath);
142
- if (!markerStats.isFile() || markerStats.isSymbolicLink()) {
143
- throw new Error('User config path must stay inside the user root');
144
- }
145
- if (markerStats.nlink !== 1) {
146
- throw new Error('Config path must not be hardlinked');
147
- }
148
- const markerReal = realpathSync(configPath);
149
- if (!isInsidePath(markerReal, userRootReal) || !isInsidePath(markerReal, peaksReal)) {
150
- throw new Error('User config path must stay inside the user root');
151
- }
152
- }
153
- catch (error) {
154
- if (error.code !== 'ENOENT') {
155
- throw error;
156
- }
157
- }
158
- }
159
- function validateArtifactWorkspaceRoot(artifactRoot, workspaceRoot) {
160
- const artifactStats = lstatSync(artifactRoot);
161
- if (!artifactStats.isDirectory() || artifactStats.isSymbolicLink()) {
162
- throw new Error('Artifact workspace marker must stay inside the artifact workspace');
163
- }
164
- const artifactRootReal = realpathSync(artifactRoot);
165
- const workspaceRootReal = realpathSync(workspaceRoot);
166
- if (isInsidePath(artifactRootReal, workspaceRootReal)) {
167
- throw new Error('Artifact workspace must stay outside the project root');
168
- }
169
- }
170
- function validateArtifactWorkspaceMarkerPath(artifactRoot, peaksPath, markerPath) {
171
- const artifactStats = lstatSync(artifactRoot);
172
- if (!artifactStats.isDirectory() || artifactStats.isSymbolicLink()) {
173
- throw new Error('Artifact workspace marker must stay inside the artifact workspace');
174
- }
175
- const artifactRootReal = realpathSync(artifactRoot);
176
- const peaksStats = lstatSync(peaksPath);
177
- const peaksReal = realpathSync(peaksPath);
178
- if (!peaksStats.isDirectory() || peaksStats.isSymbolicLink() || peaksReal !== resolve(artifactRootReal, '.peaks')) {
179
- throw new Error('Artifact workspace marker must stay inside the artifact workspace');
180
- }
181
- try {
182
- const markerStats = lstatSync(markerPath);
183
- if (!markerStats.isFile() || markerStats.isSymbolicLink()) {
184
- throw new Error('Artifact workspace marker must stay inside the artifact workspace');
185
- }
186
- if (markerStats.nlink !== 1) {
187
- throw new Error('Config path must not be hardlinked');
188
- }
189
- const markerReal = realpathSync(markerPath);
190
- if (!isInsidePath(markerReal, artifactRootReal) || !isInsidePath(markerReal, peaksReal)) {
191
- throw new Error('Artifact workspace marker must stay inside the artifact workspace');
192
- }
193
- }
194
- catch (error) {
195
- if (error.code !== 'ENOENT') {
196
- throw error;
197
- }
198
- }
199
- }
200
- function validateOpenConfigFile(fd, tempPath, errorMessage) {
201
- const fdStats = fstatSync(fd);
202
- const pathStats = lstatSync(tempPath);
203
- if (!fdStats.isFile() || !pathStats.isFile() || fdStats.dev !== pathStats.dev || fdStats.ino !== pathStats.ino) {
204
- throw new Error(errorMessage);
205
- }
206
- if (fdStats.nlink !== 1 || pathStats.nlink !== 1) {
207
- throw new Error('Config path must not be hardlinked');
208
- }
209
- }
210
- function getSafeTempOpenFlags() {
211
- const baseFlags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL;
212
- return typeof constants.O_NOFOLLOW === 'number' ? baseFlags | constants.O_NOFOLLOW : baseFlags;
213
- }
214
- function getSafeReadOpenFlags() {
215
- return typeof constants.O_NOFOLLOW === 'number' ? constants.O_RDONLY | constants.O_NOFOLLOW : constants.O_RDONLY;
216
- }
217
- function readConfigFileSafely(configPath, errorMessage) {
218
- const fd = openSync(configPath, getSafeReadOpenFlags());
219
- try {
220
- validateOpenConfigFile(fd, configPath, errorMessage);
221
- return readFileSync(fd, 'utf-8');
222
- }
223
- finally {
224
- closeSync(fd);
225
- }
226
- }
227
- function writeConfigFileSafely(configPath, content, validateBeforeWrite, errorMessage) {
228
- validateBeforeWrite();
229
- const tempPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
230
- let fd = openSync(tempPath, getSafeTempOpenFlags(), 0o600);
231
- let renamed = false;
232
- let closeError;
233
- try {
234
- validateOpenConfigFile(fd, tempPath, errorMessage);
235
- fchmodSync(fd, 0o600);
236
- writeFileSync(fd, content, 'utf-8');
237
- const writeFd = fd;
238
- fd = null;
239
- closeSync(writeFd);
240
- validateBeforeWrite();
241
- const readFd = openSync(tempPath, getSafeReadOpenFlags());
242
- try {
243
- validateOpenConfigFile(readFd, tempPath, errorMessage);
244
- }
245
- finally {
246
- closeSync(readFd);
247
- }
248
- renameSync(tempPath, configPath);
249
- renamed = true;
250
- }
251
- finally {
252
- if (fd !== null) {
253
- try {
254
- closeSync(fd);
255
- }
256
- catch (error) {
257
- closeError = error;
258
- }
259
- }
260
- try {
261
- if (!renamed && existsSync(tempPath)) {
262
- unlinkSync(tempPath);
263
- }
264
- }
265
- finally {
266
- if (closeError) {
267
- throw closeError;
268
- }
269
- }
270
- }
271
- }
272
- function writeProjectConfigFile(projectRoot, configPath, content) {
273
- writeConfigFileSafely(configPath, content, () => validateProjectBootstrapConfigPathForWrite(projectRoot, configPath), 'Project config path must stay inside the project root');
274
- }
275
- function writeUserConfigFile(configPath, content) {
276
- writeConfigFileSafely(configPath, content, () => validateUserConfigPathForWrite(configPath), 'User config path must stay inside the user root');
277
- }
6
+ import { findProjectRoot, getProjectBootstrapConfigPath, getProjectConfigPath, getUserConfigPath, isInsidePath, readConfigFileSafely, resolveProjectRootForConfig, validateArtifactWorkspaceMarkerPath, validateArtifactWorkspaceRoot, validateProjectBootstrapConfigPathForWrite, validateUserConfigPathForWrite, writeConfigFileSafely, writeProjectConfigFile, writeUserConfigFile } from './config-safety.js';
7
+ // Re-export resolveProjectRootForConfig for external consumers
8
+ export { resolveProjectRootForConfig } from './config-safety.js';
278
9
  function readJsonFile(path, validateBeforeRead, errorMessage = 'Config path must stay inside the config root') {
279
10
  if (!path || !existsSync(path))
280
11
  return null;
@@ -2,6 +2,7 @@ import { type RequestArtifactRole, type RequestArtifactSummary } from '../artifa
2
2
  import type { OpenSpecChangeSummary } from '../openspec/openspec-types.js';
3
3
  import type { McpScanReport } from '../mcp/mcp-types.js';
4
4
  import type { CapabilityItem } from '../recommendations/recommendation-types.js';
5
+ import { type SkillPresence } from '../skills/skill-presence-service.js';
5
6
  export type ProjectDashboardRequests = {
6
7
  count: number;
7
8
  byRole: Record<RequestArtifactRole, RequestArtifactSummary[]>;
@@ -39,6 +40,14 @@ export type ProjectDashboardCapabilities = {
39
40
  mcpCount: number;
40
41
  sample: Array<Pick<CapabilityItem, 'capabilityId' | 'name' | 'itemType' | 'category'>>;
41
42
  };
43
+ export type ProjectDashboardSkillPresence = {
44
+ active: boolean;
45
+ fresh: boolean;
46
+ skill?: string;
47
+ mode?: string;
48
+ gate?: string;
49
+ setAt?: string;
50
+ };
42
51
  export type ProjectDashboard = {
43
52
  generatedAt: string;
44
53
  projectRoot: string;
@@ -49,6 +58,7 @@ export type ProjectDashboard = {
49
58
  doctor: ProjectDashboardDoctor;
50
59
  runbookHealth: ProjectDashboardRunbookHealth;
51
60
  capabilities: ProjectDashboardCapabilities;
61
+ skillPresence: ProjectDashboardSkillPresence;
52
62
  };
53
63
  export type LoadProjectDashboardOptions = {
54
64
  projectRoot: string;
@@ -60,5 +70,6 @@ export type LoadProjectDashboardOptions = {
60
70
  failed: number;
61
71
  };
62
72
  runbookHealth?: ProjectDashboardRunbookHealth;
73
+ skillPresence?: SkillPresence | null;
63
74
  };
64
75
  export declare function loadProjectDashboard(options: LoadProjectDashboardOptions): Promise<ProjectDashboard>;
@@ -4,11 +4,13 @@ import { scanMcpServers } from '../mcp/mcp-scan-service.js';
4
4
  import { scanUnderstandAnything } from '../understand/understand-scan-service.js';
5
5
  import { seedCapabilityItems } from '../recommendations/capability-seed-items.js';
6
6
  import { requiredSkillNames } from '../../shared/paths.js';
7
+ import { getSkillPresence } from '../skills/skill-presence-service.js';
8
+ const SKILL_PRESENCE_FRESHNESS_THRESHOLD_MS = 24 * 60 * 60 * 1000;
7
9
  function defaultClock() {
8
10
  return new Date().toISOString();
9
11
  }
10
12
  function groupRequestsByRole(items) {
11
- const byRole = { prd: [], ui: [], rd: [], qa: [] };
13
+ const byRole = { prd: [], ui: [], rd: [], qa: [], sc: [] };
12
14
  for (const item of items) {
13
15
  byRole[item.role].push(item);
14
16
  }
@@ -72,6 +74,22 @@ function buildCapabilitiesSummary(sampleSize) {
72
74
  }))
73
75
  };
74
76
  }
77
+ function buildSkillPresenceSummary(presence) {
78
+ const resolved = presence === undefined ? getSkillPresence() : presence;
79
+ if (resolved === null) {
80
+ return { active: false, fresh: true };
81
+ }
82
+ const setAtMs = Date.parse(resolved.setAt);
83
+ const fresh = !Number.isNaN(setAtMs) && Date.now() - setAtMs <= SKILL_PRESENCE_FRESHNESS_THRESHOLD_MS;
84
+ return {
85
+ active: true,
86
+ fresh,
87
+ skill: resolved.skill,
88
+ ...(resolved.mode !== undefined ? { mode: resolved.mode } : {}),
89
+ ...(resolved.gate !== undefined ? { gate: resolved.gate } : {}),
90
+ setAt: resolved.setAt
91
+ };
92
+ }
75
93
  export async function loadProjectDashboard(options) {
76
94
  const clock = options.clock ?? defaultClock;
77
95
  const sampleSize = options.sampleCapabilities ?? 8;
@@ -107,6 +125,7 @@ export async function loadProjectDashboard(options) {
107
125
  },
108
126
  doctor: doctorAndRunbook.doctor,
109
127
  runbookHealth: doctorAndRunbook.runbookHealth,
110
- capabilities: buildCapabilitiesSummary(sampleSize)
128
+ capabilities: buildCapabilitiesSummary(sampleSize),
129
+ skillPresence: buildSkillPresenceSummary(options.skillPresence)
111
130
  };
112
131
  }
@@ -1,3 +1,4 @@
1
+ import { type SkillPresence } from '../skills/skill-presence-service.js';
1
2
  export type DoctorCheck = {
2
3
  id: string;
3
4
  ok: boolean;
@@ -21,5 +22,7 @@ export type DoctorOptions = {
21
22
  schemasBaseDir?: string;
22
23
  skillsBaseDir?: string;
23
24
  codegraphProbe?: () => CodegraphCapabilityProbe;
25
+ skillPresenceProbe?: () => SkillPresence | null;
26
+ skillPresenceFreshnessThresholdMs?: number;
24
27
  };
25
28
  export declare function runDoctor(options?: DoctorOptions): Promise<DoctorReport>;