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
|
@@ -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 };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { readSopManifest } from './sop-service.js';
|
|
5
|
+
import { sopStatePath } from './sop-paths.js';
|
|
6
|
+
import { evaluateGate } from './sop-check-service.js';
|
|
7
|
+
export class SopAdvanceError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'SopAdvanceError';
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class SopGateBlockedError extends Error {
|
|
16
|
+
code = 'SOP_GATE_BLOCKED';
|
|
17
|
+
blockedGates;
|
|
18
|
+
constructor(toPhase, blockedGates) {
|
|
19
|
+
super(`Cannot advance to "${toPhase}": ${blockedGates.length} gate(s) not satisfied (${blockedGates.map((g) => `${g.gateId}=${g.result}`).join(', ')})`);
|
|
20
|
+
this.name = 'SopGateBlockedError';
|
|
21
|
+
this.blockedGates = blockedGates;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export class SopPhaseSkipError extends Error {
|
|
25
|
+
code = 'SOP_PHASE_SKIP';
|
|
26
|
+
fromPhase;
|
|
27
|
+
toPhase;
|
|
28
|
+
expectedNext;
|
|
29
|
+
constructor(fromPhase, toPhase, expectedNext) {
|
|
30
|
+
super(`Cannot advance to "${toPhase}": it skips ahead of the declared phase order (current: ${fromPhase ?? 'none'}, next allowed: ${expectedNext}). Bypass with --allow-incomplete --reason "<why>" if you really must skip.`);
|
|
31
|
+
this.name = 'SopPhaseSkipError';
|
|
32
|
+
this.fromPhase = fromPhase;
|
|
33
|
+
this.toPhase = toPhase;
|
|
34
|
+
this.expectedNext = expectedNext;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const EMPTY_STATE = { currentPhase: null, history: [] };
|
|
38
|
+
export async function readSopState(projectRoot, id) {
|
|
39
|
+
const path = sopStatePath(projectRoot, id);
|
|
40
|
+
if (!existsSync(path)) {
|
|
41
|
+
return { currentPhase: null, history: [] };
|
|
42
|
+
}
|
|
43
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
44
|
+
return {
|
|
45
|
+
currentPhase: typeof parsed.currentPhase === 'string' ? parsed.currentPhase : null,
|
|
46
|
+
history: Array.isArray(parsed.history) ? parsed.history : []
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Enforce declared phase order: a move may stay put, step back, or advance to
|
|
51
|
+
* the immediately-next phase, but must not skip ahead. `currentPhase: null`
|
|
52
|
+
* (never advanced) is treated as index -1, so only the first phase is reachable.
|
|
53
|
+
* Throws SopPhaseSkipError on a forward skip.
|
|
54
|
+
*/
|
|
55
|
+
function assertNoPhaseSkip(phases, currentPhase, toPhase) {
|
|
56
|
+
const currentIndex = currentPhase === null ? -1 : phases.indexOf(currentPhase);
|
|
57
|
+
const targetIndex = phases.indexOf(toPhase);
|
|
58
|
+
// A current phase that is no longer declared (hand-edited manifest) cannot
|
|
59
|
+
// anchor order; fall back to "first phase only" rather than silently allowing.
|
|
60
|
+
const anchorIndex = currentIndex >= 0 ? currentIndex : -1;
|
|
61
|
+
if (targetIndex > anchorIndex + 1) {
|
|
62
|
+
// A forward skip means anchorIndex + 1 < targetIndex ≤ phases.length - 1,
|
|
63
|
+
// so the next phase always exists — no fallback branch needed.
|
|
64
|
+
const expectedNext = phases[anchorIndex + 1];
|
|
65
|
+
throw new SopPhaseSkipError(currentPhase, toPhase, expectedNext);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export async function advanceSop(options) {
|
|
69
|
+
const manifest = await readSopManifest(options.id, options.projectRoot);
|
|
70
|
+
if (manifest === null) {
|
|
71
|
+
throw new SopAdvanceError('SOP_NOT_FOUND', `No SOP found for id "${options.id}"`);
|
|
72
|
+
}
|
|
73
|
+
if (!manifest.phases.includes(options.toPhase)) {
|
|
74
|
+
throw new SopAdvanceError('INVALID_PHASE', `Phase "${options.toPhase}" is not declared by SOP "${options.id}"`);
|
|
75
|
+
}
|
|
76
|
+
const previous = await readSopState(options.projectRoot, options.id);
|
|
77
|
+
const phaseGates = manifest.gates.filter((gate) => gate.phase === options.toPhase);
|
|
78
|
+
if (options.allowIncomplete !== true) {
|
|
79
|
+
// Structural check first: a forward skip is invalid regardless of gate state.
|
|
80
|
+
assertNoPhaseSkip(manifest.phases, previous.currentPhase, options.toPhase);
|
|
81
|
+
const evaluateOptions = {};
|
|
82
|
+
if (options.allowCommands !== undefined)
|
|
83
|
+
evaluateOptions.allowCommands = options.allowCommands;
|
|
84
|
+
if (options.commandTimeoutMs !== undefined)
|
|
85
|
+
evaluateOptions.commandTimeoutMs = options.commandTimeoutMs;
|
|
86
|
+
const blocked = [];
|
|
87
|
+
for (const gate of phaseGates) {
|
|
88
|
+
const verdict = evaluateGate(options.projectRoot, gate, evaluateOptions);
|
|
89
|
+
if (verdict.result !== 'pass') {
|
|
90
|
+
blocked.push(verdict.reason === undefined
|
|
91
|
+
? { gateId: gate.id, result: verdict.result }
|
|
92
|
+
: { gateId: gate.id, result: verdict.result, reason: verdict.reason });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (blocked.length > 0) {
|
|
96
|
+
throw new SopGateBlockedError(options.toPhase, blocked);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const bypassed = options.allowIncomplete === true;
|
|
100
|
+
if (options.dryRun === true) {
|
|
101
|
+
return { id: options.id, phase: options.toPhase, bypassed, previousPhase: previous.currentPhase, applied: false };
|
|
102
|
+
}
|
|
103
|
+
const entry = options.reason === undefined
|
|
104
|
+
? { phase: options.toPhase, bypassed }
|
|
105
|
+
: { phase: options.toPhase, bypassed, reason: options.reason };
|
|
106
|
+
const nextState = {
|
|
107
|
+
currentPhase: options.toPhase,
|
|
108
|
+
history: [...previous.history, entry]
|
|
109
|
+
};
|
|
110
|
+
const path = sopStatePath(options.projectRoot, options.id);
|
|
111
|
+
await mkdir(dirname(path), { recursive: true });
|
|
112
|
+
await writeFile(path, `${JSON.stringify(nextState, null, 2)}\n`, 'utf8');
|
|
113
|
+
return { id: options.id, phase: options.toPhase, bypassed, previousPhase: previous.currentPhase, applied: true };
|
|
114
|
+
}
|
|
115
|
+
export { EMPTY_STATE };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { SopGate, SopCheckResult } from './sop-types.js';
|
|
2
|
+
export type GateVerdict = {
|
|
3
|
+
result: SopCheckResult;
|
|
4
|
+
reason?: string;
|
|
5
|
+
};
|
|
6
|
+
export type CheckGateResult = GateVerdict & {
|
|
7
|
+
id: string;
|
|
8
|
+
gateId: string;
|
|
9
|
+
phase: string;
|
|
10
|
+
};
|
|
11
|
+
export type CheckGateOptions = {
|
|
12
|
+
projectRoot: string;
|
|
13
|
+
id: string;
|
|
14
|
+
gateId: string;
|
|
15
|
+
allowCommands?: boolean;
|
|
16
|
+
/** Override the command-gate timeout (ms). Defaults to GATE_COMMAND_TIMEOUT_MS. */
|
|
17
|
+
commandTimeoutMs?: number;
|
|
18
|
+
};
|
|
19
|
+
export declare class SopCheckError extends Error {
|
|
20
|
+
readonly code: string;
|
|
21
|
+
constructor(code: string, message: string);
|
|
22
|
+
}
|
|
23
|
+
export type EvaluateGateOptions = {
|
|
24
|
+
allowCommands?: boolean;
|
|
25
|
+
commandTimeoutMs?: number;
|
|
26
|
+
};
|
|
27
|
+
/** Evaluate a single gate's check to a pass/fail/blocked verdict. Shared by `sop check` and `sop advance`. */
|
|
28
|
+
export declare function evaluateGate(projectRoot: string, gate: SopGate, options?: EvaluateGateOptions): GateVerdict;
|
|
29
|
+
export declare function checkGate(options: CheckGateOptions): Promise<CheckGateResult>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { isInsidePath } from '../../shared/path-utils.js';
|
|
5
|
+
import { readSopManifest } from './sop-service.js';
|
|
6
|
+
/**
|
|
7
|
+
* SOP gate evaluation — Feature A, Slice 2.
|
|
8
|
+
*
|
|
9
|
+
* Evaluates a single gate to one of three verdicts: pass / fail / blocked.
|
|
10
|
+
* "blocked" means the check could not be evaluated (e.g. unreadable target,
|
|
11
|
+
* command not permitted, spawn failure) — a verdict, not an error. Only an
|
|
12
|
+
* inability to start (SOP/gate missing) is an evaluator error.
|
|
13
|
+
*
|
|
14
|
+
* Security (OQ3/R1): command gates run via execFileSync with an argv array and
|
|
15
|
+
* NO shell (no injection), a hard timeout, and cwd set to the project, and they
|
|
16
|
+
* are refused unless explicitly permitted. file-exists/grep targets are pinned
|
|
17
|
+
* inside the project root. NOTE: the command executable itself is NOT
|
|
18
|
+
* sandboxed — a command gate can invoke any binary on the machine. The trust
|
|
19
|
+
* boundary is "whoever authored the SOP"; --allow-commands is the gate.
|
|
20
|
+
*/
|
|
21
|
+
const GATE_COMMAND_TIMEOUT_MS = 30_000;
|
|
22
|
+
export class SopCheckError extends Error {
|
|
23
|
+
code;
|
|
24
|
+
constructor(code, message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'SopCheckError';
|
|
27
|
+
this.code = code;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Resolve a user-authored relative path and require it to stay inside the project. */
|
|
31
|
+
function resolveInsideProject(projectRoot, target) {
|
|
32
|
+
const root = resolve(projectRoot);
|
|
33
|
+
const resolved = resolve(root, target);
|
|
34
|
+
return isInsidePath(resolved, root) ? resolved : null;
|
|
35
|
+
}
|
|
36
|
+
function evaluateFileExists(projectRoot, path) {
|
|
37
|
+
const resolved = resolveInsideProject(projectRoot, path);
|
|
38
|
+
if (resolved === null) {
|
|
39
|
+
return { result: 'blocked', reason: `path "${path}" escapes the project root` };
|
|
40
|
+
}
|
|
41
|
+
return existsSync(resolved) ? { result: 'pass' } : { result: 'fail', reason: `file "${path}" does not exist` };
|
|
42
|
+
}
|
|
43
|
+
function evaluateGrep(projectRoot, file, pattern, absent) {
|
|
44
|
+
const resolved = resolveInsideProject(projectRoot, file);
|
|
45
|
+
if (resolved === null) {
|
|
46
|
+
return { result: 'blocked', reason: `file "${file}" escapes the project root` };
|
|
47
|
+
}
|
|
48
|
+
if (!existsSync(resolved)) {
|
|
49
|
+
return { result: 'blocked', reason: `file "${file}" cannot be read` };
|
|
50
|
+
}
|
|
51
|
+
let regex;
|
|
52
|
+
try {
|
|
53
|
+
regex = new RegExp(pattern);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return { result: 'blocked', reason: `invalid grep pattern "${pattern}"` };
|
|
57
|
+
}
|
|
58
|
+
let content;
|
|
59
|
+
try {
|
|
60
|
+
content = readFileSync(resolved, 'utf8');
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return { result: 'blocked', reason: `file "${file}" cannot be read` };
|
|
64
|
+
}
|
|
65
|
+
const found = regex.test(content);
|
|
66
|
+
// absent gate: pass when the pattern is NOT present ("must not contain X").
|
|
67
|
+
const pass = absent ? !found : found;
|
|
68
|
+
if (pass) {
|
|
69
|
+
return { result: 'pass' };
|
|
70
|
+
}
|
|
71
|
+
return absent
|
|
72
|
+
? { result: 'fail', reason: `pattern "${pattern}" must be absent but was found in "${file}"` }
|
|
73
|
+
: { result: 'fail', reason: `pattern "${pattern}" not found in "${file}"` };
|
|
74
|
+
}
|
|
75
|
+
function evaluateCommand(projectRoot, run, expectExitZero, allowCommands, timeoutMs) {
|
|
76
|
+
if (!allowCommands) {
|
|
77
|
+
return { result: 'blocked', reason: 'command checks require --allow-commands' };
|
|
78
|
+
}
|
|
79
|
+
// Manifests reach here unvalidated (checkGate/advanceSop don't pre-lint), so
|
|
80
|
+
// guard the untrusted shape rather than assert run[0]; an empty run is a
|
|
81
|
+
// blocked verdict, not a thrown evaluator error.
|
|
82
|
+
const [bin, ...args] = run;
|
|
83
|
+
if (bin === undefined) {
|
|
84
|
+
return { result: 'blocked', reason: 'command gate has no executable' };
|
|
85
|
+
}
|
|
86
|
+
let exitCode;
|
|
87
|
+
try {
|
|
88
|
+
execFileSync(bin, args, { cwd: resolve(projectRoot), timeout: timeoutMs, stdio: 'ignore' });
|
|
89
|
+
exitCode = 0;
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
const err = error;
|
|
93
|
+
// Timeout surfaces as ETIMEDOUT on some platforms and killed+SIGTERM on others.
|
|
94
|
+
if (err.code === 'ETIMEDOUT' || err.killed === true) {
|
|
95
|
+
return { result: 'blocked', reason: `command timed out after ${timeoutMs}ms` };
|
|
96
|
+
}
|
|
97
|
+
if (typeof err.status === 'number') {
|
|
98
|
+
exitCode = err.status;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
return { result: 'blocked', reason: `command could not be run (${String(err.code ?? 'spawn error')})` };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const zero = exitCode === 0;
|
|
105
|
+
const pass = expectExitZero ? zero : !zero;
|
|
106
|
+
return pass ? { result: 'pass' } : { result: 'fail', reason: `command exited ${exitCode} (expectExitZero=${expectExitZero})` };
|
|
107
|
+
}
|
|
108
|
+
function evaluateCheck(projectRoot, check, allowCommands, timeoutMs) {
|
|
109
|
+
switch (check.type) {
|
|
110
|
+
case 'file-exists':
|
|
111
|
+
return evaluateFileExists(projectRoot, check.path);
|
|
112
|
+
case 'grep':
|
|
113
|
+
return evaluateGrep(projectRoot, check.file, check.pattern, check.absent === true);
|
|
114
|
+
case 'command':
|
|
115
|
+
return evaluateCommand(projectRoot, check.run, check.expectExitZero !== false, allowCommands, timeoutMs);
|
|
116
|
+
default:
|
|
117
|
+
return { result: 'blocked', reason: 'unknown check type' };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Evaluate a single gate's check to a pass/fail/blocked verdict. Shared by `sop check` and `sop advance`. */
|
|
121
|
+
export function evaluateGate(projectRoot, gate, options = {}) {
|
|
122
|
+
return evaluateCheck(projectRoot, gate.check, options.allowCommands === true, options.commandTimeoutMs ?? GATE_COMMAND_TIMEOUT_MS);
|
|
123
|
+
}
|
|
124
|
+
export async function checkGate(options) {
|
|
125
|
+
// Resolve the definition project-first then global; the gate's check still
|
|
126
|
+
// evaluates against options.projectRoot.
|
|
127
|
+
const manifest = await readSopManifest(options.id, options.projectRoot);
|
|
128
|
+
if (manifest === null) {
|
|
129
|
+
throw new SopCheckError('SOP_NOT_FOUND', `No SOP found for id "${options.id}"`);
|
|
130
|
+
}
|
|
131
|
+
const gate = manifest.gates.find((candidate) => candidate.id === options.gateId);
|
|
132
|
+
if (gate === undefined) {
|
|
133
|
+
throw new SopCheckError('GATE_NOT_FOUND', `Gate "${options.gateId}" not found in SOP "${options.id}"`);
|
|
134
|
+
}
|
|
135
|
+
const evaluateOptions = {};
|
|
136
|
+
if (options.allowCommands !== undefined) {
|
|
137
|
+
evaluateOptions.allowCommands = options.allowCommands;
|
|
138
|
+
}
|
|
139
|
+
if (options.commandTimeoutMs !== undefined) {
|
|
140
|
+
evaluateOptions.commandTimeoutMs = options.commandTimeoutMs;
|
|
141
|
+
}
|
|
142
|
+
const verdict = evaluateGate(options.projectRoot, gate, evaluateOptions);
|
|
143
|
+
const result = { id: options.id, gateId: gate.id, phase: gate.phase, result: verdict.result };
|
|
144
|
+
if (verdict.reason !== undefined) {
|
|
145
|
+
result.reason = verdict.reason;
|
|
146
|
+
}
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SOP path model — two definition layers + per-project run-state.
|
|
3
|
+
*
|
|
4
|
+
* A SOP *definition* (manifest + SKILL.md) and its registry can live in two
|
|
5
|
+
* layers:
|
|
6
|
+
* - GLOBAL (`~/.peaks/sops/`): your personal SOPs, reusable across every
|
|
7
|
+
* project on this machine.
|
|
8
|
+
* - PROJECT (`<project>/.peaks/sops/`): committed into the repo, so a teammate
|
|
9
|
+
* who clones it gets the SOP — and, with the gate hook installed, is enforced
|
|
10
|
+
* too. The project layer takes PRECEDENCE over global for the same id.
|
|
11
|
+
*
|
|
12
|
+
* A SOP's *run-state* (current phase, bypass history) always lives under the
|
|
13
|
+
* project it runs in (`<project>/.peaks/sop-state/<id>/`), so the same SOP tracks
|
|
14
|
+
* independent progress per project. Gate checks resolve their target paths
|
|
15
|
+
* against the caller's `--project`, which is what makes a relative gate path
|
|
16
|
+
* (`posts/current.md`) reusable across projects.
|
|
17
|
+
*
|
|
18
|
+
* `PEAKS_HOME` overrides the global root; tests inject a temp dir through it so
|
|
19
|
+
* a unit test never reads or writes the real `~/.peaks`.
|
|
20
|
+
*/
|
|
21
|
+
export type SopScope = 'project' | 'global';
|
|
22
|
+
/** Global Peaks home (`~/.peaks`), overridable via PEAKS_HOME for test isolation. */
|
|
23
|
+
export declare function peaksHome(): string;
|
|
24
|
+
/** Global SOP definition directory: `~/.peaks/sops/<id>`. */
|
|
25
|
+
export declare function sopDir(id: string): string;
|
|
26
|
+
/** Global SOP manifest: `~/.peaks/sops/<id>/sop.json`. */
|
|
27
|
+
export declare function sopManifestPath(id: string): string;
|
|
28
|
+
/** Global SOP skill: `~/.peaks/sops/<id>/SKILL.md`. */
|
|
29
|
+
export declare function sopSkillPath(id: string): string;
|
|
30
|
+
/** Global registry of all registered SOPs: `~/.peaks/sops/registry.json`. */
|
|
31
|
+
export declare function registryPath(): string;
|
|
32
|
+
/** Project SOP definition directory: `<project>/.peaks/sops/<id>` (committed, team-shared). */
|
|
33
|
+
export declare function projectSopDir(projectRoot: string, id: string): string;
|
|
34
|
+
/** Project SOP manifest: `<project>/.peaks/sops/<id>/sop.json`. */
|
|
35
|
+
export declare function projectSopManifestPath(projectRoot: string, id: string): string;
|
|
36
|
+
/** Project SOP skill: `<project>/.peaks/sops/<id>/SKILL.md`. */
|
|
37
|
+
export declare function projectSopSkillPath(projectRoot: string, id: string): string;
|
|
38
|
+
/** Project registry: `<project>/.peaks/sops/registry.json` (committed, team-shared). */
|
|
39
|
+
export declare function projectRegistryPath(projectRoot: string): string;
|
|
40
|
+
/** Definition directory for a scope. */
|
|
41
|
+
export declare function scopedSopDir(scope: SopScope, projectRoot: string | undefined, id: string): string;
|
|
42
|
+
/** Manifest path for a scope. */
|
|
43
|
+
export declare function scopedSopManifestPath(scope: SopScope, projectRoot: string | undefined, id: string): string;
|
|
44
|
+
/** Registry path for a scope. */
|
|
45
|
+
export declare function scopedRegistryPath(scope: SopScope, projectRoot: string | undefined): string;
|
|
46
|
+
/**
|
|
47
|
+
* Resolve where a SOP's manifest lives, project-first: the project layer wins
|
|
48
|
+
* when present, otherwise global. Returns null when neither layer has it.
|
|
49
|
+
*/
|
|
50
|
+
export declare function resolveSopManifestPath(id: string, projectRoot?: string): {
|
|
51
|
+
path: string;
|
|
52
|
+
scope: SopScope;
|
|
53
|
+
} | null;
|
|
54
|
+
/**
|
|
55
|
+
* Per-project run-state directory for a SOP: `<project>/.peaks/sop-state/<id>/`.
|
|
56
|
+
* Holds `state.json` (current phase + history) and the bypass counter, so a
|
|
57
|
+
* SOP's execution data stays isolated per project even though its definition is
|
|
58
|
+
* global.
|
|
59
|
+
*/
|
|
60
|
+
export declare function sopStateDir(projectRoot: string, id: string): string;
|
|
61
|
+
/** Per-project run-state file for a SOP: `<project>/.peaks/sop-state/<id>/state.json`. */
|
|
62
|
+
export declare function sopStatePath(projectRoot: string, id: string): string;
|