peaks-cli 1.3.9 → 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/core-artifact-commands.js +27 -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 +51 -0
- package/dist/src/cli/commands/skill-scope-commands.js +310 -0
- package/dist/src/cli/commands/workflow-commands.js +1 -1
- package/dist/src/cli/commands/workflow-plan-commands.d.ts +39 -0
- package/dist/src/cli/commands/workflow-plan-commands.js +163 -0
- package/dist/src/cli/commands/workspace-commands.js +8 -0
- package/dist/src/cli/program.js +6 -0
- package/dist/src/services/doctor/doctor-service.d.ts +40 -0
- package/dist/src/services/doctor/doctor-service.js +160 -0
- package/dist/src/services/hooks/presence-marker-detector.d.ts +16 -0
- package/dist/src/services/hooks/presence-marker-detector.js +105 -0
- package/dist/src/services/skill-scope/adapters/_stub-helper.d.ts +39 -0
- package/dist/src/services/skill-scope/adapters/_stub-helper.js +98 -0
- package/dist/src/services/skill-scope/adapters/claude-code.d.ts +59 -0
- package/dist/src/services/skill-scope/adapters/claude-code.js +304 -0
- package/dist/src/services/skill-scope/adapters/codex.d.ts +2 -0
- package/dist/src/services/skill-scope/adapters/codex.js +12 -0
- package/dist/src/services/skill-scope/adapters/cursor.d.ts +2 -0
- package/dist/src/services/skill-scope/adapters/cursor.js +13 -0
- package/dist/src/services/skill-scope/adapters/qoder.d.ts +2 -0
- package/dist/src/services/skill-scope/adapters/qoder.js +13 -0
- package/dist/src/services/skill-scope/adapters/tongyi.d.ts +2 -0
- package/dist/src/services/skill-scope/adapters/tongyi.js +13 -0
- package/dist/src/services/skill-scope/adapters/trae.d.ts +2 -0
- package/dist/src/services/skill-scope/adapters/trae.js +12 -0
- package/dist/src/services/skill-scope/detect.d.ts +81 -0
- package/dist/src/services/skill-scope/detect.js +513 -0
- package/dist/src/services/skill-scope/registry.d.ts +41 -0
- package/dist/src/services/skill-scope/registry.js +83 -0
- package/dist/src/services/skill-scope/source-of-truth.d.ts +44 -0
- package/dist/src/services/skill-scope/source-of-truth.js +118 -0
- package/dist/src/services/skill-scope/types.d.ts +195 -0
- package/dist/src/services/skill-scope/types.js +97 -0
- package/dist/src/services/standards/migrate-service.d.ts +63 -0
- package/dist/src/services/standards/migrate-service.js +193 -0
- package/dist/src/services/standards/project-standards-service.js +1 -23
- package/dist/src/services/workflow/artifact-paths.d.ts +59 -0
- package/dist/src/services/workflow/artifact-paths.js +127 -0
- package/dist/src/services/workflow/pipeline-verify-service.d.ts +6 -0
- package/dist/src/services/workflow/pipeline-verify-service.js +49 -4
- package/dist/src/services/workflow/plan-reader.d.ts +29 -0
- package/dist/src/services/workflow/plan-reader.js +158 -0
- package/dist/src/services/workflow/plan-refresher.d.ts +32 -0
- package/dist/src/services/workflow/plan-refresher.js +353 -0
- package/dist/src/services/workflow/plan-trigger-detector.d.ts +55 -0
- package/dist/src/services/workflow/plan-trigger-detector.js +142 -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 +3 -2
- package/schemas/doctor-report.schema.json +2 -2
- package/skills/peaks-qa/SKILL.md +25 -0
- package/skills/peaks-qa/references/qa-perf-test-plan.md +67 -0
- package/skills/peaks-qa/references/qa-security-test-plan.md +73 -0
- package/skills/peaks-qa/references/qa-transition-gates.md +13 -9
- package/skills/peaks-rd/SKILL.md +2 -2
- package/skills/peaks-rd/references/mandatory-perf-baseline.md +2 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source-of-truth helpers for `peaks skill scope`.
|
|
3
|
+
*
|
|
4
|
+
* The source-of-truth file `.peaks/scope/skills.json` is the canonical
|
|
5
|
+
* record of the user's scope intent. Adapters translate it to their
|
|
6
|
+
* IDE-native config; the CLI always reads back from this file on `--show`.
|
|
7
|
+
*
|
|
8
|
+
* Atomicity: every write goes through `.peaks-tmp` first, then `rename`
|
|
9
|
+
* (POSIX-atomic; on Windows `rename` is atomic for files on the same volume).
|
|
10
|
+
* See tech-doc-025 §3.1.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
/** File name for the canonical source-of-truth. */
|
|
16
|
+
export const SCOPE_FILE_NAME = 'skills.json';
|
|
17
|
+
/** Resolve the canonical source-of-truth path for a project root. */
|
|
18
|
+
export function scopeFilePath(projectRoot) {
|
|
19
|
+
return join(projectRoot, '.peaks', 'scope', SCOPE_FILE_NAME);
|
|
20
|
+
}
|
|
21
|
+
/** Resolve the per-IDE companion file path (kebab-case). */
|
|
22
|
+
export function ideCompanionFilePath(projectRoot, ide) {
|
|
23
|
+
return join(projectRoot, '.peaks', 'scope', `${ide}-skills.json`);
|
|
24
|
+
}
|
|
25
|
+
/** Resolve the `.peaks/scope/` directory for a project root. */
|
|
26
|
+
export function scopeDir(projectRoot) {
|
|
27
|
+
return join(projectRoot, '.peaks', 'scope');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Read the source-of-truth scope config, or null if it does not exist.
|
|
31
|
+
* Returns null on parse error too — the caller decides whether to surface.
|
|
32
|
+
*/
|
|
33
|
+
export async function readSourceOfTruth(projectRoot) {
|
|
34
|
+
const file = scopeFilePath(projectRoot);
|
|
35
|
+
if (!existsSync(file))
|
|
36
|
+
return null;
|
|
37
|
+
try {
|
|
38
|
+
const raw = await readFile(file, 'utf8');
|
|
39
|
+
return JSON.parse(raw);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Read the per-IDE companion file (`.peaks/scope/<ide>-skills.json`), or
|
|
47
|
+
* null if it does not exist or is unparseable.
|
|
48
|
+
*/
|
|
49
|
+
export async function readIdeCompanion(projectRoot, ide) {
|
|
50
|
+
const file = ideCompanionFilePath(projectRoot, ide);
|
|
51
|
+
if (!existsSync(file))
|
|
52
|
+
return null;
|
|
53
|
+
try {
|
|
54
|
+
const raw = await readFile(file, 'utf8');
|
|
55
|
+
return JSON.parse(raw);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Write the canonical source-of-truth atomically. The `.peaks-tmp` file
|
|
63
|
+
* is cleaned up in a finally block on partial failure.
|
|
64
|
+
*/
|
|
65
|
+
export async function writeSourceOfTruth(projectRoot, config) {
|
|
66
|
+
const file = scopeFilePath(projectRoot);
|
|
67
|
+
await mkdir(scopeDir(projectRoot), { recursive: true });
|
|
68
|
+
const tmp = `${file}.peaks-tmp`;
|
|
69
|
+
try {
|
|
70
|
+
await writeFile(tmp, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
71
|
+
await rename(tmp, file);
|
|
72
|
+
return file;
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (existsSync(tmp)) {
|
|
76
|
+
try {
|
|
77
|
+
await rm(tmp, { force: true });
|
|
78
|
+
}
|
|
79
|
+
catch { /* best-effort */ }
|
|
80
|
+
}
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Write a generic JSON document atomically. Used by stub adapters for
|
|
86
|
+
* their `<ide>-skills.json` companion file (G3 §4.1).
|
|
87
|
+
*/
|
|
88
|
+
export async function writeJsonAtomic(file, data) {
|
|
89
|
+
await mkdir(dirname(file), { recursive: true });
|
|
90
|
+
const tmp = `${file}.peaks-tmp`;
|
|
91
|
+
try {
|
|
92
|
+
await writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
93
|
+
await rename(tmp, file);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (existsSync(tmp)) {
|
|
97
|
+
try {
|
|
98
|
+
await rm(tmp, { force: true });
|
|
99
|
+
}
|
|
100
|
+
catch { /* best-effort */ }
|
|
101
|
+
}
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Remove a file if it exists. Returns true if it was removed.
|
|
107
|
+
*/
|
|
108
|
+
export async function removeIfExists(file) {
|
|
109
|
+
if (!existsSync(file))
|
|
110
|
+
return false;
|
|
111
|
+
try {
|
|
112
|
+
await rm(file, { force: true });
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks skill scope` — slice 025 multi-IDE skill scoping types.
|
|
3
|
+
*
|
|
4
|
+
* This file is the single source of truth for the `SkillScopeAdapter`
|
|
5
|
+
* interface (G1). It is consumed by every per-IDE adapter (Claude Code
|
|
6
|
+
* full impl, 5 stub adapters) and by the detection algorithm + CLI.
|
|
7
|
+
*
|
|
8
|
+
* Design notes (see tech-doc-025 §2):
|
|
9
|
+
* - The interface is deliberately small: only the four operations the CLI
|
|
10
|
+
* actually needs (detect / apply / show / reset).
|
|
11
|
+
* - `detect()` returns a confidence score in [0, 1] so the registry can
|
|
12
|
+
* pick the best match when several adapters are partially active.
|
|
13
|
+
* - Errors are typed (NotSupportedError / ScopeApplyError), not strings.
|
|
14
|
+
* The CLI maps `ScopeApplyError.code` to exit codes (see tech-doc §6.3).
|
|
15
|
+
*/
|
|
16
|
+
import type { IdeId } from '../ide/ide-types.js';
|
|
17
|
+
/** Detection bucket for a single installed skill (G5). */
|
|
18
|
+
export type SkillRelevance = 'relevant' | 'borderline' | 'irrelevant';
|
|
19
|
+
/** Skill category — used for the JSON envelope's `kind` field (AC1). */
|
|
20
|
+
export type SkillKind = 'peaks-family' | 'generic-ai' | 'language-specific' | 'other';
|
|
21
|
+
/** Per-skill detail emitted by the detect algorithm. */
|
|
22
|
+
export interface SkillScopeRecord {
|
|
23
|
+
/** Skill directory name (e.g. "peaks-rd", "tdd-guide"). */
|
|
24
|
+
readonly name: string;
|
|
25
|
+
/** Category — peaks-family, generic-ai, language-specific, other. */
|
|
26
|
+
readonly kind: SkillKind;
|
|
27
|
+
/** Detection bucket. */
|
|
28
|
+
readonly relevance: SkillRelevance;
|
|
29
|
+
/** Human-readable reasons that produced the bucket; stable for fixtures. */
|
|
30
|
+
readonly reasons: readonly string[];
|
|
31
|
+
}
|
|
32
|
+
/** Counts of skills per bucket, for the JSON envelope (AC1). */
|
|
33
|
+
export interface SkillScopeCounts {
|
|
34
|
+
readonly relevant: number;
|
|
35
|
+
readonly borderline: number;
|
|
36
|
+
readonly irrelevant: number;
|
|
37
|
+
}
|
|
38
|
+
/** Project signals extracted from package.json + tsconfig + file tree (G5). */
|
|
39
|
+
export interface ProjectSignals {
|
|
40
|
+
/** True when the project's root package.json exists. */
|
|
41
|
+
readonly hasPackageJson: boolean;
|
|
42
|
+
readonly isTypeScript: boolean;
|
|
43
|
+
readonly isTypeScriptESM: boolean;
|
|
44
|
+
readonly isReact: boolean;
|
|
45
|
+
readonly isVue: boolean;
|
|
46
|
+
readonly isSvelte: boolean;
|
|
47
|
+
readonly isNext: boolean;
|
|
48
|
+
readonly isNestJS: boolean;
|
|
49
|
+
readonly isExpress: boolean;
|
|
50
|
+
readonly isFastify: boolean;
|
|
51
|
+
readonly isPostgres: boolean;
|
|
52
|
+
readonly isMysql: boolean;
|
|
53
|
+
readonly isMongo: boolean;
|
|
54
|
+
readonly isRedis: boolean;
|
|
55
|
+
readonly isDocker: boolean;
|
|
56
|
+
readonly isK8s: boolean;
|
|
57
|
+
readonly isCommander: boolean;
|
|
58
|
+
readonly isCodegraph: boolean;
|
|
59
|
+
readonly isHeadroom: boolean;
|
|
60
|
+
/** True when the project is a Python project (no package.json / has pyproject). */
|
|
61
|
+
readonly isPython: boolean;
|
|
62
|
+
/** Major version of Node engine requirement, or null. */
|
|
63
|
+
readonly nodeEngineMajor: number | null;
|
|
64
|
+
/** Top file extensions under src/ (max 50, lexicographically sorted, unique). */
|
|
65
|
+
readonly topExtensions: readonly string[];
|
|
66
|
+
/** Per-extension presence flags derived from topExtensions. */
|
|
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>>;
|
|
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;
|
|
88
|
+
/** The shape of the always-written source-of-truth file. */
|
|
89
|
+
export interface ScopeConfig {
|
|
90
|
+
/** ISO-8601 UTC timestamp at which this scope was last applied. */
|
|
91
|
+
readonly generatedAt: string;
|
|
92
|
+
/** Detected or explicitly-selected IDE id. */
|
|
93
|
+
readonly ide: IdeId;
|
|
94
|
+
/** Strictness mode (drives borderline handling). */
|
|
95
|
+
readonly strict: boolean;
|
|
96
|
+
/** Skills the user wants available (LLM-invokable). Always includes all peaks-*. */
|
|
97
|
+
readonly allowlist: readonly string[];
|
|
98
|
+
/** Skills the user wants hidden. */
|
|
99
|
+
readonly denylist: readonly string[];
|
|
100
|
+
/** Per-skill reasons (mirrored from detect, for audit). */
|
|
101
|
+
readonly skills: readonly SkillScopeRecord[];
|
|
102
|
+
/** Project signals that drove the classification. */
|
|
103
|
+
readonly signals: ProjectSignals;
|
|
104
|
+
}
|
|
105
|
+
/** Returned from `applyScope`. */
|
|
106
|
+
export interface ApplyResult {
|
|
107
|
+
/** Adapter id that handled the apply. */
|
|
108
|
+
readonly ide: IdeId;
|
|
109
|
+
/** Whether the apply succeeded (false = NOT_SUPPORTED or hard failure). */
|
|
110
|
+
readonly ok: boolean;
|
|
111
|
+
/** Absolute paths the adapter wrote or removed. */
|
|
112
|
+
readonly writtenFiles: readonly string[];
|
|
113
|
+
/** Whether shadow stubs were used (Claude Code fallback path). */
|
|
114
|
+
readonly usedShadowStub: boolean;
|
|
115
|
+
/** Whether the adapter returned NOT_SUPPORTED and only wrote the source-of-truth. */
|
|
116
|
+
readonly notSupported: boolean;
|
|
117
|
+
/** Peaks-* skills the adapter stripped from the denylist (G6 enforcement report). */
|
|
118
|
+
readonly strippedFromDenylist?: readonly string[];
|
|
119
|
+
/** Optional error code when ok=false. */
|
|
120
|
+
readonly error?: {
|
|
121
|
+
readonly code: string;
|
|
122
|
+
readonly message: string;
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** Returned from `showScope`. */
|
|
126
|
+
export interface ShowScopeResult {
|
|
127
|
+
/** The source-of-truth config, or null if no scope has been applied. */
|
|
128
|
+
readonly source: ScopeConfig | null;
|
|
129
|
+
/** Whatever the adapter can read back from its native config. Null if not supported. */
|
|
130
|
+
readonly native: unknown;
|
|
131
|
+
/** Adapter id. */
|
|
132
|
+
readonly ide: IdeId;
|
|
133
|
+
}
|
|
134
|
+
/** Reset output mirrors apply but without the lists. */
|
|
135
|
+
export interface ResetScopeResult {
|
|
136
|
+
readonly ide: IdeId;
|
|
137
|
+
readonly removedFiles: readonly string[];
|
|
138
|
+
}
|
|
139
|
+
/** Sentinel error type for stub adapters (G3). */
|
|
140
|
+
export declare class NotSupportedError extends Error {
|
|
141
|
+
readonly code: "NOT_SUPPORTED";
|
|
142
|
+
readonly ide: IdeId;
|
|
143
|
+
constructor(ide: IdeId, message: string);
|
|
144
|
+
}
|
|
145
|
+
/** Errors emitted by adapters (validated by runtime probe in claude-code §3.4). */
|
|
146
|
+
export type ScopeApplyErrorCode = 'NOT_SUPPORTED' | 'IO_ERROR' | 'MALFORMED_CONFIG' | 'WRITE_FAILED' | 'PARTIAL_FAILURE';
|
|
147
|
+
export declare class ScopeApplyError extends Error {
|
|
148
|
+
readonly code: ScopeApplyErrorCode;
|
|
149
|
+
readonly ide: IdeId;
|
|
150
|
+
constructor(code: ScopeApplyErrorCode, message: string, ide: IdeId);
|
|
151
|
+
}
|
|
152
|
+
/** Input to `applyScope`. */
|
|
153
|
+
export interface ApplyScopeInput {
|
|
154
|
+
/** Final allowlist (the CLI guarantees peaks-* is in here before calling). */
|
|
155
|
+
readonly allowlist: readonly string[];
|
|
156
|
+
/** Final denylist. */
|
|
157
|
+
readonly denylist: readonly string[];
|
|
158
|
+
/** Strictness mode. */
|
|
159
|
+
readonly strict: boolean;
|
|
160
|
+
/** Project root for resolving relative paths. */
|
|
161
|
+
readonly projectRoot: string;
|
|
162
|
+
/** Source-of-truth config that the adapter MAY re-derive fields from. */
|
|
163
|
+
readonly sourceConfig: ScopeConfig;
|
|
164
|
+
/** When true, prefer shadow-stub fallback over the native config. */
|
|
165
|
+
readonly shadowFallback: boolean;
|
|
166
|
+
/** Test seam: simulate an adapter write failure (returns the partial path written before failure). */
|
|
167
|
+
readonly simulateWriteFailure?: boolean;
|
|
168
|
+
}
|
|
169
|
+
/** Reset input mirrors apply but without the lists. */
|
|
170
|
+
export interface ResetScopeInput {
|
|
171
|
+
readonly projectRoot: string;
|
|
172
|
+
}
|
|
173
|
+
/** The interface every adapter implements. */
|
|
174
|
+
export interface SkillScopeAdapter {
|
|
175
|
+
/** Adapter id; matches the IdeId it pairs with. */
|
|
176
|
+
readonly ide: IdeId;
|
|
177
|
+
/** Whether this adapter supports a real (non-stub) implementation. */
|
|
178
|
+
readonly supported: boolean;
|
|
179
|
+
/** Detect this adapter's IDE is active in the given project root. Returns a confidence score in [0,1]. */
|
|
180
|
+
detect(projectRoot: string): Promise<number>;
|
|
181
|
+
/** Write the IDE-specific scope config. */
|
|
182
|
+
applyScope(input: ApplyScopeInput): Promise<ApplyResult>;
|
|
183
|
+
/** Read the current scope config. */
|
|
184
|
+
showScope(projectRoot: string): Promise<ShowScopeResult>;
|
|
185
|
+
/** Remove the IDE-specific scope config. */
|
|
186
|
+
resetScope(input: ResetScopeInput): Promise<ResetScopeResult>;
|
|
187
|
+
}
|
|
188
|
+
/** Helper: build a NOT_SUPPORTED ApplyResult (for stub adapters that pre-write source-of-truth). */
|
|
189
|
+
export declare function makeNotSupportedResult(ide: IdeId, message: string, writtenFiles?: readonly string[]): ApplyResult;
|
|
190
|
+
/** Hard-coded allowlist of peaks-* skills (G6 + generic AI-engineering skills per AC2). */
|
|
191
|
+
export declare const ALWAYS_RELEVANT_SKILLS: readonly string[];
|
|
192
|
+
/** Hard-coded denylist prefixes: non-TS language families (G5 §5.4). */
|
|
193
|
+
export declare const NON_TS_SKILL_PREFIXES: readonly string[];
|
|
194
|
+
/** File extensions the file-tree walker looks for (top-50 limit per tech-doc §5.1). */
|
|
195
|
+
export declare const TRACKED_EXTENSIONS: readonly string[];
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks skill scope` — slice 025 multi-IDE skill scoping types.
|
|
3
|
+
*
|
|
4
|
+
* This file is the single source of truth for the `SkillScopeAdapter`
|
|
5
|
+
* interface (G1). It is consumed by every per-IDE adapter (Claude Code
|
|
6
|
+
* full impl, 5 stub adapters) and by the detection algorithm + CLI.
|
|
7
|
+
*
|
|
8
|
+
* Design notes (see tech-doc-025 §2):
|
|
9
|
+
* - The interface is deliberately small: only the four operations the CLI
|
|
10
|
+
* actually needs (detect / apply / show / reset).
|
|
11
|
+
* - `detect()` returns a confidence score in [0, 1] so the registry can
|
|
12
|
+
* pick the best match when several adapters are partially active.
|
|
13
|
+
* - Errors are typed (NotSupportedError / ScopeApplyError), not strings.
|
|
14
|
+
* The CLI maps `ScopeApplyError.code` to exit codes (see tech-doc §6.3).
|
|
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
|
+
}
|
|
39
|
+
/** Sentinel error type for stub adapters (G3). */
|
|
40
|
+
export class NotSupportedError extends Error {
|
|
41
|
+
code = 'NOT_SUPPORTED';
|
|
42
|
+
ide;
|
|
43
|
+
constructor(ide, message) {
|
|
44
|
+
super(`${ide}: ${message}`);
|
|
45
|
+
this.name = 'NotSupportedError';
|
|
46
|
+
this.ide = ide;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export class ScopeApplyError extends Error {
|
|
50
|
+
code;
|
|
51
|
+
ide;
|
|
52
|
+
constructor(code, message, ide) {
|
|
53
|
+
super(`${ide}: ${message}`);
|
|
54
|
+
this.name = 'ScopeApplyError';
|
|
55
|
+
this.code = code;
|
|
56
|
+
this.ide = ide;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Helper: build a NOT_SUPPORTED ApplyResult (for stub adapters that pre-write source-of-truth). */
|
|
60
|
+
export function makeNotSupportedResult(ide, message, writtenFiles = []) {
|
|
61
|
+
return {
|
|
62
|
+
ide,
|
|
63
|
+
ok: false,
|
|
64
|
+
writtenFiles: [...writtenFiles],
|
|
65
|
+
usedShadowStub: false,
|
|
66
|
+
notSupported: true,
|
|
67
|
+
error: { code: 'NOT_SUPPORTED', message },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Hard-coded allowlist of peaks-* skills (G6 + generic AI-engineering skills per AC2). */
|
|
71
|
+
export const ALWAYS_RELEVANT_SKILLS = [
|
|
72
|
+
// peaks-* family (G6 hard constraint)
|
|
73
|
+
'peaks-rd', 'peaks-qa', 'peaks-solo', 'peaks-prd', 'peaks-sc',
|
|
74
|
+
'peaks-txt', 'peaks-sop', 'peaks-solo-resume', 'peaks-solo-status',
|
|
75
|
+
'peaks-solo-test', 'peaks-ui', 'peaks-ide',
|
|
76
|
+
// generic AI-engineering skills (per AC2)
|
|
77
|
+
'tdd-guide', 'coding-standards', 'karpathy-guidelines',
|
|
78
|
+
'continuous-learning', 'code-tour', 'agent-harness-construction',
|
|
79
|
+
'security-review', 'code-review',
|
|
80
|
+
];
|
|
81
|
+
/** Hard-coded denylist prefixes: non-TS language families (G5 §5.4). */
|
|
82
|
+
export const NON_TS_SKILL_PREFIXES = [
|
|
83
|
+
'kotlin-', 'python-', 'java-', 'rust-', 'go-', 'ruby-',
|
|
84
|
+
'swift-', 'csharp-', 'cpp-',
|
|
85
|
+
];
|
|
86
|
+
/** File extensions the file-tree walker looks for (top-50 limit per tech-doc §5.1). */
|
|
87
|
+
export const TRACKED_EXTENSIONS = [
|
|
88
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
89
|
+
'.swift', '.kt', '.kts', '.java', '.scala',
|
|
90
|
+
'.py', '.pyx', '.go', '.rs',
|
|
91
|
+
'.rb', '.php', '.cs', '.cpp', '.c', '.h', '.hpp',
|
|
92
|
+
'.vue', '.svelte', '.html', '.css', '.scss',
|
|
93
|
+
'.json', '.yaml', '.yml', '.toml', '.md',
|
|
94
|
+
'.sql', '.sh', '.bash', '.ps1',
|
|
95
|
+
'.dockerfile', '.dockerignore', '.lua', '.ex', '.exs', '.erl', '.hs',
|
|
96
|
+
'.dart', '.r', '.jl', '.clj',
|
|
97
|
+
];
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice 028 (Q2=A): `peaks standards migrate` rewrites a consumer
|
|
3
|
+
* project's `CLAUDE.md` to drop the legacy heartbeat block.
|
|
4
|
+
*
|
|
5
|
+
* The legacy block (rendered by `peaks standards init` / `peaks
|
|
6
|
+
* standards update` before slice 028) contained instructions that:
|
|
7
|
+
* 1. pointed the LLM at the legacy `.peaks/.active-skill.json` path;
|
|
8
|
+
* 2. required the LLM to invoke `peaks skill heartbeat:touch` and
|
|
9
|
+
* `peaks skill presence:clear` on every turn;
|
|
10
|
+
* 3. ended with an `External reference: https://github.com/affaan-m/...`
|
|
11
|
+
* line that was peaks-cli-internal, not consumer-facing.
|
|
12
|
+
*
|
|
13
|
+
* The replacement text matches the post-slice-025 peaks-cli repo's own
|
|
14
|
+
* `CLAUDE.md`: route the LLM through `peaks skill presence --json` and
|
|
15
|
+
* render a compact status header when a valid skill is active. This
|
|
16
|
+
* service is the deterministic in-place rewriter that brings existing
|
|
17
|
+
* consumer trees in line with the new template.
|
|
18
|
+
*
|
|
19
|
+
* Behavior:
|
|
20
|
+
* - `migrateStandards({ project, apply: true })` — rewrites the
|
|
21
|
+
* file when the legacy block is present, returns
|
|
22
|
+
* `applied: true`.
|
|
23
|
+
* - `migrateStandards({ project, dryRun: true })` — returns the
|
|
24
|
+
* would-change preview, no write.
|
|
25
|
+
* - `migrateStandards({ project })` — defaults to a
|
|
26
|
+
* dry-run. `--apply` is the only opt-in for a destructive write.
|
|
27
|
+
* - File missing → `file: null`, no throw.
|
|
28
|
+
* - Legacy block not present → `foundOldBlock: false`, no write.
|
|
29
|
+
*/
|
|
30
|
+
export declare const NEW_TEMPLATE_TEXT = "Active Peaks-Cli skill presence: at the start of every response, run `peaks skill presence --json` to read the active skill marker. The CLI handles canonical-path resolution (`.peaks/_runtime/active-skill.json` with back-compat fallback to `.peaks/.active-skill.json`); do not read those files directly. When the response includes a valid skill name, display the compact status header: `Peaks-Cli Skill: <skill> | Peaks-Cli Gate: <gate> | Next: <one short action>`. Display the header on every turn while the CLI returns an active skill; omit when the CLI returns no active skill.";
|
|
31
|
+
export type MigrateStandardsInput = {
|
|
32
|
+
project: string;
|
|
33
|
+
apply?: boolean;
|
|
34
|
+
dryRun?: boolean;
|
|
35
|
+
};
|
|
36
|
+
export type MigrateStandardsData = {
|
|
37
|
+
file: string | null;
|
|
38
|
+
foundOldBlock: boolean;
|
|
39
|
+
wouldChange: boolean;
|
|
40
|
+
applied: boolean;
|
|
41
|
+
before: {
|
|
42
|
+
lines: number;
|
|
43
|
+
} | null;
|
|
44
|
+
after: {
|
|
45
|
+
lines: number;
|
|
46
|
+
} | null;
|
|
47
|
+
nextActions: string[];
|
|
48
|
+
};
|
|
49
|
+
export type MigrateStandardsResult = {
|
|
50
|
+
ok: true;
|
|
51
|
+
data: MigrateStandardsData;
|
|
52
|
+
warnings: string[];
|
|
53
|
+
};
|
|
54
|
+
export declare function detectLegacyBlock(content: string): {
|
|
55
|
+
found: boolean;
|
|
56
|
+
start: number;
|
|
57
|
+
end: number;
|
|
58
|
+
};
|
|
59
|
+
export declare function rewriteLegacyBlock(content: string, newText?: string): {
|
|
60
|
+
rewritten: string;
|
|
61
|
+
replaced: boolean;
|
|
62
|
+
};
|
|
63
|
+
export declare function migrateStandards(input: MigrateStandardsInput): MigrateStandardsResult;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Slice 028 (Q2=A): `peaks standards migrate` rewrites a consumer
|
|
5
|
+
* project's `CLAUDE.md` to drop the legacy heartbeat block.
|
|
6
|
+
*
|
|
7
|
+
* The legacy block (rendered by `peaks standards init` / `peaks
|
|
8
|
+
* standards update` before slice 028) contained instructions that:
|
|
9
|
+
* 1. pointed the LLM at the legacy `.peaks/.active-skill.json` path;
|
|
10
|
+
* 2. required the LLM to invoke `peaks skill heartbeat:touch` and
|
|
11
|
+
* `peaks skill presence:clear` on every turn;
|
|
12
|
+
* 3. ended with an `External reference: https://github.com/affaan-m/...`
|
|
13
|
+
* line that was peaks-cli-internal, not consumer-facing.
|
|
14
|
+
*
|
|
15
|
+
* The replacement text matches the post-slice-025 peaks-cli repo's own
|
|
16
|
+
* `CLAUDE.md`: route the LLM through `peaks skill presence --json` and
|
|
17
|
+
* render a compact status header when a valid skill is active. This
|
|
18
|
+
* service is the deterministic in-place rewriter that brings existing
|
|
19
|
+
* consumer trees in line with the new template.
|
|
20
|
+
*
|
|
21
|
+
* Behavior:
|
|
22
|
+
* - `migrateStandards({ project, apply: true })` — rewrites the
|
|
23
|
+
* file when the legacy block is present, returns
|
|
24
|
+
* `applied: true`.
|
|
25
|
+
* - `migrateStandards({ project, dryRun: true })` — returns the
|
|
26
|
+
* would-change preview, no write.
|
|
27
|
+
* - `migrateStandards({ project })` — defaults to a
|
|
28
|
+
* dry-run. `--apply` is the only opt-in for a destructive write.
|
|
29
|
+
* - File missing → `file: null`, no throw.
|
|
30
|
+
* - Legacy block not present → `foundOldBlock: false`, no write.
|
|
31
|
+
*/
|
|
32
|
+
export const NEW_TEMPLATE_TEXT = 'Active Peaks-Cli skill presence: at the start of every response, run `peaks skill presence --json` to read the active skill marker. The CLI handles canonical-path resolution (`.peaks/_runtime/active-skill.json` with back-compat fallback to `.peaks/.active-skill.json`); do not read those files directly. When the response includes a valid skill name, display the compact status header: `Peaks-Cli Skill: <skill> | Peaks-Cli Gate: <gate> | Next: <one short action>`. Display the header on every turn while the CLI returns an active skill; omit when the CLI returns no active skill.';
|
|
33
|
+
const LEGACY_BLOCK_OPENER_LINE = 'Peaks-Cli 心跳检测 (heartbeat check)';
|
|
34
|
+
const LEGACY_BLOCK_CLOSER = 'External reference: https://github.com/affaan-m/everything-claude-code';
|
|
35
|
+
const LEGACY_MARKER_FALLBACK = 'Do NOT skip step 3-5. The CLI heartbeat:touch command';
|
|
36
|
+
const FORBIDDEN_LEGACY_STRINGS = [
|
|
37
|
+
'heartbeat:touch',
|
|
38
|
+
'presence:clear',
|
|
39
|
+
'Default runbook',
|
|
40
|
+
'Startup sequence',
|
|
41
|
+
'Swarm parallel phase'
|
|
42
|
+
];
|
|
43
|
+
const NEW_TEMPLATE_FINGERPRINT = 'peaks skill presence --json';
|
|
44
|
+
export function detectLegacyBlock(content) {
|
|
45
|
+
const openerIndex = content.indexOf(LEGACY_BLOCK_OPENER_LINE);
|
|
46
|
+
if (openerIndex >= 0) {
|
|
47
|
+
// Walk back to the start of the `<!--` line. The opener text is
|
|
48
|
+
// typically indented on the line after `<!--` (the legacy block
|
|
49
|
+
// is a multi-line HTML comment), so we cut from the most recent
|
|
50
|
+
// `<!--` line above. If no `<!--` is found in the surrounding
|
|
51
|
+
// 4 lines, fall back to the start of the opener's own line.
|
|
52
|
+
const htmlCommentIndex = content.lastIndexOf('<!--', openerIndex);
|
|
53
|
+
const previousNewlineBeforeOpener = content.lastIndexOf('\n', openerIndex);
|
|
54
|
+
let start;
|
|
55
|
+
if (htmlCommentIndex < 0 || htmlCommentIndex < previousNewlineBeforeOpener - 200) {
|
|
56
|
+
// No `<!--` line within a reasonable distance — start of the
|
|
57
|
+
// opener's own line.
|
|
58
|
+
start = previousNewlineBeforeOpener + 1;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
start = content.lastIndexOf('\n', htmlCommentIndex) + 1;
|
|
62
|
+
}
|
|
63
|
+
const closerIndex = content.indexOf(LEGACY_BLOCK_CLOSER, openerIndex);
|
|
64
|
+
let endIndex;
|
|
65
|
+
if (closerIndex < 0) {
|
|
66
|
+
const tailIndex = content.indexOf(LEGACY_MARKER_FALLBACK, openerIndex);
|
|
67
|
+
if (tailIndex < 0) {
|
|
68
|
+
endIndex = content.length;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
endIndex = content.indexOf('\n', tailIndex);
|
|
72
|
+
if (endIndex < 0)
|
|
73
|
+
endIndex = content.length;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
endIndex = content.indexOf('\n', closerIndex);
|
|
78
|
+
if (endIndex < 0)
|
|
79
|
+
endIndex = content.length;
|
|
80
|
+
}
|
|
81
|
+
return { found: true, start, end: endIndex };
|
|
82
|
+
}
|
|
83
|
+
// Fallback: opener stripped by editor / re-format. Detect by the
|
|
84
|
+
// first forbidden string that survives the rewrite, then walk back
|
|
85
|
+
// to the start of the line.
|
|
86
|
+
for (const marker of FORBIDDEN_LEGACY_STRINGS) {
|
|
87
|
+
const idx = content.indexOf(marker);
|
|
88
|
+
if (idx >= 0) {
|
|
89
|
+
const start = content.lastIndexOf('\n', idx) + 1;
|
|
90
|
+
return { found: true, start, end: content.length };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { found: false, start: -1, end: -1 };
|
|
94
|
+
}
|
|
95
|
+
export function rewriteLegacyBlock(content, newText = NEW_TEMPLATE_TEXT) {
|
|
96
|
+
const detection = detectLegacyBlock(content);
|
|
97
|
+
if (!detection.found) {
|
|
98
|
+
return { rewritten: content, replaced: false };
|
|
99
|
+
}
|
|
100
|
+
const before = content.slice(0, detection.start);
|
|
101
|
+
const after = content.slice(detection.end);
|
|
102
|
+
const trimmedBefore = before.replace(/\s+$/u, '\n');
|
|
103
|
+
const cleanedAfter = after.replace(/^\n+/u, '\n');
|
|
104
|
+
const rewritten = `${trimmedBefore}${newText}${cleanedAfter}`;
|
|
105
|
+
return { rewritten, replaced: true };
|
|
106
|
+
}
|
|
107
|
+
export function migrateStandards(input) {
|
|
108
|
+
const project = input.project;
|
|
109
|
+
const projectRoot = isAbsolute(project) ? project : resolve(project);
|
|
110
|
+
const filePath = resolve(projectRoot, 'CLAUDE.md');
|
|
111
|
+
const apply = input.apply === true;
|
|
112
|
+
const dryRun = input.dryRun === true || apply === false;
|
|
113
|
+
if (!existsSync(filePath)) {
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
data: {
|
|
117
|
+
file: null,
|
|
118
|
+
foundOldBlock: false,
|
|
119
|
+
wouldChange: false,
|
|
120
|
+
applied: false,
|
|
121
|
+
before: null,
|
|
122
|
+
after: null,
|
|
123
|
+
nextActions: ['CLAUDE.md does not exist; nothing to migrate']
|
|
124
|
+
},
|
|
125
|
+
warnings: []
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const original = readFileSync(filePath, 'utf8');
|
|
129
|
+
const detection = detectLegacyBlock(original);
|
|
130
|
+
if (!detection.found) {
|
|
131
|
+
if (original.includes(NEW_TEMPLATE_FINGERPRINT)) {
|
|
132
|
+
return {
|
|
133
|
+
ok: true,
|
|
134
|
+
data: {
|
|
135
|
+
file: filePath,
|
|
136
|
+
foundOldBlock: false,
|
|
137
|
+
wouldChange: false,
|
|
138
|
+
applied: false,
|
|
139
|
+
before: { lines: original.split('\n').length },
|
|
140
|
+
after: null,
|
|
141
|
+
nextActions: ['CLAUDE.md is already up to date']
|
|
142
|
+
},
|
|
143
|
+
warnings: []
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
data: {
|
|
149
|
+
file: filePath,
|
|
150
|
+
foundOldBlock: false,
|
|
151
|
+
wouldChange: false,
|
|
152
|
+
applied: false,
|
|
153
|
+
before: { lines: original.split('\n').length },
|
|
154
|
+
after: null,
|
|
155
|
+
nextActions: ['CLAUDE.md has no peaks-cli block; nothing to migrate']
|
|
156
|
+
},
|
|
157
|
+
warnings: []
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const { rewritten } = rewriteLegacyBlock(original);
|
|
161
|
+
const nextActions = [];
|
|
162
|
+
if (dryRun) {
|
|
163
|
+
nextActions.push('Re-run with --apply to perform the rewrite');
|
|
164
|
+
return {
|
|
165
|
+
ok: true,
|
|
166
|
+
data: {
|
|
167
|
+
file: filePath,
|
|
168
|
+
foundOldBlock: true,
|
|
169
|
+
wouldChange: true,
|
|
170
|
+
applied: false,
|
|
171
|
+
before: { lines: original.split('\n').length },
|
|
172
|
+
after: { lines: rewritten.split('\n').length },
|
|
173
|
+
nextActions
|
|
174
|
+
},
|
|
175
|
+
warnings: []
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
writeFileSync(filePath, rewritten, 'utf8');
|
|
179
|
+
nextActions.push('CLAUDE.md rewritten; no further action required');
|
|
180
|
+
return {
|
|
181
|
+
ok: true,
|
|
182
|
+
data: {
|
|
183
|
+
file: filePath,
|
|
184
|
+
foundOldBlock: true,
|
|
185
|
+
wouldChange: true,
|
|
186
|
+
applied: true,
|
|
187
|
+
before: { lines: original.split('\n').length },
|
|
188
|
+
after: { lines: rewritten.split('\n').length },
|
|
189
|
+
nextActions
|
|
190
|
+
},
|
|
191
|
+
warnings: []
|
|
192
|
+
};
|
|
193
|
+
}
|