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.
- package/README.md +97 -3
- package/dist/src/cli/commands/core-artifact-commands.js +47 -3
- package/dist/src/cli/commands/gate-commands.d.ts +3 -0
- package/dist/src/cli/commands/gate-commands.js +103 -0
- package/dist/src/cli/commands/hooks-commands.d.ts +3 -0
- package/dist/src/cli/commands/hooks-commands.js +75 -0
- package/dist/src/cli/commands/request-commands.js +1 -1
- package/dist/src/cli/commands/sop-commands.d.ts +3 -0
- package/dist/src/cli/commands/sop-commands.js +215 -0
- package/dist/src/cli/index.js +12 -0
- package/dist/src/cli/program.js +47 -2
- package/dist/src/services/dashboard/project-dashboard-service.js +5 -3
- package/dist/src/services/doctor/doctor-service.d.ts +4 -0
- package/dist/src/services/doctor/doctor-service.js +66 -3
- package/dist/src/services/mode/mode-enforcement.js +2 -1
- package/dist/src/services/skills/hooks-settings-service.d.ts +45 -0
- package/dist/src/services/skills/hooks-settings-service.js +167 -0
- package/dist/src/services/skills/skill-presence-service.d.ts +1 -0
- package/dist/src/services/skills/skill-presence-service.js +12 -0
- package/dist/src/services/skills/skill-statusline-service.d.ts +2 -0
- package/dist/src/services/skills/skill-statusline-service.js +11 -1
- 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/doctor-report.schema.json +1 -1
- package/schemas/sop-manifest.schema.json +52 -0
- package/skills/peaks-solo/SKILL.md +32 -6
- package/skills/peaks-sop/SKILL.md +192 -0
- package/skills/peaks-sop/references/sop-authoring.md +161 -0
package/dist/src/cli/program.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { readdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
1
3
|
import { Command } from 'commander';
|
|
4
|
+
import { skillsDir } from '../shared/paths.js';
|
|
2
5
|
import { CLI_VERSION } from '../shared/version.js';
|
|
3
6
|
import { registerCoreAndArtifactCommands } from './commands/core-artifact-commands.js';
|
|
4
7
|
import { registerWorkflowCommands } from './commands/workflow-commands.js';
|
|
@@ -10,6 +13,9 @@ import { registerProjectCommands } from './commands/project-commands.js';
|
|
|
10
13
|
import { registerRequestCommands } from './commands/request-commands.js';
|
|
11
14
|
import { registerScanCommands } from './commands/scan-commands.js';
|
|
12
15
|
import { registerShadcnCommands } from './commands/shadcn-commands.js';
|
|
16
|
+
import { registerSopCommands } from './commands/sop-commands.js';
|
|
17
|
+
import { registerGateCommands } from './commands/gate-commands.js';
|
|
18
|
+
import { registerHooksCommands } from './commands/hooks-commands.js';
|
|
13
19
|
import { registerStatusLineCommands } from './commands/statusline-commands.js';
|
|
14
20
|
import { registerUnderstandCommands } from './commands/understand-commands.js';
|
|
15
21
|
import { registerWorkspaceCommands } from './commands/workspace-commands.js';
|
|
@@ -18,7 +24,14 @@ export function createProgram(io = { stdout: (text) => console.log(text), stderr
|
|
|
18
24
|
const program = new Command();
|
|
19
25
|
program
|
|
20
26
|
.name('peaks')
|
|
21
|
-
.description(
|
|
27
|
+
.description(`Peaks CLI ${CLI_VERSION} — workflow-gating CLI + skill family for Claude Code
|
|
28
|
+
|
|
29
|
+
Run peaks (no arguments) for a quickstart. You likely want one of:
|
|
30
|
+
peaks doctor check your environment
|
|
31
|
+
peaks skill list or manage skills
|
|
32
|
+
peaks sop author your own workflow gates
|
|
33
|
+
peaks hooks install the un-bypassable gate-enforcement hook
|
|
34
|
+
peaks gate enforce/bypass SOP gates on Bash commands`)
|
|
22
35
|
.configureOutput({
|
|
23
36
|
writeOut: (text) => io.stdout(text.trimEnd()),
|
|
24
37
|
writeErr: (text) => io.stderr(text.trimEnd())
|
|
@@ -26,9 +39,38 @@ export function createProgram(io = { stdout: (text) => console.log(text), stderr
|
|
|
26
39
|
.version(CLI_VERSION, '-v, --version')
|
|
27
40
|
.option('-V', 'output the version number')
|
|
28
41
|
.action(() => {
|
|
29
|
-
|
|
42
|
+
const opts = program.opts();
|
|
43
|
+
if (opts.V) {
|
|
30
44
|
io.stdout(CLI_VERSION);
|
|
45
|
+
return;
|
|
31
46
|
}
|
|
47
|
+
// Count bundled skills by reading the skills dir directly (synchronous so
|
|
48
|
+
// the quickstart renders instantly — no import/async overhead on startup).
|
|
49
|
+
let skillCount = 0;
|
|
50
|
+
const skillsPath = skillsDir;
|
|
51
|
+
try {
|
|
52
|
+
if (existsSync(skillsPath)) {
|
|
53
|
+
skillCount = readdirSync(skillsPath, { withFileTypes: true })
|
|
54
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
55
|
+
.filter((entry) => existsSync(join(skillsPath, entry.name, 'SKILL.md')))
|
|
56
|
+
.length;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch { /* disk read is best-effort; zero skills is still truthful */ }
|
|
60
|
+
io.stdout(`Peaks CLI ${CLI_VERSION} · ${skillCount} skills ready
|
|
61
|
+
|
|
62
|
+
Peaks is a workflow-gating CLI + skill family for Claude Code.
|
|
63
|
+
It turns "don't skip steps" into hard enforcement — gates that block
|
|
64
|
+
advancement in-conversation, un-bypassably.
|
|
65
|
+
|
|
66
|
+
Before diving into a project, two things worth doing now:
|
|
67
|
+
|
|
68
|
+
peaks doctor check your environment in one glance
|
|
69
|
+
peaks-sop <<< ask this skill to author your first SOP
|
|
70
|
+
|
|
71
|
+
Or jump straight in:
|
|
72
|
+
peaks sop init --id my-flow --apply && peaks hooks install
|
|
73
|
+
`);
|
|
32
74
|
})
|
|
33
75
|
.exitOverride();
|
|
34
76
|
registerCoreAndArtifactCommands(program, io);
|
|
@@ -41,6 +83,9 @@ export function createProgram(io = { stdout: (text) => console.log(text), stderr
|
|
|
41
83
|
registerRequestCommands(program, io);
|
|
42
84
|
registerScanCommands(program, io);
|
|
43
85
|
registerShadcnCommands(program, io);
|
|
86
|
+
registerSopCommands(program, io);
|
|
87
|
+
registerGateCommands(program, io);
|
|
88
|
+
registerHooksCommands(program, io);
|
|
44
89
|
registerStatusLineCommands(program, io);
|
|
45
90
|
registerUnderstandCommands(program, io);
|
|
46
91
|
registerWorkspaceCommands(program, io);
|
|
@@ -74,8 +74,10 @@ function buildCapabilitiesSummary(sampleSize) {
|
|
|
74
74
|
}))
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
|
-
function buildSkillPresenceSummary(presence) {
|
|
78
|
-
|
|
77
|
+
function buildSkillPresenceSummary(presence, projectRoot) {
|
|
78
|
+
// When the caller doesn't supply presence, resolve it from the dashboard's
|
|
79
|
+
// project root rather than the process cwd.
|
|
80
|
+
const resolved = presence === undefined ? getSkillPresence(projectRoot) : presence;
|
|
79
81
|
if (resolved === null) {
|
|
80
82
|
return { active: false, fresh: true };
|
|
81
83
|
}
|
|
@@ -126,6 +128,6 @@ export async function loadProjectDashboard(options) {
|
|
|
126
128
|
doctor: doctorAndRunbook.doctor,
|
|
127
129
|
runbookHealth: doctorAndRunbook.runbookHealth,
|
|
128
130
|
capabilities: buildCapabilitiesSummary(sampleSize),
|
|
129
|
-
skillPresence: buildSkillPresenceSummary(options.skillPresence)
|
|
131
|
+
skillPresence: buildSkillPresenceSummary(options.skillPresence, options.projectRoot)
|
|
130
132
|
};
|
|
131
133
|
}
|
|
@@ -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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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>;
|