peaks-cli 1.3.9 → 1.4.0
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/dist/src/cli/commands/core-artifact-commands.js +27 -0
- package/dist/src/cli/commands/skill-scope-commands.d.ts +49 -0
- package/dist/src/cli/commands/skill-scope-commands.js +305 -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/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 +75 -0
- package/dist/src/services/skill-scope/detect.js +480 -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 +176 -0
- package/dist/src/services/skill-scope/types.js +74 -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/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
|
@@ -5,12 +5,14 @@ import { getArtifactWorkspaceStatus, planArtifactSync } from '../../services/art
|
|
|
5
5
|
import { executeProjectMemoryBackup, executeProjectMemoryExtract, summarizeProjectMemoryBackupResult, summarizeProjectMemoryExtractResult } from '../../services/memory/project-memory-service.js';
|
|
6
6
|
import { summarizeProjectStandardsInitResult, summarizeProjectStandardsUpdateResult } from '../../services/standards/project-standards-service.js';
|
|
7
7
|
import { executeProjectStandardsInitIdeAware, executeProjectStandardsUpdateIdeAware } from '../../services/standards/ide-aware-standards-service.js';
|
|
8
|
+
import { migrateStandards } from '../../services/standards/migrate-service.js';
|
|
8
9
|
import { listProfiles } from '../../services/profiles/profile-service.js';
|
|
9
10
|
import { planProxyTest } from '../../services/proxy/proxy-service.js';
|
|
10
11
|
import { runDoctor } from '../../services/doctor/doctor-service.js';
|
|
11
12
|
import { listSkills } from '../../services/skills/skill-registry.js';
|
|
12
13
|
import { inspectSkillRunbook } from '../../services/skills/skill-runbook-service.js';
|
|
13
14
|
import { setSkillPresence, clearSkillPresence, getSkillPresence, isSkillPresenceMode, touchSkillHeartbeat } from '../../services/skills/skill-presence-service.js';
|
|
15
|
+
import { detectPresenceMarker } from '../../services/hooks/presence-marker-detector.js';
|
|
14
16
|
import { getSessionId, getSessionMeta, rotateSessionBinding, setSessionMeta, setSessionTitle, listSessionMetas } from '../../services/session/session-manager.js';
|
|
15
17
|
import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
|
|
16
18
|
import { findProjectRoot } from '../../services/config/config-safety.js';
|
|
@@ -198,6 +200,16 @@ export function registerCoreAndArtifactCommands(program, io) {
|
|
|
198
200
|
lastHeartbeat: updated.lastHeartbeat
|
|
199
201
|
}), options.json);
|
|
200
202
|
});
|
|
203
|
+
addJsonOption(skill
|
|
204
|
+
.command('detect-marker-loss')
|
|
205
|
+
.description('Detect whether the latest assistant message lost the Peaks-Cli status header while a peaks skill is still active (slice 028 detection primitive).')
|
|
206
|
+
.option('--project <path>', 'project root path (auto-detected from cwd when omitted)')
|
|
207
|
+
.option('--message <text>', 'latest assistant message text to scan (defaults to reading the most recent LLM response from the stdin pipe, or empty string when no pipe is attached)')).action((options) => {
|
|
208
|
+
const projectRoot = options.project ?? findProjectRoot(process.cwd()) ?? process.cwd();
|
|
209
|
+
const message = options.message ?? '';
|
|
210
|
+
const result = detectPresenceMarker({ project: projectRoot, latestAssistantMessage: message });
|
|
211
|
+
printResult(io, ok('skill.detect-marker-loss', result), options.json);
|
|
212
|
+
});
|
|
201
213
|
const session = program.command('session').description('Manage Peaks session directories');
|
|
202
214
|
addJsonOption(session
|
|
203
215
|
.command('list')
|
|
@@ -449,6 +461,21 @@ export function registerCoreAndArtifactCommands(program, io) {
|
|
|
449
461
|
process.exitCode = 1;
|
|
450
462
|
}
|
|
451
463
|
});
|
|
464
|
+
addJsonOption(standards
|
|
465
|
+
.command('migrate')
|
|
466
|
+
.description('Rewrite a consumer project CLAUDE.md to drop the legacy heartbeat block (slice 028). Dry-run by default; pass --apply to write.')
|
|
467
|
+
.option('--project <path>', 'target project root')
|
|
468
|
+
.option('--apply', 'rewrite the legacy block in place; default is dry-run')).action((options) => {
|
|
469
|
+
const projectRoot = options.project ?? process.cwd();
|
|
470
|
+
try {
|
|
471
|
+
const result = migrateStandards({ project: projectRoot, apply: options.apply === true });
|
|
472
|
+
printResult(io, ok('standards.migrate', result.data, [], result.data.nextActions), options.json);
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
printResult(io, fail('standards.migrate', 'STANDARDS_MIGRATE_FAILED', getErrorMessage(error), { file: null, foundOldBlock: false, wouldChange: false, applied: false, before: null, after: null, nextActions: [] }, [getErrorMessage(error)]), options.json);
|
|
476
|
+
process.exitCode = 1;
|
|
477
|
+
}
|
|
478
|
+
});
|
|
452
479
|
const memory = program.command('memory').description('Manage project-local Peaks memory');
|
|
453
480
|
addJsonOption(memory
|
|
454
481
|
.command('extract')
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks skill scope` CLI surface (slice 025.1).
|
|
3
|
+
*
|
|
4
|
+
* Four subcommands (mutually exclusive):
|
|
5
|
+
* - `--detect` — dry-run; prints the relevance matrix, never touches files.
|
|
6
|
+
* - `--apply` — writes the source-of-truth + IDE-native config.
|
|
7
|
+
* - `--show` — reads the source-of-truth + native config back.
|
|
8
|
+
* - `--reset` — removes the source-of-truth + IDE-native config.
|
|
9
|
+
*
|
|
10
|
+
* Exit code matrix (tech-doc §6.3):
|
|
11
|
+
* 0 success
|
|
12
|
+
* 1 uncaught error
|
|
13
|
+
* 2 invalid usage (missing/incompatible flags)
|
|
14
|
+
* 3 source-of-truth written but adapter returned NOT_SUPPORTED
|
|
15
|
+
* 4 adapter failure other than NOT_SUPPORTED
|
|
16
|
+
*/
|
|
17
|
+
import { Command } from 'commander';
|
|
18
|
+
import { type ResultEnvelope } from '../../shared/result.js';
|
|
19
|
+
import { type ProgramIO } from '../cli-helpers.js';
|
|
20
|
+
export type SkillScopeAction = 'detect' | 'apply' | 'show' | 'reset';
|
|
21
|
+
export interface RunSkillScopeInput {
|
|
22
|
+
readonly subcommand: SkillScopeAction;
|
|
23
|
+
readonly project: string;
|
|
24
|
+
readonly strict?: boolean;
|
|
25
|
+
readonly loose?: boolean;
|
|
26
|
+
readonly ide?: string;
|
|
27
|
+
readonly shadowFallback?: boolean;
|
|
28
|
+
readonly json?: boolean;
|
|
29
|
+
/** Test seam: override the detected allowlist (CLI re-adds peaks-* per G6). */
|
|
30
|
+
readonly overrideAllowlist?: readonly string[];
|
|
31
|
+
/** Test seam: force the source-of-truth write to fail (simulates atomicity test). */
|
|
32
|
+
readonly simulateSourceOfTruthWriteFailure?: boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface RunSkillScopeResult {
|
|
35
|
+
readonly exitCode: number;
|
|
36
|
+
readonly envelope: ResultEnvelope<unknown> | null;
|
|
37
|
+
readonly stdout: string;
|
|
38
|
+
readonly stderr: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Programmatic entry point for `peaks skill scope`. Used by the CLI shim
|
|
42
|
+
* AND by the unit tests.
|
|
43
|
+
*/
|
|
44
|
+
export declare function runSkillScopeCommand(input: RunSkillScopeInput): Promise<RunSkillScopeResult>;
|
|
45
|
+
/**
|
|
46
|
+
* Register the `peaks skill scope` subcommand on the `skill` command group.
|
|
47
|
+
* Mutually-exclusive flags: exactly one of --detect / --apply / --show / --reset.
|
|
48
|
+
*/
|
|
49
|
+
export declare function registerSkillScopeCommands(program: Command, io: ProgramIO): void;
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks skill scope` CLI surface (slice 025.1).
|
|
3
|
+
*
|
|
4
|
+
* Four subcommands (mutually exclusive):
|
|
5
|
+
* - `--detect` — dry-run; prints the relevance matrix, never touches files.
|
|
6
|
+
* - `--apply` — writes the source-of-truth + IDE-native config.
|
|
7
|
+
* - `--show` — reads the source-of-truth + native config back.
|
|
8
|
+
* - `--reset` — removes the source-of-truth + IDE-native config.
|
|
9
|
+
*
|
|
10
|
+
* Exit code matrix (tech-doc §6.3):
|
|
11
|
+
* 0 success
|
|
12
|
+
* 1 uncaught error
|
|
13
|
+
* 2 invalid usage (missing/incompatible flags)
|
|
14
|
+
* 3 source-of-truth written but adapter returned NOT_SUPPORTED
|
|
15
|
+
* 4 adapter failure other than NOT_SUPPORTED
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { detectSkillScope, } from '../../services/skill-scope/detect.js';
|
|
20
|
+
import { resolveActiveAdapter, getScopeAdapter } from '../../services/skill-scope/registry.js';
|
|
21
|
+
import { ideCompanionFilePath, readIdeCompanion, readSourceOfTruth, removeIfExists, scopeFilePath, writeSourceOfTruth, } from '../../services/skill-scope/source-of-truth.js';
|
|
22
|
+
import { ALWAYS_RELEVANT_SKILLS } from '../../services/skill-scope/types.js';
|
|
23
|
+
import { fail, getErrorMessage, ok } from '../../shared/result.js';
|
|
24
|
+
import { addJsonOption, printResult } from '../cli-helpers.js';
|
|
25
|
+
const VALID_ACTIONS = ['detect', 'apply', 'show', 'reset'];
|
|
26
|
+
const VALID_IDES = ['claude-code', 'trae', 'codex', 'cursor', 'qoder', 'tongyi-lingma'];
|
|
27
|
+
function isValidIde(value) {
|
|
28
|
+
return VALID_IDES.includes(value);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* G6: enforce the peaks-* allowlist. Re-adds any peak-* skill that is
|
|
32
|
+
* missing from the allowlist, and removes any peak-* skill from the
|
|
33
|
+
* denylist. The list is the same one declared in `types.ts`.
|
|
34
|
+
*/
|
|
35
|
+
function enforcePeaksAllowlist(allowlist) {
|
|
36
|
+
const set = new Set(allowlist);
|
|
37
|
+
for (const name of ALWAYS_RELEVANT_SKILLS) {
|
|
38
|
+
if (name.startsWith('peaks-'))
|
|
39
|
+
set.add(name);
|
|
40
|
+
}
|
|
41
|
+
return [...set];
|
|
42
|
+
}
|
|
43
|
+
function stripPeaksFromDenylist(denylist) {
|
|
44
|
+
return denylist.filter((name) => !name.startsWith('peaks-'));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Determine the IDE. Caller-supplied `--ide` wins; otherwise the registry
|
|
48
|
+
* probes the project root.
|
|
49
|
+
*/
|
|
50
|
+
async function resolveIde(projectRoot, override) {
|
|
51
|
+
if (override !== undefined) {
|
|
52
|
+
if (!isValidIde(override)) {
|
|
53
|
+
throw new Error(`Unknown IDE: ${override}. Valid: ${VALID_IDES.join(', ')}`);
|
|
54
|
+
}
|
|
55
|
+
return { ide: override, isFallback: false };
|
|
56
|
+
}
|
|
57
|
+
const resolved = await resolveActiveAdapter(projectRoot);
|
|
58
|
+
return { ide: resolved.adapter.ide, isFallback: resolved.isFallback };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Stable timestamp (no millisecond jitter) for the `generatedAt` field.
|
|
62
|
+
* `Date.now()` would still be deterministic per-run; we keep the natural
|
|
63
|
+
* one to ensure `generatedAt` matches what the user sees on disk.
|
|
64
|
+
*/
|
|
65
|
+
function nowIso() {
|
|
66
|
+
return new Date().toISOString();
|
|
67
|
+
}
|
|
68
|
+
/** Run the --detect subcommand. */
|
|
69
|
+
async function runDetect(input) {
|
|
70
|
+
try {
|
|
71
|
+
const result = await detectSkillScope({ projectRoot: input.project });
|
|
72
|
+
const envelope = ok('skill.scope.detect', result);
|
|
73
|
+
const stdout = input.json === true ? JSON.stringify(envelope, null, 2) : JSON.stringify(result, null, 2);
|
|
74
|
+
return { exitCode: 0, envelope, stdout, stderr: '' };
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
const envelope = fail('skill.scope.detect', 'DETECT_FAILED', getErrorMessage(error), null);
|
|
78
|
+
return { exitCode: 1, envelope, stdout: '', stderr: envelope.message ?? 'detect failed' };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Build the final ScopeConfig (applies G6 enforcement + override). */
|
|
82
|
+
function buildScopeConfig(args) {
|
|
83
|
+
const strict = args.strict;
|
|
84
|
+
const detected = args.detected;
|
|
85
|
+
// Build allowlist from detected.relevant + (in loose) borderline.
|
|
86
|
+
const allowFromDetect = detected.skills
|
|
87
|
+
.filter((s) => s.relevance === 'relevant' || (!strict && s.relevance === 'borderline'))
|
|
88
|
+
.map((s) => s.name);
|
|
89
|
+
const merged = args.allowOverride !== undefined ? [...args.allowOverride, ...allowFromDetect] : allowFromDetect;
|
|
90
|
+
const enforced = enforcePeaksAllowlist(merged);
|
|
91
|
+
// Denylist: irrelevant skills (strict + loose both), minus anything in allowlist.
|
|
92
|
+
const denyFromDetect = detected.skills
|
|
93
|
+
.filter((s) => s.relevance === 'irrelevant' && !enforced.includes(s.name))
|
|
94
|
+
.map((s) => s.name);
|
|
95
|
+
const finalDeny = stripPeaksFromDenylist(denyFromDetect);
|
|
96
|
+
return {
|
|
97
|
+
generatedAt: nowIso(),
|
|
98
|
+
ide: args.ide,
|
|
99
|
+
strict,
|
|
100
|
+
allowlist: enforced,
|
|
101
|
+
denylist: finalDeny,
|
|
102
|
+
skills: detected.skills,
|
|
103
|
+
signals: detected.projectSignals,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** Run the --apply subcommand. */
|
|
107
|
+
async function runApply(input) {
|
|
108
|
+
// 1. Detect the scope.
|
|
109
|
+
const detected = await detectSkillScope({ projectRoot: input.project });
|
|
110
|
+
// --strict wins when both flags are passed. Default is --loose per PRD.
|
|
111
|
+
const isStrict = input.strict === true && input.loose !== true;
|
|
112
|
+
const loose = !isStrict;
|
|
113
|
+
const { ide, isFallback } = await resolveIde(input.project, input.ide);
|
|
114
|
+
const adapter = getScopeAdapter(ide);
|
|
115
|
+
const config = buildScopeConfig({
|
|
116
|
+
ide,
|
|
117
|
+
strict: isStrict,
|
|
118
|
+
detected,
|
|
119
|
+
...(input.overrideAllowlist !== undefined ? { allowOverride: input.overrideAllowlist } : {}),
|
|
120
|
+
});
|
|
121
|
+
// 2. Write the source-of-truth first (atomic). Test seam: simulate failure.
|
|
122
|
+
let writtenFiles = [];
|
|
123
|
+
let sourceWritten = false;
|
|
124
|
+
try {
|
|
125
|
+
if (input.simulateSourceOfTruthWriteFailure) {
|
|
126
|
+
throw new Error('simulated source-of-truth write failure');
|
|
127
|
+
}
|
|
128
|
+
const file = await writeSourceOfTruth(input.project, config);
|
|
129
|
+
writtenFiles.push(file);
|
|
130
|
+
sourceWritten = true;
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
const envelope = fail('skill.scope.apply', 'WRITE_FAILED', getErrorMessage(error), { ide, sourceWritten: false }, ['Fix filesystem permissions on the project root and retry']);
|
|
134
|
+
return { exitCode: 4, envelope, stdout: '', stderr: envelope.message ?? 'write failed' };
|
|
135
|
+
}
|
|
136
|
+
// 3. Call the adapter. Stub adapters return notSupported=true; we surface it.
|
|
137
|
+
const adapterInput = {
|
|
138
|
+
allowlist: config.allowlist,
|
|
139
|
+
denylist: config.denylist,
|
|
140
|
+
strict: config.strict,
|
|
141
|
+
projectRoot: input.project,
|
|
142
|
+
sourceConfig: config,
|
|
143
|
+
shadowFallback: input.shadowFallback === true,
|
|
144
|
+
};
|
|
145
|
+
let result;
|
|
146
|
+
try {
|
|
147
|
+
result = await adapter.applyScope(adapterInput);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
// Roll back the source-of-truth on adapter failure.
|
|
151
|
+
await removeIfExists(scopeFilePath(input.project));
|
|
152
|
+
const envelope = fail('skill.scope.apply', 'ADAPTER_FAILED', getErrorMessage(error), { ide, sourceWritten: false, writtenFiles: [] }, ['Inspect the adapter error and retry']);
|
|
153
|
+
return { exitCode: 4, envelope, stdout: '', stderr: envelope.message ?? 'adapter failed' };
|
|
154
|
+
}
|
|
155
|
+
// The stub adapter also writes the canonical skills.json — that's
|
|
156
|
+
// already on disk from step 2, so its second write is a no-op update.
|
|
157
|
+
const finalWrittenFiles = [...writtenFiles, ...result.writtenFiles];
|
|
158
|
+
const envelope = ok('skill.scope.apply', {
|
|
159
|
+
ide,
|
|
160
|
+
isFallback,
|
|
161
|
+
strict: isStrict,
|
|
162
|
+
loose,
|
|
163
|
+
allowlist: config.allowlist,
|
|
164
|
+
denylist: config.denylist,
|
|
165
|
+
signals: config.signals,
|
|
166
|
+
writtenFiles: finalWrittenFiles,
|
|
167
|
+
usedShadowStub: result.usedShadowStub,
|
|
168
|
+
notSupported: result.notSupported,
|
|
169
|
+
strippedFromDenylist: result.strippedFromDenylist ?? [],
|
|
170
|
+
error: result.error,
|
|
171
|
+
});
|
|
172
|
+
const stdout = input.json === true ? JSON.stringify(envelope, null, 2) : JSON.stringify(envelope.data, null, 2);
|
|
173
|
+
if (result.notSupported) {
|
|
174
|
+
// Stub adapter: NOT_SUPPORTED → exit 3, write error to stderr.
|
|
175
|
+
const stderr = `${result.error?.code ?? 'NOT_SUPPORTED'}: ${result.error?.message ?? 'not supported'}`;
|
|
176
|
+
return { exitCode: 3, envelope, stdout, stderr };
|
|
177
|
+
}
|
|
178
|
+
return { exitCode: 0, envelope, stdout, stderr: '' };
|
|
179
|
+
}
|
|
180
|
+
/** Run the --show subcommand. */
|
|
181
|
+
async function runShow(input) {
|
|
182
|
+
const source = await readSourceOfTruth(input.project);
|
|
183
|
+
const { ide } = await resolveIde(input.project, input.ide);
|
|
184
|
+
const companionPath = ideCompanionFilePath(input.project, ide);
|
|
185
|
+
const companion = await readIdeCompanion(input.project, ide);
|
|
186
|
+
// For Claude Code, the native config is `.claude/settings.local.json`.
|
|
187
|
+
const nativeSettingsPath = join(input.project, '.claude', 'settings.local.json');
|
|
188
|
+
const nativeExists = existsSync(nativeSettingsPath);
|
|
189
|
+
let native = companion;
|
|
190
|
+
if (nativeExists) {
|
|
191
|
+
try {
|
|
192
|
+
const { readFile } = await import('node:fs/promises');
|
|
193
|
+
native = JSON.parse(await readFile(nativeSettingsPath, 'utf8'));
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
native = null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const data = {
|
|
200
|
+
ide,
|
|
201
|
+
source,
|
|
202
|
+
native,
|
|
203
|
+
nativeSettingsPath: nativeExists ? '.claude/settings.local.json' : null,
|
|
204
|
+
companionPath: existsSync(companionPath) ? companionPath : null,
|
|
205
|
+
};
|
|
206
|
+
const envelope = ok('skill.scope.show', data);
|
|
207
|
+
const stdout = input.json === true ? JSON.stringify(envelope, null, 2) : JSON.stringify(data, null, 2);
|
|
208
|
+
return { exitCode: 0, envelope, stdout, stderr: '' };
|
|
209
|
+
}
|
|
210
|
+
/** Run the --reset subcommand. */
|
|
211
|
+
async function runReset(input) {
|
|
212
|
+
const { ide } = await resolveIde(input.project, input.ide);
|
|
213
|
+
const adapter = getScopeAdapter(ide);
|
|
214
|
+
const resetResult = await adapter.resetScope({ projectRoot: input.project });
|
|
215
|
+
const sourceFile = scopeFilePath(input.project);
|
|
216
|
+
const sourceRemoved = await removeIfExists(sourceFile);
|
|
217
|
+
const allRemoved = [...resetResult.removedFiles, ...(sourceRemoved ? [sourceFile] : [])];
|
|
218
|
+
const envelope = ok('skill.scope.reset', {
|
|
219
|
+
ide,
|
|
220
|
+
removedFiles: allRemoved,
|
|
221
|
+
});
|
|
222
|
+
// Always include the canonical source-of-truth path in the human-readable
|
|
223
|
+
// summary, even if it didn't exist (so the user knows what was targeted).
|
|
224
|
+
const displayFiles = allRemoved.length > 0 ? allRemoved : [sourceFile, join(input.project, '.claude', 'settings.local.json')];
|
|
225
|
+
const summary = `removed: ${displayFiles.join(', ')}`;
|
|
226
|
+
const stdout = input.json === true ? JSON.stringify(envelope, null, 2) : summary;
|
|
227
|
+
return { exitCode: 0, envelope, stdout, stderr: '' };
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Programmatic entry point for `peaks skill scope`. Used by the CLI shim
|
|
231
|
+
* AND by the unit tests.
|
|
232
|
+
*/
|
|
233
|
+
export async function runSkillScopeCommand(input) {
|
|
234
|
+
if (!VALID_ACTIONS.includes(input.subcommand)) {
|
|
235
|
+
const envelope = fail('skill.scope', 'INVALID_USAGE', `Unknown action: ${input.subcommand}`, null);
|
|
236
|
+
return { exitCode: 2, envelope, stdout: '', stderr: envelope.message ?? 'invalid usage' };
|
|
237
|
+
}
|
|
238
|
+
switch (input.subcommand) {
|
|
239
|
+
case 'detect': return runDetect(input);
|
|
240
|
+
case 'apply': return runApply(input);
|
|
241
|
+
case 'show': return runShow(input);
|
|
242
|
+
case 'reset': return runReset(input);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Register the `peaks skill scope` subcommand on the `skill` command group.
|
|
247
|
+
* Mutually-exclusive flags: exactly one of --detect / --apply / --show / --reset.
|
|
248
|
+
*/
|
|
249
|
+
export function registerSkillScopeCommands(program, io) {
|
|
250
|
+
// Find the existing 'skill' subcommand if any.
|
|
251
|
+
let skillCmd = program.commands.find((c) => c.name() === 'skill');
|
|
252
|
+
if (skillCmd === undefined) {
|
|
253
|
+
skillCmd = program.command('skill').description('Manage Peaks skills');
|
|
254
|
+
}
|
|
255
|
+
const scope = skillCmd
|
|
256
|
+
.command('scope')
|
|
257
|
+
.description('Per-project skill scoping: detect, apply, show, reset');
|
|
258
|
+
addJsonOption(scope
|
|
259
|
+
.option('--detect', 'dry-run: print the relevance matrix')
|
|
260
|
+
.option('--apply', 'apply the scope (writes source-of-truth + IDE config)')
|
|
261
|
+
.option('--show', 'show the currently applied scope')
|
|
262
|
+
.option('--reset', 'remove the scope config')
|
|
263
|
+
.option('--project <path>', 'target project root (defaults to cwd)', process.cwd())
|
|
264
|
+
.option('--strict', '--apply: only `relevant` skills in the allowlist')
|
|
265
|
+
.option('--loose', '--apply: `relevant` + `borderline` in the allowlist (default)')
|
|
266
|
+
.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) => {
|
|
268
|
+
const flags = [options.detect, options.apply, options.show, options.reset].filter(Boolean).length;
|
|
269
|
+
if (flags !== 1) {
|
|
270
|
+
const envelope = fail('skill.scope', 'INVALID_USAGE', 'Exactly one of --detect / --apply / --show / --reset is required', null, ['Pass exactly one action flag']);
|
|
271
|
+
printResult(io, envelope, options.json === true);
|
|
272
|
+
process.exitCode = 2;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const subcommand = options.detect
|
|
276
|
+
? 'detect'
|
|
277
|
+
: options.apply
|
|
278
|
+
? 'apply'
|
|
279
|
+
: options.show
|
|
280
|
+
? 'show'
|
|
281
|
+
: 'reset';
|
|
282
|
+
const result = await runSkillScopeCommand({
|
|
283
|
+
subcommand,
|
|
284
|
+
project: options.project ?? process.cwd(),
|
|
285
|
+
...(options.strict !== undefined ? { strict: options.strict } : {}),
|
|
286
|
+
...(options.loose !== undefined ? { loose: options.loose } : {}),
|
|
287
|
+
...(options.ide !== undefined ? { ide: options.ide } : {}),
|
|
288
|
+
...(options.shadowFallback !== undefined ? { shadowFallback: options.shadowFallback } : {}),
|
|
289
|
+
...(options.json !== undefined ? { json: options.json } : {}),
|
|
290
|
+
});
|
|
291
|
+
if (options.json === true) {
|
|
292
|
+
if (result.envelope !== null)
|
|
293
|
+
printResult(io, result.envelope, true);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
if (result.stdout.length > 0)
|
|
297
|
+
io.stdout(result.stdout);
|
|
298
|
+
if (result.stderr.length > 0)
|
|
299
|
+
io.stderr(result.stderr);
|
|
300
|
+
}
|
|
301
|
+
if (result.exitCode !== 0) {
|
|
302
|
+
process.exitCode = result.exitCode;
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
}
|
|
@@ -329,7 +329,7 @@ export function registerWorkflowCommands(program, io) {
|
|
|
329
329
|
process.exitCode = exitOk;
|
|
330
330
|
}
|
|
331
331
|
catch (error) {
|
|
332
|
-
printResult(io, fail('workflow.verify-pipeline', 'VERIFY_FAILED', getErrorMessage(error), {}, ['Check that --project and --rid are correct; --change-id is optional (resolved from the artifact otherwise)']), options.json);
|
|
332
|
+
printResult(io, fail('workflow.verify-pipeline', 'VERIFY_FAILED', getErrorMessage(error), { acceptedForm: 'none', gateC: 'fail' }, ['Check that --project and --rid are correct; --change-id is optional (resolved from the artifact otherwise)']), options.json);
|
|
333
333
|
process.exitCode = 1;
|
|
334
334
|
}
|
|
335
335
|
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks workflow plan <read|refresh|detect-trigger>` — slice 025 CLI.
|
|
3
|
+
*
|
|
4
|
+
* Three subcommands under the existing `peaks workflow` verb:
|
|
5
|
+
* - `read <security|perf> --project <repo> --json`
|
|
6
|
+
* - `refresh <security|perf> --project <repo> [--apply] --json`
|
|
7
|
+
* - `detect-trigger --project <repo> --rid <rid> [--refresh] --json`
|
|
8
|
+
*
|
|
9
|
+
* CLI justification (per dev-preference rules):
|
|
10
|
+
* - `read` (2) JSON-gated — slice workflow reads plan hash.
|
|
11
|
+
* - `refresh` (3) destructive write needs explicit `--apply`.
|
|
12
|
+
* - `detect-trigger` (2) JSON-gated — slice workflow needs the verdict.
|
|
13
|
+
*/
|
|
14
|
+
import { Command } from 'commander';
|
|
15
|
+
import { type ProgramIO } from '../cli-helpers.js';
|
|
16
|
+
declare function runPlanRead(io: ProgramIO, options: {
|
|
17
|
+
type: string;
|
|
18
|
+
project?: string;
|
|
19
|
+
sessionId?: string;
|
|
20
|
+
json?: boolean;
|
|
21
|
+
}): void;
|
|
22
|
+
declare function runPlanRefresh(io: ProgramIO, options: {
|
|
23
|
+
type: string;
|
|
24
|
+
project?: string;
|
|
25
|
+
sessionId?: string;
|
|
26
|
+
apply?: boolean;
|
|
27
|
+
json?: boolean;
|
|
28
|
+
}): void;
|
|
29
|
+
declare function runPlanDetectTrigger(io: ProgramIO, options: {
|
|
30
|
+
project?: string;
|
|
31
|
+
rid?: string;
|
|
32
|
+
sessionId?: string;
|
|
33
|
+
refresh?: boolean;
|
|
34
|
+
json?: boolean;
|
|
35
|
+
}): void;
|
|
36
|
+
export declare function registerWorkflowPlanCommands(program: Command, io: ProgramIO): void;
|
|
37
|
+
export { runPlanRead as _runPlanRead };
|
|
38
|
+
export { runPlanRefresh as _runPlanRefresh };
|
|
39
|
+
export { runPlanDetectTrigger as _runPlanDetectTrigger };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { fail, getErrorMessage } from '../../shared/result.js';
|
|
2
|
+
import { addJsonOption, printResult } from '../cli-helpers.js';
|
|
3
|
+
import { readPlan } from '../../services/workflow/plan-reader.js';
|
|
4
|
+
import { refreshPlan } from '../../services/workflow/plan-refresher.js';
|
|
5
|
+
import { detectTrigger } from '../../services/workflow/plan-trigger-detector.js';
|
|
6
|
+
import { getSessionId } from '../../services/session/session-manager.js';
|
|
7
|
+
import { findProjectRoot } from '../../services/config/config-safety.js';
|
|
8
|
+
const VALID_TYPES = ['security', 'perf'];
|
|
9
|
+
// F-1 (slice 025 security): reject session ids that look like path
|
|
10
|
+
// traversal payloads. Canonical pattern is YYYY-MM-DD-<slug>.
|
|
11
|
+
const SESSION_ID_PATTERN = /^\d{4}-\d{2}-\d{2}-[a-z][a-z0-9-]*[a-z0-9]$/;
|
|
12
|
+
// F-1 (slice 025 security): reject rids that contain path separators,
|
|
13
|
+
// null bytes, or traversal sequences.
|
|
14
|
+
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
15
|
+
function isPlanType(value) {
|
|
16
|
+
return VALID_TYPES.includes(value);
|
|
17
|
+
}
|
|
18
|
+
function isValidSessionId(value) {
|
|
19
|
+
return SESSION_ID_PATTERN.test(value);
|
|
20
|
+
}
|
|
21
|
+
function isValidRequestId(value) {
|
|
22
|
+
return REQUEST_ID_PATTERN.test(value);
|
|
23
|
+
}
|
|
24
|
+
function resolveSessionId(io, command, projectRoot, explicit, asJson) {
|
|
25
|
+
if (explicit !== undefined && explicit.length > 0) {
|
|
26
|
+
if (!isValidSessionId(explicit)) {
|
|
27
|
+
printResult(io, fail(command, 'INVALID_SESSION_ID', 'session id must match YYYY-MM-DD-slug pattern', { sessionId: explicit }, ['Use --session-id <YYYY-MM-DD-slug>']), asJson === true);
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return explicit;
|
|
32
|
+
}
|
|
33
|
+
const sid = getSessionId(projectRoot);
|
|
34
|
+
if (sid === null || sid === undefined) {
|
|
35
|
+
printResult(io, fail(command, 'NO_ACTIVE_SESSION', 'No active session — pass --session-id explicitly or run peaks workspace init', { projectRoot }, ['Run peaks workspace init or pass --session-id <YYYY-MM-DD-slug>']), asJson === true);
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
// Defensive: even active-session resolution must satisfy the pattern.
|
|
40
|
+
if (!isValidSessionId(sid)) {
|
|
41
|
+
printResult(io, fail(command, 'INVALID_SESSION_ID', 'session id must match YYYY-MM-DD-slug pattern', { sessionId: sid }, ['Use --session-id <YYYY-MM-DD-slug>']), asJson === true);
|
|
42
|
+
process.exitCode = 1;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return sid;
|
|
46
|
+
}
|
|
47
|
+
function resolveProjectRoot(projectArg) {
|
|
48
|
+
if (projectArg === undefined || projectArg === '') {
|
|
49
|
+
return findProjectRoot(process.cwd()) ?? process.cwd();
|
|
50
|
+
}
|
|
51
|
+
return projectArg;
|
|
52
|
+
}
|
|
53
|
+
function runPlanRead(io, options) {
|
|
54
|
+
if (!isPlanType(options.type)) {
|
|
55
|
+
printResult(io, fail('workflow.plan.read', 'INVALID_TYPE', `Unsupported plan type: ${options.type}`, { supportedTypes: VALID_TYPES }, ['Use --type security or --type perf']), options.json === true);
|
|
56
|
+
process.exitCode = 1;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const projectRoot = resolveProjectRoot(options.project);
|
|
60
|
+
const sessionId = resolveSessionId(io, 'workflow.plan.read', projectRoot, options.sessionId, options.json);
|
|
61
|
+
if (sessionId === null)
|
|
62
|
+
return;
|
|
63
|
+
try {
|
|
64
|
+
const result = readPlan({ type: options.type, project: projectRoot, sessionId });
|
|
65
|
+
printResult(io, result, options.json === true);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
printResult(io, fail('workflow.plan.read', 'READ_FAILED', getErrorMessage(error), null, ['Check that --project is a valid repo root with a peaks session']), options.json === true);
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function runPlanRefresh(io, options) {
|
|
73
|
+
if (!isPlanType(options.type)) {
|
|
74
|
+
printResult(io, fail('workflow.plan.refresh', 'INVALID_TYPE', `Unsupported plan type: ${options.type}`, { supportedTypes: VALID_TYPES }, ['Use --type security or --type perf']), options.json === true);
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const projectRoot = resolveProjectRoot(options.project);
|
|
79
|
+
const sessionId = resolveSessionId(io, 'workflow.plan.refresh', projectRoot, options.sessionId, options.json);
|
|
80
|
+
if (sessionId === null)
|
|
81
|
+
return;
|
|
82
|
+
try {
|
|
83
|
+
const result = refreshPlan({
|
|
84
|
+
type: options.type,
|
|
85
|
+
project: projectRoot,
|
|
86
|
+
sessionId,
|
|
87
|
+
apply: options.apply === true
|
|
88
|
+
});
|
|
89
|
+
printResult(io, result, options.json === true);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
printResult(io, fail('workflow.plan.refresh', 'REFRESH_FAILED', getErrorMessage(error), null, ['Check that --project is a valid repo root and the session exists']), options.json === true);
|
|
93
|
+
process.exitCode = 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function runPlanDetectTrigger(io, options) {
|
|
97
|
+
if (options.rid === undefined || options.rid === '') {
|
|
98
|
+
printResult(io, fail('workflow.plan.detect-trigger', 'MISSING_RID', 'Missing --rid', null, ['Pass --rid <request-id>']), options.json === true);
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!isValidRequestId(options.rid)) {
|
|
103
|
+
printResult(io, fail('workflow.plan.detect-trigger', 'INVALID_RID', 'request id must match [A-Za-z0-9][A-Za-z0-9._-]*', { rid: options.rid }, ['Pass --rid <alphanumeric.request-id>']), options.json === true);
|
|
104
|
+
process.exitCode = 1;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const projectRoot = resolveProjectRoot(options.project);
|
|
108
|
+
const sessionId = resolveSessionId(io, 'workflow.plan.detect-trigger', projectRoot, options.sessionId, options.json);
|
|
109
|
+
if (sessionId === null)
|
|
110
|
+
return;
|
|
111
|
+
try {
|
|
112
|
+
const result = detectTrigger({
|
|
113
|
+
project: projectRoot,
|
|
114
|
+
rid: options.rid,
|
|
115
|
+
sessionId,
|
|
116
|
+
...(options.refresh === true ? { manualOverride: true } : {})
|
|
117
|
+
});
|
|
118
|
+
printResult(io, result, options.json === true);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
printResult(io, fail('workflow.plan.detect-trigger', 'DETECT_FAILED', getErrorMessage(error), null, ['Check that --project is a valid repo root and --rid is set']), options.json === true);
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function registerWorkflowPlanCommands(program, io) {
|
|
126
|
+
let workflowCmd = program.commands.find((c) => c.name() === 'workflow');
|
|
127
|
+
if (workflowCmd === undefined) {
|
|
128
|
+
workflowCmd = program.command('workflow').description('Plan workflow routing dry-run graphs');
|
|
129
|
+
}
|
|
130
|
+
const plan = workflowCmd
|
|
131
|
+
.command('plan')
|
|
132
|
+
.description('Read, refresh, or detect-trigger for security / perf plans (slice 025)');
|
|
133
|
+
addJsonOption(plan
|
|
134
|
+
.command('read')
|
|
135
|
+
.description('Read the project-level plan envelope (exists, path, hash, refreshedAt)')
|
|
136
|
+
.requiredOption('--type <type>', 'plan type: security or perf')
|
|
137
|
+
.option('--project <path>', 'project root', process.cwd())
|
|
138
|
+
.option('--session-id <sid>', 'session id (defaults to the active session)')).action((options) => {
|
|
139
|
+
runPlanRead(io, options);
|
|
140
|
+
});
|
|
141
|
+
addJsonOption(plan
|
|
142
|
+
.command('refresh')
|
|
143
|
+
.description('Regenerate the plan (deterministic, idempotent; --apply to write)')
|
|
144
|
+
.requiredOption('--type <type>', 'plan type: security or perf')
|
|
145
|
+
.option('--project <path>', 'project root', process.cwd())
|
|
146
|
+
.option('--session-id <sid>', 'session id (defaults to the active session)')
|
|
147
|
+
.option('--apply', 'write the plan to disk (default is dry-run preview)')).action((options) => {
|
|
148
|
+
runPlanRefresh(io, options);
|
|
149
|
+
});
|
|
150
|
+
addJsonOption(plan
|
|
151
|
+
.command('detect-trigger')
|
|
152
|
+
.description('Detect whether a plan refresh is warranted for the slice diff')
|
|
153
|
+
.requiredOption('--rid <rid>', 'request identifier')
|
|
154
|
+
.option('--project <path>', 'project root', process.cwd())
|
|
155
|
+
.option('--session-id <sid>', 'session id (defaults to the active session)')
|
|
156
|
+
.option('--refresh', 'force triggered=true (manual override)')).action((options) => {
|
|
157
|
+
runPlanDetectTrigger(io, options);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
// Re-export for tests that need a programmatic entry point.
|
|
161
|
+
export { runPlanRead as _runPlanRead };
|
|
162
|
+
export { runPlanRefresh as _runPlanRefresh };
|
|
163
|
+
export { runPlanDetectTrigger as _runPlanDetectTrigger };
|
package/dist/src/cli/program.js
CHANGED
|
@@ -27,6 +27,8 @@ import { registerHooksCommands } from './commands/hooks-commands.js';
|
|
|
27
27
|
import { registerStatusLineCommands } from './commands/statusline-commands.js';
|
|
28
28
|
import { registerUnderstandCommands } from './commands/understand-commands.js';
|
|
29
29
|
import { registerWorkspaceCommands } from './commands/workspace-commands.js';
|
|
30
|
+
import { registerSkillScopeCommands } from './commands/skill-scope-commands.js';
|
|
31
|
+
import { registerWorkflowPlanCommands } from './commands/workflow-plan-commands.js';
|
|
30
32
|
export { printResult } from './cli-helpers.js';
|
|
31
33
|
export function createProgram(io = { stdout: (text) => console.log(text), stderr: (text) => console.error(text) }) {
|
|
32
34
|
const program = new Command();
|
|
@@ -107,5 +109,9 @@ Run peaks (no arguments) for a quickstart. You likely want one of:
|
|
|
107
109
|
registerStatusLineCommands(program, io);
|
|
108
110
|
registerUnderstandCommands(program, io);
|
|
109
111
|
registerWorkspaceCommands(program, io);
|
|
112
|
+
// Slice 025: peaks skill scope — per-project multi-IDE skill scoping.
|
|
113
|
+
registerSkillScopeCommands(program, io);
|
|
114
|
+
// Slice 025: peaks workflow plan — security/perf plan/result split CLI.
|
|
115
|
+
registerWorkflowPlanCommands(program, io);
|
|
110
116
|
return program;
|
|
111
117
|
}
|