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.
Files changed (41) hide show
  1. package/README.md +97 -3
  2. package/bin/peaks.js +0 -0
  3. package/dist/src/cli/commands/core-artifact-commands.js +47 -3
  4. package/dist/src/cli/commands/gate-commands.d.ts +3 -0
  5. package/dist/src/cli/commands/gate-commands.js +103 -0
  6. package/dist/src/cli/commands/hooks-commands.d.ts +3 -0
  7. package/dist/src/cli/commands/hooks-commands.js +75 -0
  8. package/dist/src/cli/commands/request-commands.js +14 -2
  9. package/dist/src/cli/commands/sop-commands.d.ts +3 -0
  10. package/dist/src/cli/commands/sop-commands.js +215 -0
  11. package/dist/src/cli/index.js +12 -0
  12. package/dist/src/cli/program.js +47 -2
  13. package/dist/src/services/artifacts/artifact-lint-service.js +20 -1
  14. package/dist/src/services/artifacts/artifact-prerequisites.js +34 -4
  15. package/dist/src/services/dashboard/project-dashboard-service.js +5 -3
  16. package/dist/src/services/mode/mode-enforcement.js +2 -1
  17. package/dist/src/services/scan/type-sanity-service.js +11 -1
  18. package/dist/src/services/skills/hooks-settings-service.d.ts +45 -0
  19. package/dist/src/services/skills/hooks-settings-service.js +167 -0
  20. package/dist/src/services/sop/gate-enforce-service.d.ts +33 -0
  21. package/dist/src/services/sop/gate-enforce-service.js +168 -0
  22. package/dist/src/services/sop/sop-advance-service.d.ts +74 -0
  23. package/dist/src/services/sop/sop-advance-service.js +115 -0
  24. package/dist/src/services/sop/sop-check-service.d.ts +29 -0
  25. package/dist/src/services/sop/sop-check-service.js +148 -0
  26. package/dist/src/services/sop/sop-paths.d.ts +62 -0
  27. package/dist/src/services/sop/sop-paths.js +92 -0
  28. package/dist/src/services/sop/sop-registry-service.d.ts +46 -0
  29. package/dist/src/services/sop/sop-registry-service.js +89 -0
  30. package/dist/src/services/sop/sop-service.d.ts +47 -0
  31. package/dist/src/services/sop/sop-service.js +211 -0
  32. package/dist/src/services/sop/sop-types.d.ts +88 -0
  33. package/dist/src/services/sop/sop-types.js +11 -0
  34. package/dist/src/shared/paths.d.ts +1 -1
  35. package/dist/src/shared/paths.js +2 -1
  36. package/dist/src/shared/version.d.ts +1 -1
  37. package/dist/src/shared/version.js +1 -1
  38. package/package.json +1 -1
  39. package/schemas/sop-manifest.schema.json +52 -0
  40. package/skills/peaks-sop/SKILL.md +192 -0
  41. package/skills/peaks-sop/references/sop-authoring.md +161 -0
@@ -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',
@@ -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('Peaks CLI and short skill family runtime manager')
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
- if (program.opts().V) {
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(rawLine)) {
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 = join(sessionRoot, relative);
124
- if (!(await pathExists(absolute))) {
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
- const resolved = presence === undefined ? getSkillPresence() : presence;
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
- const presence = getSkillPresence();
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
+ }