peaks-cli 1.1.2 → 1.2.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 +97 -3
- package/dist/src/cli/commands/core-artifact-commands.js +47 -3
- package/dist/src/cli/commands/gate-commands.d.ts +3 -0
- package/dist/src/cli/commands/gate-commands.js +103 -0
- package/dist/src/cli/commands/hooks-commands.d.ts +3 -0
- package/dist/src/cli/commands/hooks-commands.js +75 -0
- package/dist/src/cli/commands/request-commands.js +1 -1
- package/dist/src/cli/commands/sop-commands.d.ts +3 -0
- package/dist/src/cli/commands/sop-commands.js +215 -0
- package/dist/src/cli/index.js +12 -0
- package/dist/src/cli/program.js +47 -2
- package/dist/src/services/dashboard/project-dashboard-service.js +5 -3
- package/dist/src/services/doctor/doctor-service.d.ts +4 -0
- package/dist/src/services/doctor/doctor-service.js +66 -3
- package/dist/src/services/mode/mode-enforcement.js +2 -1
- package/dist/src/services/skills/hooks-settings-service.d.ts +45 -0
- package/dist/src/services/skills/hooks-settings-service.js +167 -0
- package/dist/src/services/skills/skill-presence-service.d.ts +1 -0
- package/dist/src/services/skills/skill-presence-service.js +12 -0
- package/dist/src/services/skills/skill-statusline-service.d.ts +2 -0
- package/dist/src/services/skills/skill-statusline-service.js +11 -1
- package/dist/src/services/sop/gate-enforce-service.d.ts +33 -0
- package/dist/src/services/sop/gate-enforce-service.js +168 -0
- package/dist/src/services/sop/sop-advance-service.d.ts +74 -0
- package/dist/src/services/sop/sop-advance-service.js +115 -0
- package/dist/src/services/sop/sop-check-service.d.ts +29 -0
- package/dist/src/services/sop/sop-check-service.js +148 -0
- package/dist/src/services/sop/sop-paths.d.ts +62 -0
- package/dist/src/services/sop/sop-paths.js +92 -0
- package/dist/src/services/sop/sop-registry-service.d.ts +46 -0
- package/dist/src/services/sop/sop-registry-service.js +89 -0
- package/dist/src/services/sop/sop-service.d.ts +47 -0
- package/dist/src/services/sop/sop-service.js +211 -0
- package/dist/src/services/sop/sop-types.d.ts +88 -0
- package/dist/src/services/sop/sop-types.js +11 -0
- package/dist/src/shared/paths.d.ts +1 -1
- package/dist/src/shared/paths.js +2 -1
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +1 -1
- package/schemas/doctor-report.schema.json +1 -1
- package/schemas/sop-manifest.schema.json +52 -0
- package/skills/peaks-solo/SKILL.md +32 -6
- package/skills/peaks-sop/SKILL.md +192 -0
- package/skills/peaks-sop/references/sop-authoring.md +161 -0
package/README.md
CHANGED
|
@@ -10,13 +10,25 @@ npm install -g peaks-cli
|
|
|
10
10
|
|
|
11
11
|
安装后,Peaks 会把内置 skills 注册到 Claude Code,你可以在对话里直接调用。
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
验证安装,跑这三条:
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
peaks
|
|
17
|
-
peaks skill
|
|
16
|
+
peaks -V # prints the version
|
|
17
|
+
peaks # shows a quickstart with installed-skill count
|
|
18
|
+
peaks doctor # checks skills, config, env in one glance
|
|
18
19
|
```
|
|
19
20
|
|
|
21
|
+
## 80 秒上手
|
|
22
|
+
|
|
23
|
+
1. `peaks` 看一眼有哪些东西
|
|
24
|
+
2. `peaks doctor` 确认环境 OK
|
|
25
|
+
3. `peaks sop init --id my-flow --apply` 创建你的第一个 SOP 骨架
|
|
26
|
+
4. 编辑 `~/.peaks/sops/my-flow/sop.json` 写上你的流程和门禁
|
|
27
|
+
5. `peaks sop register --id my-flow` 注册它
|
|
28
|
+
6. 声明 guard 把不可逆动作绑到 phase,`peaks hooks install` 装上强制 hook
|
|
29
|
+
|
|
30
|
+
然后让 Claude 在对话里改文件、打算跑被 guard 的命令——它会被当场拦住。详见 [自定义 SOP](#自定义-sop用户自创流程门禁) 一节。如果用自然语言描述流程更顺手,直接用 `peaks-sop` 技能,它全程走上面的 CLI 链。
|
|
31
|
+
|
|
20
32
|
## 使用 Skills
|
|
21
33
|
|
|
22
34
|
在 Claude Code 对话里,直接用 `skill名称 + 自然语言描述` 发起工作流:
|
|
@@ -79,6 +91,88 @@ peaks doctor --json
|
|
|
79
91
|
peaks skill doctor --json
|
|
80
92
|
```
|
|
81
93
|
|
|
94
|
+
## 自定义 SOP(用户自创流程门禁)
|
|
95
|
+
|
|
96
|
+
除了内置的 `peaks-*` 技能家族,你还能用 `peaks sop` 命令族定义**自己的 SOP**:一组有序阶段(phases)加上绑定在阶段上的门禁(gates)。门禁不通过,就推不进对应阶段——把"流程不丢环节"落到你自己的工作流上。
|
|
97
|
+
|
|
98
|
+
**这是一个通用的流程门禁工具,不限于研发。** 凡是"有先后阶段、进入下一步前必须满足某些可检查条件"的流程都适用——内容发布、合规/审批清单、数据校验管线、入职流程、运维 runbook、个人可重复流程……研发发布只是其中一个例子,往往非研发场景更有价值。
|
|
99
|
+
|
|
100
|
+
> 更顺手的用法:直接用 **`peaks-sop` 技能**——在对话里用自然语言描述你的流程,由 LLM 帮你生成、调试、注册 SOP,无需手写 JSON 或记命令。下面的 CLI 是它底层调用的引擎。
|
|
101
|
+
|
|
102
|
+
### 不可绕过的门禁(杀手锏)
|
|
103
|
+
|
|
104
|
+
CI 只能在**合并时**拦,`CLAUDE.md` 里的规矩靠 agent **自觉**。Peaks 能做到 CI 和提示词都做不到的事:在**对话流程中途、面向 agent 本身**把不可逆动作摁住。
|
|
105
|
+
|
|
106
|
+
给某个 phase 声明 **guard**(把一个 Bash 命令绑到该 phase),再装一个 PreToolUse hook:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# sop.json 里:把「发布」绑定到 git push,并要求正文无 TODO
|
|
110
|
+
# "gates": [{ "id":"no-todo","phase":"publish",
|
|
111
|
+
# "check":{ "type":"grep","file":"posts/current.md","pattern":"TODO","absent":true } }]
|
|
112
|
+
# "guards": [{ "phase":"publish","bash":"git +push" }]
|
|
113
|
+
peaks hooks install --project . # 显式 opt-in,只写一条 PreToolUse 条目
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
此后 agent 正文里还留着 TODO 却想 `git push` 时,Claude Code 收到 `permissionDecision:"deny"`,命令**在任何权限检查之前被拦下——连 `--dangerously-skip-permissions` 都绕不过**。满足门禁后自动放行;紧急情况用 `peaks gate bypass --sop <id> --phase <phase> --reason "<原因>"` 一次性放行(每项目每 SOP 有上限、记原因)。
|
|
117
|
+
|
|
118
|
+
强制层**故障即放行**(fail-open):Peaks 自身任何内部错误都放行命令,绝不会卡死你的 Claude Code,只有真实门禁失败才拦。装 hook 是显式用户命令,skill 自己永不写 `settings.json`。`peaks hooks status` / `peaks hooks uninstall` 管理它。
|
|
119
|
+
|
|
120
|
+
**团队强制**:把 SOP 用 `peaks sop init/register --project <repo>` 落到**仓库里**(`<repo>/.peaks/sops/`,随仓库提交)。队友 clone 后——哪怕全局 `~/.peaks` 是空的——装上 hook 就被同一套门禁强制。SOP 定义分两层:仓库层(团队共享、随 PR 评审)优先,全局层(你个人跨项目复用)兜底。只放在全局的 SOP 只对你本机生效。
|
|
121
|
+
|
|
122
|
+
**两层定义、执行按项目**:SOP 定义(`sop.json` + 可注册的 `SKILL.md`)可放在**全局** `~/.peaks/sops/`(个人跨项目复用,`init`/`lint`/`register` 默认层)或**仓库** `<repo>/.peaks/sops/`(随仓库提交、团队共享,加 `--project <repo>`);同 id 时**仓库层优先**。运行态(当前阶段、历史)始终按项目落在 `<project>/.peaks/sop-state/<sop-id>/`,各项目独立进度。`check`/`advance` 带 `--project`(默认当前目录)指定对哪个项目执行、用哪层定义。
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
# 1. 创建 SOP 骨架到 ~/.peaks/sops(默认预览不落盘,--apply 才写入)
|
|
126
|
+
peaks sop init --id team-release --name "Team Release" --apply --json
|
|
127
|
+
|
|
128
|
+
# 2. 校验 manifest(门禁 id 唯一、阶段合法、check 字段完整)
|
|
129
|
+
peaks sop lint --id team-release --json
|
|
130
|
+
|
|
131
|
+
# 3. 注册进全局门禁注册表(--dry-run 预览)
|
|
132
|
+
peaks sop register --id team-release --json
|
|
133
|
+
|
|
134
|
+
# 4. 列出注册表里所有自定义门禁(内置 peaks-* 门禁永不出现)
|
|
135
|
+
peaks sop registry --json
|
|
136
|
+
|
|
137
|
+
# 5. 评估单个门禁(在某项目下,返回 pass / fail / blocked)
|
|
138
|
+
peaks sop check --id team-release --gate changelog --project . --json
|
|
139
|
+
|
|
140
|
+
# 6. 推进到某阶段——门禁须全过、且不能跳级,否则被真正阻断
|
|
141
|
+
peaks sop advance --id team-release --to ship --project . --json
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`sop.json` 示例:
|
|
145
|
+
|
|
146
|
+
```json
|
|
147
|
+
{
|
|
148
|
+
"id": "team-release",
|
|
149
|
+
"name": "Team Release",
|
|
150
|
+
"phases": ["draft", "review", "ship"],
|
|
151
|
+
"gates": [
|
|
152
|
+
{ "id": "changelog", "phase": "ship", "check": { "type": "file-exists", "path": "CHANGELOG.md" } },
|
|
153
|
+
{ "id": "no-fixme", "phase": "review", "check": { "type": "grep", "file": "src/index.ts", "pattern": "FIXME", "absent": true } },
|
|
154
|
+
{ "id": "tests", "phase": "ship", "check": { "type": "command", "run": ["npm", "test"] } }
|
|
155
|
+
]
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
门禁 check 支持三类:
|
|
160
|
+
|
|
161
|
+
| 类型 | 字段 | 含义 |
|
|
162
|
+
|------|------|------|
|
|
163
|
+
| `file-exists` | `path` | 文件存在 → pass |
|
|
164
|
+
| `grep` | `file` + `pattern`(+ `absent`) | 文件内匹配到正则 → pass;加 `absent: true` 则反转——匹配不到才 pass(表达「不准有 X」,纯文本、免 `--allow-commands`、跨平台) |
|
|
165
|
+
| `command` | `run`(参数数组)+ `expectExitZero` | 运行命令并按退出码判定 |
|
|
166
|
+
|
|
167
|
+
「不准有 TODO / 占位符 / 遗留项」这类最常见的诉求,优先用 `grep` + `absent: true`,不必动用 `command` 门禁。
|
|
168
|
+
|
|
169
|
+
安全约束:
|
|
170
|
+
- `command` 类门禁运行用户定义的命令,**默认拒绝**,必须显式加 `--allow-commands` 才会评估;命令以参数数组执行(无 shell、无注入面)、有超时上限、工作目录锁定项目根。
|
|
171
|
+
- `file-exists` / `grep` 的路径锁在项目根内,越界路径返回 `blocked`。
|
|
172
|
+
- 有副作用的命令(init/register/advance)都支持 `--dry-run` 预览且不落盘。
|
|
173
|
+
- `advance` 还会校验**阶段顺序**:可停留当前阶段、可回退,但不能越过下一个阶段跳级,跳级返回 `SOP_PHASE_SKIP`。
|
|
174
|
+
- 被门禁阻断或被判跳级时,可用 `--allow-incomplete --reason "<原因>"` 显式绕过(同时绕过门禁与顺序校验);assisted/strict 模式下还需 `--confirm`,且每个项目内每个 SOP 的绕过次数有上限。
|
|
175
|
+
|
|
82
176
|
## 许可
|
|
83
177
|
|
|
84
178
|
MIT License,详见 [LICENSE](LICENSE)。
|
|
@@ -19,7 +19,20 @@ export function registerCoreAndArtifactCommands(program, io) {
|
|
|
19
19
|
const result = report.summary.ok
|
|
20
20
|
? ok('doctor', report)
|
|
21
21
|
: fail('doctor', 'DOCTOR_FAILED', 'One or more doctor checks failed', report, ['Fix failed checks and rerun peaks doctor']);
|
|
22
|
-
|
|
22
|
+
if (options.json === true) {
|
|
23
|
+
printResult(io, result, true);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
// Human-readable: one line per check, green/red indicators, no JSON.
|
|
27
|
+
for (const check of report.checks) {
|
|
28
|
+
const icon = check.ok ? '+' : '×';
|
|
29
|
+
io.stdout(` ${icon} ${check.message}`);
|
|
30
|
+
}
|
|
31
|
+
io.stdout(`\n ${report.summary.passed} passed, ${report.summary.failed} failed`);
|
|
32
|
+
if (!report.summary.ok) {
|
|
33
|
+
io.stderr(`\nDOCTOR_FAILED: ${report.summary.failed} check(s) failed. Fix them and rerun peaks doctor.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
23
36
|
if (!report.summary.ok) {
|
|
24
37
|
process.exitCode = 1;
|
|
25
38
|
}
|
|
@@ -27,13 +40,44 @@ export function registerCoreAndArtifactCommands(program, io) {
|
|
|
27
40
|
const skill = program.command('skill').description('Manage Peaks skills');
|
|
28
41
|
addJsonOption(skill.command('list').description('List skills derived from skills/*/SKILL.md')).action(async (options) => {
|
|
29
42
|
const skills = await listSkills();
|
|
30
|
-
|
|
43
|
+
if (options.json === true) {
|
|
44
|
+
printResult(io, ok('skill.list', { skills }), true);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
const sorted = [...skills].sort((a, b) => {
|
|
48
|
+
if (a.name === 'peaks-sop')
|
|
49
|
+
return -1;
|
|
50
|
+
if (b.name === 'peaks-sop')
|
|
51
|
+
return 1;
|
|
52
|
+
if (a.name === 'peaks-solo')
|
|
53
|
+
return -1;
|
|
54
|
+
if (b.name === 'peaks-solo')
|
|
55
|
+
return 1;
|
|
56
|
+
return a.name.localeCompare(b.name);
|
|
57
|
+
});
|
|
58
|
+
for (const skill of sorted) {
|
|
59
|
+
io.stdout(` ${skill.name.padEnd(14)}${skill.description}`);
|
|
60
|
+
}
|
|
61
|
+
io.stdout(`\n Invoke any skill by typing its name in conversation (e.g. \`peaks-sop\`).`);
|
|
62
|
+
}
|
|
31
63
|
});
|
|
32
64
|
addJsonOption(skill.command('doctor').description('Run skill-related doctor checks')).action(async (options) => {
|
|
33
65
|
const report = await runDoctor();
|
|
34
66
|
const skillChecks = report.checks.filter((check) => check.id.startsWith('skill'));
|
|
35
67
|
const failed = skillChecks.filter((check) => !check.ok).length;
|
|
36
|
-
|
|
68
|
+
if (options.json === true) {
|
|
69
|
+
printResult(io, ok('skill.doctor', { checks: skillChecks, ok: failed === 0 }), true);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
for (const check of skillChecks) {
|
|
73
|
+
const icon = check.ok ? '+' : '×';
|
|
74
|
+
io.stdout(` ${icon} ${check.message}`);
|
|
75
|
+
}
|
|
76
|
+
io.stdout(`\n ${skillChecks.length - failed} passed, ${failed} failed`);
|
|
77
|
+
if (failed > 0) {
|
|
78
|
+
io.stderr('\nOne or more skill checks failed.');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
37
81
|
if (failed > 0) {
|
|
38
82
|
process.exitCode = 1;
|
|
39
83
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { enforceBashCommand, recordGateBypass, GateBypassError } from '../../services/sop/gate-enforce-service.js';
|
|
2
|
+
import { fail, ok } from '../../shared/result.js';
|
|
3
|
+
import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
|
|
4
|
+
/** Read the PreToolUse hook payload. `PEAKS_HOOK_STDIN` is a test seam; production reads stdin. */
|
|
5
|
+
async function readHookPayload() {
|
|
6
|
+
const override = process.env.PEAKS_HOOK_STDIN;
|
|
7
|
+
if (override !== undefined) {
|
|
8
|
+
return override;
|
|
9
|
+
}
|
|
10
|
+
if (process.stdin.isTTY) {
|
|
11
|
+
return '';
|
|
12
|
+
}
|
|
13
|
+
return new Promise((resolveStdin) => {
|
|
14
|
+
let data = '';
|
|
15
|
+
process.stdin.setEncoding('utf8');
|
|
16
|
+
process.stdin.on('data', (chunk) => {
|
|
17
|
+
data += chunk;
|
|
18
|
+
});
|
|
19
|
+
process.stdin.on('end', () => resolveStdin(data));
|
|
20
|
+
process.stdin.on('error', () => resolveStdin(data));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function emitDeny(io, reason) {
|
|
24
|
+
// The exact, verified PreToolUse decision shape. permissionDecision:"deny"
|
|
25
|
+
// blocks the tool call before Claude Code's permission checks (un-bypassable).
|
|
26
|
+
io.stdout(JSON.stringify({
|
|
27
|
+
hookSpecificOutput: {
|
|
28
|
+
hookEventName: 'PreToolUse',
|
|
29
|
+
permissionDecision: 'deny',
|
|
30
|
+
permissionDecisionReason: reason
|
|
31
|
+
}
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
export function registerGateCommands(program, io) {
|
|
35
|
+
const gate = program.command('gate').description('SOP gate enforcement (PreToolUse hook handler and bypass)');
|
|
36
|
+
addJsonOption(gate
|
|
37
|
+
.command('enforce')
|
|
38
|
+
.description('PreToolUse hook handler: deny a Bash command guarded by an unsatisfied SOP gate')
|
|
39
|
+
.option('--project <path>', 'project the gates evaluate against (default: current directory)', '.')).action(async (options) => {
|
|
40
|
+
// Trust red line: this runs on (potentially) every Bash call. Any failure to
|
|
41
|
+
// decide must FAIL-OPEN (allow), never block the user's Claude Code.
|
|
42
|
+
try {
|
|
43
|
+
const raw = await readHookPayload();
|
|
44
|
+
let command;
|
|
45
|
+
let toolName;
|
|
46
|
+
if (raw.trim().length > 0) {
|
|
47
|
+
const payload = JSON.parse(raw);
|
|
48
|
+
toolName = payload.tool_name;
|
|
49
|
+
command = payload.tool_input?.command;
|
|
50
|
+
}
|
|
51
|
+
if (toolName !== 'Bash' || typeof command !== 'string' || command.trim().length === 0) {
|
|
52
|
+
// Not a guarded surface — allow (no output = normal permission flow).
|
|
53
|
+
if (options.json === true) {
|
|
54
|
+
printResult(io, ok('gate.enforce', { decision: 'allow', skipped: true }), true);
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const decision = await enforceBashCommand(options.project, command);
|
|
59
|
+
if (decision.decision === 'deny') {
|
|
60
|
+
emitDeny(io, decision.reason);
|
|
61
|
+
if (options.json === true) {
|
|
62
|
+
io.stderr(JSON.stringify(ok('gate.enforce', decision)));
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (decision.warnings && decision.warnings.length > 0) {
|
|
67
|
+
for (const warning of decision.warnings) {
|
|
68
|
+
io.stderr(warning);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (options.json === true) {
|
|
72
|
+
io.stderr(JSON.stringify(ok('gate.enforce', decision)));
|
|
73
|
+
}
|
|
74
|
+
// allow: emit nothing on stdout → normal permission flow.
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
// Fail-open: a bug in enforcement must not brick Claude Code.
|
|
78
|
+
io.stderr(`gate enforce: internal error, allowing command (${getErrorMessage(error)})`);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
addJsonOption(gate
|
|
82
|
+
.command('bypass')
|
|
83
|
+
.description('Record a one-shot bypass so the next guarded Bash command is allowed once')
|
|
84
|
+
.requiredOption('--sop <id>', 'SOP id whose guard to bypass')
|
|
85
|
+
.requiredOption('--phase <phase>', 'phase whose gate to bypass')
|
|
86
|
+
.requiredOption('--reason <text>', 'justification recorded for the bypass')
|
|
87
|
+
.option('--project <path>', 'project whose run-state holds the token (default: current directory)', '.')).action((options) => {
|
|
88
|
+
try {
|
|
89
|
+
if (options.reason.trim().length === 0) {
|
|
90
|
+
printResult(io, fail('gate.bypass', 'BYPASS_REASON_REQUIRED', '--reason must not be empty', { sop: options.sop, phase: options.phase }, ['Provide --reason "<why>"']), options.json);
|
|
91
|
+
process.exitCode = 1;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const result = recordGateBypass(options.project, options.sop, options.phase, options.reason);
|
|
95
|
+
printResult(io, ok('gate.bypass', { sop: options.sop, phase: options.phase, count: result.count }, [], ['The next guarded Bash command for this transition will be allowed once']), options.json);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
const code = error instanceof GateBypassError ? error.code : 'GATE_BYPASS_FAILED';
|
|
99
|
+
printResult(io, fail('gate.bypass', code, getErrorMessage(error), { sop: options.sop, phase: options.phase }, ['Satisfy the gate instead of bypassing']), options.json);
|
|
100
|
+
process.exitCode = 1;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { fail, ok } from '../../shared/result.js';
|
|
2
|
+
import { addJsonOption, printResult, getErrorMessage } from '../cli-helpers.js';
|
|
3
|
+
import { findProjectRoot } from '../../services/config/config-safety.js';
|
|
4
|
+
import { applyHookInstall, planHookInstall, readHookStatus, removeHookInstall } from '../../services/skills/hooks-settings-service.js';
|
|
5
|
+
function resolveScope(options) {
|
|
6
|
+
return options.global ? 'global' : 'project';
|
|
7
|
+
}
|
|
8
|
+
function resolveProjectRoot(scope, project) {
|
|
9
|
+
return scope === 'project' ? (project ?? findProjectRoot(process.cwd()) ?? process.cwd()) : undefined;
|
|
10
|
+
}
|
|
11
|
+
export function registerHooksCommands(program, io) {
|
|
12
|
+
const hooks = program
|
|
13
|
+
.command('hooks')
|
|
14
|
+
.description('Manage the Peaks gate-enforcement PreToolUse hook in .claude/settings.json');
|
|
15
|
+
addJsonOption(hooks
|
|
16
|
+
.command('install')
|
|
17
|
+
.description('Install the un-bypassable SOP gate hook (project scope by default)')
|
|
18
|
+
.option('--global', 'install into the user-level ~/.claude/settings.json instead of the project')
|
|
19
|
+
.option('--project <path>', 'project root path (auto-detected from cwd when omitted)')
|
|
20
|
+
.option('--dry-run', 'show what would change without writing')).action((options) => {
|
|
21
|
+
const scope = resolveScope(options);
|
|
22
|
+
const projectRoot = resolveProjectRoot(scope, options.project);
|
|
23
|
+
try {
|
|
24
|
+
if (options.dryRun === true) {
|
|
25
|
+
const plan = planHookInstall(scope, projectRoot);
|
|
26
|
+
printResult(io, ok('hooks.install', { ...plan, applied: false, dryRun: true }), options.json);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const result = applyHookInstall(scope, projectRoot);
|
|
30
|
+
const nextActions = result.applied
|
|
31
|
+
? ['Restart Claude Code (or reload the window) so the gate hook takes effect']
|
|
32
|
+
: [];
|
|
33
|
+
printResult(io, ok('hooks.install', { ...result, dryRun: false }, [], nextActions), options.json);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
const message = getErrorMessage(error);
|
|
37
|
+
printResult(io, fail('hooks.install', 'HOOKS_INSTALL_FAILED', message, { scope, applied: false }, [message]), options.json);
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
addJsonOption(hooks
|
|
42
|
+
.command('uninstall')
|
|
43
|
+
.description('Remove the Peaks gate-enforcement hook from .claude/settings.json')
|
|
44
|
+
.option('--global', 'remove from the user-level ~/.claude/settings.json instead of the project')
|
|
45
|
+
.option('--project <path>', 'project root path (auto-detected from cwd when omitted)')).action((options) => {
|
|
46
|
+
const scope = resolveScope(options);
|
|
47
|
+
const projectRoot = resolveProjectRoot(scope, options.project);
|
|
48
|
+
try {
|
|
49
|
+
const result = removeHookInstall(scope, projectRoot);
|
|
50
|
+
printResult(io, ok('hooks.uninstall', result), options.json);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
const message = getErrorMessage(error);
|
|
54
|
+
printResult(io, fail('hooks.uninstall', 'HOOKS_UNINSTALL_FAILED', message, { scope, removed: false }, [message]), options.json);
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
addJsonOption(hooks
|
|
59
|
+
.command('status')
|
|
60
|
+
.description('Report whether the Peaks gate hook is installed')
|
|
61
|
+
.option('--global', 'inspect the user-level ~/.claude/settings.json instead of the project')
|
|
62
|
+
.option('--project <path>', 'project root path (auto-detected from cwd when omitted)')).action((options) => {
|
|
63
|
+
const scope = resolveScope(options);
|
|
64
|
+
const projectRoot = resolveProjectRoot(scope, options.project);
|
|
65
|
+
try {
|
|
66
|
+
const status = readHookStatus(scope, projectRoot);
|
|
67
|
+
printResult(io, ok('hooks.status', status), options.json);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
const message = getErrorMessage(error);
|
|
71
|
+
printResult(io, fail('hooks.status', 'HOOKS_STATUS_FAILED', message, { scope }, [message]), options.json);
|
|
72
|
+
process.exitCode = 1;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -146,7 +146,7 @@ export function registerRequestCommands(program, io) {
|
|
|
146
146
|
// Restrict --allow-incomplete in assisted/strict modes: require --confirm
|
|
147
147
|
if (options.allowIncomplete === true && options.forceConfirm !== true) {
|
|
148
148
|
const { getSkillPresence } = await import('../../services/skills/skill-presence-service.js');
|
|
149
|
-
const presence = getSkillPresence();
|
|
149
|
+
const presence = getSkillPresence(options.project);
|
|
150
150
|
if (presence?.mode === 'assisted' || presence?.mode === 'strict') {
|
|
151
151
|
if (options.confirm !== true) {
|
|
152
152
|
printResult(io, fail('request.transition', 'ALLOW_INCOMPLETE_RESTRICTED', `--allow-incomplete requires --confirm in ${presence.mode} mode`, { role, requestId, mode: presence.mode }, ['Add --confirm to proceed non-interactively, or run in an interactive terminal.']), options.json);
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { initSop, lintSop } from '../../services/sop/sop-service.js';
|
|
3
|
+
import { registerSop, readRegistry, SopRegisterError } from '../../services/sop/sop-registry-service.js';
|
|
4
|
+
import { checkGate, SopCheckError } from '../../services/sop/sop-check-service.js';
|
|
5
|
+
import { advanceSop, SopAdvanceError, SopGateBlockedError, SopPhaseSkipError } from '../../services/sop/sop-advance-service.js';
|
|
6
|
+
import { sopStateDir } from '../../services/sop/sop-paths.js';
|
|
7
|
+
import { getSkillPresence } from '../../services/skills/skill-presence-service.js';
|
|
8
|
+
import { recordBypass, isBypassLimitReached, MAX_BYPASSES_PER_SESSION } from '../../services/mode/bypass-tracker.js';
|
|
9
|
+
import { fail, ok } from '../../shared/result.js';
|
|
10
|
+
import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
|
|
11
|
+
export function registerSopCommands(program, io) {
|
|
12
|
+
const sop = program.command('sop').description('Author and validate user-defined SOP skills');
|
|
13
|
+
addJsonOption(sop
|
|
14
|
+
.command('init')
|
|
15
|
+
.description('Scaffold a user-authored SOP (manifest + SKILL.md); global by default, --project commits it into a repo')
|
|
16
|
+
.requiredOption('--id <sop-id>', 'SOP id (lowercase kebab, e.g. team-release)')
|
|
17
|
+
.option('--name <name>', 'human-readable SOP name (defaults to the id)')
|
|
18
|
+
.option('--apply', 'write the SOP files (default: preview only)')
|
|
19
|
+
.option('--project <path>', 'scaffold into the repo (<path>/.peaks/sops, committed & team-shared) instead of global')).action(async (options) => {
|
|
20
|
+
try {
|
|
21
|
+
const initOptions = { id: options.id };
|
|
22
|
+
if (options.name !== undefined) {
|
|
23
|
+
initOptions.name = options.name;
|
|
24
|
+
}
|
|
25
|
+
if (options.apply === true) {
|
|
26
|
+
initOptions.apply = true;
|
|
27
|
+
}
|
|
28
|
+
if (options.project !== undefined) {
|
|
29
|
+
initOptions.projectRoot = options.project;
|
|
30
|
+
}
|
|
31
|
+
const result = await initSop(initOptions);
|
|
32
|
+
// Side-effecting scaffold returns explicit next steps so the user doesn't
|
|
33
|
+
// have to recall the runbook: applied → edit then lint; preview → apply.
|
|
34
|
+
const nextActions = result.applied
|
|
35
|
+
? [
|
|
36
|
+
`Edit ${result.manifestPath} to define your real phases and gates`,
|
|
37
|
+
`peaks sop lint --id ${result.id} --json`
|
|
38
|
+
]
|
|
39
|
+
: [`Re-run with --apply to write ${result.manifestPath}`];
|
|
40
|
+
printResult(io, ok('sop.init', result, [], nextActions), options.json);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
printResult(io, fail('sop.init', 'SOP_INIT_FAILED', getErrorMessage(error), { id: options.id }, ['Check the SOP id before retrying']), options.json);
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
addJsonOption(sop
|
|
48
|
+
.command('lint')
|
|
49
|
+
.description('Validate a SOP manifest (id namespace, phases, gate ids, check fields)')
|
|
50
|
+
.requiredOption('--id <sop-id>', 'SOP id to lint')
|
|
51
|
+
.option('--allow-commands', 'permit command-type gates (they run shell-less processes)')
|
|
52
|
+
.option('--project <path>', 'lint the project-layer SOP (<path>/.peaks/sops) instead of global')).action(async (options) => {
|
|
53
|
+
try {
|
|
54
|
+
const lintOptions = { id: options.id };
|
|
55
|
+
if (options.allowCommands === true) {
|
|
56
|
+
lintOptions.allowCommands = true;
|
|
57
|
+
}
|
|
58
|
+
if (options.project !== undefined) {
|
|
59
|
+
lintOptions.projectRoot = options.project;
|
|
60
|
+
}
|
|
61
|
+
const result = await lintSop(lintOptions);
|
|
62
|
+
if (result === null) {
|
|
63
|
+
printResult(io, fail('sop.lint', 'SOP_NOT_FOUND', `No SOP found for id "${options.id}"`, { id: options.id }, ['Run peaks sop init --id <sop-id> --apply first']), options.json);
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
printResult(io, result.ok
|
|
68
|
+
? ok('sop.lint', result)
|
|
69
|
+
: fail('sop.lint', 'SOP_LINT_FAILED', `${result.findings.filter((f) => f.severity === 'error').length} lint error(s) in SOP "${options.id}"`, result, ['Fix the reported findings, then re-run peaks sop lint']), options.json);
|
|
70
|
+
if (!result.ok) {
|
|
71
|
+
process.exitCode = 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
printResult(io, fail('sop.lint', 'SOP_LINT_ERROR', getErrorMessage(error), { id: options.id }, ['Check the SOP id before retrying']), options.json);
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
addJsonOption(sop
|
|
80
|
+
.command('register')
|
|
81
|
+
.description('Validate a SOP and record its gates in the gate registry (global, or --project for the repo)')
|
|
82
|
+
.requiredOption('--id <sop-id>', 'SOP id to register')
|
|
83
|
+
.option('--allow-commands', 'permit command-type gates when validating')
|
|
84
|
+
.option('--dry-run', 'preview the registration without writing registry.json')
|
|
85
|
+
.option('--project <path>', 'register into the repo (<path>/.peaks/sops, committed & team-shared) instead of global')).action(async (options) => {
|
|
86
|
+
try {
|
|
87
|
+
const registerOptions = { id: options.id };
|
|
88
|
+
if (options.allowCommands === true) {
|
|
89
|
+
registerOptions.allowCommands = true;
|
|
90
|
+
}
|
|
91
|
+
if (options.dryRun === true) {
|
|
92
|
+
registerOptions.dryRun = true;
|
|
93
|
+
}
|
|
94
|
+
if (options.project !== undefined) {
|
|
95
|
+
registerOptions.projectRoot = options.project;
|
|
96
|
+
}
|
|
97
|
+
const result = await registerSop(registerOptions);
|
|
98
|
+
printResult(io, ok('sop.register', result, [], result.applied ? [] : ['Re-run without --dry-run to write registry.json']), options.json);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
const code = error instanceof SopRegisterError ? error.code : 'SOP_REGISTER_FAILED';
|
|
102
|
+
printResult(io, fail('sop.register', code, getErrorMessage(error), { id: options.id }, ['Run peaks sop lint to see why the SOP is not registrable']), options.json);
|
|
103
|
+
process.exitCode = 1;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
addJsonOption(sop
|
|
107
|
+
.command('registry')
|
|
108
|
+
.description('List registered SOPs and gates (global; --project merges in the repo layer)')
|
|
109
|
+
.option('--project <path>', 'also include and prefer the repo layer (<path>/.peaks/sops)')).action(async (options) => {
|
|
110
|
+
try {
|
|
111
|
+
const registry = await readRegistry(options.project);
|
|
112
|
+
printResult(io, ok('sop.registry', registry), options.json);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
printResult(io, fail('sop.registry', 'SOP_REGISTRY_FAILED', getErrorMessage(error), {}, ['The global registry may be corrupted; inspect ~/.peaks/sops/registry.json']), options.json);
|
|
116
|
+
process.exitCode = 1;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
addJsonOption(sop
|
|
120
|
+
.command('check')
|
|
121
|
+
.description('Evaluate a single SOP gate (returns pass / fail / blocked)')
|
|
122
|
+
.requiredOption('--id <sop-id>', 'SOP id')
|
|
123
|
+
.requiredOption('--gate <gate-id>', 'gate id within the SOP')
|
|
124
|
+
.option('--project <path>', 'project the gate evaluates against (default: current directory)', '.')
|
|
125
|
+
.option('--allow-commands', 'permit evaluating command-type gates')).action(async (options) => {
|
|
126
|
+
try {
|
|
127
|
+
const checkOptions = { projectRoot: options.project, id: options.id, gateId: options.gate };
|
|
128
|
+
if (options.allowCommands === true) {
|
|
129
|
+
checkOptions.allowCommands = true;
|
|
130
|
+
}
|
|
131
|
+
const result = await checkGate(checkOptions);
|
|
132
|
+
printResult(io, ok('sop.check', result), options.json);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
const code = error instanceof SopCheckError ? error.code : 'SOP_CHECK_FAILED';
|
|
136
|
+
printResult(io, fail('sop.check', code, getErrorMessage(error), { id: options.id, gateId: options.gate }, ['Verify the SOP id and gate id with peaks sop lint']), options.json);
|
|
137
|
+
process.exitCode = 1;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
addJsonOption(sop
|
|
141
|
+
.command('advance')
|
|
142
|
+
.description('Advance a SOP to a phase; gates guarding that phase must pass (or be explicitly bypassed)')
|
|
143
|
+
.requiredOption('--id <sop-id>', 'SOP id')
|
|
144
|
+
.requiredOption('--to <phase>', 'phase to advance into')
|
|
145
|
+
.option('--project <path>', 'project whose run-state advances (default: current directory)', '.')
|
|
146
|
+
.option('--allow-commands', 'permit evaluating command-type gates')
|
|
147
|
+
.option('--allow-incomplete', 'bypass the phase gates AND phase-order check (requires --reason)')
|
|
148
|
+
.option('--reason <text>', 'justification recorded when bypassing gates')
|
|
149
|
+
.option('--confirm', 'skip interactive confirmation for a bypass in assisted/strict mode')
|
|
150
|
+
.option('--force-confirm', 'bypass mode-enforced confirmation (use with caution)')
|
|
151
|
+
.option('--dry-run', 'evaluate gates without recording the advance in state.json')).action(async (options) => {
|
|
152
|
+
try {
|
|
153
|
+
// Bypass policy mirrors `request transition`: a bypass needs a reason, and
|
|
154
|
+
// in assisted/strict mode (resolved from the target project) it needs an
|
|
155
|
+
// explicit --confirm and counts against the per-SOP bypass cap.
|
|
156
|
+
if (options.allowIncomplete === true && (options.reason === undefined || options.reason.trim().length === 0)) {
|
|
157
|
+
printResult(io, fail('sop.advance', 'BYPASS_REASON_REQUIRED', '--allow-incomplete requires --reason explaining why the gates are skipped', { id: options.id, to: options.to }, ['Add --reason "<short justification>" or satisfy the gates']), options.json);
|
|
158
|
+
process.exitCode = 1;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (options.allowIncomplete === true && options.forceConfirm !== true) {
|
|
162
|
+
const presence = getSkillPresence(options.project);
|
|
163
|
+
if (presence?.mode === 'assisted' || presence?.mode === 'strict') {
|
|
164
|
+
if (options.confirm !== true) {
|
|
165
|
+
printResult(io, fail('sop.advance', 'ALLOW_INCOMPLETE_RESTRICTED', `--allow-incomplete requires --confirm in ${presence.mode} mode`, { id: options.id, mode: presence.mode }, ['Add --confirm to bypass non-interactively']), options.json);
|
|
166
|
+
process.exitCode = 1;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// The bypass counter is keyed to the per-project SOP run-state dir (not
|
|
170
|
+
// a session); the shared cap constant is reused. Keying it per-project
|
|
171
|
+
// means a bypass in one project never consumes another project's budget.
|
|
172
|
+
const bypassRoot = sopStateDir(options.project, options.id);
|
|
173
|
+
// The per-project state dir may not exist yet (no successful advance has
|
|
174
|
+
// written state.json), so ensure it before the counter writes its file.
|
|
175
|
+
mkdirSync(bypassRoot, { recursive: true });
|
|
176
|
+
if (isBypassLimitReached(bypassRoot)) {
|
|
177
|
+
printResult(io, fail('sop.advance', 'BYPASS_LIMIT_REACHED', `gate bypass limit reached (${MAX_BYPASSES_PER_SESSION} bypasses per SOP)`, { id: options.id, limit: MAX_BYPASSES_PER_SESSION }, ['Satisfy the gates instead of bypassing']), options.json);
|
|
178
|
+
process.exitCode = 1;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// A dry-run preview must not consume a bypass.
|
|
182
|
+
if (options.dryRun !== true) {
|
|
183
|
+
recordBypass(bypassRoot);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const advanceOptions = { projectRoot: options.project, id: options.id, toPhase: options.to };
|
|
188
|
+
if (options.allowCommands === true)
|
|
189
|
+
advanceOptions.allowCommands = true;
|
|
190
|
+
if (options.allowIncomplete === true)
|
|
191
|
+
advanceOptions.allowIncomplete = true;
|
|
192
|
+
if (options.reason !== undefined)
|
|
193
|
+
advanceOptions.reason = options.reason;
|
|
194
|
+
if (options.dryRun === true)
|
|
195
|
+
advanceOptions.dryRun = true;
|
|
196
|
+
const result = await advanceSop(advanceOptions);
|
|
197
|
+
printResult(io, ok('sop.advance', result, [], result.applied ? [] : ['Gates passed; re-run without --dry-run to record the advance']), options.json);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (error instanceof SopGateBlockedError) {
|
|
201
|
+
printResult(io, fail('sop.advance', error.code, error.message, { id: options.id, to: options.to, blockedGates: error.blockedGates }, ['Satisfy the blocking gates, or bypass with --allow-incomplete --reason "<why>"']), options.json);
|
|
202
|
+
process.exitCode = 1;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (error instanceof SopPhaseSkipError) {
|
|
206
|
+
printResult(io, fail('sop.advance', error.code, error.message, { id: options.id, to: options.to, fromPhase: error.fromPhase, expectedNext: error.expectedNext }, [`Advance to "${error.expectedNext}" first, or bypass with --allow-incomplete --reason "<why>"`]), options.json);
|
|
207
|
+
process.exitCode = 1;
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const code = error instanceof SopAdvanceError ? error.code : 'SOP_ADVANCE_FAILED';
|
|
211
|
+
printResult(io, fail('sop.advance', code, getErrorMessage(error), { id: options.id, to: options.to }, ['Verify the SOP id and phase with peaks sop lint']), options.json);
|
|
212
|
+
process.exitCode = 1;
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
package/dist/src/cli/index.js
CHANGED
|
@@ -5,6 +5,18 @@ createProgram().parseAsync(process.argv).catch((error) => {
|
|
|
5
5
|
if (error instanceof CommanderError && error.code === 'commander.version') {
|
|
6
6
|
return;
|
|
7
7
|
}
|
|
8
|
+
// exitOverride() also throws for help; suppress those — the text already went
|
|
9
|
+
// to stdout/stderr, the error envelope confuses newcomers. --help is success
|
|
10
|
+
// (exit 0); a bad command/option is an error (exit 1).
|
|
11
|
+
if (error instanceof CommanderError) {
|
|
12
|
+
if (error.code === 'commander.help' || error.code === 'commander.helpDisplayed') {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (error.code === 'commander.missingArgument' || error.code === 'commander.unknownCommand' || error.code === 'commander.unknownOption') {
|
|
16
|
+
process.exitCode = 1;
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
8
20
|
console.error(JSON.stringify({
|
|
9
21
|
ok: false,
|
|
10
22
|
command: 'cli',
|