peaks-cli 1.1.2 → 1.2.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/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/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/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/sop-manifest.schema.json +52 -0
- package/skills/peaks-sop/SKILL.md +192 -0
- package/skills/peaks-sop/references/sop-authoring.md +161 -0
package/dist/src/cli/program.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { readdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
1
3
|
import { Command } from 'commander';
|
|
4
|
+
import { skillsDir } from '../shared/paths.js';
|
|
2
5
|
import { CLI_VERSION } from '../shared/version.js';
|
|
3
6
|
import { registerCoreAndArtifactCommands } from './commands/core-artifact-commands.js';
|
|
4
7
|
import { registerWorkflowCommands } from './commands/workflow-commands.js';
|
|
@@ -10,6 +13,9 @@ import { registerProjectCommands } from './commands/project-commands.js';
|
|
|
10
13
|
import { registerRequestCommands } from './commands/request-commands.js';
|
|
11
14
|
import { registerScanCommands } from './commands/scan-commands.js';
|
|
12
15
|
import { registerShadcnCommands } from './commands/shadcn-commands.js';
|
|
16
|
+
import { registerSopCommands } from './commands/sop-commands.js';
|
|
17
|
+
import { registerGateCommands } from './commands/gate-commands.js';
|
|
18
|
+
import { registerHooksCommands } from './commands/hooks-commands.js';
|
|
13
19
|
import { registerStatusLineCommands } from './commands/statusline-commands.js';
|
|
14
20
|
import { registerUnderstandCommands } from './commands/understand-commands.js';
|
|
15
21
|
import { registerWorkspaceCommands } from './commands/workspace-commands.js';
|
|
@@ -18,7 +24,14 @@ export function createProgram(io = { stdout: (text) => console.log(text), stderr
|
|
|
18
24
|
const program = new Command();
|
|
19
25
|
program
|
|
20
26
|
.name('peaks')
|
|
21
|
-
.description(
|
|
27
|
+
.description(`Peaks CLI ${CLI_VERSION} — workflow-gating CLI + skill family for Claude Code
|
|
28
|
+
|
|
29
|
+
Run peaks (no arguments) for a quickstart. You likely want one of:
|
|
30
|
+
peaks doctor check your environment
|
|
31
|
+
peaks skill list or manage skills
|
|
32
|
+
peaks sop author your own workflow gates
|
|
33
|
+
peaks hooks install the un-bypassable gate-enforcement hook
|
|
34
|
+
peaks gate enforce/bypass SOP gates on Bash commands`)
|
|
22
35
|
.configureOutput({
|
|
23
36
|
writeOut: (text) => io.stdout(text.trimEnd()),
|
|
24
37
|
writeErr: (text) => io.stderr(text.trimEnd())
|
|
@@ -26,9 +39,38 @@ export function createProgram(io = { stdout: (text) => console.log(text), stderr
|
|
|
26
39
|
.version(CLI_VERSION, '-v, --version')
|
|
27
40
|
.option('-V', 'output the version number')
|
|
28
41
|
.action(() => {
|
|
29
|
-
|
|
42
|
+
const opts = program.opts();
|
|
43
|
+
if (opts.V) {
|
|
30
44
|
io.stdout(CLI_VERSION);
|
|
45
|
+
return;
|
|
31
46
|
}
|
|
47
|
+
// Count bundled skills by reading the skills dir directly (synchronous so
|
|
48
|
+
// the quickstart renders instantly — no import/async overhead on startup).
|
|
49
|
+
let skillCount = 0;
|
|
50
|
+
const skillsPath = skillsDir;
|
|
51
|
+
try {
|
|
52
|
+
if (existsSync(skillsPath)) {
|
|
53
|
+
skillCount = readdirSync(skillsPath, { withFileTypes: true })
|
|
54
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
55
|
+
.filter((entry) => existsSync(join(skillsPath, entry.name, 'SKILL.md')))
|
|
56
|
+
.length;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch { /* disk read is best-effort; zero skills is still truthful */ }
|
|
60
|
+
io.stdout(`Peaks CLI ${CLI_VERSION} · ${skillCount} skills ready
|
|
61
|
+
|
|
62
|
+
Peaks is a workflow-gating CLI + skill family for Claude Code.
|
|
63
|
+
It turns "don't skip steps" into hard enforcement — gates that block
|
|
64
|
+
advancement in-conversation, un-bypassably.
|
|
65
|
+
|
|
66
|
+
Before diving into a project, two things worth doing now:
|
|
67
|
+
|
|
68
|
+
peaks doctor check your environment in one glance
|
|
69
|
+
peaks-sop <<< ask this skill to author your first SOP
|
|
70
|
+
|
|
71
|
+
Or jump straight in:
|
|
72
|
+
peaks sop init --id my-flow --apply && peaks hooks install
|
|
73
|
+
`);
|
|
32
74
|
})
|
|
33
75
|
.exitOverride();
|
|
34
76
|
registerCoreAndArtifactCommands(program, io);
|
|
@@ -41,6 +83,9 @@ export function createProgram(io = { stdout: (text) => console.log(text), stderr
|
|
|
41
83
|
registerRequestCommands(program, io);
|
|
42
84
|
registerScanCommands(program, io);
|
|
43
85
|
registerShadcnCommands(program, io);
|
|
86
|
+
registerSopCommands(program, io);
|
|
87
|
+
registerGateCommands(program, io);
|
|
88
|
+
registerHooksCommands(program, io);
|
|
44
89
|
registerStatusLineCommands(program, io);
|
|
45
90
|
registerUnderstandCommands(program, io);
|
|
46
91
|
registerWorkspaceCommands(program, io);
|
|
@@ -74,8 +74,10 @@ function buildCapabilitiesSummary(sampleSize) {
|
|
|
74
74
|
}))
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
|
-
function buildSkillPresenceSummary(presence) {
|
|
78
|
-
|
|
77
|
+
function buildSkillPresenceSummary(presence, projectRoot) {
|
|
78
|
+
// When the caller doesn't supply presence, resolve it from the dashboard's
|
|
79
|
+
// project root rather than the process cwd.
|
|
80
|
+
const resolved = presence === undefined ? getSkillPresence(projectRoot) : presence;
|
|
79
81
|
if (resolved === null) {
|
|
80
82
|
return { active: false, fresh: true };
|
|
81
83
|
}
|
|
@@ -126,6 +128,6 @@ export async function loadProjectDashboard(options) {
|
|
|
126
128
|
doctor: doctorAndRunbook.doctor,
|
|
127
129
|
runbookHealth: doctorAndRunbook.runbookHealth,
|
|
128
130
|
capabilities: buildCapabilitiesSummary(sampleSize),
|
|
129
|
-
skillPresence: buildSkillPresenceSummary(options.skillPresence)
|
|
131
|
+
skillPresence: buildSkillPresenceSummary(options.skillPresence, options.projectRoot)
|
|
130
132
|
};
|
|
131
133
|
}
|
|
@@ -31,7 +31,8 @@ export class ConfirmationRequiredError extends Error {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
export async function requireUserConfirmation(options) {
|
|
34
|
-
|
|
34
|
+
// Resolve presence from the project being operated on, not the process cwd.
|
|
35
|
+
const presence = getSkillPresence(options.projectRoot);
|
|
35
36
|
if (!presence?.mode) {
|
|
36
37
|
return;
|
|
37
38
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installs (and removes) the Peaks gate-enforcement PreToolUse hook in a Claude
|
|
3
|
+
* Code settings.json. The hook runs `peaks gate enforce` before every Bash call;
|
|
4
|
+
* when a SOP guard's gates fail it returns `permissionDecision: "deny"`, which
|
|
5
|
+
* blocks the tool call BEFORE Claude Code's permission checks — making the gate
|
|
6
|
+
* un-bypassable by the agent (it holds even under --dangerously-skip-permissions).
|
|
7
|
+
*
|
|
8
|
+
* Installation is an EXPLICIT user command (never postinstall): skills describe,
|
|
9
|
+
* the CLI performs side effects. Writes preserve all other settings keys and any
|
|
10
|
+
* other hooks, reject symlinked targets, and use an atomic rename so a partial
|
|
11
|
+
* write can never corrupt the settings file. Our entry is merged into (not
|
|
12
|
+
* replacing) the existing `hooks.PreToolUse` array and is identified by a
|
|
13
|
+
* sentinel substring in its command, so install is idempotent and uninstall
|
|
14
|
+
* removes only our own entry.
|
|
15
|
+
*/
|
|
16
|
+
export type HookScope = 'project' | 'global';
|
|
17
|
+
/** The hook command written into settings. `${CLAUDE_PROJECT_DIR}` is injected by Claude Code. */
|
|
18
|
+
export declare const HOOK_ENFORCE_COMMAND = "peaks gate enforce --project \"${CLAUDE_PROJECT_DIR}\"";
|
|
19
|
+
/** Substring that identifies a Peaks-managed PreToolUse hook entry. */
|
|
20
|
+
export declare const HOOK_SENTINEL = "peaks gate enforce";
|
|
21
|
+
export type HookInstallPlan = {
|
|
22
|
+
scope: HookScope;
|
|
23
|
+
settingsPath: string;
|
|
24
|
+
exists: boolean;
|
|
25
|
+
alreadyInstalled: boolean;
|
|
26
|
+
desiredCommand: string;
|
|
27
|
+
};
|
|
28
|
+
export type HookInstallResult = HookInstallPlan & {
|
|
29
|
+
applied: boolean;
|
|
30
|
+
};
|
|
31
|
+
export type HookRemoveResult = {
|
|
32
|
+
scope: HookScope;
|
|
33
|
+
settingsPath: string;
|
|
34
|
+
removed: boolean;
|
|
35
|
+
};
|
|
36
|
+
export type HookStatus = {
|
|
37
|
+
scope: HookScope;
|
|
38
|
+
settingsPath: string;
|
|
39
|
+
exists: boolean;
|
|
40
|
+
installed: boolean;
|
|
41
|
+
};
|
|
42
|
+
export declare function planHookInstall(scope: HookScope, projectRoot?: string): HookInstallPlan;
|
|
43
|
+
export declare function applyHookInstall(scope: HookScope, projectRoot?: string): HookInstallResult;
|
|
44
|
+
export declare function removeHookInstall(scope: HookScope, projectRoot?: string): HookRemoveResult;
|
|
45
|
+
export declare function readHookStatus(scope: HookScope, projectRoot?: string): HookStatus;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
/** The hook command written into settings. `${CLAUDE_PROJECT_DIR}` is injected by Claude Code. */
|
|
6
|
+
export const HOOK_ENFORCE_COMMAND = 'peaks gate enforce --project "${CLAUDE_PROJECT_DIR}"';
|
|
7
|
+
/** Substring that identifies a Peaks-managed PreToolUse hook entry. */
|
|
8
|
+
export const HOOK_SENTINEL = 'peaks gate enforce';
|
|
9
|
+
const HOOK_MATCHER = 'Bash';
|
|
10
|
+
function isInsidePath(childPath, parentPath) {
|
|
11
|
+
const rel = relative(parentPath, childPath);
|
|
12
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
13
|
+
}
|
|
14
|
+
function resolveSettingsRoot(scope, projectRoot) {
|
|
15
|
+
if (scope === 'global')
|
|
16
|
+
return resolve(homedir());
|
|
17
|
+
if (!projectRoot) {
|
|
18
|
+
throw new Error('Project scope requires a project root');
|
|
19
|
+
}
|
|
20
|
+
return resolve(projectRoot);
|
|
21
|
+
}
|
|
22
|
+
function resolveSettingsPath(scope, projectRoot) {
|
|
23
|
+
return join(resolveSettingsRoot(scope, projectRoot), '.claude', 'settings.json');
|
|
24
|
+
}
|
|
25
|
+
function assertSafeSettingsPath(scope, root, settingsPath) {
|
|
26
|
+
const claudeDir = join(root, '.claude');
|
|
27
|
+
if (existsSync(claudeDir) && lstatSync(claudeDir).isSymbolicLink()) {
|
|
28
|
+
throw new Error('.claude directory must not be a symlink');
|
|
29
|
+
}
|
|
30
|
+
if (existsSync(settingsPath)) {
|
|
31
|
+
if (lstatSync(settingsPath).isSymbolicLink()) {
|
|
32
|
+
throw new Error('settings.json must not be a symlink');
|
|
33
|
+
}
|
|
34
|
+
const realRoot = realpathSync(root);
|
|
35
|
+
if (!isInsidePath(realpathSync(settingsPath), realRoot)) {
|
|
36
|
+
throw new Error(`settings.json must stay inside the ${scope} root`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function readSettings(settingsPath) {
|
|
41
|
+
if (!existsSync(settingsPath))
|
|
42
|
+
return {};
|
|
43
|
+
const fd = openSync(settingsPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
44
|
+
try {
|
|
45
|
+
const raw = readFileSync(fd, 'utf8').trim();
|
|
46
|
+
if (raw.length === 0)
|
|
47
|
+
return {};
|
|
48
|
+
const parsed = JSON.parse(raw);
|
|
49
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
50
|
+
throw new Error('settings.json must contain a JSON object');
|
|
51
|
+
}
|
|
52
|
+
return parsed;
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
closeSync(fd);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function atomicWriteJson(settingsPath, settings) {
|
|
59
|
+
const dir = dirname(settingsPath);
|
|
60
|
+
mkdirSync(dir, { recursive: true });
|
|
61
|
+
const tempPath = join(dir, `.settings.${randomUUID()}.tmp`);
|
|
62
|
+
const fd = openSync(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
63
|
+
try {
|
|
64
|
+
writeFileSync(fd, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
closeSync(fd);
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
renameSync(tempPath, settingsPath);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
try {
|
|
74
|
+
unlinkSync(tempPath);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// best effort cleanup
|
|
78
|
+
}
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Read the existing PreToolUse matcher entries (tolerant of any prior shape). */
|
|
83
|
+
function readPreToolUse(settings) {
|
|
84
|
+
const hooks = settings.hooks;
|
|
85
|
+
if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks))
|
|
86
|
+
return [];
|
|
87
|
+
const pre = hooks.PreToolUse;
|
|
88
|
+
return Array.isArray(pre) ? pre : [];
|
|
89
|
+
}
|
|
90
|
+
function entryIsPeaksManaged(entry) {
|
|
91
|
+
const handlers = Array.isArray(entry?.hooks) ? entry.hooks : [];
|
|
92
|
+
return handlers.length > 0 && handlers.every((h) => typeof h?.command === 'string' && h.command.includes(HOOK_SENTINEL));
|
|
93
|
+
}
|
|
94
|
+
function isInstalled(settings) {
|
|
95
|
+
return readPreToolUse(settings).some(entryIsPeaksManaged);
|
|
96
|
+
}
|
|
97
|
+
export function planHookInstall(scope, projectRoot) {
|
|
98
|
+
const root = resolveSettingsRoot(scope, projectRoot);
|
|
99
|
+
const settingsPath = resolveSettingsPath(scope, projectRoot);
|
|
100
|
+
assertSafeSettingsPath(scope, root, settingsPath);
|
|
101
|
+
const exists = existsSync(settingsPath);
|
|
102
|
+
const settings = readSettings(settingsPath);
|
|
103
|
+
return { scope, settingsPath, exists, alreadyInstalled: isInstalled(settings), desiredCommand: HOOK_ENFORCE_COMMAND };
|
|
104
|
+
}
|
|
105
|
+
/** Merge our PreToolUse entry into settings, preserving all other keys and hooks. */
|
|
106
|
+
function withHookInstalled(settings) {
|
|
107
|
+
const existingHooks = (settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks))
|
|
108
|
+
? settings.hooks
|
|
109
|
+
: {};
|
|
110
|
+
const preToolUse = readPreToolUse(settings);
|
|
111
|
+
const ourEntry = { matcher: HOOK_MATCHER, hooks: [{ type: 'command', command: HOOK_ENFORCE_COMMAND }] };
|
|
112
|
+
return {
|
|
113
|
+
...settings,
|
|
114
|
+
hooks: { ...existingHooks, PreToolUse: [...preToolUse, ourEntry] }
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
export function applyHookInstall(scope, projectRoot) {
|
|
118
|
+
const root = resolveSettingsRoot(scope, projectRoot);
|
|
119
|
+
const settingsPath = resolveSettingsPath(scope, projectRoot);
|
|
120
|
+
assertSafeSettingsPath(scope, root, settingsPath);
|
|
121
|
+
const exists = existsSync(settingsPath);
|
|
122
|
+
const settings = readSettings(settingsPath);
|
|
123
|
+
if (isInstalled(settings)) {
|
|
124
|
+
return { scope, settingsPath, exists, alreadyInstalled: true, desiredCommand: HOOK_ENFORCE_COMMAND, applied: false };
|
|
125
|
+
}
|
|
126
|
+
atomicWriteJson(settingsPath, withHookInstalled(settings));
|
|
127
|
+
return { scope, settingsPath, exists, alreadyInstalled: false, desiredCommand: HOOK_ENFORCE_COMMAND, applied: true };
|
|
128
|
+
}
|
|
129
|
+
export function removeHookInstall(scope, projectRoot) {
|
|
130
|
+
const root = resolveSettingsRoot(scope, projectRoot);
|
|
131
|
+
const settingsPath = resolveSettingsPath(scope, projectRoot);
|
|
132
|
+
assertSafeSettingsPath(scope, root, settingsPath);
|
|
133
|
+
if (!existsSync(settingsPath)) {
|
|
134
|
+
return { scope, settingsPath, removed: false };
|
|
135
|
+
}
|
|
136
|
+
const settings = readSettings(settingsPath);
|
|
137
|
+
const preToolUse = readPreToolUse(settings);
|
|
138
|
+
const kept = preToolUse.filter((entry) => !entryIsPeaksManaged(entry));
|
|
139
|
+
if (kept.length === preToolUse.length) {
|
|
140
|
+
return { scope, settingsPath, removed: false };
|
|
141
|
+
}
|
|
142
|
+
const existingHooks = settings.hooks ?? {};
|
|
143
|
+
const nextHooks = { ...existingHooks };
|
|
144
|
+
if (kept.length > 0) {
|
|
145
|
+
nextHooks.PreToolUse = kept;
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
delete nextHooks.PreToolUse;
|
|
149
|
+
}
|
|
150
|
+
const nextSettings = { ...settings };
|
|
151
|
+
if (Object.keys(nextHooks).length > 0) {
|
|
152
|
+
nextSettings.hooks = nextHooks;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
delete nextSettings.hooks;
|
|
156
|
+
}
|
|
157
|
+
atomicWriteJson(settingsPath, nextSettings);
|
|
158
|
+
return { scope, settingsPath, removed: true };
|
|
159
|
+
}
|
|
160
|
+
export function readHookStatus(scope, projectRoot) {
|
|
161
|
+
const root = resolveSettingsRoot(scope, projectRoot);
|
|
162
|
+
const settingsPath = resolveSettingsPath(scope, projectRoot);
|
|
163
|
+
assertSafeSettingsPath(scope, root, settingsPath);
|
|
164
|
+
const exists = existsSync(settingsPath);
|
|
165
|
+
const settings = exists ? readSettings(settingsPath) : {};
|
|
166
|
+
return { scope, settingsPath, exists, installed: isInstalled(settings) };
|
|
167
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { BlockedGate } from './sop-advance-service.js';
|
|
2
|
+
export type MatchedGuard = {
|
|
3
|
+
sopId: string;
|
|
4
|
+
phase: string;
|
|
5
|
+
/** The non-passing gates that block this phase's guarded action. */
|
|
6
|
+
failing: BlockedGate[];
|
|
7
|
+
};
|
|
8
|
+
export type EnforceDecision = {
|
|
9
|
+
decision: 'allow';
|
|
10
|
+
bypassed?: boolean;
|
|
11
|
+
warnings?: string[];
|
|
12
|
+
} | {
|
|
13
|
+
decision: 'deny';
|
|
14
|
+
reason: string;
|
|
15
|
+
matched: MatchedGuard[];
|
|
16
|
+
};
|
|
17
|
+
export declare class GateBypassError extends Error {
|
|
18
|
+
readonly code: string;
|
|
19
|
+
constructor(code: string, message: string);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Record a one-shot bypass token for `<sopId>:<phase>` in this project. The next
|
|
23
|
+
* `enforceBashCommand` that the transition blocks consumes it and allows once.
|
|
24
|
+
* Capped per-project-per-SOP by MAX_BYPASSES_PER_SESSION.
|
|
25
|
+
*/
|
|
26
|
+
export declare function recordGateBypass(projectRoot: string, sopId: string, phase: string, reason: string): {
|
|
27
|
+
count: number;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Decide whether a Bash command may run. Pure given the filesystem; never throws
|
|
31
|
+
* (fail-open on any internal error). Returns allow/deny for the PreToolUse hook.
|
|
32
|
+
*/
|
|
33
|
+
export declare function enforceBashCommand(projectRoot: string, command: string): Promise<EnforceDecision>;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { readRegistry } from './sop-registry-service.js';
|
|
4
|
+
import { readSopManifest } from './sop-service.js';
|
|
5
|
+
import { evaluateGate } from './sop-check-service.js';
|
|
6
|
+
import { sopStateDir } from './sop-paths.js';
|
|
7
|
+
import { isBypassLimitReached, recordBypass, MAX_BYPASSES_PER_SESSION } from '../mode/bypass-tracker.js';
|
|
8
|
+
/**
|
|
9
|
+
* Gate enforcement — Feature A, Slice 4 (the un-bypassable closure).
|
|
10
|
+
*
|
|
11
|
+
* `enforceBashCommand` is the brain behind the PreToolUse hook: given a Bash
|
|
12
|
+
* command the agent is about to run, it finds every registered SOP whose phase
|
|
13
|
+
* `guards` match that command, evaluates that phase's gates, and decides
|
|
14
|
+
* allow/deny. A deny, surfaced by the hook as `permissionDecision: "deny"`,
|
|
15
|
+
* blocks the tool call before Claude Code's permission checks — so the action
|
|
16
|
+
* cannot happen while a gate fails, regardless of agent cooperation or
|
|
17
|
+
* `--dangerously-skip-permissions`.
|
|
18
|
+
*
|
|
19
|
+
* TRUST RED LINE: this runs on (potentially) every Bash call. A bug here must
|
|
20
|
+
* never brick the user's Claude Code. So every internal failure (unreadable
|
|
21
|
+
* registry, malformed manifest, invalid guard regex) is FAIL-OPEN — it allows
|
|
22
|
+
* the command and emits a warning. Only a genuine gate failure denies.
|
|
23
|
+
*
|
|
24
|
+
* Escape hatch: a one-shot bypass token (written by `peaks gate bypass`) for a
|
|
25
|
+
* matched transition is consumed here and turns the deny into an allow, capped
|
|
26
|
+
* per-project-per-SOP by the shared bypass tracker.
|
|
27
|
+
*/
|
|
28
|
+
const BYPASS_TOKENS_FILE = '.gate-bypass.json';
|
|
29
|
+
function bypassTokensPath(projectRoot, sopId) {
|
|
30
|
+
return join(sopStateDir(projectRoot, sopId), BYPASS_TOKENS_FILE);
|
|
31
|
+
}
|
|
32
|
+
function readBypassTokens(projectRoot, sopId) {
|
|
33
|
+
const path = bypassTokensPath(projectRoot, sopId);
|
|
34
|
+
if (!existsSync(path)) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
39
|
+
if (!Array.isArray(parsed)) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
return parsed.filter((t) => t !== null && typeof t === 'object' && typeof t.phase === 'string');
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function writeBypassTokens(projectRoot, sopId, tokens) {
|
|
49
|
+
const dir = sopStateDir(projectRoot, sopId);
|
|
50
|
+
mkdirSync(dir, { recursive: true });
|
|
51
|
+
writeFileSync(bypassTokensPath(projectRoot, sopId), `${JSON.stringify(tokens, null, 2)}\n`, 'utf8');
|
|
52
|
+
}
|
|
53
|
+
function hasBypassToken(projectRoot, sopId, phase) {
|
|
54
|
+
return readBypassTokens(projectRoot, sopId).some((t) => t.phase === phase);
|
|
55
|
+
}
|
|
56
|
+
/** Remove the first matching bypass token (one-shot). Returns true if one was consumed. */
|
|
57
|
+
function consumeBypassToken(projectRoot, sopId, phase) {
|
|
58
|
+
const tokens = readBypassTokens(projectRoot, sopId);
|
|
59
|
+
const index = tokens.findIndex((t) => t.phase === phase);
|
|
60
|
+
if (index === -1) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
tokens.splice(index, 1);
|
|
64
|
+
writeBypassTokens(projectRoot, sopId, tokens);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
export class GateBypassError extends Error {
|
|
68
|
+
code;
|
|
69
|
+
constructor(code, message) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = 'GateBypassError';
|
|
72
|
+
this.code = code;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Record a one-shot bypass token for `<sopId>:<phase>` in this project. The next
|
|
77
|
+
* `enforceBashCommand` that the transition blocks consumes it and allows once.
|
|
78
|
+
* Capped per-project-per-SOP by MAX_BYPASSES_PER_SESSION.
|
|
79
|
+
*/
|
|
80
|
+
export function recordGateBypass(projectRoot, sopId, phase, reason) {
|
|
81
|
+
const root = sopStateDir(projectRoot, sopId);
|
|
82
|
+
if (isBypassLimitReached(root)) {
|
|
83
|
+
throw new GateBypassError('BYPASS_LIMIT_REACHED', `gate bypass limit reached (${MAX_BYPASSES_PER_SESSION} bypasses per SOP per project)`);
|
|
84
|
+
}
|
|
85
|
+
const tokens = readBypassTokens(projectRoot, sopId);
|
|
86
|
+
writeBypassTokens(projectRoot, sopId, [...tokens, { phase, reason }]);
|
|
87
|
+
const count = recordBypass(root);
|
|
88
|
+
return { count };
|
|
89
|
+
}
|
|
90
|
+
function denyReason(matched) {
|
|
91
|
+
const lines = matched.map((m) => {
|
|
92
|
+
const gates = m.failing.map((g) => `${g.gateId}=${g.result}${g.reason ? ` (${g.reason})` : ''}`).join(', ');
|
|
93
|
+
return `SOP "${m.sopId}" phase "${m.phase}": ${gates}`;
|
|
94
|
+
});
|
|
95
|
+
const hint = matched
|
|
96
|
+
.map((m) => `peaks gate bypass --sop ${m.sopId} --phase ${m.phase} --reason "<why>"`)
|
|
97
|
+
.join(' ; ');
|
|
98
|
+
return `Blocked by Peaks gate(s): ${lines.join(' | ')}. Satisfy the gate(s), or bypass once: ${hint}`;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Decide whether a Bash command may run. Pure given the filesystem; never throws
|
|
102
|
+
* (fail-open on any internal error). Returns allow/deny for the PreToolUse hook.
|
|
103
|
+
*/
|
|
104
|
+
export async function enforceBashCommand(projectRoot, command) {
|
|
105
|
+
const warnings = [];
|
|
106
|
+
let sopIds;
|
|
107
|
+
try {
|
|
108
|
+
// Merged view: project-layer SOPs (committed in the repo — a teammate who
|
|
109
|
+
// clones gets them) over global. This is what makes enforcement work for a
|
|
110
|
+
// teammate who has only the repo, not your global ~/.peaks.
|
|
111
|
+
sopIds = (await readRegistry(projectRoot)).sops.map((sop) => sop.id);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
return { decision: 'allow', warnings: [`gate enforce: could not read registry (${error instanceof Error ? error.message : 'error'}); allowing`] };
|
|
115
|
+
}
|
|
116
|
+
const matched = [];
|
|
117
|
+
for (const sopId of sopIds) {
|
|
118
|
+
let manifest;
|
|
119
|
+
try {
|
|
120
|
+
manifest = await readSopManifest(sopId, projectRoot);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
warnings.push(`gate enforce: SOP "${sopId}" manifest unreadable (${error instanceof Error ? error.message : 'error'}); skipping`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (manifest === null || !Array.isArray(manifest.guards) || manifest.guards.length === 0) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
for (const guard of manifest.guards) {
|
|
130
|
+
let regex;
|
|
131
|
+
try {
|
|
132
|
+
regex = new RegExp(guard.bash);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
warnings.push(`gate enforce: SOP "${sopId}" guard has an invalid regex "${guard.bash}"; skipping`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (!regex.test(command)) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const failing = [];
|
|
142
|
+
for (const gate of manifest.gates.filter((g) => g.phase === guard.phase)) {
|
|
143
|
+
const verdict = evaluateGate(projectRoot, gate, { allowCommands: true });
|
|
144
|
+
if (verdict.result !== 'pass') {
|
|
145
|
+
failing.push(verdict.reason === undefined
|
|
146
|
+
? { gateId: gate.id, result: verdict.result }
|
|
147
|
+
: { gateId: gate.id, result: verdict.result, reason: verdict.reason });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (failing.length > 0) {
|
|
151
|
+
matched.push({ sopId, phase: guard.phase, failing });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (matched.length === 0) {
|
|
156
|
+
return warnings.length > 0 ? { decision: 'allow', warnings } : { decision: 'allow' };
|
|
157
|
+
}
|
|
158
|
+
// One-shot bypass: only allow if EVERY blocked transition has a token to spend
|
|
159
|
+
// (don't burn tokens on a partial pass-through).
|
|
160
|
+
const allBypassable = matched.every((m) => hasBypassToken(projectRoot, m.sopId, m.phase));
|
|
161
|
+
if (allBypassable) {
|
|
162
|
+
for (const m of matched) {
|
|
163
|
+
consumeBypassToken(projectRoot, m.sopId, m.phase);
|
|
164
|
+
}
|
|
165
|
+
return { decision: 'allow', bypassed: true, ...(warnings.length > 0 ? { warnings } : {}) };
|
|
166
|
+
}
|
|
167
|
+
return { decision: 'deny', reason: denyReason(matched), matched };
|
|
168
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { SopCheckResult } from './sop-types.js';
|
|
2
|
+
/**
|
|
3
|
+
* SOP phase advancement with gate enforcement — Feature A, Slice 3 (range 3).
|
|
4
|
+
*
|
|
5
|
+
* `advanceSop` moves a SOP to a target phase only if (a) the move does not skip
|
|
6
|
+
* ahead in the declared phase order, and (b) every gate guarding that phase
|
|
7
|
+
* passes. A forward skip throws SopPhaseSkipError (SOP_PHASE_SKIP); a
|
|
8
|
+
* fail/blocked gate throws SopGateBlockedError (SOP_GATE_BLOCKED). Both block
|
|
9
|
+
* UNCONDITIONALLY in all modes — a gate is an objective check, not a
|
|
10
|
+
* confirmation prompt, so a mode could never silently skip it (that would
|
|
11
|
+
* defeat "don't drop steps"). The only escape is an explicit bypass
|
|
12
|
+
* (allowIncomplete), which the CLI gates behind --reason / --confirm / a cap.
|
|
13
|
+
*
|
|
14
|
+
* The SOP *definition* is global (`~/.peaks/sops/`), but run-state is
|
|
15
|
+
* PER-PROJECT (`<project>/.peaks/sop-state/<id>.json`) so the same authored SOP
|
|
16
|
+
* tracks independent progress in every project it runs in.
|
|
17
|
+
*
|
|
18
|
+
* This is a standalone command path: it does NOT touch the built-in request
|
|
19
|
+
* artifact transition machinery or mode-enforcement, so those keep their exact
|
|
20
|
+
* behavior (preserved behavior P2/P3).
|
|
21
|
+
*/
|
|
22
|
+
export type BlockedGate = {
|
|
23
|
+
gateId: string;
|
|
24
|
+
result: SopCheckResult;
|
|
25
|
+
reason?: string;
|
|
26
|
+
};
|
|
27
|
+
export type SopHistoryEntry = {
|
|
28
|
+
phase: string;
|
|
29
|
+
bypassed: boolean;
|
|
30
|
+
reason?: string;
|
|
31
|
+
};
|
|
32
|
+
export type SopState = {
|
|
33
|
+
currentPhase: string | null;
|
|
34
|
+
history: SopHistoryEntry[];
|
|
35
|
+
};
|
|
36
|
+
export type AdvanceSopResult = {
|
|
37
|
+
id: string;
|
|
38
|
+
phase: string;
|
|
39
|
+
bypassed: boolean;
|
|
40
|
+
previousPhase: string | null;
|
|
41
|
+
/** false when --dry-run evaluated the gates without recording the advance. */
|
|
42
|
+
applied: boolean;
|
|
43
|
+
};
|
|
44
|
+
export type AdvanceSopOptions = {
|
|
45
|
+
projectRoot: string;
|
|
46
|
+
id: string;
|
|
47
|
+
toPhase: string;
|
|
48
|
+
allowCommands?: boolean;
|
|
49
|
+
allowIncomplete?: boolean;
|
|
50
|
+
reason?: string;
|
|
51
|
+
commandTimeoutMs?: number;
|
|
52
|
+
/** Evaluate gates (still blocks on failure) but do not write state.json. */
|
|
53
|
+
dryRun?: boolean;
|
|
54
|
+
};
|
|
55
|
+
export declare class SopAdvanceError extends Error {
|
|
56
|
+
readonly code: string;
|
|
57
|
+
constructor(code: string, message: string);
|
|
58
|
+
}
|
|
59
|
+
export declare class SopGateBlockedError extends Error {
|
|
60
|
+
readonly code = "SOP_GATE_BLOCKED";
|
|
61
|
+
readonly blockedGates: BlockedGate[];
|
|
62
|
+
constructor(toPhase: string, blockedGates: BlockedGate[]);
|
|
63
|
+
}
|
|
64
|
+
export declare class SopPhaseSkipError extends Error {
|
|
65
|
+
readonly code = "SOP_PHASE_SKIP";
|
|
66
|
+
readonly fromPhase: string | null;
|
|
67
|
+
readonly toPhase: string;
|
|
68
|
+
readonly expectedNext: string;
|
|
69
|
+
constructor(fromPhase: string | null, toPhase: string, expectedNext: string);
|
|
70
|
+
}
|
|
71
|
+
declare const EMPTY_STATE: SopState;
|
|
72
|
+
export declare function readSopState(projectRoot: string, id: string): Promise<SopState>;
|
|
73
|
+
export declare function advanceSop(options: AdvanceSopOptions): Promise<AdvanceSopResult>;
|
|
74
|
+
export { EMPTY_STATE };
|