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
|
@@ -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 };
|
|
@@ -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.
|
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
|
}
|
|
@@ -44,6 +44,43 @@ export type WorkspaceLayoutInspection = {
|
|
|
44
44
|
perChangeIdDirs?: string[];
|
|
45
45
|
};
|
|
46
46
|
export type WorkspaceLayoutProbe = () => WorkspaceLayoutInspection;
|
|
47
|
+
/**
|
|
48
|
+
* 2026-06-10 — `gateguard-fact-force` (a third-party PreToolUse hook,
|
|
49
|
+
* NOT peaks-cli) fires on Edit / Write and demands a 4-fact questionnaire
|
|
50
|
+
* before allowing the edit. When the LLM is in a peaks-qa flow and tries
|
|
51
|
+
* to update `.peaks/_runtime/<sid>/qa/requests/*.md` via the Edit/Write
|
|
52
|
+
* tool, the hook demands facts that are inapplicable to QA envelope
|
|
53
|
+
* templates (no importers, no public API, no data files, user
|
|
54
|
+
* instruction already in the conversation context). The check detects
|
|
55
|
+
* this hook in the user's global and project `.claude/settings.json` and
|
|
56
|
+
* warns when no `.peaks/**` skip is configured. The probe is injected so
|
|
57
|
+
* tests do not depend on the real `~/.claude/settings.json` state.
|
|
58
|
+
*/
|
|
59
|
+
export type GateguardHookLocation = {
|
|
60
|
+
/** Source file the hook was discovered in (`global` or `project .claude/settings.json`). */
|
|
61
|
+
source: 'global' | 'project';
|
|
62
|
+
/** Resolved absolute path to the source file (for the message). */
|
|
63
|
+
sourcePath: string;
|
|
64
|
+
/** The PreToolUse entry that contains a gateguard hook command. */
|
|
65
|
+
entry: {
|
|
66
|
+
matcher?: string;
|
|
67
|
+
hooks: ReadonlyArray<{
|
|
68
|
+
type?: string;
|
|
69
|
+
command?: string;
|
|
70
|
+
}>;
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
export type GateguardProbeResult = {
|
|
74
|
+
/** Absolute path to `~/.claude/settings.json` (or null when the probe could not resolve it). */
|
|
75
|
+
globalSettingsPath: string | null;
|
|
76
|
+
/** Parsed global settings payload (or null when missing / unreadable / malformed). */
|
|
77
|
+
globalSettings: unknown;
|
|
78
|
+
/** Absolute path to the project `.claude/settings.json` (or null when the project root is not in a peaks project). */
|
|
79
|
+
projectSettingsPath: string | null;
|
|
80
|
+
/** Parsed project settings payload (or null when missing / unreadable / malformed). */
|
|
81
|
+
projectSettings: unknown;
|
|
82
|
+
};
|
|
83
|
+
export type GateguardProbe = () => GateguardProbeResult;
|
|
47
84
|
export type DoctorOptions = {
|
|
48
85
|
schemasBaseDir?: string;
|
|
49
86
|
skillsBaseDir?: string;
|
|
@@ -59,6 +96,8 @@ export type DoctorOptions = {
|
|
|
59
96
|
distVersionProbe?: DistVersionProbe;
|
|
60
97
|
/** Injected for the build:workspace-layout-canonical check (defaults to inspectWorkspaceLayout on disk). */
|
|
61
98
|
workspaceLayoutProbe?: WorkspaceLayoutProbe;
|
|
99
|
+
/** Injected for the integration:gateguard-peaks-conflict check (defaults to defaultGateguardProbe on disk). */
|
|
100
|
+
gateguardProbe?: GateguardProbe;
|
|
62
101
|
};
|
|
63
102
|
/**
|
|
64
103
|
* Pure helper extracted from `defaultWorkspaceInitializedProbe` so tests can
|
|
@@ -99,4 +138,5 @@ export declare function inspectWorkspaceLayout(opts: {
|
|
|
99
138
|
dotfileScanner?: (root: string) => string[];
|
|
100
139
|
perChangeIdScanner?: (root: string) => string[];
|
|
101
140
|
}): WorkspaceLayoutInspection;
|
|
141
|
+
export declare function collectGateguardEntries(probe: GateguardProbeResult): GateguardHookLocation[];
|
|
102
142
|
export declare function runDoctor(options?: DoctorOptions): Promise<DoctorReport>;
|
|
@@ -270,6 +270,121 @@ function findDestructiveApplyLines(section) {
|
|
|
270
270
|
const lines = section.split(/\r?\n/);
|
|
271
271
|
return lines.filter((line) => DESTRUCTIVE_APPLY_PATTERNS.some((pattern) => pattern.test(line)));
|
|
272
272
|
}
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// 2026-06-10 — gateguard-fact-force integration check (NOT a peaks-cli hook).
|
|
275
|
+
//
|
|
276
|
+
// The `gateguard-fact-force` hook is a third-party PreToolUse hook that
|
|
277
|
+
// fires on Edit / Write / MultiEdit and demands a 4-fact questionnaire
|
|
278
|
+
// before allowing the edit. It is unrelated to peaks-cli, but when the
|
|
279
|
+
// LLM is in a peaks-qa flow and edits `.peaks/_runtime/<sid>/qa/requests/
|
|
280
|
+
// *.md`, the questionnaire demands facts that do not apply (no
|
|
281
|
+
// importers, no public API, no data files, user instruction already
|
|
282
|
+
// in the conversation context). The check below detects the hook in
|
|
283
|
+
// `~/.claude/settings.json` and the project `.claude/settings.json`,
|
|
284
|
+
// and warns when no `.peaks/**` skip is configured.
|
|
285
|
+
//
|
|
286
|
+
// Probing is split out of the check so the check itself stays a pure
|
|
287
|
+
// mapping over `GateguardProbeResult`. Tests inject the probe to keep
|
|
288
|
+
// `~/.claude/settings.json` from leaking into test fixtures.
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
/** Hook command fragments that identify the gateguard-fact-force hook. */
|
|
291
|
+
const GATEGUARD_HOOK_NEEDLES = ['gateguard', 'fact-force', 'fact_force'];
|
|
292
|
+
/** Token the gateguard hook exposes for "skip these paths" — the check
|
|
293
|
+
* treats any match against `.peaks` (path or globs) as a routed
|
|
294
|
+
* configuration. We accept a few common spellings because the third-
|
|
295
|
+
* party hook's CLI surface is not part of peaks-cli's contract. */
|
|
296
|
+
const GATEGUARD_PEAKS_SKIP_NEEDLES = [
|
|
297
|
+
'.peaks',
|
|
298
|
+
'peaks-skip',
|
|
299
|
+
'skip-glob',
|
|
300
|
+
'--skip',
|
|
301
|
+
'skip_paths'
|
|
302
|
+
];
|
|
303
|
+
function commandMentionsGateguard(command) {
|
|
304
|
+
if (typeof command !== 'string' || command.length === 0)
|
|
305
|
+
return false;
|
|
306
|
+
const lower = command.toLowerCase();
|
|
307
|
+
return GATEGUARD_HOOK_NEEDLES.some((needle) => lower.includes(needle));
|
|
308
|
+
}
|
|
309
|
+
function entrySkipsPeaks(entry) {
|
|
310
|
+
const matcher = typeof entry.matcher === 'string' ? entry.matcher : '';
|
|
311
|
+
const matcherMentionsPeaks = matcher.toLowerCase().includes('.peaks');
|
|
312
|
+
if (matcherMentionsPeaks)
|
|
313
|
+
return true;
|
|
314
|
+
for (const hook of entry.hooks) {
|
|
315
|
+
const command = typeof hook.command === 'string' ? hook.command : '';
|
|
316
|
+
const lower = command.toLowerCase();
|
|
317
|
+
if (GATEGUARD_PEAKS_SKIP_NEEDLES.some((needle) => lower.includes(needle))) {
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
function extractGateguardEntries(source, sourcePath, settings) {
|
|
324
|
+
if (settings === null || typeof settings !== 'object')
|
|
325
|
+
return [];
|
|
326
|
+
const hooks = settings.hooks;
|
|
327
|
+
if (hooks === null || typeof hooks !== 'object')
|
|
328
|
+
return [];
|
|
329
|
+
const preToolUse = hooks.PreToolUse;
|
|
330
|
+
if (!Array.isArray(preToolUse))
|
|
331
|
+
return [];
|
|
332
|
+
const out = [];
|
|
333
|
+
for (const rawEntry of preToolUse) {
|
|
334
|
+
if (rawEntry === null || typeof rawEntry !== 'object')
|
|
335
|
+
continue;
|
|
336
|
+
const entry = rawEntry;
|
|
337
|
+
if (!Array.isArray(entry.hooks))
|
|
338
|
+
continue;
|
|
339
|
+
const hooks = [];
|
|
340
|
+
for (const rawHook of entry.hooks) {
|
|
341
|
+
if (rawHook === null || typeof rawHook !== 'object')
|
|
342
|
+
continue;
|
|
343
|
+
const h = rawHook;
|
|
344
|
+
const hookEntry = {};
|
|
345
|
+
if (typeof h.type === 'string')
|
|
346
|
+
hookEntry.type = h.type;
|
|
347
|
+
if (typeof h.command === 'string')
|
|
348
|
+
hookEntry.command = h.command;
|
|
349
|
+
hooks.push(hookEntry);
|
|
350
|
+
}
|
|
351
|
+
if (!hooks.some((h) => commandMentionsGateguard(h.command)))
|
|
352
|
+
continue;
|
|
353
|
+
const outEntry = { hooks };
|
|
354
|
+
if (typeof entry.matcher === 'string')
|
|
355
|
+
outEntry.matcher = entry.matcher;
|
|
356
|
+
out.push({ source, sourcePath, entry: outEntry });
|
|
357
|
+
}
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
360
|
+
function readSettingsJson(path) {
|
|
361
|
+
if (!existsSync(path))
|
|
362
|
+
return null;
|
|
363
|
+
try {
|
|
364
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function defaultGateguardProbe() {
|
|
371
|
+
const projectRoot = findProjectRoot(process.cwd());
|
|
372
|
+
const globalPath = join(homedir(), '.claude', 'settings.json');
|
|
373
|
+
const projectPath = projectRoot === null ? null : join(projectRoot, '.claude', 'settings.json');
|
|
374
|
+
return {
|
|
375
|
+
globalSettingsPath: globalPath,
|
|
376
|
+
globalSettings: readSettingsJson(globalPath),
|
|
377
|
+
projectSettingsPath: projectPath,
|
|
378
|
+
projectSettings: projectPath === null ? null : readSettingsJson(projectPath)
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
export function collectGateguardEntries(probe) {
|
|
382
|
+
const fromGlobal = extractGateguardEntries('global', probe.globalSettingsPath ?? '~/.claude/settings.json', probe.globalSettings);
|
|
383
|
+
const fromProject = probe.projectSettingsPath === null
|
|
384
|
+
? []
|
|
385
|
+
: extractGateguardEntries('project', probe.projectSettingsPath, probe.projectSettings);
|
|
386
|
+
return [...fromGlobal, ...fromProject];
|
|
387
|
+
}
|
|
273
388
|
export async function runDoctor(options = {}) {
|
|
274
389
|
const checks = [];
|
|
275
390
|
const registry = await loadSkillRegistry(options.skillsBaseDir);
|
|
@@ -619,6 +734,51 @@ export async function runDoctor(options = {}) {
|
|
|
619
734
|
message: `Workspace layout check failed: ${getErrorMessage(error)}`
|
|
620
735
|
});
|
|
621
736
|
}
|
|
737
|
+
// 2026-06-10 — gateguard-fact-force integration check. The hook is a
|
|
738
|
+
// third-party PreToolUse that fires on Edit / Write and demands a
|
|
739
|
+
// 4-fact questionnaire; when the LLM is in a peaks-qa flow updating
|
|
740
|
+
// `.peaks/_runtime/<sid>/qa/requests/*.md` the facts do not apply.
|
|
741
|
+
// We warn when the hook is installed and no `.peaks/**` skip is
|
|
742
|
+
// configured. The check stays a pure mapping over the probe result
|
|
743
|
+
// so tests can drive it without touching the real `~/.claude/`.
|
|
744
|
+
const gateguardProbe = options.gateguardProbe ?? defaultGateguardProbe;
|
|
745
|
+
try {
|
|
746
|
+
const probe = gateguardProbe();
|
|
747
|
+
const offending = collectGateguardEntries(probe);
|
|
748
|
+
if (offending.length === 0) {
|
|
749
|
+
checks.push({
|
|
750
|
+
id: 'integration:gateguard-peaks-conflict',
|
|
751
|
+
ok: true,
|
|
752
|
+
message: 'No gateguard-fact-force PreToolUse hook detected in ~/.claude/settings.json or project .claude/settings.json; the Edit/Write fact-forcing flow will not interfere with peaks-qa .peaks/ artifact writes'
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
else {
|
|
756
|
+
const unrouted = offending.filter((location) => !entrySkipsPeaks(location.entry));
|
|
757
|
+
if (unrouted.length === 0) {
|
|
758
|
+
checks.push({
|
|
759
|
+
id: 'integration:gateguard-peaks-conflict',
|
|
760
|
+
ok: true,
|
|
761
|
+
message: `gateguard-fact-force hook is installed in ${offending.map((l) => l.source).join(' + ')} but a .peaks/** skip pattern is configured; peaks-qa .peaks/ artifact writes are not blocked`
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
const sources = Array.from(new Set(unrouted.map((u) => u.sourcePath))).join(' + ');
|
|
766
|
+
const matchers = unrouted.map((u) => u.entry.matcher ?? '*').join(', ');
|
|
767
|
+
checks.push({
|
|
768
|
+
id: 'integration:gateguard-peaks-conflict',
|
|
769
|
+
ok: false,
|
|
770
|
+
message: `gateguard-fact-force PreToolUse hook is installed (${sources}, matcher: ${matchers}) with no .peaks/** skip pattern; every Edit/Write of a peaks-qa envelope (.peaks/_runtime/<sid>/qa/requests/*.md) will be intercepted and demand a 4-fact questionnaire that does not apply to QA templates. Workaround: set \`ECC_DISABLED_HOOKS=pre:edit-write:gateguard-fact-force\` for the session, OR add a paired PreToolUse entry whose matcher restricts the hook to non-.peaks paths. peaks-cli is NOT the source of this hook.`
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
catch (error) {
|
|
776
|
+
checks.push({
|
|
777
|
+
id: 'integration:gateguard-peaks-conflict',
|
|
778
|
+
ok: true,
|
|
779
|
+
message: `gateguard probe failed (${getErrorMessage(error)}); skipping check`
|
|
780
|
+
});
|
|
781
|
+
}
|
|
622
782
|
try {
|
|
623
783
|
const schemaText = await readText(join(schemaRoot, 'doctor-report.schema.json'));
|
|
624
784
|
const schema = JSON.parse(schemaText);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type DetectPresenceMarkerInput = {
|
|
2
|
+
project: string;
|
|
3
|
+
latestAssistantMessage: string;
|
|
4
|
+
};
|
|
5
|
+
export type DetectPresenceMarkerResult = {
|
|
6
|
+
active: boolean;
|
|
7
|
+
skill?: string;
|
|
8
|
+
markerFound: boolean;
|
|
9
|
+
warning?: string;
|
|
10
|
+
};
|
|
11
|
+
export type PresenceMarkerWarning = (typeof PRESENCE_MARKER_WARNING)[number];
|
|
12
|
+
export declare const PRESENCE_MARKER_WARNING: readonly ["Peaks skill context may have been lost from this conversation; please re-invoke /peaks-<skill>."];
|
|
13
|
+
/**
|
|
14
|
+
* Pure read-only presence-marker detection. No I/O side effects.
|
|
15
|
+
*/
|
|
16
|
+
export declare function detectPresenceMarker(input: DetectPresenceMarkerInput): DetectPresenceMarkerResult;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Slice 028 (Q1=A): hook-based skill-presence marker detection.
|
|
5
|
+
*
|
|
6
|
+
* Background: the consumer-facing CLAUDE.md template (rendered by
|
|
7
|
+
* `peaks standards init` / `peaks standards update`) instructs the LLM
|
|
8
|
+
* to display a compact status header
|
|
9
|
+
* `Peaks-Cli Skill: <skill> | Peaks-Cli Gate: <gate> | Next: <one short action>`
|
|
10
|
+
* on every turn while a peaks skill is active. If the LLM forgets (e.g.
|
|
11
|
+
* because of context compaction or a fresh session), the user is left
|
|
12
|
+
* without an at-a-glance signal that peaks is orchestrating the work.
|
|
13
|
+
*
|
|
14
|
+
* This service is the read-only side of the slice-028 detection
|
|
15
|
+
* mechanism. The PostToolUse hook (or any other consumer, e.g.
|
|
16
|
+
* `peaks skill detect-marker-loss`) calls
|
|
17
|
+
* `detectPresenceMarker({ project, latestAssistantMessage })`
|
|
18
|
+
* and gets back:
|
|
19
|
+
*
|
|
20
|
+
* - `active`: whether an active-skill marker was found on disk.
|
|
21
|
+
* - `skill?`: the active skill name, if any.
|
|
22
|
+
* - `markerFound`: whether the latest assistant message carries the
|
|
23
|
+
* expected `Peaks-Cli Skill:` / `Peaks-Cli Gate:`
|
|
24
|
+
* marker. Always `false` when `active` is `false`.
|
|
25
|
+
* - `warning?`: a human-readable warning emitted when the marker
|
|
26
|
+
* is missing while the presence is active.
|
|
27
|
+
*
|
|
28
|
+
* The function is pure: it does not write to disk, does not clear the
|
|
29
|
+
* presence file, and does not depend on `process.cwd()`. The caller is
|
|
30
|
+
* expected to provide the absolute project root (peaks-cli convention
|
|
31
|
+
* from the standards-commands family — see dev-preference rule
|
|
32
|
+
* `project-option-is-canonical-project-root-source`).
|
|
33
|
+
*/
|
|
34
|
+
const PRESENCE_CANONICAL_PATH = '.peaks/_runtime/active-skill.json';
|
|
35
|
+
const PRESENCE_LEGACY_PATH = '.peaks/.active-skill.json';
|
|
36
|
+
const MARKER_PRIMARY = 'Peaks-Cli Skill:';
|
|
37
|
+
const MARKER_SECONDARY = 'Peaks-Cli Gate:';
|
|
38
|
+
const SKILL_NAME_RE = /"skill"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/;
|
|
39
|
+
export const PRESENCE_MARKER_WARNING = [
|
|
40
|
+
'Peaks skill context may have been lost from this conversation; please re-invoke /peaks-<skill>.'
|
|
41
|
+
];
|
|
42
|
+
function readPresenceFile(absolutePath) {
|
|
43
|
+
if (!existsSync(absolutePath))
|
|
44
|
+
return null;
|
|
45
|
+
let raw;
|
|
46
|
+
try {
|
|
47
|
+
raw = readFileSync(absolutePath, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(raw);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (parsed === null || typeof parsed !== 'object')
|
|
60
|
+
return null;
|
|
61
|
+
const skillMatch = SKILL_NAME_RE.exec(JSON.stringify(parsed));
|
|
62
|
+
if (skillMatch === null)
|
|
63
|
+
return null;
|
|
64
|
+
if (typeof skillMatch[1] !== 'string' || skillMatch[1].length === 0)
|
|
65
|
+
return null;
|
|
66
|
+
return { skill: skillMatch[1] };
|
|
67
|
+
}
|
|
68
|
+
function readPresenceBackCompat(project) {
|
|
69
|
+
const projectRoot = resolve(project);
|
|
70
|
+
const canonicalPath = resolve(projectRoot, PRESENCE_CANONICAL_PATH);
|
|
71
|
+
const legacyPath = resolve(projectRoot, PRESENCE_LEGACY_PATH);
|
|
72
|
+
for (const candidate of [canonicalPath, legacyPath]) {
|
|
73
|
+
const parsed = readPresenceFile(candidate);
|
|
74
|
+
if (parsed === null)
|
|
75
|
+
continue;
|
|
76
|
+
return { skill: parsed.skill, path: candidate };
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
function messageHasMarker(message) {
|
|
81
|
+
if (message.length === 0)
|
|
82
|
+
return false;
|
|
83
|
+
return message.includes(MARKER_PRIMARY) || message.includes(MARKER_SECONDARY);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Pure read-only presence-marker detection. No I/O side effects.
|
|
87
|
+
*/
|
|
88
|
+
export function detectPresenceMarker(input) {
|
|
89
|
+
const project = input.project;
|
|
90
|
+
const message = input.latestAssistantMessage ?? '';
|
|
91
|
+
const presence = readPresenceBackCompat(project);
|
|
92
|
+
if (presence === null) {
|
|
93
|
+
return { active: false, markerFound: false };
|
|
94
|
+
}
|
|
95
|
+
const markerFound = messageHasMarker(message);
|
|
96
|
+
if (markerFound) {
|
|
97
|
+
return { active: true, skill: presence.skill, markerFound: true };
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
active: true,
|
|
101
|
+
skill: presence.skill,
|
|
102
|
+
markerFound: false,
|
|
103
|
+
warning: PRESENCE_MARKER_WARNING[0]
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared `makeStubAdapter` helper for the 5 non-shipped IDEs (Trae, Cursor,
|
|
3
|
+
* Codex, Qoder, Tongyi Lingma).
|
|
4
|
+
*
|
|
5
|
+
* Each stub adapter:
|
|
6
|
+
* 1. Implements `SkillScopeAdapter` with `supported: false`.
|
|
7
|
+
* 2. In `applyScope`, ALWAYS writes the companion source-of-truth
|
|
8
|
+
* `.peaks/scope/<ide>-skills.json` first, then returns a NOT_SUPPORTED
|
|
9
|
+
* ApplyResult (the test contract asserts the source-of-truth is on disk
|
|
10
|
+
* even when the adapter can't apply it natively).
|
|
11
|
+
* 3. In `showScope`, reads from the companion source-of-truth file.
|
|
12
|
+
* 4. In `resetScope`, removes the companion source-of-truth file.
|
|
13
|
+
* 5. In `detect`, returns 0.0 (the stub does not actually probe).
|
|
14
|
+
*
|
|
15
|
+
* The TODO comment in each stub file points at the follow-up slice (025.2+).
|
|
16
|
+
*/
|
|
17
|
+
import type { SkillScopeAdapter } from '../types.js';
|
|
18
|
+
/**
|
|
19
|
+
* IDE-id -> companion source-of-truth shape. The companion file is a
|
|
20
|
+
* parallel record so the user can see "this is what would have applied"
|
|
21
|
+
* even when the IDE doesn't support a real implementation.
|
|
22
|
+
*/
|
|
23
|
+
export interface StubSourceOfTruth {
|
|
24
|
+
readonly ide: string;
|
|
25
|
+
readonly generatedAt: string;
|
|
26
|
+
readonly strict: boolean;
|
|
27
|
+
readonly allowlist: readonly string[];
|
|
28
|
+
readonly denylist: readonly string[];
|
|
29
|
+
readonly todoRef: string;
|
|
30
|
+
readonly notes: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The factory: every stub is a thin wrapper around this function. The
|
|
34
|
+
* `applyScope` implementation ALWAYS writes the source-of-truth, then
|
|
35
|
+
* returns a NOT_SUPPORTED ApplyResult (NOT a thrown error — the contract
|
|
36
|
+
* for stub adapters is "return ok:false, notSupported:true" so the CLI
|
|
37
|
+
* can keep going and surface the error to the user).
|
|
38
|
+
*/
|
|
39
|
+
export declare function makeStubAdapter(ide: SkillScopeAdapter['ide'], todoRef: string, displayName: string): SkillScopeAdapter;
|