peaks-cli 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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/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/sop-manifest.schema.json +52 -0
- package/skills/peaks-sop/SKILL.md +192 -0
- package/skills/peaks-sop/references/sop-authoring.md +161 -0
|
@@ -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;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
/** Global Peaks home (`~/.peaks`), overridable via PEAKS_HOME for test isolation. */
|
|
5
|
+
export function peaksHome() {
|
|
6
|
+
const override = process.env.PEAKS_HOME;
|
|
7
|
+
return override !== undefined && override.length > 0 ? resolve(override) : join(homedir(), '.peaks');
|
|
8
|
+
}
|
|
9
|
+
/** Global SOP definition directory: `~/.peaks/sops/<id>`. */
|
|
10
|
+
export function sopDir(id) {
|
|
11
|
+
return join(peaksHome(), 'sops', id);
|
|
12
|
+
}
|
|
13
|
+
/** Global SOP manifest: `~/.peaks/sops/<id>/sop.json`. */
|
|
14
|
+
export function sopManifestPath(id) {
|
|
15
|
+
return join(sopDir(id), 'sop.json');
|
|
16
|
+
}
|
|
17
|
+
/** Global SOP skill: `~/.peaks/sops/<id>/SKILL.md`. */
|
|
18
|
+
export function sopSkillPath(id) {
|
|
19
|
+
return join(sopDir(id), 'SKILL.md');
|
|
20
|
+
}
|
|
21
|
+
/** Global registry of all registered SOPs: `~/.peaks/sops/registry.json`. */
|
|
22
|
+
export function registryPath() {
|
|
23
|
+
return join(peaksHome(), 'sops', 'registry.json');
|
|
24
|
+
}
|
|
25
|
+
/** Project SOP definition directory: `<project>/.peaks/sops/<id>` (committed, team-shared). */
|
|
26
|
+
export function projectSopDir(projectRoot, id) {
|
|
27
|
+
return join(projectRoot, '.peaks', 'sops', id);
|
|
28
|
+
}
|
|
29
|
+
/** Project SOP manifest: `<project>/.peaks/sops/<id>/sop.json`. */
|
|
30
|
+
export function projectSopManifestPath(projectRoot, id) {
|
|
31
|
+
return join(projectSopDir(projectRoot, id), 'sop.json');
|
|
32
|
+
}
|
|
33
|
+
/** Project SOP skill: `<project>/.peaks/sops/<id>/SKILL.md`. */
|
|
34
|
+
export function projectSopSkillPath(projectRoot, id) {
|
|
35
|
+
return join(projectSopDir(projectRoot, id), 'SKILL.md');
|
|
36
|
+
}
|
|
37
|
+
/** Project registry: `<project>/.peaks/sops/registry.json` (committed, team-shared). */
|
|
38
|
+
export function projectRegistryPath(projectRoot) {
|
|
39
|
+
return join(projectRoot, '.peaks', 'sops', 'registry.json');
|
|
40
|
+
}
|
|
41
|
+
/** Definition directory for a scope. */
|
|
42
|
+
export function scopedSopDir(scope, projectRoot, id) {
|
|
43
|
+
if (scope === 'project') {
|
|
44
|
+
if (projectRoot === undefined)
|
|
45
|
+
throw new Error('Project scope requires a project root');
|
|
46
|
+
return projectSopDir(projectRoot, id);
|
|
47
|
+
}
|
|
48
|
+
return sopDir(id);
|
|
49
|
+
}
|
|
50
|
+
/** Manifest path for a scope. */
|
|
51
|
+
export function scopedSopManifestPath(scope, projectRoot, id) {
|
|
52
|
+
return join(scopedSopDir(scope, projectRoot, id), 'sop.json');
|
|
53
|
+
}
|
|
54
|
+
/** Registry path for a scope. */
|
|
55
|
+
export function scopedRegistryPath(scope, projectRoot) {
|
|
56
|
+
if (scope === 'project') {
|
|
57
|
+
if (projectRoot === undefined)
|
|
58
|
+
throw new Error('Project scope requires a project root');
|
|
59
|
+
return projectRegistryPath(projectRoot);
|
|
60
|
+
}
|
|
61
|
+
return registryPath();
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Resolve where a SOP's manifest lives, project-first: the project layer wins
|
|
65
|
+
* when present, otherwise global. Returns null when neither layer has it.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveSopManifestPath(id, projectRoot) {
|
|
68
|
+
if (projectRoot !== undefined) {
|
|
69
|
+
const projectPath = projectSopManifestPath(projectRoot, id);
|
|
70
|
+
if (existsSync(projectPath)) {
|
|
71
|
+
return { path: projectPath, scope: 'project' };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const globalPath = sopManifestPath(id);
|
|
75
|
+
if (existsSync(globalPath)) {
|
|
76
|
+
return { path: globalPath, scope: 'global' };
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Per-project run-state directory for a SOP: `<project>/.peaks/sop-state/<id>/`.
|
|
82
|
+
* Holds `state.json` (current phase + history) and the bypass counter, so a
|
|
83
|
+
* SOP's execution data stays isolated per project even though its definition is
|
|
84
|
+
* global.
|
|
85
|
+
*/
|
|
86
|
+
export function sopStateDir(projectRoot, id) {
|
|
87
|
+
return join(projectRoot, '.peaks', 'sop-state', id);
|
|
88
|
+
}
|
|
89
|
+
/** Per-project run-state file for a SOP: `<project>/.peaks/sop-state/<id>/state.json`. */
|
|
90
|
+
export function sopStatePath(projectRoot, id) {
|
|
91
|
+
return join(sopStateDir(projectRoot, id), 'state.json');
|
|
92
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type SopScope } from './sop-paths.js';
|
|
2
|
+
import type { RegisteredSop, SopRegistry } from './sop-types.js';
|
|
3
|
+
/**
|
|
4
|
+
* SOP gate registry — Feature A (Slice 2) + team layer (Slice 4b).
|
|
5
|
+
*
|
|
6
|
+
* `registerSop` validates a SOP (must lint clean) then upserts its gates into a
|
|
7
|
+
* registry. SOPs live in two layers: GLOBAL (`~/.peaks/sops/registry.json`, your
|
|
8
|
+
* personal SOPs) and PROJECT (`<project>/.peaks/sops/registry.json`, committed
|
|
9
|
+
* into the repo so teammates get them). `registerSop` writes to the layer the
|
|
10
|
+
* caller targets (project when `projectRoot` is set, else global). `readRegistry`
|
|
11
|
+
* returns the MERGED view (project entries win over global by id) so execution
|
|
12
|
+
* and enumeration see every applicable SOP. The registry is the single
|
|
13
|
+
* enumerable, countable source a future metering layer (Feature B) reads; this
|
|
14
|
+
* slice only records and counts — NO limit, tier, or billing logic. Built-in
|
|
15
|
+
* peaks-* gates are never recorded here.
|
|
16
|
+
*/
|
|
17
|
+
export type RegisterSopResult = {
|
|
18
|
+
id: string;
|
|
19
|
+
registered: RegisteredSop;
|
|
20
|
+
/** Total gates across all registered SOPs after this upsert (the workspace pool count). */
|
|
21
|
+
gateCount: number;
|
|
22
|
+
/** Which layer the SOP was registered into. */
|
|
23
|
+
scope: SopScope;
|
|
24
|
+
/** false when --dry-run previewed the registration without writing registry.json. */
|
|
25
|
+
applied: boolean;
|
|
26
|
+
};
|
|
27
|
+
export type RegisterSopOptions = {
|
|
28
|
+
id: string;
|
|
29
|
+
allowCommands?: boolean;
|
|
30
|
+
/** Preview the registration without writing registry.json. */
|
|
31
|
+
dryRun?: boolean;
|
|
32
|
+
/** When set, register into the project layer (`<projectRoot>/.peaks/sops`) instead of global. */
|
|
33
|
+
projectRoot?: string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Read the registry that execution/enumeration should see: the MERGED view of
|
|
37
|
+
* the project layer (when projectRoot is given) over the global layer — a
|
|
38
|
+
* project entry wins over a global one with the same id. Without projectRoot,
|
|
39
|
+
* the global registry only.
|
|
40
|
+
*/
|
|
41
|
+
export declare function readRegistry(projectRoot?: string): Promise<SopRegistry>;
|
|
42
|
+
export declare class SopRegisterError extends Error {
|
|
43
|
+
readonly code: string;
|
|
44
|
+
constructor(code: string, message: string);
|
|
45
|
+
}
|
|
46
|
+
export declare function registerSop(options: RegisterSopOptions): Promise<RegisterSopResult>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { lintSop } from './sop-service.js';
|
|
5
|
+
import { scopedRegistryPath, scopedSopManifestPath } from './sop-paths.js';
|
|
6
|
+
/** Manifest location relative to its Peaks home (`~/.peaks/` or `<project>/.peaks/`). Machine-independent. */
|
|
7
|
+
function relativeManifestPath(id) {
|
|
8
|
+
return `sops/${id}/sop.json`;
|
|
9
|
+
}
|
|
10
|
+
function countGates(sops) {
|
|
11
|
+
// Tolerate a hand-edited / corrupted registry: a malformed entry counts as 0
|
|
12
|
+
// gates rather than crashing the read-only `sop registry` command.
|
|
13
|
+
return sops.reduce((total, sop) => total + (Array.isArray(sop?.gates) ? sop.gates.length : 0), 0);
|
|
14
|
+
}
|
|
15
|
+
/** Read a single registry layer; empty when absent. Throws on corrupt JSON. */
|
|
16
|
+
async function readRegistryAt(scope, projectRoot) {
|
|
17
|
+
if (scope === 'project' && projectRoot === undefined) {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
const path = scopedRegistryPath(scope, projectRoot);
|
|
21
|
+
if (!existsSync(path)) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
25
|
+
return Array.isArray(parsed.sops) ? parsed.sops : [];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Read the registry that execution/enumeration should see: the MERGED view of
|
|
29
|
+
* the project layer (when projectRoot is given) over the global layer — a
|
|
30
|
+
* project entry wins over a global one with the same id. Without projectRoot,
|
|
31
|
+
* the global registry only.
|
|
32
|
+
*/
|
|
33
|
+
export async function readRegistry(projectRoot) {
|
|
34
|
+
const globalSops = await readRegistryAt('global', undefined);
|
|
35
|
+
const projectSops = projectRoot !== undefined ? await readRegistryAt('project', projectRoot) : [];
|
|
36
|
+
const byId = new Map();
|
|
37
|
+
for (const sop of globalSops)
|
|
38
|
+
byId.set(sop.id, sop);
|
|
39
|
+
for (const sop of projectSops)
|
|
40
|
+
byId.set(sop.id, sop); // project wins
|
|
41
|
+
const sops = [...byId.values()].sort((left, right) => left.id.localeCompare(right.id));
|
|
42
|
+
return { version: 1, sops, gateCount: countGates(sops) };
|
|
43
|
+
}
|
|
44
|
+
export class SopRegisterError extends Error {
|
|
45
|
+
code;
|
|
46
|
+
constructor(code, message) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = 'SopRegisterError';
|
|
49
|
+
this.code = code;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export async function registerSop(options) {
|
|
53
|
+
const scope = options.projectRoot !== undefined ? 'project' : 'global';
|
|
54
|
+
// Register the EXACT layer the caller targets — not the precedence resolution.
|
|
55
|
+
const manifestPath = scopedSopManifestPath(scope, options.projectRoot, options.id);
|
|
56
|
+
if (!existsSync(manifestPath)) {
|
|
57
|
+
throw new SopRegisterError('SOP_NOT_FOUND', `No SOP found for id "${options.id}"`);
|
|
58
|
+
}
|
|
59
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
60
|
+
const lintOptions = { id: options.id };
|
|
61
|
+
if (options.allowCommands === true)
|
|
62
|
+
lintOptions.allowCommands = true;
|
|
63
|
+
if (options.projectRoot !== undefined)
|
|
64
|
+
lintOptions.projectRoot = options.projectRoot;
|
|
65
|
+
const lint = await lintSop(lintOptions);
|
|
66
|
+
if (lint === null || !lint.ok) {
|
|
67
|
+
throw new SopRegisterError('SOP_INVALID', `SOP "${options.id}" must lint clean before it can be registered`);
|
|
68
|
+
}
|
|
69
|
+
const gates = manifest.gates.map((gate) => ({
|
|
70
|
+
ref: `${manifest.id}/${gate.id}`,
|
|
71
|
+
gateId: gate.id,
|
|
72
|
+
sopId: manifest.id,
|
|
73
|
+
phase: gate.phase,
|
|
74
|
+
transition: `${manifest.id}:${gate.phase}`
|
|
75
|
+
}));
|
|
76
|
+
const registered = { id: manifest.id, path: relativeManifestPath(manifest.id), gates };
|
|
77
|
+
// Upsert within the SAME layer's registry (not the merged view).
|
|
78
|
+
const current = await readRegistryAt(scope, options.projectRoot);
|
|
79
|
+
const others = current.filter((sop) => sop.id !== manifest.id);
|
|
80
|
+
const sops = [...others, registered].sort((left, right) => left.id.localeCompare(right.id));
|
|
81
|
+
const registry = { version: 1, sops, gateCount: countGates(sops) };
|
|
82
|
+
if (options.dryRun === true) {
|
|
83
|
+
return { id: manifest.id, registered, gateCount: registry.gateCount, scope, applied: false };
|
|
84
|
+
}
|
|
85
|
+
const path = scopedRegistryPath(scope, options.projectRoot);
|
|
86
|
+
await mkdir(dirname(path), { recursive: true });
|
|
87
|
+
await writeFile(path, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
|
|
88
|
+
return { id: manifest.id, registered, gateCount: registry.gateCount, scope, applied: true };
|
|
89
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type SopManifest } from './sop-types.js';
|
|
2
|
+
export type SopInitOptions = {
|
|
3
|
+
id: string;
|
|
4
|
+
name?: string;
|
|
5
|
+
apply?: boolean;
|
|
6
|
+
/** When set, scaffold into the project layer (`<projectRoot>/.peaks/sops`) instead of global. */
|
|
7
|
+
projectRoot?: string;
|
|
8
|
+
};
|
|
9
|
+
export type SopInitResult = {
|
|
10
|
+
id: string;
|
|
11
|
+
dir: string;
|
|
12
|
+
manifestPath: string;
|
|
13
|
+
skillPath: string;
|
|
14
|
+
manifest: SopManifest;
|
|
15
|
+
skillContent: string;
|
|
16
|
+
applied: boolean;
|
|
17
|
+
};
|
|
18
|
+
export type SopLintSeverity = 'error' | 'warning';
|
|
19
|
+
export type SopLintFinding = {
|
|
20
|
+
code: string;
|
|
21
|
+
message: string;
|
|
22
|
+
gateId?: string;
|
|
23
|
+
severity: SopLintSeverity;
|
|
24
|
+
};
|
|
25
|
+
export type SopLintResult = {
|
|
26
|
+
ok: boolean;
|
|
27
|
+
id: string;
|
|
28
|
+
manifestPath: string;
|
|
29
|
+
gateCount: number;
|
|
30
|
+
gateIds: string[];
|
|
31
|
+
findings: SopLintFinding[];
|
|
32
|
+
};
|
|
33
|
+
export type SopLintOptions = {
|
|
34
|
+
id: string;
|
|
35
|
+
/** `command`-type gates run shell-less processes; require explicit opt-in (OQ3 security). */
|
|
36
|
+
allowCommands?: boolean;
|
|
37
|
+
/** When set, lint the project-layer manifest (`<projectRoot>/.peaks/sops`) instead of global. */
|
|
38
|
+
projectRoot?: string;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Read and JSON-parse a SOP manifest for EXECUTION, resolving project-first then
|
|
42
|
+
* global (the project layer wins). Returns null when neither layer has it;
|
|
43
|
+
* throws on malformed JSON. Callers that need validation should run lintSop.
|
|
44
|
+
*/
|
|
45
|
+
export declare function readSopManifest(id: string, projectRoot?: string): Promise<SopManifest | null>;
|
|
46
|
+
export declare function initSop(options: SopInitOptions): Promise<SopInitResult>;
|
|
47
|
+
export declare function lintSop(options: SopLintOptions): Promise<SopLintResult | null>;
|