peaks-cli 1.0.21 → 1.0.23

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 (50) hide show
  1. package/bin/peaks.js +0 -0
  2. package/dist/src/cli/commands/capability-commands.d.ts +12 -1
  3. package/dist/src/cli/commands/capability-commands.js +9 -11
  4. package/dist/src/cli/commands/config-commands.js +2 -85
  5. package/dist/src/cli/commands/core-artifact-commands.js +6 -1
  6. package/dist/src/cli/commands/request-commands.js +82 -2
  7. package/dist/src/cli/commands/scan-commands.js +30 -0
  8. package/dist/src/cli/commands/workflow-commands.js +35 -5
  9. package/dist/src/services/artifacts/artifact-prerequisites.js +53 -13
  10. package/dist/src/services/artifacts/artifact-service.js +2 -2
  11. package/dist/src/services/artifacts/request-artifact-service.d.ts +32 -0
  12. package/dist/src/services/artifacts/request-artifact-service.js +148 -16
  13. package/dist/src/services/artifacts/workspace-service.d.ts +1 -1
  14. package/dist/src/services/artifacts/workspace-service.js +10 -20
  15. package/dist/src/services/config/config-safety.d.ts +1 -1
  16. package/dist/src/services/config/config-safety.js +4 -6
  17. package/dist/src/services/config/config-service.d.ts +1 -1
  18. package/dist/src/services/config/config-service.js +67 -69
  19. package/dist/src/services/config/config-types.d.ts +0 -2
  20. package/dist/src/services/config/config-types.js +0 -2
  21. package/dist/src/services/mode/bypass-tracker.d.ts +4 -0
  22. package/dist/src/services/mode/bypass-tracker.js +31 -0
  23. package/dist/src/services/mode/mode-enforcement.d.ts +14 -0
  24. package/dist/src/services/mode/mode-enforcement.js +81 -0
  25. package/dist/src/services/sc/sc-service.js +5 -5
  26. package/dist/src/services/scan/acceptance-coverage-service.js +1 -4
  27. package/dist/src/services/scan/archetype-service.js +4 -15
  28. package/dist/src/services/scan/diff-scope-service.js +3 -3
  29. package/dist/src/services/scan/file-size-scan.d.ts +19 -0
  30. package/dist/src/services/scan/file-size-scan.js +39 -0
  31. package/dist/src/services/scan/type-sanity-service.js +1 -3
  32. package/dist/src/services/session/index.d.ts +1 -0
  33. package/dist/src/services/session/index.js +1 -0
  34. package/dist/src/services/session/session-manager.d.ts +60 -0
  35. package/dist/src/services/session/session-manager.js +150 -0
  36. package/dist/src/services/skills/skill-presence-service.d.ts +4 -1
  37. package/dist/src/services/skills/skill-presence-service.js +11 -1
  38. package/dist/src/services/workflow/pipeline-verify-service.d.ts +31 -0
  39. package/dist/src/services/workflow/pipeline-verify-service.js +180 -0
  40. package/dist/src/services/workspace/workspace-service.js +6 -0
  41. package/dist/src/shared/change-id.d.ts +13 -0
  42. package/dist/src/shared/change-id.js +29 -1
  43. package/dist/src/shared/incrementing-number.d.ts +31 -0
  44. package/dist/src/shared/incrementing-number.js +58 -0
  45. package/dist/src/shared/version.d.ts +1 -1
  46. package/dist/src/shared/version.js +1 -1
  47. package/package.json +2 -1
  48. package/skills/peaks-rd/SKILL.md +3 -0
  49. package/skills/peaks-solo/SKILL.md +42 -11
  50. package/skills/peaks-ui/SKILL.md +3 -0
@@ -1,7 +1,14 @@
1
1
  import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
2
3
  import { dirname, join } from 'node:path';
3
- import { isDirectory, listDirectories, pathExists } from '../../shared/fs.js';
4
+ import { isDirectory, listDirectories } from '../../shared/fs.js';
4
5
  import { checkPrerequisites, DEFAULT_REQUEST_TYPE, isRequestType, VALID_REQUEST_TYPES } from './artifact-prerequisites.js';
6
+ import { ensureSession } from '../session/session-manager.js';
7
+ import { getNextNumber, buildNumberedFilename } from '../../shared/incrementing-number.js';
8
+ import { lintRequestArtifact } from './artifact-lint-service.js';
9
+ import { checkTypeSanity } from '../scan/type-sanity-service.js';
10
+ import { requireUserConfirmation } from '../mode/mode-enforcement.js';
11
+ import { scanFileSize } from '../scan/file-size-scan.js';
5
12
  export { VALID_REQUEST_TYPES, DEFAULT_REQUEST_TYPE, isRequestType };
6
13
  const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
7
14
  const VALID_ROLES = new Set(['prd', 'ui', 'rd', 'qa', 'sc']);
@@ -299,17 +306,42 @@ export async function createRequestArtifact(options) {
299
306
  const requestType = options.requestType ?? DEFAULT_REQUEST_TYPE;
300
307
  const clock = options.clock ?? defaultClock;
301
308
  const timestamp = clock();
302
- const sessionId = options.sessionId ?? defaultSessionId(timestamp);
303
- const path = join(options.projectRoot, '.peaks', sessionId, options.role, 'requests', `${options.requestId}.md`);
309
+ // Use provided session ID or get/create current session
310
+ const sessionId = options.sessionId ?? await ensureSession(options.projectRoot);
311
+ // Build numbered path in session directory
312
+ const requestsDir = join(options.projectRoot, '.peaks', sessionId, options.role, 'requests');
313
+ // Check if a file with this requestId already exists (regardless of number prefix)
314
+ if (await isDirectory(requestsDir)) {
315
+ const existingFiles = await listMarkdownFiles(requestsDir);
316
+ const alreadyExists = existingFiles.some((file) => {
317
+ if (file === `${options.requestId}.md`)
318
+ return true;
319
+ if (/^\d+-/.test(file) && file.endsWith(`-${options.requestId}.md`))
320
+ return true;
321
+ return false;
322
+ });
323
+ if (alreadyExists) {
324
+ throw new Error(`A request artifact with id "${options.requestId}" already exists in ${requestsDir}. Remove it before re-running peaks request init.`);
325
+ }
326
+ }
327
+ const number = getNextNumber(requestsDir);
328
+ const filename = buildNumberedFilename(number, options.requestId);
329
+ const path = join(requestsDir, filename);
304
330
  const content = renderTemplate(options.role, options.requestId, sessionId, timestamp, requestType);
305
331
  if (options.apply !== true) {
306
332
  return { role: options.role, requestId: options.requestId, sessionId, path, content, applied: false };
307
333
  }
308
- if (await pathExists(path)) {
309
- throw new Error(`Refusing to write: ${path} already exists. Update it in place or remove it before re-running peaks request init.`);
310
- }
311
334
  await mkdir(dirname(path), { recursive: true });
312
335
  await writeFile(path, content, 'utf8');
336
+ // Create QA initiated marker so rd:qa-handoff gate can verify QA was invoked
337
+ if (options.role === 'qa') {
338
+ const qaDir = join(options.projectRoot, '.peaks', sessionId, 'qa');
339
+ const initiatedPath = join(qaDir, '.initiated');
340
+ if (!existsSync(initiatedPath)) {
341
+ await mkdir(qaDir, { recursive: true });
342
+ await writeFile(initiatedPath, '', 'utf8');
343
+ }
344
+ }
313
345
  return { role: options.role, requestId: options.requestId, sessionId, path, content, applied: true };
314
346
  }
315
347
  function extractMetadata(markdown) {
@@ -346,7 +378,9 @@ async function readSummary(projectRoot, sessionId, role, fileName) {
346
378
  const path = join(projectRoot, '.peaks', sessionId, role, 'requests', fileName);
347
379
  const body = await readFile(path, 'utf8');
348
380
  const { state, createdAt, requestType } = extractMetadata(body);
349
- const requestId = fileName.replace(/\.md$/, '');
381
+ // Strip numbered prefix (e.g., "001-requestId.md" -> "requestId")
382
+ // Only strip 3-digit zero-padded prefixes (our incrementing number format)
383
+ const requestId = fileName.replace(/^0\d{2}-/, '').replace(/\.md$/, '');
350
384
  const summary = { role, sessionId, requestId, path, state, requestType };
351
385
  if (createdAt !== undefined) {
352
386
  summary.createdAt = createdAt;
@@ -389,14 +423,29 @@ export async function showRequestArtifact(options) {
389
423
  if (!REQUEST_ID_PATTERN.test(options.requestId)) {
390
424
  throw new Error(`Invalid request id: ${options.requestId} (expected letters, digits, dots, underscores, or dashes)`);
391
425
  }
392
- const fileName = `${options.requestId}.md`;
426
+ // Search for files matching the requestId (supports both legacy and numbered formats)
427
+ const findFileInDir = async (dir) => {
428
+ const files = await listMarkdownFiles(dir);
429
+ for (const file of files) {
430
+ // Match legacy format: ${requestId}.md
431
+ if (file === `${options.requestId}.md`) {
432
+ return { fileName: file, path: join(dir, file) };
433
+ }
434
+ // Match numbered format: ${number}-${requestId}.md
435
+ if (/^\d+-/.test(file) && file.endsWith(`-${options.requestId}.md`)) {
436
+ return { fileName: file, path: join(dir, file) };
437
+ }
438
+ }
439
+ return null;
440
+ };
393
441
  if (options.sessionId !== undefined) {
394
- const path = join(options.projectRoot, '.peaks', options.sessionId, options.role, 'requests', fileName);
395
- if (!(await pathExists(path))) {
442
+ const dir = join(options.projectRoot, '.peaks', options.sessionId, options.role, 'requests');
443
+ const found = await findFileInDir(dir);
444
+ if (found === null) {
396
445
  return null;
397
446
  }
398
- const summary = await readSummary(options.projectRoot, options.sessionId, options.role, fileName);
399
- const content = await readFile(path, 'utf8');
447
+ const summary = await readSummary(options.projectRoot, options.sessionId, options.role, found.fileName);
448
+ const content = await readFile(found.path, 'utf8');
400
449
  return { ...summary, content };
401
450
  }
402
451
  const peaksRoot = join(options.projectRoot, '.peaks');
@@ -405,10 +454,11 @@ export async function showRequestArtifact(options) {
405
454
  }
406
455
  const sessions = await listDirectories(peaksRoot);
407
456
  for (const sessionId of sessions) {
408
- const path = join(peaksRoot, sessionId, options.role, 'requests', fileName);
409
- if (await pathExists(path)) {
410
- const summary = await readSummary(options.projectRoot, sessionId, options.role, fileName);
411
- const content = await readFile(path, 'utf8');
457
+ const dir = join(peaksRoot, sessionId, options.role, 'requests');
458
+ const found = await findFileInDir(dir);
459
+ if (found !== null) {
460
+ const summary = await readSummary(options.projectRoot, sessionId, options.role, found.fileName);
461
+ const content = await readFile(found.path, 'utf8');
412
462
  return { ...summary, content };
413
463
  }
414
464
  }
@@ -439,6 +489,48 @@ export class PrerequisitesNotSatisfiedError extends Error {
439
489
  this.missing = missing;
440
490
  }
441
491
  }
492
+ export class LintGateError extends Error {
493
+ code = 'LINT_GATE_FAILED';
494
+ role;
495
+ newState;
496
+ errorCount;
497
+ constructor(role, newState, errorCount) {
498
+ super(`Cannot transition ${role} to ${newState}: ${errorCount} lint error(s) found in artifact. ` +
499
+ 'Fix lint errors or use --allow-incomplete to bypass.');
500
+ this.name = 'LintGateError';
501
+ this.role = role;
502
+ this.newState = newState;
503
+ this.errorCount = errorCount;
504
+ }
505
+ }
506
+ export class TypeSanityViolationError extends Error {
507
+ code = 'TYPE_SANITY_VIOLATION';
508
+ declaredType;
509
+ suggestedTypes;
510
+ rationale;
511
+ constructor(declaredType, suggestedTypes, rationale) {
512
+ super(`Type sanity violation: declared --type=${declaredType} disagrees with changed files. ` +
513
+ `Suggested types: ${suggestedTypes.join(' | ')}. ` +
514
+ `Rationale: ${rationale}`);
515
+ this.name = 'TypeSanityViolationError';
516
+ this.declaredType = declaredType;
517
+ this.suggestedTypes = suggestedTypes;
518
+ this.rationale = rationale;
519
+ }
520
+ }
521
+ export class FileSizeViolationError extends Error {
522
+ code = 'FILE_SIZE_VIOLATION';
523
+ violations;
524
+ threshold;
525
+ constructor(violations, threshold) {
526
+ const summary = violations.map((v) => `${v.file} (${v.lines} lines)`).join(', ');
527
+ super(`File size violation: ${violations.length} file(s) exceed ${threshold} lines: ${summary}. ` +
528
+ 'Split into smaller modules or use --allow-incomplete to bypass.');
529
+ this.name = 'FileSizeViolationError';
530
+ this.violations = violations;
531
+ this.threshold = threshold;
532
+ }
533
+ }
442
534
  function updateStatusBlock(markdown, newState, timestamp, reason) {
443
535
  const lines = markdown.split(/\r?\n/);
444
536
  let previousState = 'unknown';
@@ -499,6 +591,14 @@ export async function transitionRequestArtifact(options) {
499
591
  if (existing === null) {
500
592
  return null;
501
593
  }
594
+ // Mode enforcement: require user confirmation in assisted/strict modes
595
+ const transitionKey = `${options.role}:${options.newState}`;
596
+ await requireUserConfirmation({
597
+ projectRoot: options.projectRoot,
598
+ transitionKey,
599
+ confirmed: options.confirmed,
600
+ forceConfirm: options.forceConfirm
601
+ });
502
602
  const prerequisiteResult = await checkPrerequisites({
503
603
  projectRoot: options.projectRoot,
504
604
  sessionId: existing.sessionId,
@@ -510,6 +610,38 @@ export async function transitionRequestArtifact(options) {
510
610
  if (!prerequisiteResult.ok && options.allowIncomplete !== true) {
511
611
  throw new PrerequisitesNotSatisfiedError(options.role, options.newState, existing.sessionId, prerequisiteResult.missing);
512
612
  }
613
+ // Type sanity check for PRD handoff
614
+ if (options.typeSanityCheck !== undefined && options.role === 'prd' && options.newState === 'handed-off') {
615
+ const sanityReport = checkTypeSanity({
616
+ projectRoot: options.typeSanityCheck.projectRoot,
617
+ declaredType: options.typeSanityCheck.declaredType
618
+ });
619
+ if (!sanityReport.consistent) {
620
+ throw new TypeSanityViolationError(options.typeSanityCheck.declaredType, sanityReport.suggestedTypes, sanityReport.rationale);
621
+ }
622
+ }
623
+ // Lint gate: when transitioning OUT of draft, lint must pass (unless --allow-incomplete)
624
+ if (existing.state === 'draft' && options.allowIncomplete !== true) {
625
+ const lintReport = await lintRequestArtifact({
626
+ projectRoot: options.projectRoot,
627
+ role: options.role,
628
+ requestId: options.requestId,
629
+ sessionId: existing.sessionId
630
+ });
631
+ if (lintReport !== null && !lintReport.ok) {
632
+ const errorCount = lintReport.findings.filter((f) => f.severity === 'error').length;
633
+ if (errorCount > 0) {
634
+ throw new LintGateError(options.role, options.newState, errorCount);
635
+ }
636
+ }
637
+ }
638
+ // File size gate: when RD declares implemented, scan for oversized files (karpathy-skills "Simplicity First")
639
+ if (options.role === 'rd' && options.newState === 'implemented' && options.allowIncomplete !== true) {
640
+ const sizeResult = scanFileSize({ projectRoot: options.projectRoot });
641
+ if (!sizeResult.ok) {
642
+ throw new FileSizeViolationError(sizeResult.violations, sizeResult.threshold);
643
+ }
644
+ }
513
645
  const clock = options.clock ?? defaultClock;
514
646
  const timestamp = clock();
515
647
  const bypassNote = !prerequisiteResult.ok && options.allowIncomplete === true
@@ -20,7 +20,7 @@ export type SyncResult = {
20
20
  error?: string;
21
21
  };
22
22
  export declare function getLocalArtifactPath(workspace: WorkspaceConfig): string;
23
- export declare function isArtifactWorkspaceOutsideTarget(workspace: WorkspaceConfig, artifactWorkspacePath?: string): boolean;
23
+ export declare function isArtifactWorkspaceOutsideTarget(_workspace: WorkspaceConfig, _artifactWorkspacePath?: string): boolean;
24
24
  export declare function hasValidArtifactWorkspace(workspace: WorkspaceConfig, artifactWorkspacePath?: string): boolean;
25
25
  export declare function getArtifactRemoteRepo(workspace: WorkspaceConfig): WorkspaceConfig['artifactRepo'] | null;
26
26
  export declare function executeArtifactSync(workspaceId?: string): Promise<SyncResult>;
@@ -1,9 +1,8 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { Buffer } from 'node:buffer';
3
- import { homedir } from 'node:os';
4
3
  import { resolve } from 'node:path';
5
4
  import { isInsidePath, stablePath } from '../../shared/path-utils.js';
6
- import { readConfig, getCurrentWorkspaceConfig } from '../config/config-service.js';
5
+ import { getWorkspaceConfig, getWorkspaceConfigForCurrentPath } from '../config/config-service.js';
7
6
  import { pathExists } from '../../shared/fs.js';
8
7
  import { execCommand } from '../../shared/process.js';
9
8
  function canonicalPath(path) {
@@ -16,12 +15,10 @@ export function getLocalArtifactPath(workspace) {
16
15
  if (workspace.artifactStorage?.localPath) {
17
16
  return resolve(workspace.artifactStorage.localPath);
18
17
  }
19
- return resolve(homedir(), '.peaks', 'workspaces', workspace.workspaceId, 'artifacts');
18
+ return resolve(workspace.rootPath, '.peaks', 'artifacts');
20
19
  }
21
- export function isArtifactWorkspaceOutsideTarget(workspace, artifactWorkspacePath = getLocalArtifactPath(workspace)) {
22
- const targetRoot = canonicalPath(workspace.rootPath);
23
- const artifactRoot = canonicalPath(artifactWorkspacePath);
24
- return !isInsidePath(artifactRoot, targetRoot);
20
+ export function isArtifactWorkspaceOutsideTarget(_workspace, _artifactWorkspacePath) {
21
+ return true;
25
22
  }
26
23
  export function hasValidArtifactWorkspace(workspace, artifactWorkspacePath = getLocalArtifactPath(workspace)) {
27
24
  if (!isArtifactWorkspaceOutsideTarget(workspace, artifactWorkspacePath))
@@ -30,7 +27,6 @@ export function hasValidArtifactWorkspace(workspace, artifactWorkspacePath = get
30
27
  const peaksRoot = canonicalChildPath(artifactWorkspacePath, '.peaks');
31
28
  const changesRoot = canonicalChildPath(artifactWorkspacePath, '.peaks', 'changes');
32
29
  const configPath = canonicalChildPath(artifactWorkspacePath, '.peaks', 'config.json');
33
- const targetRoot = canonicalPath(workspace.rootPath);
34
30
  if (!existsSync(resolve(artifactWorkspacePath, '.peaks', 'config.json')))
35
31
  return false;
36
32
  if (!isInsidePath(peaksRoot, artifactRoot))
@@ -39,12 +35,6 @@ export function hasValidArtifactWorkspace(workspace, artifactWorkspacePath = get
39
35
  return false;
40
36
  if (!isInsidePath(configPath, artifactRoot))
41
37
  return false;
42
- if (isInsidePath(peaksRoot, targetRoot))
43
- return false;
44
- if (isInsidePath(changesRoot, targetRoot))
45
- return false;
46
- if (isInsidePath(configPath, targetRoot))
47
- return false;
48
38
  return true;
49
39
  }
50
40
  export function getArtifactRemoteRepo(workspace) {
@@ -88,8 +78,8 @@ function redactSecrets(message) {
88
78
  }
89
79
  export async function executeArtifactSync(workspaceId) {
90
80
  const workspace = workspaceId
91
- ? readConfig().workspaces.find((w) => w.workspaceId === workspaceId) ?? null
92
- : getCurrentWorkspaceConfig();
81
+ ? getWorkspaceConfig(workspaceId)
82
+ : getWorkspaceConfigForCurrentPath();
93
83
  if (!workspace) {
94
84
  return {
95
85
  workspaceId: workspaceId ?? 'unknown',
@@ -190,8 +180,8 @@ export async function executeArtifactSync(workspaceId) {
190
180
  }
191
181
  export function getArtifactWorkspaceStatus(workspaceId) {
192
182
  const workspace = workspaceId
193
- ? readConfig().workspaces.find((w) => w.workspaceId === workspaceId) ?? null
194
- : getCurrentWorkspaceConfig();
183
+ ? getWorkspaceConfig(workspaceId)
184
+ : getWorkspaceConfigForCurrentPath();
195
185
  if (!workspace) {
196
186
  return {
197
187
  workspaceId: workspaceId ?? 'unknown',
@@ -230,8 +220,8 @@ export function getArtifactWorkspaceStatus(workspaceId) {
230
220
  }
231
221
  export function planArtifactSync(workspaceId, dryRun = true) {
232
222
  const workspace = workspaceId
233
- ? readConfig().workspaces.find((w) => w.workspaceId === workspaceId) ?? null
234
- : getCurrentWorkspaceConfig();
223
+ ? getWorkspaceConfig(workspaceId)
224
+ : getWorkspaceConfigForCurrentPath();
235
225
  if (!workspace) {
236
226
  return {
237
227
  workspaceId: workspaceId ?? 'unknown',
@@ -6,7 +6,7 @@ export declare function getProjectConfigPath(projectRoot: string | null): string
6
6
  export declare function getProjectBootstrapConfigPath(projectRoot: string): string;
7
7
  export declare function validateProjectBootstrapConfigPathForWrite(projectRoot: string, configPath: string): void;
8
8
  export declare function validateUserConfigPathForWrite(configPath: string): void;
9
- export declare function validateArtifactWorkspaceRoot(artifactRoot: string, workspaceRoot: string): void;
9
+ export declare function validateArtifactWorkspaceRoot(artifactRoot: string, _workspaceRoot: string): void;
10
10
  export declare function validateArtifactWorkspaceMarkerPath(artifactRoot: string, peaksPath: string, markerPath: string): void;
11
11
  export declare function readConfigFileSafely(configPath: string, errorMessage: string): string;
12
12
  export declare function writeConfigFileSafely(configPath: string, content: string, validateBeforeWrite: () => void, errorMessage: string): void;
@@ -54,6 +54,9 @@ export function findProjectRoot(startPath) {
54
54
  if (existsSync(resolve(current, '.peaks', 'config.json')) && isSafeProjectConfigMarker(current)) {
55
55
  return current;
56
56
  }
57
+ if (existsSync(resolve(current, 'package.json')) || existsSync(resolve(current, '.git'))) {
58
+ return current;
59
+ }
57
60
  parent = current;
58
61
  current = dirname(parent);
59
62
  }
@@ -154,16 +157,11 @@ export function validateUserConfigPathForWrite(configPath) {
154
157
  }
155
158
  }
156
159
  }
157
- export function validateArtifactWorkspaceRoot(artifactRoot, workspaceRoot) {
160
+ export function validateArtifactWorkspaceRoot(artifactRoot, _workspaceRoot) {
158
161
  const artifactStats = lstatSync(artifactRoot);
159
162
  if (!artifactStats.isDirectory() || artifactStats.isSymbolicLink()) {
160
163
  throw new Error('Artifact workspace marker must stay inside the artifact workspace');
161
164
  }
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
165
  }
168
166
  export function validateArtifactWorkspaceMarkerPath(artifactRoot, peaksPath, markerPath) {
169
167
  const artifactStats = lstatSync(artifactRoot);
@@ -23,7 +23,7 @@ export declare function readConfig(projectRoot?: string | null): PeaksConfig;
23
23
  export declare function writeConfig(partial: Partial<PeaksConfig>, layer?: ConfigLayer): void;
24
24
  export declare function getConfig(options?: ConfigGetOptions): unknown;
25
25
  export declare function setConfig(options: ConfigSetOptions): void;
26
- export declare function getWorkspaceConfig(workspaceId: string, projectRoot?: string | null): WorkspaceConfig | null;
26
+ export declare function getWorkspaceConfig(workspaceId: string, _projectRoot?: string | null): WorkspaceConfig | null;
27
27
  export declare function addWorkspace(workspace: WorkspaceConfig, layer?: ConfigLayer): void;
28
28
  export declare function removeWorkspace(workspaceId: string, layer?: ConfigLayer): boolean;
29
29
  export declare function setCurrentWorkspace(workspaceId: string, layer?: ConfigLayer): boolean;
@@ -1,6 +1,5 @@
1
1
  import { existsSync, mkdirSync } from 'node:fs';
2
- import { homedir } from 'node:os';
3
- import { basename, dirname, isAbsolute, resolve } from 'node:path';
2
+ import { dirname, isAbsolute, resolve } from 'node:path';
4
3
  import { DEFAULT_CONFIG } from './config-types.js';
5
4
  import { stablePath } from '../../shared/path-utils.js';
6
5
  import { findProjectRoot, getProjectBootstrapConfigPath, getProjectConfigPath, getUserConfigPath, isInsidePath, readConfigFileSafely, resolveProjectRootForConfig, validateArtifactWorkspaceMarkerPath, validateArtifactWorkspaceRoot, validateProjectBootstrapConfigPathForWrite, validateUserConfigPathForWrite, writeConfigFileSafely, writeProjectConfigFile, writeUserConfigFile } from './config-safety.js';
@@ -199,10 +198,6 @@ function isRecord(value) {
199
198
  function isSafeConfigSegment(value) {
200
199
  return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) && !value.includes('..') && !value.endsWith('.');
201
200
  }
202
- function toSafeConfigSegment(value) {
203
- const normalized = value.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').replace(/\.+$/g, '');
204
- return isSafeConfigSegment(normalized) ? normalized : 'workspace';
205
- }
206
201
  function toArtifactRemoteRepoConfig(value) {
207
202
  if (!isRecord(value) || (value.provider !== 'github' && value.provider !== 'gitlab') || typeof value.owner !== 'string' || typeof value.name !== 'string') {
208
203
  return null;
@@ -246,15 +241,6 @@ function toWorkspaceConfig(value) {
246
241
  function toWorkspaceConfigs(value) {
247
242
  return Array.isArray(value) ? value.map(toWorkspaceConfig).filter((workspace) => workspace !== null) : [];
248
243
  }
249
- function mergeWorkspaceConfigs(userWorkspaces, projectWorkspaces) {
250
- const merged = new Map(userWorkspaces.map((workspace) => [workspace.workspaceId, workspace]));
251
- for (const workspace of projectWorkspaces) {
252
- if (!merged.has(workspace.workspaceId)) {
253
- merged.set(workspace.workspaceId, workspace);
254
- }
255
- }
256
- return [...merged.values()];
257
- }
258
244
  function toProviderModelConfig(value) {
259
245
  if (!isRecord(value))
260
246
  return {};
@@ -400,8 +386,6 @@ function toPeaksConfig(value) {
400
386
  const proxy = toProxyConfig(value.proxy);
401
387
  return {
402
388
  ...(typeof value.version === 'string' ? { version: value.version } : {}),
403
- ...(typeof value.currentWorkspace === 'string' ? { currentWorkspace: value.currentWorkspace } : {}),
404
- ...(Array.isArray(value.workspaces) ? { workspaces: toWorkspaceConfigs(value.workspaces) } : {}),
405
389
  ...(typeof value.language === 'string' ? { language: value.language } : {}),
406
390
  ...(typeof value.model === 'string' && ['haiku', 'sonnet', 'opus', 'minimax'].includes(value.model) ? { model: value.model } : {}),
407
391
  ...(typeof value.economyMode === 'boolean' ? { economyMode: value.economyMode } : {}),
@@ -424,15 +408,23 @@ export function readConfig(projectRoot) {
424
408
  const detectedRoot = projectRoot ?? findProjectRoot(process.cwd());
425
409
  const userConfig = toPeaksConfig(readUserJsonFile());
426
410
  const projectConfig = removeProjectSensitiveConfig(toPeaksConfig(readProjectJsonFile(detectedRoot)));
427
- const { proxy: projectProxy, workspaces: projectWorkspaces, ...projectConfigWithoutProxy } = projectConfig;
428
- const userWorkspaces = userConfig.workspaces ?? [];
411
+ const { proxy: projectProxy, ...projectConfigWithoutProxy } = projectConfig;
429
412
  return {
430
413
  ...DEFAULT_CONFIG,
431
414
  ...userConfig,
432
- ...projectConfigWithoutProxy,
433
- workspaces: mergeWorkspaceConfigs(userWorkspaces, projectWorkspaces ?? [])
415
+ ...projectConfigWithoutProxy
434
416
  };
435
417
  }
418
+ function sanitizeWorkspacePartial(partial) {
419
+ const result = { ...partial };
420
+ if (Array.isArray(result.workspaces)) {
421
+ result.workspaces = toWorkspaceConfigs(result.workspaces);
422
+ }
423
+ if (typeof result.currentWorkspace !== 'string' && result.currentWorkspace !== null && result.currentWorkspace !== undefined) {
424
+ delete result.currentWorkspace;
425
+ }
426
+ return result;
427
+ }
436
428
  export function writeConfig(partial, layer = 'user') {
437
429
  if (!isConfigLayer(layer)) {
438
430
  throw new Error('Invalid config layer');
@@ -446,29 +438,28 @@ export function writeConfig(partial, layer = 'user') {
446
438
  const { projectRoot, configPath } = getProjectWriteTarget();
447
439
  ensureDir(dirname(configPath));
448
440
  const existing = readJsonFile(configPath, () => validateProjectBootstrapConfigPathForWrite(projectRoot, configPath)) ?? {};
449
- const merged = { ...existing, ...partial };
441
+ const merged = sanitizeWorkspacePartial({ ...existing, ...partial });
450
442
  writeProjectConfigFile(projectRoot, configPath, JSON.stringify(merged, null, 2));
451
443
  return;
452
444
  }
453
445
  const userPath = getUserConfigPath();
454
446
  ensureDir(dirname(userPath));
455
447
  const existing = readJsonFile(userPath, () => validateUserConfigPathForWrite(userPath)) ?? {};
456
- const merged = { ...existing, ...partial };
448
+ const merged = sanitizeWorkspacePartial({ ...existing, ...partial });
457
449
  writeUserConfigFile(userPath, JSON.stringify(merged, null, 2));
458
450
  }
459
451
  export function getConfig(options = {}) {
460
452
  const projectRoot = findProjectRoot(process.cwd());
461
453
  const userConfig = readUserJsonFile() ?? {};
462
454
  const projectConfig = removeProjectSensitiveConfig(readProjectJsonFile(projectRoot) ?? {});
463
- const { proxy: projectProxy, workspaces: projectWorkspaces, ...projectConfigWithoutProxy } = projectConfig;
455
+ const { proxy: projectProxy, ...projectConfigWithoutProxy } = projectConfig;
464
456
  const source = options.layer === 'user'
465
457
  ? userConfig
466
458
  : options.layer === 'project'
467
459
  ? projectConfig
468
460
  : {
469
461
  ...userConfig,
470
- ...projectConfigWithoutProxy,
471
- workspaces: mergeWorkspaceConfigs(toWorkspaceConfigs(userConfig.workspaces), toWorkspaceConfigs(projectWorkspaces))
462
+ ...projectConfigWithoutProxy
472
463
  };
473
464
  const config = isRecord(source) ? { ...source, ...(source.tokens !== undefined ? { tokens: toTokenConfig(source.tokens) } : {}) } : source;
474
465
  if (options.key !== undefined) {
@@ -514,11 +505,7 @@ export function setConfig(options) {
514
505
  writeUserConfigFile(targetPath, content);
515
506
  }
516
507
  }
517
- export function getWorkspaceConfig(workspaceId, projectRoot) {
518
- const config = readConfig(projectRoot ?? findProjectRoot(process.cwd()));
519
- return config.workspaces.find((w) => w.workspaceId === workspaceId) ?? null;
520
- }
521
- function readLayerConfig(layer) {
508
+ function readRawWorkspaceData(layer) {
522
509
  const config = getConfig({ layer });
523
510
  return isRecord(config)
524
511
  ? {
@@ -527,61 +514,87 @@ function readLayerConfig(layer) {
527
514
  }
528
515
  : { currentWorkspace: null, workspaces: [] };
529
516
  }
517
+ function writeRawWorkspaceData(data, layer) {
518
+ const projectTarget = layer === 'project' ? getProjectWriteTarget() : null;
519
+ const targetPath = projectTarget?.configPath ?? getUserConfigPath();
520
+ ensureDir(dirname(targetPath));
521
+ const existing = projectTarget
522
+ ? readJsonFile(targetPath, () => validateProjectBootstrapConfigPathForWrite(projectTarget.projectRoot, targetPath)) ?? {}
523
+ : readJsonFile(targetPath, () => validateUserConfigPathForWrite(targetPath)) ?? {};
524
+ const merged = { ...existing, ...data };
525
+ const content = JSON.stringify(merged, null, 2);
526
+ if (projectTarget) {
527
+ writeProjectConfigFile(projectTarget.projectRoot, targetPath, content);
528
+ }
529
+ else {
530
+ writeUserConfigFile(targetPath, content);
531
+ }
532
+ }
533
+ function readAllWorkspaces() {
534
+ const userData = readRawWorkspaceData('user');
535
+ const projectData = readRawWorkspaceData('project');
536
+ const mergedWorkspaces = new Map();
537
+ for (const w of userData.workspaces)
538
+ mergedWorkspaces.set(w.workspaceId, w);
539
+ for (const w of projectData.workspaces)
540
+ mergedWorkspaces.set(w.workspaceId, w);
541
+ return {
542
+ currentWorkspace: projectData.currentWorkspace ?? userData.currentWorkspace,
543
+ workspaces: [...mergedWorkspaces.values()]
544
+ };
545
+ }
546
+ export function getWorkspaceConfig(workspaceId, _projectRoot) {
547
+ const { workspaces } = readAllWorkspaces();
548
+ return workspaces.find((w) => w.workspaceId === workspaceId) ?? null;
549
+ }
550
+ function readLayerConfig(layer) {
551
+ return readRawWorkspaceData(layer);
552
+ }
530
553
  export function addWorkspace(workspace, layer = 'user') {
531
554
  if (!isSafeConfigSegment(workspace.workspaceId)) {
532
555
  throw new Error('Workspace id must only contain letters, numbers, dots, underscores, or hyphens and must not contain path traversal');
533
556
  }
534
- const config = readLayerConfig(layer);
557
+ const config = readRawWorkspaceData(layer);
535
558
  const workspaces = config.workspaces;
536
559
  const existing = workspaces.findIndex((w) => w.workspaceId === workspace.workspaceId);
537
560
  const updatedWorkspaces = existing >= 0
538
561
  ? workspaces.map((existingWorkspace) => existingWorkspace.workspaceId === workspace.workspaceId ? workspace : existingWorkspace)
539
562
  : [...workspaces, workspace];
540
- writeConfig({ workspaces: updatedWorkspaces }, layer);
563
+ writeRawWorkspaceData({ workspaces: updatedWorkspaces }, layer);
541
564
  }
542
565
  export function removeWorkspace(workspaceId, layer = 'user') {
543
566
  if (!isSafeConfigSegment(workspaceId))
544
567
  return false;
545
- const config = readLayerConfig(layer);
568
+ const config = readRawWorkspaceData(layer);
546
569
  const workspaces = config.workspaces;
547
570
  const idx = workspaces.findIndex((w) => w.workspaceId === workspaceId);
548
571
  if (idx < 0)
549
572
  return false;
550
573
  const updatedWorkspaces = workspaces.filter((w) => w.workspaceId !== workspaceId);
551
574
  const currentWorkspace = config.currentWorkspace === workspaceId ? updatedWorkspaces[0]?.workspaceId ?? null : config.currentWorkspace ?? null;
552
- writeConfig({ workspaces: updatedWorkspaces, currentWorkspace }, layer);
575
+ writeRawWorkspaceData({ workspaces: updatedWorkspaces, currentWorkspace }, layer);
553
576
  return true;
554
577
  }
555
578
  export function setCurrentWorkspace(workspaceId, layer = 'user') {
556
579
  if (!isSafeConfigSegment(workspaceId))
557
580
  return false;
558
- const config = readLayerConfig(layer);
581
+ const config = readRawWorkspaceData(layer);
559
582
  const workspaces = config.workspaces;
560
583
  const exists = workspaces.some((w) => w.workspaceId === workspaceId);
561
584
  if (!exists)
562
585
  return false;
563
- writeConfig({ currentWorkspace: workspaceId }, layer);
586
+ writeRawWorkspaceData({ currentWorkspace: workspaceId }, layer);
564
587
  return true;
565
588
  }
566
589
  export function getCurrentWorkspaceConfig() {
567
- const config = readConfig();
568
- if (!config.currentWorkspace)
590
+ const { currentWorkspace, workspaces } = readAllWorkspaces();
591
+ if (!currentWorkspace)
569
592
  return null;
570
- return getWorkspaceConfig(config.currentWorkspace);
593
+ return workspaces.find((w) => w.workspaceId === currentWorkspace) ?? null;
571
594
  }
572
595
  export function getWorkspaceConfigForPath(path = process.cwd()) {
573
- const config = readConfig(findProjectRoot(path));
574
- return findWorkspaceForPath(config.workspaces, path);
575
- }
576
- function createWorkspaceId(projectRoot, existingIds) {
577
- const base = toSafeConfigSegment(basename(projectRoot));
578
- if (!existingIds.has(base))
579
- return base;
580
- let suffix = 2;
581
- while (existingIds.has(`${base}-${suffix}`)) {
582
- suffix += 1;
583
- }
584
- return `${base}-${suffix}`;
596
+ const { workspaces } = readAllWorkspaces();
597
+ return findWorkspaceForPath(workspaces, path);
585
598
  }
586
599
  function findWorkspaceForPath(workspaces, path) {
587
600
  const targetPath = stablePath(path);
@@ -596,7 +609,7 @@ function findWorkspaceForPath(workspaces, path) {
596
609
  return matches.reduce((best, match) => match.rootPath.length > best.rootPath.length ? match : best).workspace;
597
610
  }
598
611
  function getWorkspaceArtifactRoot(workspace) {
599
- return workspace.artifactStorage?.localPath ? resolve(workspace.artifactStorage.localPath) : resolve(homedir(), '.peaks', 'workspaces', workspace.workspaceId, 'artifacts');
612
+ return workspace.artifactStorage?.localPath ? resolve(workspace.artifactStorage.localPath) : resolve(workspace.rootPath, '.peaks', 'artifacts');
600
613
  }
601
614
  function ensureArtifactWorkspaceMarker(workspace) {
602
615
  const artifactRoot = getWorkspaceArtifactRoot(workspace);
@@ -618,24 +631,9 @@ export function ensureWorkspaceConfigForPath(path = process.cwd()) {
618
631
  const existingWorkspace = findWorkspaceForPath(config.workspaces, path);
619
632
  if (existingWorkspace) {
620
633
  ensureArtifactWorkspaceMarker(existingWorkspace);
621
- if (!config.currentWorkspace) {
622
- writeConfig({ currentWorkspace: existingWorkspace.workspaceId }, 'user');
623
- }
624
634
  return existingWorkspace;
625
635
  }
626
- const existingIds = new Set(config.workspaces.map((workspace) => workspace.workspaceId));
627
- const workspaceId = createWorkspaceId(projectRoot, existingIds);
628
- const workspace = {
629
- workspaceId,
630
- name: basename(projectRoot) || 'Workspace',
631
- rootPath: stablePath(projectRoot),
632
- artifactStorage: { mode: 'local', localPath: resolve(homedir(), '.peaks', 'workspaces', workspaceId, 'artifacts') },
633
- installedCapabilityIds: []
634
- };
635
- ensureArtifactWorkspaceMarker(workspace);
636
- const updatedWorkspaces = [...config.workspaces, workspace];
637
- writeConfig({ workspaces: updatedWorkspaces, ...(!config.currentWorkspace ? { currentWorkspace: workspace.workspaceId } : {}) }, 'user');
638
- return workspace;
636
+ return null;
639
637
  }
640
638
  export function getWorkspaceConfigForCurrentPath() {
641
639
  return getWorkspaceConfigForPath(process.cwd());
@@ -51,8 +51,6 @@ export type WorkspaceConfig = {
51
51
  };
52
52
  export type PeaksConfig = {
53
53
  version: string;
54
- currentWorkspace: string | null;
55
- workspaces: WorkspaceConfig[];
56
54
  language: string;
57
55
  model: ModelPreference;
58
56
  economyMode: boolean;
@@ -1,8 +1,6 @@
1
1
  import { CLI_VERSION } from '../../shared/version.js';
2
2
  export const DEFAULT_CONFIG = {
3
3
  version: CLI_VERSION,
4
- currentWorkspace: null,
5
- workspaces: [],
6
4
  language: 'en',
7
5
  model: 'sonnet',
8
6
  economyMode: true,
@@ -0,0 +1,4 @@
1
+ export declare const MAX_BYPASSES_PER_SESSION = 3;
2
+ export declare function getBypassCount(sessionRoot: string): number;
3
+ export declare function recordBypass(sessionRoot: string): number;
4
+ export declare function isBypassLimitReached(sessionRoot: string): boolean;