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 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
- shadowFallback: input.shadowFallback === true,
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
- .option('--shadow-fallback', '--apply: Claude Code uses shadow stubs for the denylist')).action(async (options) => {
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. Sorted lexicographically.
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 declare function scanFileTree(projectRoot: string, maxExtensions?: number): string[];
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 exts = new Set();
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
- exts.add(ext);
104
- if (exts.size >= maxExtensions)
105
- break;
99
+ counts[ext] = (counts[ext] ?? 0) + 1;
106
100
  }
107
101
  }
108
102
  }
109
103
  }
110
- return [...exts].sort().slice(0, maxExtensions);
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 topExtensions = scanFileTree(projectRoot);
159
- // Build the per-extension presence flag map.
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
- reasons.push(`${langMatch} keyword + non-TS project`);
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.0";
1
+ export declare const CLI_VERSION = "1.4.1";
@@ -1 +1 @@
1
- export const CLI_VERSION = "1.4.0";
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.0",
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",