peaks-cli 1.1.1 → 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/bin/peaks.js +0 -0
- 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 +14 -2
- 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/artifacts/artifact-lint-service.js +20 -1
- package/dist/src/services/artifacts/artifact-prerequisites.js +34 -4
- 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/scan/type-sanity-service.js +11 -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/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',
|
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);
|
|
@@ -35,6 +35,15 @@ const ALLOWLIST_PATTERNS = [
|
|
|
35
35
|
function isAllowlisted(line) {
|
|
36
36
|
return ALLOWLIST_PATTERNS.some((pattern) => pattern.test(line));
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Remove inline code spans (`...`) before applying placeholder rules. Content
|
|
40
|
+
* inside backticks is literal example text — e.g. a documented command syntax
|
|
41
|
+
* `peaks sop init <id>` — not an unfilled prose placeholder. Lint checks prose,
|
|
42
|
+
* not code, so a `<...>` token only counts when it appears outside code spans.
|
|
43
|
+
*/
|
|
44
|
+
function stripInlineCode(line) {
|
|
45
|
+
return line.replace(/`[^`]*`/g, '');
|
|
46
|
+
}
|
|
38
47
|
export async function lintRequestArtifact(options) {
|
|
39
48
|
const showOptions = {
|
|
40
49
|
projectRoot: options.projectRoot,
|
|
@@ -50,14 +59,24 @@ export async function lintRequestArtifact(options) {
|
|
|
50
59
|
}
|
|
51
60
|
const lines = artifact.content.split(/\r?\n/);
|
|
52
61
|
const findings = [];
|
|
62
|
+
let insideFence = false;
|
|
53
63
|
for (let index = 0; index < lines.length; index += 1) {
|
|
54
64
|
const rawLine = lines[index];
|
|
55
65
|
if (rawLine === undefined)
|
|
56
66
|
continue;
|
|
67
|
+
// Fenced code blocks hold literal examples, not prose to fill; skip their
|
|
68
|
+
// contents entirely (the fence delimiters themselves toggle the state).
|
|
69
|
+
if (/^\s*```/.test(rawLine)) {
|
|
70
|
+
insideFence = !insideFence;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (insideFence)
|
|
74
|
+
continue;
|
|
57
75
|
if (isAllowlisted(rawLine))
|
|
58
76
|
continue;
|
|
77
|
+
const testLine = stripInlineCode(rawLine);
|
|
59
78
|
for (const rule of RULES) {
|
|
60
|
-
if (rule.test(
|
|
79
|
+
if (rule.test(testLine)) {
|
|
61
80
|
findings.push({
|
|
62
81
|
line: index + 1,
|
|
63
82
|
text: rawLine.trim(),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { join } from 'node:path';
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
1
|
+
import { join, dirname, basename } from 'node:path';
|
|
2
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
3
3
|
import { pathExists } from '../../shared/fs.js';
|
|
4
4
|
export const VALID_REQUEST_TYPES = [
|
|
5
5
|
'feature',
|
|
@@ -111,6 +111,36 @@ export function getPrerequisitesFor(role, newState, requestType = DEFAULT_REQUES
|
|
|
111
111
|
function resolvePrerequisitePath(prerequisite, requestId) {
|
|
112
112
|
return prerequisite.relativePath.replace('<rid>', requestId);
|
|
113
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Resolve a prerequisite to an on-disk path, tolerating the numbered filename
|
|
116
|
+
* prefix that `request init` writes (e.g. `001-<rid>.md`). When the prerequisite
|
|
117
|
+
* path contains `<rid>`, we accept either the legacy bare `<rid>.md` form or any
|
|
118
|
+
* `NNN-<rid>.md` numbered form — mirroring the matcher in request-artifact-service.
|
|
119
|
+
* Returns the matched absolute path, or null when nothing matches.
|
|
120
|
+
*/
|
|
121
|
+
async function resolvePrerequisiteAbsolutePath(sessionRoot, prerequisite, requestId) {
|
|
122
|
+
const relative = resolvePrerequisitePath(prerequisite, requestId);
|
|
123
|
+
const exact = join(sessionRoot, relative);
|
|
124
|
+
if (await pathExists(exact)) {
|
|
125
|
+
return exact;
|
|
126
|
+
}
|
|
127
|
+
// Only `<rid>`-templated prerequisites can carry a numbered prefix; fixed paths
|
|
128
|
+
// (e.g. rd/tech-doc.md) are matched exactly above.
|
|
129
|
+
if (!prerequisite.relativePath.includes('<rid>')) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const dir = dirname(exact);
|
|
133
|
+
const targetSuffix = `-${basename(exact)}`;
|
|
134
|
+
let entries;
|
|
135
|
+
try {
|
|
136
|
+
entries = await readdir(dir);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const match = entries.find((name) => /^\d+-/.test(name) && name.endsWith(targetSuffix));
|
|
142
|
+
return match ? join(dir, match) : null;
|
|
143
|
+
}
|
|
114
144
|
export async function checkPrerequisites(options) {
|
|
115
145
|
const requirements = getPrerequisitesFor(options.role, options.newState, options.requestType);
|
|
116
146
|
if (requirements.length === 0) {
|
|
@@ -120,8 +150,8 @@ export async function checkPrerequisites(options) {
|
|
|
120
150
|
const missing = [];
|
|
121
151
|
for (const prerequisite of requirements) {
|
|
122
152
|
const relative = resolvePrerequisitePath(prerequisite, options.requestId);
|
|
123
|
-
const absolute =
|
|
124
|
-
if (
|
|
153
|
+
const absolute = await resolvePrerequisiteAbsolutePath(sessionRoot, prerequisite, options.requestId);
|
|
154
|
+
if (absolute === null) {
|
|
125
155
|
missing.push({ path: relative, description: prerequisite.description });
|
|
126
156
|
continue;
|
|
127
157
|
}
|
|
@@ -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
|
}
|
|
@@ -25,6 +25,16 @@ function classifyFile(filePath) {
|
|
|
25
25
|
return 'source';
|
|
26
26
|
return 'unknown';
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Peaks' own artifact workspace. Changes here (PRD/RD/QA markdown, session
|
|
30
|
+
* state) are never the "code change" a request type describes, so they must be
|
|
31
|
+
* excluded from the diff — otherwise a PRD-planning-phase handoff that only
|
|
32
|
+
* wrote `.peaks/**` markdown would be misclassified as a docs change.
|
|
33
|
+
*/
|
|
34
|
+
function isArtifactWorkspaceFile(filePath) {
|
|
35
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
36
|
+
return normalized === '.peaks' || normalized.startsWith('.peaks/');
|
|
37
|
+
}
|
|
28
38
|
function tryGitDiffFiles(projectRoot, baseRef) {
|
|
29
39
|
try {
|
|
30
40
|
// Combine: tracked changes vs baseRef + untracked files. Use porcelain status for untracked too.
|
|
@@ -32,7 +42,7 @@ function tryGitDiffFiles(projectRoot, baseRef) {
|
|
|
32
42
|
const tracked = trackedRaw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
33
43
|
const untrackedRaw = execFileSync('git', ['-C', projectRoot, 'ls-files', '--others', '--exclude-standard'], { encoding: 'utf8' });
|
|
34
44
|
const untracked = untrackedRaw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
35
|
-
const merged = Array.from(new Set([...tracked, ...untracked]));
|
|
45
|
+
const merged = Array.from(new Set([...tracked, ...untracked])).filter((file) => !isArtifactWorkspaceFile(file));
|
|
36
46
|
return { ok: true, files: merged };
|
|
37
47
|
}
|
|
38
48
|
catch {
|
|
@@ -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
|
+
}
|