peaks-cli 1.1.2 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) 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/doctor/doctor-service.d.ts +4 -0
  14. package/dist/src/services/doctor/doctor-service.js +66 -3
  15. package/dist/src/services/mode/mode-enforcement.js +2 -1
  16. package/dist/src/services/skills/hooks-settings-service.d.ts +45 -0
  17. package/dist/src/services/skills/hooks-settings-service.js +167 -0
  18. package/dist/src/services/skills/skill-presence-service.d.ts +1 -0
  19. package/dist/src/services/skills/skill-presence-service.js +12 -0
  20. package/dist/src/services/skills/skill-statusline-service.d.ts +2 -0
  21. package/dist/src/services/skills/skill-statusline-service.js +11 -1
  22. package/dist/src/services/sop/gate-enforce-service.d.ts +33 -0
  23. package/dist/src/services/sop/gate-enforce-service.js +168 -0
  24. package/dist/src/services/sop/sop-advance-service.d.ts +74 -0
  25. package/dist/src/services/sop/sop-advance-service.js +115 -0
  26. package/dist/src/services/sop/sop-check-service.d.ts +29 -0
  27. package/dist/src/services/sop/sop-check-service.js +148 -0
  28. package/dist/src/services/sop/sop-paths.d.ts +62 -0
  29. package/dist/src/services/sop/sop-paths.js +92 -0
  30. package/dist/src/services/sop/sop-registry-service.d.ts +46 -0
  31. package/dist/src/services/sop/sop-registry-service.js +89 -0
  32. package/dist/src/services/sop/sop-service.d.ts +47 -0
  33. package/dist/src/services/sop/sop-service.js +211 -0
  34. package/dist/src/services/sop/sop-types.d.ts +88 -0
  35. package/dist/src/services/sop/sop-types.js +11 -0
  36. package/dist/src/shared/paths.d.ts +1 -1
  37. package/dist/src/shared/paths.js +2 -1
  38. package/dist/src/shared/version.d.ts +1 -1
  39. package/dist/src/shared/version.js +1 -1
  40. package/package.json +1 -1
  41. package/schemas/doctor-report.schema.json +1 -1
  42. package/schemas/sop-manifest.schema.json +52 -0
  43. package/skills/peaks-solo/SKILL.md +32 -6
  44. package/skills/peaks-sop/SKILL.md +192 -0
  45. 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
  }
@@ -25,5 +25,9 @@ export type DoctorOptions = {
25
25
  skillPresenceProbe?: () => SkillPresence | null;
26
26
  skillPresenceFreshnessThresholdMs?: number;
27
27
  statusLineInstalledProbe?: () => boolean;
28
+ /** Returns true when a Peaks workspace session (.peaks/.session.json) exists. */
29
+ workspaceInitializedProbe?: () => boolean;
30
+ /** Platform string (defaults to process.platform); injectable for tests. */
31
+ platform?: NodeJS.Platform;
28
32
  };
29
33
  export declare function runDoctor(options?: DoctorOptions): Promise<DoctorReport>;
@@ -10,6 +10,7 @@ import { loadSkillRegistry } from '../skills/skill-registry.js';
10
10
  import { getSkillPresence } from '../skills/skill-presence-service.js';
11
11
  import { planStatusLineInstall } from '../skills/statusline-settings-service.js';
12
12
  import { findProjectRoot } from '../config/config-safety.js';
13
+ import { CLI_VERSION } from '../../shared/version.js';
13
14
  const CODEGRAPH_EXPECTED_VERSION = '0.7.10';
14
15
  const SKILL_PRESENCE_FRESHNESS_THRESHOLD_MS = 24 * 60 * 60 * 1000;
15
16
  function defaultCodegraphProbe() {
@@ -26,15 +27,29 @@ function defaultCodegraphProbe() {
26
27
  }
27
28
  function defaultStatusLineInstalledProbe() {
28
29
  const projectRoot = findProjectRoot(process.cwd());
29
- if (projectRoot === null)
30
- return false;
30
+ // Check both scopes: a user may have installed the statusLine globally, which
31
+ // the project-only check would miss and falsely report as "not installed".
31
32
  try {
32
- return planStatusLineInstall('project', projectRoot).alreadyInstalled;
33
+ if (projectRoot !== null && planStatusLineInstall('project', projectRoot).alreadyInstalled) {
34
+ return true;
35
+ }
36
+ }
37
+ catch {
38
+ /* fall through to global */
39
+ }
40
+ try {
41
+ return planStatusLineInstall('global').alreadyInstalled;
33
42
  }
34
43
  catch {
35
44
  return false;
36
45
  }
37
46
  }
47
+ function defaultWorkspaceInitializedProbe() {
48
+ const projectRoot = findProjectRoot(process.cwd());
49
+ if (projectRoot === null)
50
+ return false;
51
+ return existsSync(join(projectRoot, '.peaks', '.session.json'));
52
+ }
38
53
  const DESTRUCTIVE_APPLY_PATTERNS = [
39
54
  /peaks\s+memory\s+sync[^\n]*--apply/,
40
55
  /peaks\s+memory\s+extract[^\n]*--apply/,
@@ -203,6 +218,34 @@ export async function runDoctor(options = {}) {
203
218
  }
204
219
  }
205
220
  }
221
+ // Workspace guard: an active workflow presence (peaks-solo) with no workspace
222
+ // session means the skill was anchored but `peaks workspace init` never ran —
223
+ // the #1 reported failure where .peaks/ artifacts are never created. This
224
+ // turns the SKILL.md "MUST create the workspace" prose into an executable check.
225
+ const workspaceProbe = options.workspaceInitializedProbe ?? defaultWorkspaceInitializedProbe;
226
+ let workspaceInitialized = false;
227
+ try {
228
+ workspaceInitialized = workspaceProbe();
229
+ }
230
+ catch {
231
+ workspaceInitialized = false;
232
+ }
233
+ if (presence !== null && !workspaceInitialized) {
234
+ checks.push({
235
+ id: 'skill-presence:workspace',
236
+ ok: false,
237
+ message: `Skill ${presence.skill} is active but no workspace session exists (.peaks/.session.json missing); run \`peaks workspace init --project <repo>\` — peaks-solo Step 0 must anchor the workspace before any work`
238
+ });
239
+ }
240
+ else {
241
+ checks.push({
242
+ id: 'skill-presence:workspace',
243
+ ok: true,
244
+ message: presence === null
245
+ ? 'No active skill presence; workspace guard not applicable'
246
+ : `Workspace session present for active skill ${presence.skill}`
247
+ });
248
+ }
206
249
  // Discoverability nudge: when a skill is actively orchestrating but the
207
250
  // out-of-band statusLine isn't installed, the user has no terminal-level
208
251
  // signal that Peaks is in control. Suggest installing it (non-failing).
@@ -230,6 +273,26 @@ export async function runDoctor(options = {}) {
230
273
  : 'Peaks statusLine not installed (no active skill; install optional)'
231
274
  });
232
275
  }
276
+ // Runtime/platform diagnostic for the "statusLine shows nothing" reports.
277
+ // Surfaces (a) the running peaks version — a stale global install predating
278
+ // the statusLine feature is a common cause — and (b) on Windows, the fact that
279
+ // the bare `peaks statusline` command must resolve in the shell Claude Code
280
+ // spawns, which fails when the npm global bin dir is not on that shell's PATH.
281
+ const platform = options.platform ?? process.platform;
282
+ if (platform === 'win32') {
283
+ checks.push({
284
+ id: 'statusline:runtime',
285
+ ok: true,
286
+ message: `peaks ${CLI_VERSION} (win32): if the statusLine shows nothing in git bash, verify \`peaks\` resolves on PATH in the shell Claude Code uses (run \`peaks -v\` there), reinstall globally with \`npm i -g peaks-cli@latest\` if the version is older than ${CLI_VERSION}, then re-run \`peaks statusline install\` and reload Claude Code`
287
+ });
288
+ }
289
+ else {
290
+ checks.push({
291
+ id: 'statusline:runtime',
292
+ ok: true,
293
+ message: `peaks ${CLI_VERSION} (${platform}): statusLine command is \`peaks statusline\``
294
+ });
295
+ }
233
296
  const probe = options.codegraphProbe ?? defaultCodegraphProbe;
234
297
  try {
235
298
  const result = probe();
@@ -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
+ }
@@ -6,6 +6,7 @@ export type SkillPresence = {
6
6
  mode?: SkillPresenceMode;
7
7
  gate?: string;
8
8
  sessionId?: string;
9
+ claudeSessionId?: string;
9
10
  setAt: string;
10
11
  lastHeartbeat?: string;
11
12
  };
@@ -10,6 +10,16 @@ export const VALID_SKILL_PRESENCE_MODES = [
10
10
  export function isSkillPresenceMode(value) {
11
11
  return VALID_SKILL_PRESENCE_MODES.includes(value);
12
12
  }
13
+ /**
14
+ * The current Claude Code session id, exposed to Bash tool calls via the
15
+ * CLAUDE_CODE_SESSION_ID environment variable. Stamping it onto the presence
16
+ * file lets the read-only status line tell whether the recorded skill belongs
17
+ * to the live session (show it) or a previous one (render idle).
18
+ */
19
+ function getCurrentClaudeSessionId() {
20
+ const value = process.env.CLAUDE_CODE_SESSION_ID;
21
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
22
+ }
13
23
  const PRESENCE_FILE = '.peaks/.active-skill.json';
14
24
  const SESSION_FILE = '.peaks/.session.json';
15
25
  function resolveProjectRoot(override) {
@@ -40,12 +50,14 @@ export function exportSkillPresence(projectRootOverride) {
40
50
  export function setSkillPresence(skill, mode, gate, projectRootOverride) {
41
51
  const validatedMode = mode && isSkillPresenceMode(mode) ? mode : undefined;
42
52
  const sessionId = getCurrentSessionId(projectRootOverride);
53
+ const claudeSessionId = getCurrentClaudeSessionId();
43
54
  const now = new Date().toISOString();
44
55
  const presence = {
45
56
  skill,
46
57
  ...(validatedMode ? { mode: validatedMode } : {}),
47
58
  ...(gate ? { gate } : {}),
48
59
  ...(sessionId ? { sessionId } : {}),
60
+ ...(claudeSessionId ? { claudeSessionId } : {}),
49
61
  setAt: now,
50
62
  lastHeartbeat: now
51
63
  };
@@ -4,6 +4,7 @@ export type StatusLineStdin = {
4
4
  project_dir?: string;
5
5
  };
6
6
  cwd?: string;
7
+ session_id?: string;
7
8
  };
8
9
  export type StatusLineState = 'active' | 'idle' | 'stale' | 'invalid-presence';
9
10
  export type StatusLinePresence = {
@@ -11,6 +12,7 @@ export type StatusLinePresence = {
11
12
  mode?: string;
12
13
  gate?: string;
13
14
  setAt?: string;
15
+ claudeSessionId?: string;
14
16
  };
15
17
  export type StatusLineModel = {
16
18
  state: StatusLineState;
@@ -65,7 +65,8 @@ function readPresenceReadOnly(projectRoot) {
65
65
  skill: candidate.skill,
66
66
  ...(typeof candidate.mode === 'string' ? { mode: candidate.mode } : {}),
67
67
  ...(typeof candidate.gate === 'string' ? { gate: candidate.gate } : {}),
68
- ...(typeof candidate.setAt === 'string' ? { setAt: candidate.setAt } : {})
68
+ ...(typeof candidate.setAt === 'string' ? { setAt: candidate.setAt } : {}),
69
+ ...(typeof candidate.claudeSessionId === 'string' ? { claudeSessionId: candidate.claudeSessionId } : {})
69
70
  },
70
71
  invalid: false
71
72
  };
@@ -87,6 +88,15 @@ export function buildStatusLineModel(stdin, nowMs) {
87
88
  if (presence === null) {
88
89
  return { state: 'idle', projectRoot, presence: null, ageMs: null };
89
90
  }
91
+ // Session binding: when the presence was stamped with a Claude session id and
92
+ // the live session (from stdin) is a different one, the recorded skill belongs
93
+ // to a previous session — render idle instead of a stale "active" skill. When
94
+ // either id is absent (legacy presence, or harness that omits session_id) we
95
+ // fall back to the time-based behavior below for backward compatibility.
96
+ const liveSessionId = typeof stdin?.session_id === 'string' && stdin.session_id.length > 0 ? stdin.session_id : null;
97
+ if (presence.claudeSessionId && liveSessionId && presence.claudeSessionId !== liveSessionId) {
98
+ return { state: 'idle', projectRoot, presence: null, ageMs: null };
99
+ }
90
100
  const setAtMs = presence.setAt ? Date.parse(presence.setAt) : Number.NaN;
91
101
  const ageMs = Number.isNaN(setAtMs) ? null : nowMs - setAtMs;
92
102
  const state = ageMs !== null && ageMs > STALE_THRESHOLD_MS ? 'stale' : 'active';
@@ -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>;