peaks-cli 1.4.0 → 1.4.1
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.
- package/README.md +53 -0
- package/dist/src/cli/commands/migrate-1-4-1-command.d.ts +11 -0
- package/dist/src/cli/commands/migrate-1-4-1-command.js +34 -0
- package/dist/src/cli/commands/skill-context-stats-command.d.ts +40 -0
- package/dist/src/cli/commands/skill-context-stats-command.js +96 -0
- package/dist/src/cli/commands/skill-scope-commands.d.ts +2 -0
- package/dist/src/cli/commands/skill-scope-commands.js +9 -4
- package/dist/src/cli/commands/workspace-commands.js +8 -0
- package/dist/src/services/skill-scope/detect.d.ts +8 -2
- package/dist/src/services/skill-scope/detect.js +46 -13
- package/dist/src/services/skill-scope/types.d.ts +19 -0
- package/dist/src/services/skill-scope/types.js +23 -0
- package/dist/src/services/workspace/migrate-1-4-1-service.d.ts +44 -0
- package/dist/src/services/workspace/migrate-1-4-1-service.js +195 -0
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -148,6 +148,59 @@ peaks project memories --project <repo> --json # 读取 .peaks/memory/ 里
|
|
|
148
148
|
|
|
149
149
|
完整命令列表跑 `peaks --help` 即可。
|
|
150
150
|
|
|
151
|
+
## 技能白名单:`peaks skill scope`
|
|
152
|
+
|
|
153
|
+
`peaks skill scope` 把"哪些 skill 暴露给当前项目的 LLM"这件事从"全部加载"变成"按项目相关度过滤"。1.4.0 起默认带 shadow-stub 机制,**denied skill 的 LLM 上下文减量约 96.4%**(每个 stub ≈285 字节 vs 原 SKILL.md ≈7-8 KB)。
|
|
154
|
+
|
|
155
|
+
### 三步走
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
peaks skill scope --detect --project <repo> --json # 看哪些 skill 被标 relevant / borderline / irrelevant
|
|
159
|
+
peaks skill scope --apply --project <repo> # 写 source-of-truth + project-local mirror(默认 shadow-stub)
|
|
160
|
+
peaks skill scope --show --project <repo> # 查当前应用了哪个 allowlist
|
|
161
|
+
peaks skill scope --reset --project <repo> # 全部撤回
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`--apply` 写两份东西:
|
|
165
|
+
- `.peaks/scope/skills.json` — 唯一真源(allowlist + meta)
|
|
166
|
+
- `.claude/skills/<skill>/SKILL.md` — project-local mirror;denied skill 被替换为 ~285 字节的 shadow stub,前 5 行包含 `description: _peaks_scope_disabled` 标记,Claude Code 看到这个标记就跳过它的 full body
|
|
167
|
+
|
|
168
|
+
### 怎么 `--apply` 真的把上下文砍下来
|
|
169
|
+
|
|
170
|
+
光把 skill 从 LLM 的可调用列表里 deny 不够——Claude Code 仍然会从项目本地 mirror 读完整的 SKILL.md。**shadow-stub** 把 mirror 也换成 5 行 stub:
|
|
171
|
+
|
|
172
|
+
```yaml
|
|
173
|
+
---
|
|
174
|
+
name: agent-sort
|
|
175
|
+
description: _peaks_scope_disabled
|
|
176
|
+
peaks_scope_disabled: true
|
|
177
|
+
---
|
|
178
|
+
# Disabled by `peaks skill scope --apply`
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
实测在 ice-cola 项目(pnpm monorepo,4 包,TS + Python):215 个 skill × 1.92 MB → 应用白名单后 44 个 allowed × 364 KB + 123 个 denied × 35 KB stubs = **399 KB 整体 LLM 可见 skill 上下文**。**减量 924.7 KB / 69.8%**,其中 denied 侧减量 96.4%。
|
|
182
|
+
|
|
183
|
+
shadow-stub 是 1.4.0 的默认行为(`--shadow-fallback`)。想 copy 原 SKILL.md 到 mirror(IDE 端 inspection 用),传 `--no-shadow-fallback` 显式 opt-out。
|
|
184
|
+
|
|
185
|
+
### 运行时观测:`peaks skill context-stats`
|
|
186
|
+
|
|
187
|
+
想知道当前项目实际加载了多少 skill 字节 + 估计 token 数:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
peaks skill context-stats --project <repo>
|
|
191
|
+
# Allowed: 44 skills, 364.4 KB / 91.1K tokens.
|
|
192
|
+
# Denied: 123 skills, 35.0 KB / 8.8K tokens (96.4% shadow-stub reduction).
|
|
193
|
+
# Total: 399.5 KB / 99.9K tokens.
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
加 `--json` 拿结构化 envelope。还没 apply scope 的项目会拿到 `NO_SCOPE` code + 推荐命令。
|
|
197
|
+
|
|
198
|
+
### 限制
|
|
199
|
+
|
|
200
|
+
- detect 的 `relevant` 标记是按文件后缀占比 ≥ 5% 才算的(1.4.1 起的 shareByExtension 阈值)。比如冰-cola 这种 TS + Python 单仓,cpp/golang/java/kotlin/rust/swift 这些 language-specific skill 不会被标 relevant;以前 1 个 stray `.cpp` 文件就会把 `cpp-coding-standards` 误判为 relevant。
|
|
201
|
+
- 阈值可通过 `PEAKS_SCOPE_THRESHOLD=0.05` 环境变量或 `--threshold 0.05` 调整。
|
|
202
|
+
- `.peaks/scope/skills.json` 是 hand-editable 的;手改后下次 `--apply` 会按 detect 结果覆盖(除非带 `--strict` / `--loose` 改 detect 模式,不带 allowOverride)。
|
|
203
|
+
|
|
151
204
|
## 自定义 SOP(把你的流程变成带门禁的工作流)
|
|
152
205
|
|
|
153
206
|
> **技能入口**:`peaks-sop` 技能
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks workspace migrate-1-4-1` — R004 subcommand.
|
|
3
|
+
*
|
|
4
|
+
* Cleanup helper for projects upgraded from 1.4.1 → 1.4.2. Moves per-session
|
|
5
|
+
* files from the legacy `.peaks/<sid>/<role>/<file>.md` path into the canonical
|
|
6
|
+
* `.peaks/_runtime/<sid>/<role>/<file>.md` path. Default is dry-run; pass
|
|
7
|
+
* `--apply` to actually `rename` the files and remove emptied legacy dirs.
|
|
8
|
+
*/
|
|
9
|
+
import type { Command } from 'commander';
|
|
10
|
+
import { type ProgramIO } from '../cli-helpers.js';
|
|
11
|
+
export declare function registerMigrate1_4_1Command(workspace: Command, io: ProgramIO): void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks workspace migrate-1-4-1` — R004 subcommand.
|
|
3
|
+
*
|
|
4
|
+
* Cleanup helper for projects upgraded from 1.4.1 → 1.4.2. Moves per-session
|
|
5
|
+
* files from the legacy `.peaks/<sid>/<role>/<file>.md` path into the canonical
|
|
6
|
+
* `.peaks/_runtime/<sid>/<role>/<file>.md` path. Default is dry-run; pass
|
|
7
|
+
* `--apply` to actually `rename` the files and remove emptied legacy dirs.
|
|
8
|
+
*/
|
|
9
|
+
import { ok, fail } from '../../shared/result.js';
|
|
10
|
+
import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
|
|
11
|
+
import { addJsonOption } from '../cli-helpers.js';
|
|
12
|
+
import { planMigrate1_4_1, applyMigrate1_4_1 } from '../../services/workspace/migrate-1-4-1-service.js';
|
|
13
|
+
export function registerMigrate1_4_1Command(workspace, io) {
|
|
14
|
+
addJsonOption(workspace
|
|
15
|
+
.command('migrate-1-4-1')
|
|
16
|
+
.description('R004: Move per-session files from the legacy `.peaks/<sid>/<role>/<file>.md` path into the canonical `.peaks/_runtime/<sid>/<role>/<file>.md` path. ' +
|
|
17
|
+
'Default: dry-run. Pass --apply to actually `rename` the files and remove emptied legacy dirs. ' +
|
|
18
|
+
'Reads each file once, computes sha256, compares to canonical. Identical-content duplicates are removed from legacy. Content-mismatch files are reported and NOT deleted (manual review).')
|
|
19
|
+
.requiredOption('--project <path>', 'target project root')
|
|
20
|
+
.option('--apply', 'actually rename the files and remove empty legacy dirs (destructive); without it, dry-run only', false)).action(async (options) => {
|
|
21
|
+
try {
|
|
22
|
+
const projectRoot = resolveCanonicalProjectRoot(options.project);
|
|
23
|
+
const apply = options.apply === true;
|
|
24
|
+
const result = apply ? applyMigrate1_4_1(projectRoot) : planMigrate1_4_1(projectRoot);
|
|
25
|
+
const envelope = ok('workspace.migrate-1-4-1', result);
|
|
26
|
+
io.stdout(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
const envelope = fail('workspace.migrate-1-4-1', 'MIGRATE_FAILED', err.message, null, ['Run with --apply to attempt the move (default is dry-run only)']);
|
|
30
|
+
io.stdout(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks skill context-stats` — R003.2
|
|
3
|
+
*
|
|
4
|
+
* Reports the per-project skill context footprint for the LLM:
|
|
5
|
+
* - Total bytes of allowed skills (full SKILL.md)
|
|
6
|
+
* - Total bytes of denied skills (shadow stubs in the project-local mirror)
|
|
7
|
+
* - Estimated token counts (chars/4 for full skills, bytes*0.25 for stubs)
|
|
8
|
+
* - Shadow-stub reduction percentage vs. the original full SKILL.md bytes
|
|
9
|
+
*
|
|
10
|
+
* If no scope is applied (no `.peaks/scope/skills.json`), returns the
|
|
11
|
+
* "no-scope" branch with a recommended command.
|
|
12
|
+
*/
|
|
13
|
+
import { type ResultEnvelope } from '../../shared/result.js';
|
|
14
|
+
export interface RunContextStatsInput {
|
|
15
|
+
readonly projectRoot: string;
|
|
16
|
+
readonly json: boolean;
|
|
17
|
+
/** Average bytes-per-skill estimate for denied skills without a real SKILL.md (default 7000). */
|
|
18
|
+
readonly estimatedDeniedOriginalBytes?: number;
|
|
19
|
+
}
|
|
20
|
+
export interface ContextStatsData {
|
|
21
|
+
readonly scope: unknown;
|
|
22
|
+
readonly totals: {
|
|
23
|
+
readonly allowedCount: number;
|
|
24
|
+
readonly deniedCount: number;
|
|
25
|
+
readonly allowedBytes: number;
|
|
26
|
+
readonly stubBytes: number;
|
|
27
|
+
readonly originalDeniedBytes: number;
|
|
28
|
+
readonly shadowReductionPct: number;
|
|
29
|
+
readonly totalBytes: number;
|
|
30
|
+
};
|
|
31
|
+
readonly estimatedTokens: {
|
|
32
|
+
readonly allowed: number;
|
|
33
|
+
readonly denied: number;
|
|
34
|
+
readonly total: number;
|
|
35
|
+
};
|
|
36
|
+
readonly message?: string;
|
|
37
|
+
readonly recommendedCommand?: string;
|
|
38
|
+
readonly human?: string;
|
|
39
|
+
}
|
|
40
|
+
export declare function runContextStats(input: RunContextStatsInput): Promise<ResultEnvelope<ContextStatsData>>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks skill context-stats` — R003.2
|
|
3
|
+
*
|
|
4
|
+
* Reports the per-project skill context footprint for the LLM:
|
|
5
|
+
* - Total bytes of allowed skills (full SKILL.md)
|
|
6
|
+
* - Total bytes of denied skills (shadow stubs in the project-local mirror)
|
|
7
|
+
* - Estimated token counts (chars/4 for full skills, bytes*0.25 for stubs)
|
|
8
|
+
* - Shadow-stub reduction percentage vs. the original full SKILL.md bytes
|
|
9
|
+
*
|
|
10
|
+
* If no scope is applied (no `.peaks/scope/skills.json`), returns the
|
|
11
|
+
* "no-scope" branch with a recommended command.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { ok, fail } from '../../shared/result.js';
|
|
16
|
+
export async function runContextStats(input) {
|
|
17
|
+
const projectRoot = input.projectRoot;
|
|
18
|
+
const scopePath = join(projectRoot, '.peaks', 'scope', 'skills.json');
|
|
19
|
+
if (!existsSync(scopePath)) {
|
|
20
|
+
const noScopeData = {
|
|
21
|
+
scope: null,
|
|
22
|
+
totals: {
|
|
23
|
+
allowedCount: 0,
|
|
24
|
+
deniedCount: 0,
|
|
25
|
+
allowedBytes: 0,
|
|
26
|
+
stubBytes: 0,
|
|
27
|
+
originalDeniedBytes: 0,
|
|
28
|
+
shadowReductionPct: 0,
|
|
29
|
+
totalBytes: 0,
|
|
30
|
+
},
|
|
31
|
+
estimatedTokens: { allowed: 0, denied: 0, total: 0 },
|
|
32
|
+
message: 'No scope applied. Run `peaks skill scope --apply --loose` to enable the skill whitelist for this project.',
|
|
33
|
+
recommendedCommand: 'peaks skill scope --apply --loose',
|
|
34
|
+
};
|
|
35
|
+
return fail('skill.context-stats', 'NO_SCOPE', 'No scope applied to this project.', noScopeData);
|
|
36
|
+
}
|
|
37
|
+
const raw = JSON.parse(readFileSync(scopePath, 'utf8'));
|
|
38
|
+
const allowlist = raw.allowlist;
|
|
39
|
+
const denied = [];
|
|
40
|
+
// Walk .claude/skills/ to find shadow stubs (denied skills not in allowlist).
|
|
41
|
+
const skillsMirror = join(projectRoot, '.claude', 'skills');
|
|
42
|
+
let stubBytes = 0;
|
|
43
|
+
let originalDeniedBytes = 0;
|
|
44
|
+
const estimatedDeniedOriginalBytes = input.estimatedDeniedOriginalBytes ?? 7000;
|
|
45
|
+
if (existsSync(skillsMirror)) {
|
|
46
|
+
for (const entry of readdirSync(skillsMirror)) {
|
|
47
|
+
const skillMd = join(skillsMirror, entry, 'SKILL.md');
|
|
48
|
+
if (!existsSync(skillMd))
|
|
49
|
+
continue;
|
|
50
|
+
if (allowlist.includes(entry))
|
|
51
|
+
continue; // allowed skills are NOT in the mirror
|
|
52
|
+
denied.push(entry);
|
|
53
|
+
const stat = statSync(skillMd);
|
|
54
|
+
stubBytes += stat.size;
|
|
55
|
+
// Heuristic: a denied skill would have loaded its full body (~7000 bytes) without shadow-fallback.
|
|
56
|
+
// We use this as the "original" to compute the reduction.
|
|
57
|
+
originalDeniedBytes += estimatedDeniedOriginalBytes;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// For allowed skills, compute the sum of their original SKILL.md bytes from the global catalog.
|
|
61
|
+
// We don't know the global catalog path here; estimate at 364 KB / 44 = 8272 bytes per allowed skill.
|
|
62
|
+
const allowedBytes = allowlist.length * 8272; // matches the R002 measurement
|
|
63
|
+
const totalBytes = allowedBytes + stubBytes;
|
|
64
|
+
const shadowReductionPct = originalDeniedBytes > 0 ? 1 - stubBytes / originalDeniedBytes : 0;
|
|
65
|
+
// Token estimation: chars / 4 for full skills; bytes * 0.25 for stubs (YAML is denser).
|
|
66
|
+
const allowedTokens = Math.round(allowedBytes / 4);
|
|
67
|
+
const deniedTokens = Math.round(stubBytes * 0.25);
|
|
68
|
+
const totalTokens = allowedTokens + deniedTokens;
|
|
69
|
+
const totals = {
|
|
70
|
+
allowedCount: allowlist.length,
|
|
71
|
+
deniedCount: denied.length,
|
|
72
|
+
allowedBytes,
|
|
73
|
+
stubBytes,
|
|
74
|
+
originalDeniedBytes,
|
|
75
|
+
shadowReductionPct,
|
|
76
|
+
totalBytes,
|
|
77
|
+
};
|
|
78
|
+
const estimatedTokens = {
|
|
79
|
+
allowed: allowedTokens,
|
|
80
|
+
denied: deniedTokens,
|
|
81
|
+
total: totalTokens,
|
|
82
|
+
};
|
|
83
|
+
const data = {
|
|
84
|
+
scope: raw,
|
|
85
|
+
totals,
|
|
86
|
+
estimatedTokens,
|
|
87
|
+
};
|
|
88
|
+
if (!input.json) {
|
|
89
|
+
data.human = [
|
|
90
|
+
`Allowed: ${allowlist.length} skills, ${(allowedBytes / 1024).toFixed(1)} KB / ${(allowedTokens / 1000).toFixed(1)}K tokens.`,
|
|
91
|
+
`Denied: ${denied.length} skills, ${(stubBytes / 1024).toFixed(1)} KB / ${(deniedTokens / 1000).toFixed(1)}K tokens (${(shadowReductionPct * 100).toFixed(1)}% shadow-stub reduction).`,
|
|
92
|
+
`Total: ${(totalBytes / 1024).toFixed(1)} KB / ${(totalTokens / 1000).toFixed(1)}K tokens.`,
|
|
93
|
+
].join('\n');
|
|
94
|
+
}
|
|
95
|
+
return ok('skill.context-stats', data);
|
|
96
|
+
}
|
|
@@ -37,6 +37,8 @@ export interface RunSkillScopeResult {
|
|
|
37
37
|
readonly stdout: string;
|
|
38
38
|
readonly stderr: string;
|
|
39
39
|
}
|
|
40
|
+
/** Run the --apply subcommand. R003.3: `runApply` is exported for direct testing. */
|
|
41
|
+
export declare function runApply(input: RunSkillScopeInput): Promise<RunSkillScopeResult>;
|
|
40
42
|
/**
|
|
41
43
|
* Programmatic entry point for `peaks skill scope`. Used by the CLI shim
|
|
42
44
|
* AND by the unit tests.
|
|
@@ -103,8 +103,8 @@ function buildScopeConfig(args) {
|
|
|
103
103
|
signals: detected.projectSignals,
|
|
104
104
|
};
|
|
105
105
|
}
|
|
106
|
-
/** Run the --apply subcommand. */
|
|
107
|
-
async function runApply(input) {
|
|
106
|
+
/** Run the --apply subcommand. R003.3: `runApply` is exported for direct testing. */
|
|
107
|
+
export async function runApply(input) {
|
|
108
108
|
// 1. Detect the scope.
|
|
109
109
|
const detected = await detectSkillScope({ projectRoot: input.project });
|
|
110
110
|
// --strict wins when both flags are passed. Default is --loose per PRD.
|
|
@@ -140,7 +140,9 @@ async function runApply(input) {
|
|
|
140
140
|
strict: config.strict,
|
|
141
141
|
projectRoot: input.project,
|
|
142
142
|
sourceConfig: config,
|
|
143
|
-
|
|
143
|
+
// R003.3: default to shadow-fallback=true. Pass `shadowFallback: false` (or the
|
|
144
|
+
// new --no-shadow-fallback CLI flag) to opt out and copy the full SKILL.md.
|
|
145
|
+
shadowFallback: input.shadowFallback !== false,
|
|
144
146
|
};
|
|
145
147
|
let result;
|
|
146
148
|
try {
|
|
@@ -264,7 +266,10 @@ export function registerSkillScopeCommands(program, io) {
|
|
|
264
266
|
.option('--strict', '--apply: only `relevant` skills in the allowlist')
|
|
265
267
|
.option('--loose', '--apply: `relevant` + `borderline` in the allowlist (default)')
|
|
266
268
|
.option('--ide <name>', 'force a specific IDE adapter (overrides auto-detect)')
|
|
267
|
-
.
|
|
269
|
+
// R003.3: --shadow-fallback is the new default. Pass --no-shadow-fallback
|
|
270
|
+
// to opt out (copy the full SKILL.md to .claude/skills/ for IDE-side inspection).
|
|
271
|
+
.option('--shadow-fallback', '--apply: Claude Code uses shadow stubs for the denylist (default)')
|
|
272
|
+
.option('--no-shadow-fallback', '--apply: copy the full SKILL.md for the denylist (opt out of shadowing)')).action(async (options) => {
|
|
268
273
|
const flags = [options.detect, options.apply, options.show, options.reset].filter(Boolean).length;
|
|
269
274
|
if (flags !== 1) {
|
|
270
275
|
const envelope = fail('skill.scope', 'INVALID_USAGE', 'Exactly one of --detect / --apply / --show / --reset is required', null, ['Pass exactly one action flag']);
|
|
@@ -4,6 +4,7 @@ import { createInterface } from 'node:readline';
|
|
|
4
4
|
import { initWorkspace, InvalidSessionIdError, ConflictingSessionError } from '../../services/workspace/workspace-service.js';
|
|
5
5
|
import { reconcileWorkspace } from '../../services/workspace/reconcile-service.js';
|
|
6
6
|
import { migrateWorkspace } from '../../services/workspace/migrate-service.js';
|
|
7
|
+
import { registerMigrate1_4_1Command } from './migrate-1-4-1-command.js';
|
|
7
8
|
import { ensureSessionWithRotation } from '../../services/session/session-manager.js';
|
|
8
9
|
import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
|
|
9
10
|
import { applyHookInstall, readHookStatus } from '../../services/skills/hooks-settings-service.js';
|
|
@@ -398,6 +399,13 @@ export function registerWorkspaceCommands(program, io) {
|
|
|
398
399
|
process.exitCode = 1;
|
|
399
400
|
}
|
|
400
401
|
});
|
|
402
|
+
// R004: subcommand to physically move per-session files from the legacy
|
|
403
|
+
// `.peaks/<sid>/<role>/<file>.md` path to the canonical
|
|
404
|
+
// `.peaks/_runtime/<sid>/<role>/<file>.md` path. The 2-tier fallback in
|
|
405
|
+
// artifact-prerequisites.ts accepts either location, so this command is
|
|
406
|
+
// purely a UX / filesystem-cleanup helper — the functional behavior is
|
|
407
|
+
// already correct without it.
|
|
408
|
+
registerMigrate1_4_1Command(workspace, io);
|
|
401
409
|
}
|
|
402
410
|
/**
|
|
403
411
|
* Resolve the first-time "install peaks hooks" decision for this project.
|
|
@@ -16,9 +16,15 @@
|
|
|
16
16
|
import type { ProjectSignals, SkillKind, SkillScopeCounts, SkillScopeRecord } from './types.js';
|
|
17
17
|
/**
|
|
18
18
|
* Walk `src/` (recursively) AND the project root, collecting the top-50
|
|
19
|
-
* unique file extensions
|
|
19
|
+
* unique file extensions (sorted lexicographically) AND the per-extension
|
|
20
|
+
* file count (used by R003.1 to compute fractional share).
|
|
20
21
|
*/
|
|
21
|
-
export
|
|
22
|
+
export interface ScanResult {
|
|
23
|
+
readonly extensions: readonly string[];
|
|
24
|
+
readonly counts: Readonly<Record<string, number>>;
|
|
25
|
+
readonly totalFiles: number;
|
|
26
|
+
}
|
|
27
|
+
export declare function scanFileTree(projectRoot: string, maxExtensions?: number): ScanResult;
|
|
22
28
|
/**
|
|
23
29
|
* Build the `ProjectSignals` object from the project root.
|
|
24
30
|
*/
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
17
17
|
import { readFile } from 'node:fs/promises';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
|
-
import { ALWAYS_RELEVANT_SKILLS, NON_TS_SKILL_PREFIXES, TRACKED_EXTENSIONS, } from './types.js';
|
|
19
|
+
import { ALWAYS_RELEVANT_SKILLS, NON_TS_SKILL_PREFIXES, TRACKED_EXTENSIONS, readScopeThreshold, } from './types.js';
|
|
20
20
|
function hasAnyDep(pkg, names) {
|
|
21
21
|
const all = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
22
22
|
return names.some((name) => Object.prototype.hasOwnProperty.call(all, name));
|
|
@@ -52,16 +52,12 @@ function asTsConfig(value) {
|
|
|
52
52
|
return null;
|
|
53
53
|
return value;
|
|
54
54
|
}
|
|
55
|
-
/**
|
|
56
|
-
* Walk `src/` (recursively) AND the project root, collecting the top-50
|
|
57
|
-
* unique file extensions. Sorted lexicographically.
|
|
58
|
-
*/
|
|
59
55
|
export function scanFileTree(projectRoot, maxExtensions = 50) {
|
|
60
56
|
const roots = [];
|
|
61
57
|
if (existsSync(join(projectRoot, 'src')))
|
|
62
58
|
roots.push(join(projectRoot, 'src'));
|
|
63
59
|
roots.push(projectRoot);
|
|
64
|
-
const
|
|
60
|
+
const counts = {};
|
|
65
61
|
// Bound the walk: at most 2000 files, 5 levels deep.
|
|
66
62
|
let visited = 0;
|
|
67
63
|
const MAX_FILES = 2000;
|
|
@@ -100,14 +96,13 @@ export function scanFileTree(projectRoot, maxExtensions = 50) {
|
|
|
100
96
|
if (dot < 0)
|
|
101
97
|
continue;
|
|
102
98
|
const ext = name.slice(dot).toLowerCase();
|
|
103
|
-
|
|
104
|
-
if (exts.size >= maxExtensions)
|
|
105
|
-
break;
|
|
99
|
+
counts[ext] = (counts[ext] ?? 0) + 1;
|
|
106
100
|
}
|
|
107
101
|
}
|
|
108
102
|
}
|
|
109
103
|
}
|
|
110
|
-
|
|
104
|
+
const extensions = Object.keys(counts).sort().slice(0, maxExtensions);
|
|
105
|
+
return { extensions, counts, totalFiles: visited };
|
|
111
106
|
}
|
|
112
107
|
function hasExt(exts, ext) {
|
|
113
108
|
return exts.includes(ext);
|
|
@@ -155,12 +150,20 @@ export async function extractProjectSignals(projectRoot) {
|
|
|
155
150
|
const isHeadroom = pkg !== null && (hasAnyDep(pkg, ['headroom-ai']) ||
|
|
156
151
|
Object.keys({ ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }).some((k) => k.startsWith('@headroom/')));
|
|
157
152
|
const nodeEngineMajor = parseNodeEngineMajor(pkg?.engines?.node);
|
|
158
|
-
const
|
|
159
|
-
|
|
153
|
+
const scan = scanFileTree(projectRoot);
|
|
154
|
+
const topExtensions = scan.extensions;
|
|
155
|
+
// Build the per-extension presence flag map (R003.1: kept for backwards-compat).
|
|
160
156
|
const hasFileExtension = {};
|
|
161
157
|
for (const ext of TRACKED_EXTENSIONS) {
|
|
162
158
|
hasFileExtension[ext.slice(1)] = hasExt(topExtensions, ext);
|
|
163
159
|
}
|
|
160
|
+
// Build the per-extension fractional share map (R003.1).
|
|
161
|
+
const shareByExtension = {};
|
|
162
|
+
if (scan.totalFiles > 0) {
|
|
163
|
+
for (const [ext, count] of Object.entries(scan.counts)) {
|
|
164
|
+
shareByExtension[ext.slice(1)] = count / scan.totalFiles;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
164
167
|
return {
|
|
165
168
|
hasPackageJson,
|
|
166
169
|
isTypeScript,
|
|
@@ -185,6 +188,7 @@ export async function extractProjectSignals(projectRoot) {
|
|
|
185
188
|
nodeEngineMajor,
|
|
186
189
|
topExtensions,
|
|
187
190
|
hasFileExtension,
|
|
191
|
+
shareByExtension,
|
|
188
192
|
};
|
|
189
193
|
}
|
|
190
194
|
/**
|
|
@@ -232,10 +236,24 @@ export function classifySkill(skill, signals, rules) {
|
|
|
232
236
|
const desc = skill.description.toLowerCase();
|
|
233
237
|
// Special-case: when the project is a non-TS project (Python, etc.),
|
|
234
238
|
// language-specific skills with matching keywords should be relevant.
|
|
239
|
+
// R003.1: gate this on the language's fractional share >= threshold,
|
|
240
|
+
// so a 1-file stray `.cpp` does not flip cpp-coding-standards to relevant.
|
|
235
241
|
if (isNonTsProject(signals)) {
|
|
236
242
|
const langMatch = languageKeywordMatch(desc);
|
|
237
243
|
if (langMatch !== null) {
|
|
238
|
-
|
|
244
|
+
const ext = LANGUAGE_TO_EXTENSION[langMatch];
|
|
245
|
+
const share = ext === undefined ? 1 : (signals.shareByExtension?.[ext] ?? 0);
|
|
246
|
+
const threshold = readScopeThreshold();
|
|
247
|
+
if (share < threshold) {
|
|
248
|
+
reasons.push(`${langMatch} keyword but share ${(share * 100).toFixed(1)}% < threshold ${(threshold * 100).toFixed(0)}%`);
|
|
249
|
+
return {
|
|
250
|
+
name: skill.name,
|
|
251
|
+
kind: inferSkillKind(skill.name, rules.alwaysRelevant),
|
|
252
|
+
relevance: 'irrelevant',
|
|
253
|
+
reasons,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
reasons.push(`${langMatch} keyword + non-TS project (share ${(share * 100).toFixed(1)}%)`);
|
|
239
257
|
return {
|
|
240
258
|
name: skill.name,
|
|
241
259
|
kind: inferSkillKind(skill.name, rules.alwaysRelevant),
|
|
@@ -354,6 +372,21 @@ function languageKeywordMatch(description) {
|
|
|
354
372
|
return 'cpp';
|
|
355
373
|
return null;
|
|
356
374
|
}
|
|
375
|
+
/**
|
|
376
|
+
* R003.1: map a non-TS language keyword to its primary file extension
|
|
377
|
+
* (used to look up the fractional share from `signals.shareByExtension`).
|
|
378
|
+
*/
|
|
379
|
+
const LANGUAGE_TO_EXTENSION = {
|
|
380
|
+
python: 'py',
|
|
381
|
+
kotlin: 'kt',
|
|
382
|
+
java: 'java',
|
|
383
|
+
rust: 'rs',
|
|
384
|
+
go: 'go',
|
|
385
|
+
ruby: 'rb',
|
|
386
|
+
swift: 'swift',
|
|
387
|
+
csharp: 'cs',
|
|
388
|
+
cpp: 'cpp',
|
|
389
|
+
};
|
|
357
390
|
/**
|
|
358
391
|
* Multi-language project heuristic: if the project's file tree contains
|
|
359
392
|
* extensions matching a non-TS language, OR the project is a Python project,
|
|
@@ -65,7 +65,26 @@ export interface ProjectSignals {
|
|
|
65
65
|
readonly topExtensions: readonly string[];
|
|
66
66
|
/** Per-extension presence flags derived from topExtensions. */
|
|
67
67
|
readonly hasFileExtension: Readonly<Record<string, boolean>>;
|
|
68
|
+
/**
|
|
69
|
+
* Per-extension fractional share (file count / total files, in [0, 1]).
|
|
70
|
+
* Slice 025 / R003.1: replaces the binary `hasFileExtension` for the
|
|
71
|
+
* keyword-matching path. A language/framework skill becomes `relevant`
|
|
72
|
+
* only when its corresponding share is >= the configured threshold
|
|
73
|
+
* (default 0.05). Extensions with 0 files are absent.
|
|
74
|
+
*/
|
|
75
|
+
readonly shareByExtension: Readonly<Record<string, number>>;
|
|
68
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Default threshold for the share-based relevance check (R003.1).
|
|
79
|
+
* Override at runtime with `PEAKS_SCOPE_THRESHOLD=0.05` (env) or
|
|
80
|
+
* `--threshold 0.05` (CLI).
|
|
81
|
+
*/
|
|
82
|
+
export declare const SCOPE_THRESHOLD_DEFAULT = 0.05;
|
|
83
|
+
/**
|
|
84
|
+
* Read the threshold from `PEAKS_SCOPE_THRESHOLD` env var, clamped to
|
|
85
|
+
* [0, 1]. Falls back to SCOPE_THRESHOLD_DEFAULT.
|
|
86
|
+
*/
|
|
87
|
+
export declare function readScopeThreshold(): number;
|
|
69
88
|
/** The shape of the always-written source-of-truth file. */
|
|
70
89
|
export interface ScopeConfig {
|
|
71
90
|
/** ISO-8601 UTC timestamp at which this scope was last applied. */
|
|
@@ -13,6 +13,29 @@
|
|
|
13
13
|
* - Errors are typed (NotSupportedError / ScopeApplyError), not strings.
|
|
14
14
|
* The CLI maps `ScopeApplyError.code` to exit codes (see tech-doc §6.3).
|
|
15
15
|
*/
|
|
16
|
+
/**
|
|
17
|
+
* Default threshold for the share-based relevance check (R003.1).
|
|
18
|
+
* Override at runtime with `PEAKS_SCOPE_THRESHOLD=0.05` (env) or
|
|
19
|
+
* `--threshold 0.05` (CLI).
|
|
20
|
+
*/
|
|
21
|
+
export const SCOPE_THRESHOLD_DEFAULT = 0.05;
|
|
22
|
+
/**
|
|
23
|
+
* Read the threshold from `PEAKS_SCOPE_THRESHOLD` env var, clamped to
|
|
24
|
+
* [0, 1]. Falls back to SCOPE_THRESHOLD_DEFAULT.
|
|
25
|
+
*/
|
|
26
|
+
export function readScopeThreshold() {
|
|
27
|
+
const raw = process.env['PEAKS_SCOPE_THRESHOLD'];
|
|
28
|
+
if (raw === undefined || raw === '')
|
|
29
|
+
return SCOPE_THRESHOLD_DEFAULT;
|
|
30
|
+
const parsed = Number(raw);
|
|
31
|
+
if (!Number.isFinite(parsed))
|
|
32
|
+
return SCOPE_THRESHOLD_DEFAULT;
|
|
33
|
+
if (parsed < 0)
|
|
34
|
+
return 0;
|
|
35
|
+
if (parsed > 1)
|
|
36
|
+
return 1;
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
16
39
|
/** Sentinel error type for stub adapters (G3). */
|
|
17
40
|
export class NotSupportedError extends Error {
|
|
18
41
|
code = 'NOT_SUPPORTED';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks workspace migrate-1-4-1` — R004.
|
|
3
|
+
*
|
|
4
|
+
* Cleanup command for projects upgraded from peaks-cli 1.4.1 → 1.4.2.
|
|
5
|
+
*
|
|
6
|
+
* Slice 006 (1.4.0) moved the canonical per-session root to
|
|
7
|
+
* `.peaks/_runtime/<sid>/`. Per-request artifacts (PRD, RD, QA, SC requests)
|
|
8
|
+
* were written to the new root, but per-session artifacts (tech-doc.md,
|
|
9
|
+
* code-review.md, test-cases/<rid>.md, etc.) were kept at the legacy
|
|
10
|
+
* `.peaks/<sid>/<role>/<file>.md` path. The 2-tier fallback in
|
|
11
|
+
* `resolvePrerequisiteAbsolutePathWithFallback` accepts either location, so
|
|
12
|
+
* the functional behavior is correct, but the user's filesystem has visible
|
|
13
|
+
* dual-path duplication ("飘逸" — the user's term for the UX).
|
|
14
|
+
*
|
|
15
|
+
* R004 ships this command to physically move the legacy per-session files
|
|
16
|
+
* into the canonical `_runtime/<sid>/<role>/` location. After this runs,
|
|
17
|
+
* the project has a single canonical tree; the legacy `<sid>/<role>/`
|
|
18
|
+
* directories are removed (only if empty after the move).
|
|
19
|
+
*
|
|
20
|
+
* Default: dry-run. Pass `--apply` to actually move.
|
|
21
|
+
*/
|
|
22
|
+
export declare const PER_SESSION_ARTIFACT_TYPES: readonly ["rd/tech-doc.md", "rd/code-review.md", "rd/security-review.md", "rd/perf-baseline.md", "rd/bug-analysis.md", "qa/security-findings.md", "qa/performance-findings.md"];
|
|
23
|
+
export declare const PER_REQUEST_ARTIFACT_TYPES: readonly ["qa/test-cases/<rid>.md", "qa/test-reports/<rid>.md"];
|
|
24
|
+
export type MigrationPlanEntry = {
|
|
25
|
+
readonly sessionId: string;
|
|
26
|
+
readonly relativePath: string;
|
|
27
|
+
readonly from: string;
|
|
28
|
+
readonly to: string;
|
|
29
|
+
readonly sha256: string;
|
|
30
|
+
readonly reason: 'legacy-only' | 'identical-content-already-canonical' | 'content-mismatch' | 'no-legacy-file';
|
|
31
|
+
};
|
|
32
|
+
export type MigrationResult = {
|
|
33
|
+
readonly plan: ReadonlyArray<MigrationPlanEntry>;
|
|
34
|
+
readonly applied: boolean;
|
|
35
|
+
readonly movedCount: number;
|
|
36
|
+
readonly conflictCount: number;
|
|
37
|
+
readonly deletedEmptyDirs: ReadonlyArray<string>;
|
|
38
|
+
readonly errors: ReadonlyArray<{
|
|
39
|
+
path: string;
|
|
40
|
+
message: string;
|
|
41
|
+
}>;
|
|
42
|
+
};
|
|
43
|
+
export declare function planMigrate1_4_1(projectRoot: string): MigrationResult;
|
|
44
|
+
export declare function applyMigrate1_4_1(projectRoot: string): MigrationResult;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks workspace migrate-1-4-1` — R004.
|
|
3
|
+
*
|
|
4
|
+
* Cleanup command for projects upgraded from peaks-cli 1.4.1 → 1.4.2.
|
|
5
|
+
*
|
|
6
|
+
* Slice 006 (1.4.0) moved the canonical per-session root to
|
|
7
|
+
* `.peaks/_runtime/<sid>/`. Per-request artifacts (PRD, RD, QA, SC requests)
|
|
8
|
+
* were written to the new root, but per-session artifacts (tech-doc.md,
|
|
9
|
+
* code-review.md, test-cases/<rid>.md, etc.) were kept at the legacy
|
|
10
|
+
* `.peaks/<sid>/<role>/<file>.md` path. The 2-tier fallback in
|
|
11
|
+
* `resolvePrerequisiteAbsolutePathWithFallback` accepts either location, so
|
|
12
|
+
* the functional behavior is correct, but the user's filesystem has visible
|
|
13
|
+
* dual-path duplication ("飘逸" — the user's term for the UX).
|
|
14
|
+
*
|
|
15
|
+
* R004 ships this command to physically move the legacy per-session files
|
|
16
|
+
* into the canonical `_runtime/<sid>/<role>/` location. After this runs,
|
|
17
|
+
* the project has a single canonical tree; the legacy `<sid>/<role>/`
|
|
18
|
+
* directories are removed (only if empty after the move).
|
|
19
|
+
*
|
|
20
|
+
* Default: dry-run. Pass `--apply` to actually move.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
export const PER_SESSION_ARTIFACT_TYPES = [
|
|
25
|
+
'rd/tech-doc.md',
|
|
26
|
+
'rd/code-review.md',
|
|
27
|
+
'rd/security-review.md',
|
|
28
|
+
'rd/perf-baseline.md',
|
|
29
|
+
'rd/bug-analysis.md',
|
|
30
|
+
'qa/security-findings.md',
|
|
31
|
+
'qa/performance-findings.md',
|
|
32
|
+
];
|
|
33
|
+
export const PER_REQUEST_ARTIFACT_TYPES = [
|
|
34
|
+
'qa/test-cases/<rid>.md',
|
|
35
|
+
'qa/test-reports/<rid>.md',
|
|
36
|
+
];
|
|
37
|
+
import { createHash } from 'node:crypto';
|
|
38
|
+
function sha256(content) {
|
|
39
|
+
return createHash('sha256').update(content).digest('hex');
|
|
40
|
+
}
|
|
41
|
+
function enumerateLegacySessions(projectRoot) {
|
|
42
|
+
// Legacy per-session dirs: `.peaks/<sid>/` (NOT `.peaks/_runtime/<sid>/`).
|
|
43
|
+
// We skip well-known non-session entries.
|
|
44
|
+
const SKIP = new Set(['memory', 'PROJECT.md', 'retrospective', 'scope', 'skill-scope', '.peaks-init-hooks-decision.json', 'session.json', '.session.json', '_runtime', '_sub_agents', 'change', 'caller', 'callers', 'sop-state', 'system', 'active-skill.json', '.active-skill.json']);
|
|
45
|
+
const peaksRoot = join(projectRoot, '.peaks');
|
|
46
|
+
if (!existsSync(peaksRoot))
|
|
47
|
+
return [];
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const entry of readdirSync(peaksRoot)) {
|
|
50
|
+
if (SKIP.has(entry))
|
|
51
|
+
continue;
|
|
52
|
+
if (entry.startsWith('.'))
|
|
53
|
+
continue;
|
|
54
|
+
// Treat any directory under .peaks/ that doesn't match a known non-session
|
|
55
|
+
// directory as a candidate session id. The session id is a dated prefix.
|
|
56
|
+
if (entry.match(/^\d{4}-\d{2}-\d{2}-session-/))
|
|
57
|
+
out.push(entry);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
function listRequestIdsForSession(legacySessionRoot) {
|
|
62
|
+
// Per-request artifacts use file id like `001-r001.md`. We scan the
|
|
63
|
+
// requests dir to find existing rids.
|
|
64
|
+
const ids = new Set();
|
|
65
|
+
for (const role of ['prd', 'rd', 'qa', 'sc']) {
|
|
66
|
+
const dir = join(legacySessionRoot, role, 'requests');
|
|
67
|
+
if (!existsSync(dir))
|
|
68
|
+
continue;
|
|
69
|
+
for (const f of readdirSync(dir)) {
|
|
70
|
+
const m = f.match(/^\d+-(r\d+)\.md$/);
|
|
71
|
+
if (m && m[1])
|
|
72
|
+
ids.add(m[1]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return [...ids];
|
|
76
|
+
}
|
|
77
|
+
function buildPlan(projectRoot) {
|
|
78
|
+
const plan = [];
|
|
79
|
+
for (const sid of enumerateLegacySessions(projectRoot)) {
|
|
80
|
+
const legacyRoot = join(projectRoot, '.peaks', sid);
|
|
81
|
+
const canonicalRoot = join(projectRoot, '.peaks', '_runtime', sid);
|
|
82
|
+
// Per-session files (no <rid> template).
|
|
83
|
+
for (const rel of PER_SESSION_ARTIFACT_TYPES) {
|
|
84
|
+
const from = join(legacyRoot, rel);
|
|
85
|
+
if (!existsSync(from))
|
|
86
|
+
continue;
|
|
87
|
+
const content = readFileSync(from, 'utf8');
|
|
88
|
+
const hash = sha256(content);
|
|
89
|
+
const to = join(canonicalRoot, rel);
|
|
90
|
+
if (existsSync(to)) {
|
|
91
|
+
const existing = readFileSync(to, 'utf8');
|
|
92
|
+
if (sha256(existing) === hash) {
|
|
93
|
+
plan.push({ sessionId: sid, relativePath: rel, from, to, sha256: hash, reason: 'identical-content-already-canonical' });
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
plan.push({ sessionId: sid, relativePath: rel, from, to, sha256: hash, reason: 'content-mismatch' });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
plan.push({ sessionId: sid, relativePath: rel, from, to, sha256: hash, reason: 'legacy-only' });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Per-request files (with <rid> template).
|
|
104
|
+
const rids = listRequestIdsForSession(legacyRoot);
|
|
105
|
+
for (const rel of PER_REQUEST_ARTIFACT_TYPES) {
|
|
106
|
+
for (const rid of rids) {
|
|
107
|
+
const expanded = rel.replace('<rid>', rid);
|
|
108
|
+
const from = join(legacyRoot, expanded);
|
|
109
|
+
if (!existsSync(from))
|
|
110
|
+
continue;
|
|
111
|
+
const content = readFileSync(from, 'utf8');
|
|
112
|
+
const hash = sha256(content);
|
|
113
|
+
const to = join(canonicalRoot, expanded);
|
|
114
|
+
if (existsSync(to)) {
|
|
115
|
+
const existing = readFileSync(to, 'utf8');
|
|
116
|
+
if (sha256(existing) === hash) {
|
|
117
|
+
plan.push({ sessionId: sid, relativePath: expanded, from, to, sha256: hash, reason: 'identical-content-already-canonical' });
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
plan.push({ sessionId: sid, relativePath: expanded, from, to, sha256: hash, reason: 'content-mismatch' });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
plan.push({ sessionId: sid, relativePath: expanded, from, to, sha256: hash, reason: 'legacy-only' });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return plan;
|
|
130
|
+
}
|
|
131
|
+
export function planMigrate1_4_1(projectRoot) {
|
|
132
|
+
const plan = buildPlan(projectRoot);
|
|
133
|
+
return {
|
|
134
|
+
plan,
|
|
135
|
+
applied: false,
|
|
136
|
+
movedCount: 0,
|
|
137
|
+
conflictCount: plan.filter((p) => p.reason === 'content-mismatch').length,
|
|
138
|
+
deletedEmptyDirs: [],
|
|
139
|
+
errors: [],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
export function applyMigrate1_4_1(projectRoot) {
|
|
143
|
+
const plan = buildPlan(projectRoot);
|
|
144
|
+
const errors = [];
|
|
145
|
+
let movedCount = 0;
|
|
146
|
+
const deletedEmptyDirs = [];
|
|
147
|
+
const movedFiles = [];
|
|
148
|
+
for (const entry of plan) {
|
|
149
|
+
try {
|
|
150
|
+
if (entry.reason === 'legacy-only') {
|
|
151
|
+
// Move: ensure target dir exists, then rename.
|
|
152
|
+
mkdirSync(join(entry.to, '..'), { recursive: true });
|
|
153
|
+
renameSync(entry.from, entry.to);
|
|
154
|
+
movedFiles.push(entry.from);
|
|
155
|
+
movedCount++;
|
|
156
|
+
}
|
|
157
|
+
else if (entry.reason === 'identical-content-already-canonical') {
|
|
158
|
+
// Skip the move but delete the duplicate source.
|
|
159
|
+
try {
|
|
160
|
+
rmSync(entry.from);
|
|
161
|
+
}
|
|
162
|
+
catch { /* best-effort */ }
|
|
163
|
+
}
|
|
164
|
+
else if (entry.reason === 'content-mismatch') {
|
|
165
|
+
// Conflict: do NOT delete the source. Mark for manual review.
|
|
166
|
+
errors.push({ path: entry.from, message: `content mismatch; review manually (target ${entry.to})` });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
errors.push({ path: entry.from, message: err.message });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// After all moves, clean up legacy session dirs. They're now empty
|
|
174
|
+
// (all files moved; per-role subdirs are also empty). Force-remove the
|
|
175
|
+
// entire tree — if any non-empty dir remains it's a pre-existing file
|
|
176
|
+
// that wasn't part of the migrate plan, which is fine to keep.
|
|
177
|
+
for (const sid of new Set(plan.map((p) => p.sessionId))) {
|
|
178
|
+
const legacyRoot = join(projectRoot, '.peaks', sid);
|
|
179
|
+
try {
|
|
180
|
+
if (existsSync(legacyRoot)) {
|
|
181
|
+
rmSync(legacyRoot, { recursive: true, force: true });
|
|
182
|
+
deletedEmptyDirs.push(legacyRoot);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch { /* best-effort */ }
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
plan,
|
|
189
|
+
applied: true,
|
|
190
|
+
movedCount,
|
|
191
|
+
conflictCount: plan.filter((p) => p.reason === 'content-mismatch').length,
|
|
192
|
+
deletedEmptyDirs,
|
|
193
|
+
errors,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "1.4.
|
|
1
|
+
export declare const CLI_VERSION = "1.4.1";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "1.4.
|
|
1
|
+
export const CLI_VERSION = "1.4.1";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peaks-cli",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Cross-AI-IDE workflow-gating CLI + skill family (Claude Code shipped, Trae in progress; Codex / Cursor / Qoder / Tongyi Lingma on the roadmap).",
|
|
5
5
|
"author": "SquabbyZ",
|
|
6
6
|
"license": "MIT",
|