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.
Files changed (37) hide show
  1. package/README.md +97 -3
  2. package/dist/src/cli/commands/core-artifact-commands.js +47 -3
  3. package/dist/src/cli/commands/gate-commands.d.ts +3 -0
  4. package/dist/src/cli/commands/gate-commands.js +103 -0
  5. package/dist/src/cli/commands/hooks-commands.d.ts +3 -0
  6. package/dist/src/cli/commands/hooks-commands.js +75 -0
  7. package/dist/src/cli/commands/request-commands.js +1 -1
  8. package/dist/src/cli/commands/sop-commands.d.ts +3 -0
  9. package/dist/src/cli/commands/sop-commands.js +215 -0
  10. package/dist/src/cli/index.js +12 -0
  11. package/dist/src/cli/program.js +47 -2
  12. package/dist/src/services/dashboard/project-dashboard-service.js +5 -3
  13. package/dist/src/services/mode/mode-enforcement.js +2 -1
  14. package/dist/src/services/skills/hooks-settings-service.d.ts +45 -0
  15. package/dist/src/services/skills/hooks-settings-service.js +167 -0
  16. package/dist/src/services/sop/gate-enforce-service.d.ts +33 -0
  17. package/dist/src/services/sop/gate-enforce-service.js +168 -0
  18. package/dist/src/services/sop/sop-advance-service.d.ts +74 -0
  19. package/dist/src/services/sop/sop-advance-service.js +115 -0
  20. package/dist/src/services/sop/sop-check-service.d.ts +29 -0
  21. package/dist/src/services/sop/sop-check-service.js +148 -0
  22. package/dist/src/services/sop/sop-paths.d.ts +62 -0
  23. package/dist/src/services/sop/sop-paths.js +92 -0
  24. package/dist/src/services/sop/sop-registry-service.d.ts +46 -0
  25. package/dist/src/services/sop/sop-registry-service.js +89 -0
  26. package/dist/src/services/sop/sop-service.d.ts +47 -0
  27. package/dist/src/services/sop/sop-service.js +211 -0
  28. package/dist/src/services/sop/sop-types.d.ts +88 -0
  29. package/dist/src/services/sop/sop-types.js +11 -0
  30. package/dist/src/shared/paths.d.ts +1 -1
  31. package/dist/src/shared/paths.js +2 -1
  32. package/dist/src/shared/version.d.ts +1 -1
  33. package/dist/src/shared/version.js +1 -1
  34. package/package.json +1 -1
  35. package/schemas/sop-manifest.schema.json +52 -0
  36. package/skills/peaks-sop/SKILL.md +192 -0
  37. package/skills/peaks-sop/references/sop-authoring.md +161 -0
@@ -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);
@@ -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
  }
@@ -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 };