peaks-cli 1.0.2 → 1.0.4

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 (42) hide show
  1. package/dist/src/cli/commands/config-commands.js +3 -2
  2. package/dist/src/cli/commands/sc-commands.js +1 -1
  3. package/dist/src/cli/commands/workflow-commands.js +13 -2
  4. package/dist/src/services/artifacts/artifact-service.js +5 -4
  5. package/dist/src/services/artifacts/workspace-service.d.ts +1 -0
  6. package/dist/src/services/artifacts/workspace-service.js +56 -28
  7. package/dist/src/services/config/config-service.d.ts +2 -0
  8. package/dist/src/services/config/config-service.js +139 -12
  9. package/dist/src/services/config/config-types.d.ts +16 -5
  10. package/dist/src/services/rd/rd-service.js +16 -14
  11. package/dist/src/services/refactor/refactor-service.js +7 -4
  12. package/dist/src/services/sc/sc-service.d.ts +2 -1
  13. package/dist/src/services/sc/sc-service.js +65 -36
  14. package/dist/src/services/tech/tech-service.js +59 -15
  15. package/dist/src/services/workflow/workflow-autonomous-service.js +31 -8
  16. package/dist/src/shared/change-id.js +1 -1
  17. package/dist/src/shared/version.d.ts +1 -1
  18. package/dist/src/shared/version.js +1 -1
  19. package/output-styles/peaks-skill-swarm.md +132 -0
  20. package/package.json +4 -1
  21. package/schemas/artifact-retention-report.schema.json +31 -10
  22. package/scripts/install-skills.mjs +65 -8
  23. package/skills/peaks-prd/SKILL.md +59 -0
  24. package/skills/peaks-prd/references/artifact-contracts.md +4 -0
  25. package/skills/peaks-prd/references/workflow.md +29 -0
  26. package/skills/peaks-qa/SKILL.md +44 -2
  27. package/skills/peaks-qa/references/artifact-contracts.md +4 -0
  28. package/skills/peaks-qa/references/regression-gates.md +9 -1
  29. package/skills/peaks-rd/SKILL.md +78 -2
  30. package/skills/peaks-rd/references/artifact-contracts.md +4 -0
  31. package/skills/peaks-rd/references/refactor-workflow.md +11 -3
  32. package/skills/peaks-sc/SKILL.md +11 -3
  33. package/skills/peaks-sc/references/artifact-retention.md +3 -3
  34. package/skills/peaks-solo/SKILL.md +54 -1
  35. package/skills/peaks-solo/references/artifact-contracts.md +4 -0
  36. package/skills/peaks-solo/references/refactor-mode.md +2 -2
  37. package/skills/peaks-solo/references/workflow.md +18 -0
  38. package/skills/peaks-txt/SKILL.md +28 -1
  39. package/skills/peaks-txt/references/artifact-contracts.md +4 -0
  40. package/skills/peaks-txt/references/context-capsule.md +2 -1
  41. package/skills/peaks-ui/SKILL.md +25 -0
  42. package/skills/peaks-ui/references/workflow.md +17 -1
@@ -98,7 +98,9 @@ function readArtifactFile(rootPath, artifactWorkspacePath, artifact) {
98
98
  try {
99
99
  const artifactWorkspaceRealPath = stableRealPath(artifactWorkspacePath);
100
100
  const rootRealPath = stableRealPath(rootPath);
101
- if (!isInsidePath(rootRealPath, artifactWorkspaceRealPath)) {
101
+ const rdRootPath = resolve(rootPath, '..');
102
+ const sessionRootPath = resolve(rdRootPath, '..');
103
+ if (lstatSync(sessionRootPath).isSymbolicLink() || lstatSync(rdRootPath).isSymbolicLink() || lstatSync(rootPath).isSymbolicLink() || !isInsidePath(rootRealPath, artifactWorkspaceRealPath)) {
102
104
  return null;
103
105
  }
104
106
  const artifactStat = lstatSync(artifactPath);
@@ -138,7 +140,7 @@ function getConcreteTargetAreas(request, artifactWorkspacePath, hasApprovedTechA
138
140
  if (!artifactWorkspacePath || !hasApprovedTechArtifacts || !hasPlannerArtifactWorkspace(request, artifactWorkspacePath)) {
139
141
  return [];
140
142
  }
141
- const architectureRoot = join(artifactWorkspacePath, '.peaks', 'changes', request.changeId, 'architecture');
143
+ const architectureRoot = join(artifactWorkspacePath, '.peaks', request.changeId, 'rd', 'architecture');
142
144
  const candidates = TECH_REQUIRED_ARTIFACTS.flatMap((artifact) => {
143
145
  if (artifact === 'tech-approval-record.md') {
144
146
  return [];
@@ -155,7 +157,7 @@ function buildPlan(request) {
155
157
  const executionModelId = request.executionModelId?.trim() || getConfiguredExecutionModelId(undefined);
156
158
  const { workerTarget, blockedReasons } = resolveWorkerTarget(request.maxWorkers);
157
159
  const artifactWorkspacePath = resolveArtifactWorkspacePath(request);
158
- const artifactRoot = buildArtifactRelativePath(request.changeId, 'swarm');
160
+ const artifactRoot = buildArtifactRelativePath(request.changeId, 'rd', 'swarm');
159
161
  const techStatus = getTechStatus({
160
162
  changeId: request.changeId,
161
163
  ...(artifactWorkspacePath ? { artifactWorkspacePath } : {}),
@@ -174,10 +176,10 @@ function buildPlan(request) {
174
176
  conflictGroups: [],
175
177
  artifactRoot,
176
178
  outputs: {
177
- taskGraph: buildArtifactRelativePath(request.changeId, 'swarm', 'task-graph.json'),
179
+ taskGraph: buildArtifactRelativePath(request.changeId, 'rd', 'swarm', 'task-graph.json'),
178
180
  waveManifests: [],
179
181
  workerBriefs: [],
180
- reducerReport: buildArtifactRelativePath(request.changeId, 'swarm', 'reducer-report.md'),
182
+ reducerReport: buildArtifactRelativePath(request.changeId, 'rd', 'swarm', 'reducer-report.md'),
181
183
  },
182
184
  gateStatus: {
183
185
  techApprovalRequired: requiresTechApproval,
@@ -199,10 +201,10 @@ function buildPlan(request) {
199
201
  conflictGroups: [],
200
202
  artifactRoot,
201
203
  outputs: {
202
- taskGraph: buildArtifactRelativePath(request.changeId, 'swarm', 'task-graph.json'),
204
+ taskGraph: buildArtifactRelativePath(request.changeId, 'rd', 'swarm', 'task-graph.json'),
203
205
  waveManifests: [],
204
206
  workerBriefs: [],
205
- reducerReport: buildArtifactRelativePath(request.changeId, 'swarm', 'reducer-report.md'),
207
+ reducerReport: buildArtifactRelativePath(request.changeId, 'rd', 'swarm', 'reducer-report.md'),
206
208
  },
207
209
  gateStatus: {
208
210
  techApprovalRequired: true,
@@ -223,10 +225,10 @@ function buildPlan(request) {
223
225
  conflictGroups: [],
224
226
  artifactRoot,
225
227
  outputs: {
226
- taskGraph: buildArtifactRelativePath(request.changeId, 'swarm', 'task-graph.json'),
228
+ taskGraph: buildArtifactRelativePath(request.changeId, 'rd', 'swarm', 'task-graph.json'),
227
229
  waveManifests: [],
228
230
  workerBriefs: [],
229
- reducerReport: buildArtifactRelativePath(request.changeId, 'swarm', 'reducer-report.md'),
231
+ reducerReport: buildArtifactRelativePath(request.changeId, 'rd', 'swarm', 'reducer-report.md'),
230
232
  },
231
233
  gateStatus: {
232
234
  techApprovalRequired: requiresTechApproval,
@@ -263,7 +265,7 @@ function buildPlan(request) {
263
265
  };
264
266
  const tasks = taskIds.map((taskId, index) => {
265
267
  const wave = index < 8 ? 'discovery' : index < 16 ? 'planning' : index < taskIds.length - 8 ? 'implementation candidates' : index < taskIds.length - 5 ? 'unit-test execution' : index < taskIds.length - 1 ? 'quality gates' : 'reducer';
266
- const briefPath = buildArtifactRelativePath(request.changeId, 'swarm', 'workers', taskId, 'brief.md');
268
+ const briefPath = buildArtifactRelativePath(request.changeId, 'rd', 'swarm', 'workers', taskId, 'brief.md');
267
269
  const implementationIndex = index - 16;
268
270
  const targetArea = wave === 'implementation candidates' && hasConcreteTargetAreas(concreteTargetAreas)
269
271
  ? selectConcreteTargetArea(concreteTargetAreas, implementationIndex)
@@ -294,7 +296,7 @@ function buildPlan(request) {
294
296
  });
295
297
  const conflictGroups = waves.map((wave) => ({
296
298
  groupId: `group-${wave.name.replace(/\s+/g, '-')}`,
297
- ownedPaths: wave.taskIds.map((taskId) => buildArtifactRelativePath(request.changeId, 'swarm', 'workers', taskId, 'brief.md')),
299
+ ownedPaths: wave.taskIds.map((taskId) => buildArtifactRelativePath(request.changeId, 'rd', 'swarm', 'workers', taskId, 'brief.md')),
298
300
  parallelismPolicy: wave.taskIds.length > 1 ? 'parallel' : 'sequential',
299
301
  reason: `${wave.name} work is isolated by worker output path`,
300
302
  }));
@@ -308,10 +310,10 @@ function buildPlan(request) {
308
310
  conflictGroups,
309
311
  artifactRoot,
310
312
  outputs: {
311
- taskGraph: buildArtifactRelativePath(request.changeId, 'swarm', 'task-graph.json'),
312
- waveManifests: waves.map((wave, index) => buildArtifactRelativePath(request.changeId, 'swarm', 'waves', `wave-${index + 1}-${wave.name}.json`)),
313
+ taskGraph: buildArtifactRelativePath(request.changeId, 'rd', 'swarm', 'task-graph.json'),
314
+ waveManifests: waves.map((wave, index) => buildArtifactRelativePath(request.changeId, 'rd', 'swarm', 'waves', `wave-${index + 1}-${wave.name}.json`)),
313
315
  workerBriefs: tasks.map((task) => task.outputs[0]),
314
- reducerReport: buildArtifactRelativePath(request.changeId, 'swarm', 'reducer-report.md'),
316
+ reducerReport: buildArtifactRelativePath(request.changeId, 'rd', 'swarm', 'reducer-report.md'),
315
317
  },
316
318
  gateStatus: {
317
319
  techApprovalRequired: requiresTechApproval,
@@ -12,7 +12,8 @@ export function createRefactorDryRun(mode) {
12
12
  'Generate strict verifiable spec before each slice',
13
13
  'Require peaks-prd and peaks-qa artifacts even for direct peaks-rd refactor',
14
14
  'Require 100% acceptance for each slice',
15
- 'Commit code and intermediate artifacts before the next slice'
15
+ 'Retain code changes and intermediate artifacts in local .peaks/<session-id>/ storage before the next slice',
16
+ 'Commit or sync artifacts only after explicit authorization'
16
17
  ],
17
18
  requiredArtifacts: [
18
19
  'project-scan.md',
@@ -20,13 +21,15 @@ export function createRefactorDryRun(mode) {
20
21
  'feature-slice-map.md',
21
22
  'slice-spec.md',
22
23
  'acceptance-spec.md',
24
+ 'code-review-report.md',
25
+ 'security-review-report.md',
26
+ 'post-check-dry-run.md',
23
27
  'validation-report.md',
24
- 'artifact-retention-report.md',
25
- 'commit-required.md'
28
+ 'retention-boundary.md'
26
29
  ],
27
30
  nextActions: [
28
31
  'Run doctor checks',
29
- 'Initialize or link the remote artifact repository',
32
+ 'Create or discover local .peaks/<session-id>/ artifact workspace',
30
33
  'Generate the first refactor slice spec before implementation'
31
34
  ]
32
35
  };
@@ -26,7 +26,8 @@ export type ArtifactRetentionReport = {
26
26
  coverageArtifacts: string[];
27
27
  reviewArtifacts: string[];
28
28
  codeChanges: string[];
29
- commitStatus: 'committed' | 'pending' | 'rolled-back';
29
+ retentionStatus: 'local-ready' | 'pending' | 'explicitly-committed' | 'rolled-back';
30
+ commitHash: string | null;
30
31
  rollbackPoint: string | null;
31
32
  };
32
33
  export type ChangeTraceabilityStatus = {
@@ -1,20 +1,25 @@
1
1
  import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { basename, relative, resolve } from 'node:path';
4
+ import { isInsidePath } from '../../shared/path-utils.js';
4
5
  import { getCurrentWorkspaceConfig } from '../config/config-service.js';
5
- import { getArtifactWorkspaceStatus, getLocalArtifactPath } from '../artifacts/workspace-service.js';
6
+ import { getArtifactRemoteRepo, getArtifactWorkspaceStatus, getLocalArtifactPath } from '../artifacts/workspace-service.js';
6
7
  const REQUIRED_ARTIFACTS = [
7
- { name: 'artifact-retention-report.md', path: ['qa', 'artifact-retention-report.md'] },
8
+ { name: 'retention-boundary.md', path: ['sc', 'retention-boundary.md'] },
8
9
  { name: 'change-impact.json', path: ['sc', 'change-impact.json'] },
9
- { name: 'commit-boundary.md', path: ['checkpoints', 'commit-boundary.md'] },
10
- { name: 'coverage-report.md', path: ['qa', 'coverage-report.md'] }
10
+ { name: 'coverage-report.md', path: ['rd', 'coverage-report.md'] }
11
11
  ];
12
12
  const RETENTION_REQUIREMENTS = [
13
- ['product', 'prd.md'],
14
- ['architecture', 'slice-spec.md'],
13
+ ['prd', 'refactor-goal.md'],
14
+ ['rd', 'slice-spec.md'],
15
+ ['rd', 'coverage-report.md'],
16
+ ['rd', 'code-review-report.md'],
17
+ ['rd', 'security-review-report.md'],
18
+ ['rd', 'post-check-dry-run.md'],
15
19
  ['qa', 'validation-report.md'],
16
- ['qa', 'coverage-report.md'],
17
- ['review', 'code-review.md']
20
+ ['sc', 'change-impact.json'],
21
+ ['sc', 'retention-boundary.md'],
22
+ ['txt', 'context-capsule.md']
18
23
  ];
19
24
  const SLICE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]+$/;
20
25
  function getPeaksPath(workspaceRoot) {
@@ -27,12 +32,16 @@ function resolveCurrentChangeId(peaksPath) {
27
32
  try {
28
33
  const stat = lstatSync(currentChangePath);
29
34
  if (stat.isSymbolicLink()) {
30
- return basename(realpathSync(currentChangePath));
35
+ const targetPath = realpathSync(currentChangePath);
36
+ if (!isInsidePath(targetPath, realpathSync(peaksPath)))
37
+ return null;
38
+ const targetId = basename(targetPath);
39
+ return SLICE_ID_PATTERN.test(targetId) ? targetId : null;
31
40
  }
32
41
  const raw = readFileSync(currentChangePath, 'utf-8').trim();
33
- if (!raw)
42
+ if (!raw || !SLICE_ID_PATTERN.test(raw))
34
43
  return null;
35
- return basename(raw);
44
+ return raw;
36
45
  }
37
46
  catch {
38
47
  return null;
@@ -63,24 +72,42 @@ function mapSyncState(syncStatus) {
63
72
  return 'pending';
64
73
  return 'failed';
65
74
  }
66
- function getCurrentArtifactDir(workspaceRoot) {
67
- const peaksPath = getPeaksPath(workspaceRoot);
75
+ function getCurrentArtifactDir(artifactWorkspacePath) {
76
+ const peaksPath = getPeaksPath(artifactWorkspacePath);
68
77
  const changeId = resolveCurrentChangeId(peaksPath);
69
- const effectiveChangeId = changeId ?? 'unknown-change';
78
+ const effectiveChangeId = changeId ?? 'unknown-session';
70
79
  return {
71
80
  peaksPath,
72
81
  changeId,
73
- changeDir: resolve(peaksPath, 'changes', effectiveChangeId)
82
+ changeDir: resolve(peaksPath, effectiveChangeId)
74
83
  };
75
84
  }
76
- function getRetentionChangeDir(workspaceRoot, sliceId) {
77
- const peaksPath = getPeaksPath(workspaceRoot);
85
+ function getRetentionChangeDir(artifactWorkspacePath, sliceId) {
86
+ const peaksPath = getPeaksPath(artifactWorkspacePath);
78
87
  return {
79
88
  peaksPath,
80
89
  changeId: sliceId,
81
- changeDir: resolve(peaksPath, 'changes', sliceId)
90
+ changeDir: resolve(peaksPath, sliceId)
82
91
  };
83
92
  }
93
+ function isRetainedArtifactFile(filePath, artifactWorkspacePath, changesRoot, changeDir) {
94
+ if (!existsSync(filePath))
95
+ return false;
96
+ try {
97
+ const artifactWorkspaceRealPath = realpathSync(artifactWorkspacePath);
98
+ const changesRootRealPath = realpathSync(changesRoot);
99
+ const changeDirRealPath = realpathSync(changeDir);
100
+ const fileRealPath = realpathSync(filePath);
101
+ return !lstatSync(changesRoot).isSymbolicLink()
102
+ && !lstatSync(changeDir).isSymbolicLink()
103
+ && isInsidePath(changesRootRealPath, artifactWorkspaceRealPath)
104
+ && isInsidePath(changeDirRealPath, changesRootRealPath)
105
+ && isInsidePath(fileRealPath, changeDirRealPath);
106
+ }
107
+ catch {
108
+ return false;
109
+ }
110
+ }
84
111
  export function getChangeTraceabilityStatus() {
85
112
  const workspace = getCurrentWorkspaceConfig();
86
113
  const artifactStatus = getArtifactWorkspaceStatus(workspace?.workspaceId);
@@ -92,31 +119,30 @@ export function getChangeTraceabilityStatus() {
92
119
  localArtifactPath: '.peaks-artifacts',
93
120
  requiredArtifacts: REQUIRED_ARTIFACTS.map((artifact) => ({
94
121
  name: artifact.name,
95
- path: resolve('.peaks', 'changes', '<change-id>', ...artifact.path),
122
+ path: resolve('.peaks', '<session-id>', ...artifact.path),
96
123
  exists: false
97
124
  })),
98
125
  nextActions: ['Add a workspace: peaks config workspace add --id <id> --name <name> --path <path>']
99
126
  };
100
127
  }
101
- const { peaksPath, changeId, changeDir } = getCurrentArtifactDir(workspace.rootPath);
102
- const hasArtifactRepo = Boolean(workspace.artifactRepo);
128
+ const artifactWorkspacePath = getLocalArtifactPath(workspace);
129
+ const { peaksPath, changeId, changeDir } = getCurrentArtifactDir(artifactWorkspacePath);
130
+ const artifactRepo = getArtifactRemoteRepo(workspace);
131
+ const hasArtifactRepo = Boolean(artifactRepo);
132
+ const changesRoot = peaksPath;
103
133
  const requiredArtifacts = REQUIRED_ARTIFACTS.map((artifact) => {
104
134
  const artifactPath = resolve(changeDir, ...artifact.path);
105
135
  return {
106
136
  name: artifact.name,
107
- path: resolve(peaksPath, 'changes', changeId ?? '<change-id>', ...artifact.path),
108
- exists: existsSync(artifactPath)
137
+ path: resolve(peaksPath, changeId ?? '<session-id>', ...artifact.path),
138
+ exists: isRetainedArtifactFile(artifactPath, artifactWorkspacePath, changesRoot, changeDir)
109
139
  };
110
140
  });
111
141
  const nextActions = [];
112
142
  if (!changeId) {
113
143
  nextActions.push('Set the current change in .peaks/current-change');
114
144
  }
115
- if (!hasArtifactRepo) {
116
- nextActions.push('Configure artifact repo: peaks config workspace add --id <id> --provider github --repo-owner <owner> --repo-name <name>');
117
- nextActions.push('Then run: peaks artifacts init --provider github --name <repo> --dry-run');
118
- }
119
- else if (artifactStatus.syncStatus === 'pending') {
145
+ if (hasArtifactRepo && artifactStatus.syncStatus === 'pending') {
120
146
  nextActions.push(`Run peaks artifacts sync --workspace ${workspace.workspaceId} --dry-run`);
121
147
  }
122
148
  return {
@@ -130,7 +156,7 @@ export function getChangeTraceabilityStatus() {
130
156
  }
131
157
  export function createChangeImpact(options) {
132
158
  const workspace = getCurrentWorkspaceConfig();
133
- const artifactRepo = workspace?.artifactRepo ?? null;
159
+ const artifactRepo = workspace ? getArtifactRemoteRepo(workspace) : null;
134
160
  return {
135
161
  changeId: options.changeId,
136
162
  sourceArtifacts: options.sourceArtifacts ?? [],
@@ -161,7 +187,8 @@ export function createArtifactRetentionReport(options) {
161
187
  coverageArtifacts: options.coverageArtifacts ?? [],
162
188
  reviewArtifacts: options.reviewArtifacts ?? [],
163
189
  codeChanges: options.codeChanges ?? [],
164
- commitStatus: 'pending',
190
+ retentionStatus: 'pending',
191
+ commitHash: null,
165
192
  rollbackPoint: null
166
193
  };
167
194
  }
@@ -192,13 +219,15 @@ export function validateArtifactRetention(sliceId) {
192
219
  return {
193
220
  valid: false,
194
221
  missingArtifacts: ['Invalid slice id'],
195
- warnings: ['Slice id must stay inside .peaks/changes and only contain letters, numbers, dots, underscores, or hyphens']
222
+ warnings: ['Slice id must stay inside .peaks/<session-id> and only contain letters, numbers, dots, underscores, or hyphens']
196
223
  };
197
224
  }
198
- const { changeDir } = getRetentionChangeDir(workspace.rootPath, sliceId);
225
+ const artifactWorkspacePath = getLocalArtifactPath(workspace);
226
+ const { peaksPath, changeDir } = getRetentionChangeDir(artifactWorkspacePath, sliceId);
227
+ const changesRoot = peaksPath;
199
228
  const missingArtifacts = RETENTION_REQUIREMENTS
200
229
  .map(([folder, file]) => resolve(changeDir, folder, file))
201
- .filter((filePath) => !existsSync(filePath))
230
+ .filter((filePath) => !isRetainedArtifactFile(filePath, artifactWorkspacePath, changesRoot, changeDir))
202
231
  .map((filePath) => relative(changeDir, filePath).replace(/\\/g, '/'));
203
232
  return {
204
233
  valid: missingArtifacts.length === 0,
@@ -212,12 +241,12 @@ export function getScHelpText() {
212
241
  'peaks sc impact --change-id <id> Generate change impact artifact',
213
242
  'peaks sc retention --slice-id <id> Create artifact retention report',
214
243
  'peaks sc validate --slice-id <id> Validate artifact retention',
215
- 'peaks sc boundary --slice-id <id> Record commit boundary for slice',
244
+ 'peaks sc boundary --slice-id <id> Record retention boundary for slice',
216
245
  '',
217
246
  'Change traceability workflow integration:',
218
247
  ' 1. Run peaks sc status to check current state',
219
248
  ' 2. After slice completion, run peaks sc retention --slice-id <id>',
220
- ' 3. Artifact sync is automatic when artifact repo is configured',
221
- ' 4. Commit boundary is recorded when code is committed'
249
+ ' 3. Keep artifacts local in .peaks/<session-id>/ by default',
250
+ ' 4. Commit or sync artifacts only after explicit authorization'
222
251
  ];
223
252
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, lstatSync, readFileSync } from 'node:fs';
1
+ import { closeSync, existsSync, fstatSync, lstatSync, openSync, readSync, statSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { isInsidePath, stableRealPath } from '../../shared/path-utils.js';
4
4
  import { buildArtifactRelativePath, validateChangeIdOrThrow } from '../../shared/change-id.js';
@@ -28,7 +28,7 @@ function assertNonEmptyGoal(goal) {
28
28
  }
29
29
  }
30
30
  function architectureRoot(changeId) {
31
- return buildArtifactRelativePath(changeId, 'architecture');
31
+ return buildArtifactRelativePath(changeId, 'rd', 'architecture');
32
32
  }
33
33
  function hasPlannerArtifactWorkspace(artifactWorkspacePath, workspace) {
34
34
  return !!workspace && hasValidArtifactWorkspace(workspace, artifactWorkspacePath);
@@ -38,12 +38,59 @@ function isEscapedArchitectureRoot(rootPath, artifactWorkspacePath) {
38
38
  return false;
39
39
  }
40
40
  try {
41
- return !isInsidePath(stableRealPath(rootPath), stableRealPath(artifactWorkspacePath));
41
+ const rdRootPath = resolve(rootPath, '..');
42
+ const sessionRootPath = resolve(rdRootPath, '..');
43
+ return lstatSync(sessionRootPath).isSymbolicLink()
44
+ || lstatSync(rdRootPath).isSymbolicLink()
45
+ || lstatSync(rootPath).isSymbolicLink()
46
+ || !isInsidePath(stableRealPath(rootPath), stableRealPath(artifactWorkspacePath));
42
47
  }
43
48
  catch {
44
49
  return true;
45
50
  }
46
51
  }
52
+ const MAX_TECH_ARTIFACT_BYTES = 256_000;
53
+ function readTechArtifactFile(rootPath, artifact) {
54
+ const artifactPath = resolve(rootPath, artifact);
55
+ try {
56
+ const rootRealPath = stableRealPath(rootPath);
57
+ const artifactStat = lstatSync(artifactPath);
58
+ if (artifactStat.isSymbolicLink() || !artifactStat.isFile() || artifactStat.size > MAX_TECH_ARTIFACT_BYTES) {
59
+ return null;
60
+ }
61
+ if (!isInsidePath(stableRealPath(artifactPath), rootRealPath)) {
62
+ return null;
63
+ }
64
+ const fd = openSync(artifactPath, 'r');
65
+ try {
66
+ const openedStat = fstatSync(fd);
67
+ const currentStat = statSync(artifactPath);
68
+ if (!openedStat.isFile() || openedStat.size > MAX_TECH_ARTIFACT_BYTES || openedStat.dev !== artifactStat.dev || openedStat.ino !== artifactStat.ino || openedStat.dev !== currentStat.dev || openedStat.ino !== currentStat.ino) {
69
+ return null;
70
+ }
71
+ const buffer = Buffer.alloc(openedStat.size);
72
+ let offset = 0;
73
+ while (offset < openedStat.size) {
74
+ const bytesRead = readSync(fd, buffer, offset, openedStat.size - offset, offset);
75
+ if (bytesRead === 0) {
76
+ return null;
77
+ }
78
+ offset += bytesRead;
79
+ }
80
+ const finalStat = fstatSync(fd);
81
+ if (finalStat.dev !== openedStat.dev || finalStat.ino !== openedStat.ino) {
82
+ return null;
83
+ }
84
+ return buffer.toString('utf8');
85
+ }
86
+ finally {
87
+ closeSync(fd);
88
+ }
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
47
94
  function isValidArtifactFile(rootPath, artifact) {
48
95
  const artifactPath = resolve(rootPath, artifact);
49
96
  try {
@@ -61,7 +108,7 @@ function isValidArtifactFile(rootPath, artifact) {
61
108
  }
62
109
  }
63
110
  function waveManifestPath(changeId, index, wave) {
64
- return buildArtifactRelativePath(changeId, 'architecture', 'waves', `wave-${index + 1}-${wave}.json`);
111
+ return buildArtifactRelativePath(changeId, 'rd', 'architecture', 'waves', `wave-${index + 1}-${wave}.json`);
65
112
  }
66
113
  function taskPurpose(taskId, goal) {
67
114
  return `${taskId.replace(/^tech-/, '').replace(/-/g, ' ')} for ${goal}`;
@@ -82,7 +129,7 @@ function createTechGraph(request) {
82
129
  : wave.name === 'review'
83
130
  ? [...documentTaskIds]
84
131
  : [...reviewTaskIds];
85
- const briefPath = buildArtifactRelativePath(request.changeId, 'architecture', 'workers', taskId, 'brief.md');
132
+ const briefPath = buildArtifactRelativePath(request.changeId, 'rd', 'architecture', 'workers', taskId, 'brief.md');
86
133
  return {
87
134
  taskId,
88
135
  wave: wave.name,
@@ -104,10 +151,10 @@ function createTechGraph(request) {
104
151
  waves,
105
152
  tasks,
106
153
  outputs: {
107
- taskGraph: buildArtifactRelativePath(request.changeId, 'architecture', 'tech-task-graph.json'),
154
+ taskGraph: buildArtifactRelativePath(request.changeId, 'rd', 'architecture', 'tech-task-graph.json'),
108
155
  waveManifests: waves.map((wave, index) => waveManifestPath(request.changeId, index, wave.name)),
109
- reviewChecklist: buildArtifactRelativePath(request.changeId, 'architecture', 'tech-review-checklist.md'),
110
- approvalTemplate: buildArtifactRelativePath(request.changeId, 'architecture', 'tech-approval-record.template.md'),
156
+ reviewChecklist: buildArtifactRelativePath(request.changeId, 'rd', 'architecture', 'tech-review-checklist.md'),
157
+ approvalTemplate: buildArtifactRelativePath(request.changeId, 'rd', 'architecture', 'tech-approval-record.template.md'),
111
158
  },
112
159
  blockedReasons: [],
113
160
  nextActions: [],
@@ -156,8 +203,8 @@ export function getTechStatus(options) {
156
203
  nextActions: [...WORKSPACE_UNAVAILABLE_NEXT_ACTIONS],
157
204
  };
158
205
  }
159
- const rootPath = resolve(options.artifactWorkspacePath, '.peaks', 'changes', options.changeId, 'architecture');
160
- const approvalRecord = buildArtifactRelativePath(options.changeId, 'architecture', 'tech-approval-record.md');
206
+ const rootPath = resolve(options.artifactWorkspacePath, '.peaks', options.changeId, 'rd', 'architecture');
207
+ const approvalRecord = buildArtifactRelativePath(options.changeId, 'rd', 'architecture', 'tech-approval-record.md');
161
208
  if (isEscapedArchitectureRoot(rootPath, options.artifactWorkspacePath)) {
162
209
  return {
163
210
  changeId: options.changeId,
@@ -195,11 +242,8 @@ export function getTechStatus(options) {
195
242
  nextActions: ['Run peaks tech plan --dry-run, then persist and review the required tech artifacts.'],
196
243
  };
197
244
  }
198
- let approvalContent;
199
- try {
200
- approvalContent = readFileSync(join(rootPath, 'tech-approval-record.md'), 'utf8');
201
- }
202
- catch {
245
+ const approvalContent = readTechArtifactFile(rootPath, 'tech-approval-record.md');
246
+ if (approvalContent === null) {
203
247
  return {
204
248
  changeId: options.changeId,
205
249
  status: 'blocked',
@@ -216,10 +216,10 @@ function createGoalCommand(goalPackage) {
216
216
  function getResumeRequiredArtifacts(changeId) {
217
217
  return [
218
218
  buildArtifactRelativePath(changeId, 'prd', 'autonomous-goal-package.json'),
219
- buildArtifactRelativePath(changeId, 'swarm', 'autonomous-rd-plan.json'),
220
- buildArtifactRelativePath(changeId, 'swarm', 'checkpoints', 'checkpoint-1.json'),
221
- buildArtifactRelativePath(changeId, 'swarm', 'evidence', 'validation-report.md'),
222
- buildArtifactRelativePath(changeId, 'swarm', 'resume-instructions.md')
219
+ buildArtifactRelativePath(changeId, 'rd', 'swarm', 'autonomous-rd-plan.json'),
220
+ buildArtifactRelativePath(changeId, 'rd', 'swarm', 'checkpoints', 'checkpoint-1.json'),
221
+ buildArtifactRelativePath(changeId, 'rd', 'swarm', 'evidence', 'validation-report.md'),
222
+ buildArtifactRelativePath(changeId, 'rd', 'swarm', 'resume-instructions.md')
223
223
  ];
224
224
  }
225
225
  function isObjectRecord(value) {
@@ -249,8 +249,31 @@ function readResumeArtifact(artifactWorkspacePath, artifact) {
249
249
  if (artifactStat.isSymbolicLink() || !artifactStat.isFile() || artifactStat.size > MAX_RESUME_ARTIFACT_BYTES) {
250
250
  return null;
251
251
  }
252
+ const pathSegments = artifact.replace(/\\/g, '/').split('/');
253
+ if (pathSegments.length < 4 || pathSegments[0] !== '.peaks') {
254
+ return null;
255
+ }
256
+ const sessionRootPath = resolve(artifactWorkspacePath, '.peaks', pathSegments[1]);
257
+ const roleRootPath = resolve(sessionRootPath, pathSegments[2]);
258
+ if (lstatSync(sessionRootPath).isSymbolicLink() || lstatSync(roleRootPath).isSymbolicLink()) {
259
+ return null;
260
+ }
261
+ let allowedRootRealPath;
262
+ if (pathSegments[2] === 'rd') {
263
+ const swarmRootPath = resolve(roleRootPath, 'swarm');
264
+ if (pathSegments[3] !== 'swarm' || lstatSync(swarmRootPath).isSymbolicLink()) {
265
+ return null;
266
+ }
267
+ allowedRootRealPath = realpathSync(swarmRootPath);
268
+ }
269
+ else if (pathSegments[2] === 'prd') {
270
+ allowedRootRealPath = realpathSync(roleRootPath);
271
+ }
272
+ else {
273
+ return null;
274
+ }
252
275
  const artifactRealPath = realpathSync(artifactPath);
253
- if (!isInsidePath(artifactRealPath, artifactWorkspaceRealPath)) {
276
+ if (!isInsidePath(allowedRootRealPath, artifactWorkspaceRealPath) || !isInsidePath(artifactRealPath, allowedRootRealPath)) {
254
277
  return null;
255
278
  }
256
279
  const fd = openSync(artifactPath, 'r');
@@ -385,7 +408,7 @@ function isSafeEvidenceRef(ref) {
385
408
  return ref.toLowerCase() !== 'validation-report.md' && /^[A-Za-z0-9][A-Za-z0-9._-]*\.md$/.test(ref) && !ref.includes('..');
386
409
  }
387
410
  function evidenceRefsExist(artifactWorkspacePath, changeId, refs) {
388
- return refs.every((ref) => isSafeEvidenceRef(ref) && readResumeArtifact(artifactWorkspacePath, buildArtifactRelativePath(changeId, 'swarm', 'evidence', ref)) !== null);
411
+ return refs.every((ref) => isSafeEvidenceRef(ref) && readResumeArtifact(artifactWorkspacePath, buildArtifactRelativePath(changeId, 'rd', 'swarm', 'evidence', ref)) !== null);
389
412
  }
390
413
  function hasMatchingEvidenceRefs(artifactWorkspacePath, changeId, validationReportContent, checkpointContent) {
391
414
  const expectedRefs = getCheckpointValidationRefs(checkpointContent);
@@ -425,8 +448,8 @@ function getResumeArtifactsStatus(artifactWorkspacePath, requiredArtifacts, chan
425
448
  hasInvalidArtifact = true;
426
449
  }
427
450
  }
428
- const checkpointContent = artifactContents.get(buildArtifactRelativePath(changeId, 'swarm', 'checkpoints', 'checkpoint-1.json'));
429
- const validationReportContent = artifactContents.get(buildArtifactRelativePath(changeId, 'swarm', 'evidence', 'validation-report.md'));
451
+ const checkpointContent = artifactContents.get(buildArtifactRelativePath(changeId, 'rd', 'swarm', 'checkpoints', 'checkpoint-1.json'));
452
+ const validationReportContent = artifactContents.get(buildArtifactRelativePath(changeId, 'rd', 'swarm', 'evidence', 'validation-report.md'));
430
453
  if (!checkpointContent || !validationReportContent || !hasMatchingEvidenceRefs(artifactWorkspacePath, changeId, validationReportContent, checkpointContent)) {
431
454
  hasInvalidArtifact = true;
432
455
  }
@@ -61,7 +61,7 @@ export function isUnsafeArtifactPath(path) {
61
61
  export function buildArtifactRelativePath(changeId, ...segments) {
62
62
  validateChangeIdOrThrow(changeId);
63
63
  const joined = segments.map((segment) => normalizeForwardSlashes(segment)).join('/');
64
- const candidatePath = `.peaks/changes/${changeId}/${joined}`;
64
+ const candidatePath = `.peaks/${changeId}/${joined}`;
65
65
  if (isUnsafeArtifactPath(joined) || isUnsafeArtifactPath(candidatePath)) {
66
66
  throw new ChangeIdValidationError(changeId);
67
67
  }
@@ -1 +1 @@
1
- export declare const CLI_VERSION = "1.0.2";
1
+ export declare const CLI_VERSION = "1.0.4";
@@ -1 +1 @@
1
- export const CLI_VERSION = "1.0.2";
1
+ export const CLI_VERSION = "1.0.4";