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,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>;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { SOP_GATE_CHECK_TYPES, SOP_ID_PATTERN } from './sop-types.js';
|
|
4
|
+
import { sopDir, sopManifestPath, sopSkillPath, projectSopDir, projectSopManifestPath, projectSopSkillPath, resolveSopManifestPath } from './sop-paths.js';
|
|
5
|
+
/**
|
|
6
|
+
* SOP authoring substrate — Feature A, Slice 1.
|
|
7
|
+
*
|
|
8
|
+
* `initSop` scaffolds a user-authored SOP (manifest + registrable SKILL.md);
|
|
9
|
+
* `lintSop` validates the manifest. No registry, no enforcement here — Slice 1
|
|
10
|
+
* only lets users create and validate a SOP. The SOP id grammar
|
|
11
|
+
* (SOP_ID_PATTERN: lowercase kebab, no dots/slashes) doubles as the path
|
|
12
|
+
* traversal guard, matching how request artifacts guard their ids.
|
|
13
|
+
*
|
|
14
|
+
* SOP definitions are GLOBAL (`~/.peaks/sops/<id>/`, see ./sop-paths) so one
|
|
15
|
+
* authored SOP is reusable across projects; only run-state is per-project. The
|
|
16
|
+
* authoring ops here therefore take no projectRoot.
|
|
17
|
+
*/
|
|
18
|
+
const RESERVED_ID_PREFIX = 'peaks-';
|
|
19
|
+
const RESERVED_IDS = new Set(['peaks']);
|
|
20
|
+
/**
|
|
21
|
+
* Read and JSON-parse a SOP manifest for EXECUTION, resolving project-first then
|
|
22
|
+
* global (the project layer wins). Returns null when neither layer has it;
|
|
23
|
+
* throws on malformed JSON. Callers that need validation should run lintSop.
|
|
24
|
+
*/
|
|
25
|
+
export async function readSopManifest(id, projectRoot) {
|
|
26
|
+
const resolved = resolveSopManifestPath(id, projectRoot);
|
|
27
|
+
if (resolved === null) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return JSON.parse(await readFile(resolved.path, 'utf8'));
|
|
31
|
+
}
|
|
32
|
+
/** Why an id cannot be used; null when the id is acceptable. */
|
|
33
|
+
function reservedIdReason(id) {
|
|
34
|
+
if (id.startsWith(RESERVED_ID_PREFIX) || RESERVED_IDS.has(id)) {
|
|
35
|
+
return `SOP id "${id}" collides with the reserved built-in peaks-* namespace`;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
function scaffoldManifest(id, name) {
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
name,
|
|
43
|
+
description: '',
|
|
44
|
+
phases: ['draft', 'review', 'done'],
|
|
45
|
+
gates: [
|
|
46
|
+
{ id: 'example-gate', phase: 'review', check: { type: 'file-exists', path: 'README.md' } }
|
|
47
|
+
]
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function scaffoldSkill(manifest) {
|
|
51
|
+
const phaseList = manifest.phases.join(' → ');
|
|
52
|
+
return [
|
|
53
|
+
'---',
|
|
54
|
+
`name: ${manifest.id}`,
|
|
55
|
+
`description: User-authored Peaks SOP "${manifest.name}". Phases: ${phaseList}.`,
|
|
56
|
+
'---',
|
|
57
|
+
'',
|
|
58
|
+
`# ${manifest.name}`,
|
|
59
|
+
'',
|
|
60
|
+
'A user-authored SOP. Edit `sop.json` to define phases and gates, then run',
|
|
61
|
+
'`peaks sop lint` to validate it.',
|
|
62
|
+
'',
|
|
63
|
+
'## Phases',
|
|
64
|
+
'',
|
|
65
|
+
...manifest.phases.map((phase) => `- ${phase}`),
|
|
66
|
+
''
|
|
67
|
+
].join('\n');
|
|
68
|
+
}
|
|
69
|
+
export async function initSop(options) {
|
|
70
|
+
if (!SOP_ID_PATTERN.test(options.id)) {
|
|
71
|
+
throw new Error(`Invalid SOP id: ${options.id} (expected lowercase letters, digits, and dashes, starting alphanumeric)`);
|
|
72
|
+
}
|
|
73
|
+
const reserved = reservedIdReason(options.id);
|
|
74
|
+
if (reserved !== null) {
|
|
75
|
+
throw new Error(reserved);
|
|
76
|
+
}
|
|
77
|
+
const inProject = options.projectRoot !== undefined;
|
|
78
|
+
const dir = inProject ? projectSopDir(options.projectRoot, options.id) : sopDir(options.id);
|
|
79
|
+
const manifestPath = inProject ? projectSopManifestPath(options.projectRoot, options.id) : sopManifestPath(options.id);
|
|
80
|
+
const skillPath = inProject ? projectSopSkillPath(options.projectRoot, options.id) : sopSkillPath(options.id);
|
|
81
|
+
if (existsSync(manifestPath)) {
|
|
82
|
+
throw new Error(`A SOP with id "${options.id}" already exists at ${manifestPath}. Remove it before re-running peaks sop init.`);
|
|
83
|
+
}
|
|
84
|
+
const manifest = scaffoldManifest(options.id, options.name ?? options.id);
|
|
85
|
+
const skillContent = scaffoldSkill(manifest);
|
|
86
|
+
const result = {
|
|
87
|
+
id: options.id,
|
|
88
|
+
dir,
|
|
89
|
+
manifestPath,
|
|
90
|
+
skillPath,
|
|
91
|
+
manifest,
|
|
92
|
+
skillContent,
|
|
93
|
+
applied: false
|
|
94
|
+
};
|
|
95
|
+
if (options.apply !== true) {
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
await mkdir(dir, { recursive: true });
|
|
99
|
+
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
100
|
+
await writeFile(skillPath, skillContent, 'utf8');
|
|
101
|
+
return { ...result, applied: true };
|
|
102
|
+
}
|
|
103
|
+
function pushError(findings, code, message, gateId) {
|
|
104
|
+
findings.push(gateId === undefined ? { code, message, severity: 'error' } : { code, message, gateId, severity: 'error' });
|
|
105
|
+
}
|
|
106
|
+
function lintGate(gate, index, phases, seenGateIds, allowCommands, findings) {
|
|
107
|
+
const label = typeof gate?.id === 'string' && gate.id.length > 0 ? gate.id : `#${index}`;
|
|
108
|
+
if (typeof gate?.id !== 'string' || !SOP_ID_PATTERN.test(gate.id)) {
|
|
109
|
+
pushError(findings, 'INVALID_GATE_ID', `Gate ${label} has an invalid id (expected lowercase kebab)`, label);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (seenGateIds.has(gate.id)) {
|
|
113
|
+
pushError(findings, 'DUPLICATE_GATE_ID', `Duplicate gate id "${gate.id}"`, gate.id);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
seenGateIds.add(gate.id);
|
|
117
|
+
if (typeof gate.phase !== 'string' || !phases.has(gate.phase)) {
|
|
118
|
+
pushError(findings, 'GATE_PHASE_UNKNOWN', `Gate "${gate.id}" binds to unknown phase "${String(gate.phase)}"`, gate.id);
|
|
119
|
+
}
|
|
120
|
+
const check = gate.check;
|
|
121
|
+
if (check === null || typeof check !== 'object' || !SOP_GATE_CHECK_TYPES.includes(check.type ?? '')) {
|
|
122
|
+
pushError(findings, 'INVALID_CHECK_TYPE', `Gate "${gate.id}" has an invalid or missing check type (expected ${SOP_GATE_CHECK_TYPES.join(' | ')})`, gate.id);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (check.type === 'file-exists' && (typeof check.path !== 'string' || check.path.length === 0)) {
|
|
126
|
+
pushError(findings, 'CHECK_MISSING_FIELD', `Gate "${gate.id}" file-exists check requires a non-empty "path"`, gate.id);
|
|
127
|
+
}
|
|
128
|
+
if (check.type === 'grep' && (typeof check.file !== 'string' || check.file.length === 0 || typeof check.pattern !== 'string' || check.pattern.length === 0)) {
|
|
129
|
+
pushError(findings, 'CHECK_MISSING_FIELD', `Gate "${gate.id}" grep check requires non-empty "file" and "pattern"`, gate.id);
|
|
130
|
+
}
|
|
131
|
+
if (check.type === 'command') {
|
|
132
|
+
if (!Array.isArray(check.run) || check.run.length === 0 || !check.run.every((part) => typeof part === 'string')) {
|
|
133
|
+
pushError(findings, 'CHECK_MISSING_FIELD', `Gate "${gate.id}" command check requires a non-empty string array "run"`, gate.id);
|
|
134
|
+
}
|
|
135
|
+
if (!allowCommands) {
|
|
136
|
+
pushError(findings, 'COMMAND_NOT_ALLOWED', `Gate "${gate.id}" uses a command check; re-run with --allow-commands to permit command-type gates`, gate.id);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function lintGuard(guard, index, phases, findings) {
|
|
141
|
+
const label = `#${index}`;
|
|
142
|
+
if (typeof guard?.phase !== 'string' || !phases.has(guard.phase)) {
|
|
143
|
+
pushError(findings, 'GUARD_PHASE_UNKNOWN', `Guard ${label} binds to unknown phase "${String(guard?.phase)}"`);
|
|
144
|
+
}
|
|
145
|
+
if (typeof guard?.bash !== 'string' || guard.bash.length === 0) {
|
|
146
|
+
pushError(findings, 'GUARD_MISSING_PATTERN', `Guard ${label} requires a non-empty "bash" pattern`);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
// eslint-disable-next-line no-new
|
|
151
|
+
new RegExp(guard.bash);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
pushError(findings, 'GUARD_INVALID_PATTERN', `Guard ${label} has an invalid bash regex "${guard.bash}"`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export async function lintSop(options) {
|
|
158
|
+
// lint validates the EXACT layer the caller targets (project when projectRoot
|
|
159
|
+
// is set, else global) — not the precedence resolution used for execution.
|
|
160
|
+
const manifestPath = options.projectRoot !== undefined
|
|
161
|
+
? projectSopManifestPath(options.projectRoot, options.id)
|
|
162
|
+
: sopManifestPath(options.id);
|
|
163
|
+
if (!existsSync(manifestPath)) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
const findings = [];
|
|
167
|
+
let manifest = null;
|
|
168
|
+
try {
|
|
169
|
+
manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
pushError(findings, 'INVALID_JSON', `Manifest is not valid JSON: ${error instanceof Error ? error.message : 'parse error'}`);
|
|
173
|
+
return { ok: false, id: options.id, manifestPath, gateCount: 0, gateIds: [], findings };
|
|
174
|
+
}
|
|
175
|
+
if (typeof manifest.id !== 'string' || !SOP_ID_PATTERN.test(manifest.id)) {
|
|
176
|
+
pushError(findings, 'INVALID_ID', `Manifest id "${String(manifest.id)}" is invalid (expected lowercase kebab)`);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
const reserved = reservedIdReason(manifest.id);
|
|
180
|
+
if (reserved !== null) {
|
|
181
|
+
pushError(findings, 'RESERVED_ID', reserved);
|
|
182
|
+
}
|
|
183
|
+
if (manifest.id !== options.id) {
|
|
184
|
+
pushError(findings, 'ID_MISMATCH', `Manifest id "${manifest.id}" does not match its directory "${options.id}"`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const phases = Array.isArray(manifest.phases) ? manifest.phases : [];
|
|
188
|
+
if (phases.length === 0) {
|
|
189
|
+
pushError(findings, 'EMPTY_PHASES', 'Manifest must declare at least one phase');
|
|
190
|
+
}
|
|
191
|
+
const phaseSet = new Set();
|
|
192
|
+
for (const phase of phases) {
|
|
193
|
+
if (phaseSet.has(phase)) {
|
|
194
|
+
pushError(findings, 'DUPLICATE_PHASE', `Duplicate phase "${phase}"`);
|
|
195
|
+
}
|
|
196
|
+
phaseSet.add(phase);
|
|
197
|
+
}
|
|
198
|
+
const gates = Array.isArray(manifest.gates) ? manifest.gates : [];
|
|
199
|
+
const seenGateIds = new Set();
|
|
200
|
+
gates.forEach((gate, index) => lintGate(gate, index, phaseSet, seenGateIds, options.allowCommands === true, findings));
|
|
201
|
+
const guards = Array.isArray(manifest.guards) ? manifest.guards : [];
|
|
202
|
+
guards.forEach((guard, index) => lintGuard(guard, index, phaseSet, findings));
|
|
203
|
+
return {
|
|
204
|
+
ok: findings.every((finding) => finding.severity !== 'error'),
|
|
205
|
+
id: options.id,
|
|
206
|
+
manifestPath,
|
|
207
|
+
gateCount: gates.length,
|
|
208
|
+
gateIds: [...seenGateIds],
|
|
209
|
+
findings
|
|
210
|
+
};
|
|
211
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-authored SOP (standard operating procedure) skill types — Feature A.
|
|
3
|
+
*
|
|
4
|
+
* A SOP is a user-defined workflow: ordered phases plus gates that guard entry
|
|
5
|
+
* into a phase. Gates are first-class, addressable objects (stable id) so a
|
|
6
|
+
* later metering layer (Feature B) can count them; Slice 1 only models and
|
|
7
|
+
* validates them — no registry, no enforcement.
|
|
8
|
+
*/
|
|
9
|
+
export type SopGateCheckType = 'file-exists' | 'grep' | 'command';
|
|
10
|
+
export type SopGateCheck = {
|
|
11
|
+
type: 'file-exists';
|
|
12
|
+
path: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* grep: by default passes when `pattern` is FOUND in `file`. Set `absent: true`
|
|
16
|
+
* to invert — pass when the pattern is NOT found. `absent` expresses "must not
|
|
17
|
+
* contain X" (e.g. no leftover TODO) as a pure-text check, with no command gate
|
|
18
|
+
* and no `--allow-commands` escalation.
|
|
19
|
+
*/
|
|
20
|
+
| {
|
|
21
|
+
type: 'grep';
|
|
22
|
+
file: string;
|
|
23
|
+
pattern: string;
|
|
24
|
+
absent?: boolean;
|
|
25
|
+
} | {
|
|
26
|
+
type: 'command';
|
|
27
|
+
run: string[];
|
|
28
|
+
expectExitZero?: boolean;
|
|
29
|
+
};
|
|
30
|
+
export type SopGate = {
|
|
31
|
+
/** Stable id, unique within the SOP. Addressed by `peaks sop check --gate <id>`. */
|
|
32
|
+
id: string;
|
|
33
|
+
/** The phase whose entry this gate guards; must be one of the SOP's phases. */
|
|
34
|
+
phase: string;
|
|
35
|
+
check: SopGateCheck;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Binds a concrete irreversible Bash action to a phase: running a command that
|
|
39
|
+
* matches `bash` (a JS regex) counts as performing that phase's action, so the
|
|
40
|
+
* phase's gates must pass first. Enforced un-bypassably by the PreToolUse hook
|
|
41
|
+
* (`peaks gate enforce`). Optional — a SOP without guards still works exactly as
|
|
42
|
+
* before; enforcement is an opt-in overlay.
|
|
43
|
+
*/
|
|
44
|
+
export type SopPhaseGuard = {
|
|
45
|
+
/** The phase whose gates must pass before the matching command may run. Must be a declared phase. */
|
|
46
|
+
phase: string;
|
|
47
|
+
/** JS regular expression tested against the Bash `tool_input.command`. */
|
|
48
|
+
bash: string;
|
|
49
|
+
};
|
|
50
|
+
export type SopManifest = {
|
|
51
|
+
/** SOP id; namespaced separately from built-in peaks-* skills. */
|
|
52
|
+
id: string;
|
|
53
|
+
name: string;
|
|
54
|
+
description?: string;
|
|
55
|
+
/** Ordered, unique phase names. */
|
|
56
|
+
phases: string[];
|
|
57
|
+
gates: SopGate[];
|
|
58
|
+
/** Optional Bash-action guards enforced by the PreToolUse hook (opt-in). */
|
|
59
|
+
guards?: SopPhaseGuard[];
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* A registered gate's workspace-level identity. `ref` (`<sopId>/<gateId>`) is
|
|
63
|
+
* unique across the workspace; `transition` (`<sopId>:<phase>`) is the binding
|
|
64
|
+
* a future enforcement layer (Slice 3) consults. Built-in peaks-* gates are
|
|
65
|
+
* never recorded here.
|
|
66
|
+
*/
|
|
67
|
+
export type RegisteredGate = {
|
|
68
|
+
ref: string;
|
|
69
|
+
gateId: string;
|
|
70
|
+
sopId: string;
|
|
71
|
+
phase: string;
|
|
72
|
+
transition: string;
|
|
73
|
+
};
|
|
74
|
+
export type RegisteredSop = {
|
|
75
|
+
id: string;
|
|
76
|
+
path: string;
|
|
77
|
+
gates: RegisteredGate[];
|
|
78
|
+
};
|
|
79
|
+
export type SopRegistry = {
|
|
80
|
+
version: 1;
|
|
81
|
+
sops: RegisteredSop[];
|
|
82
|
+
/** Total gate count across all registered SOPs — the workspace pool a metering layer would read. */
|
|
83
|
+
gateCount: number;
|
|
84
|
+
};
|
|
85
|
+
export type SopCheckResult = 'pass' | 'fail' | 'blocked';
|
|
86
|
+
export declare const SOP_GATE_CHECK_TYPES: ReadonlyArray<SopGateCheckType>;
|
|
87
|
+
/** SOP id grammar: lowercase kebab, must start alphanumeric. */
|
|
88
|
+
export declare const SOP_ID_PATTERN: RegExp;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-authored SOP (standard operating procedure) skill types — Feature A.
|
|
3
|
+
*
|
|
4
|
+
* A SOP is a user-defined workflow: ordered phases plus gates that guard entry
|
|
5
|
+
* into a phase. Gates are first-class, addressable objects (stable id) so a
|
|
6
|
+
* later metering layer (Feature B) can count them; Slice 1 only models and
|
|
7
|
+
* validates them — no registry, no enforcement.
|
|
8
|
+
*/
|
|
9
|
+
export const SOP_GATE_CHECK_TYPES = ['file-exists', 'grep', 'command'];
|
|
10
|
+
/** SOP id grammar: lowercase kebab, must start alphanumeric. */
|
|
11
|
+
export const SOP_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
@@ -2,5 +2,5 @@ export declare const repoRoot: string;
|
|
|
2
2
|
export declare const skillsDir: string;
|
|
3
3
|
export declare const schemasDir: string;
|
|
4
4
|
export declare const templatesDir: string;
|
|
5
|
-
export declare const requiredSkillNames: readonly ["peaks-solo", "peaks-prd", "peaks-ui", "peaks-rd", "peaks-qa", "peaks-sc", "peaks-txt"];
|
|
5
|
+
export declare const requiredSkillNames: readonly ["peaks-solo", "peaks-prd", "peaks-ui", "peaks-rd", "peaks-qa", "peaks-sc", "peaks-txt", "peaks-sop"];
|
|
6
6
|
export declare const requiredSchemaFiles: readonly ["artifact-manifest.schema.json", "context-capsule.schema.json", "approval-record.schema.json", "change-impact.schema.json", "refactor-slice-spec.schema.json", "artifact-retention-report.schema.json", "capability-source.schema.json", "capability-item.schema.json", "capability-availability.schema.json", "recommendation-plan.schema.json", "artifact-workspace.schema.json", "mcp-server.schema.json", "mcp-install-spec.schema.json", "mcp-install-plan.schema.json", "mcp-apply-result.schema.json", "openspec-change-summary.schema.json", "openspec-render-request.schema.json", "openspec-validation-result.schema.json", "doctor-report.schema.json"];
|
package/dist/src/shared/paths.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "1.1
|
|
1
|
+
export declare const CLI_VERSION = "1.2.1";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "1.1
|
|
1
|
+
export const CLI_VERSION = "1.2.1";
|
package/package.json
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"id": {
|
|
15
15
|
"type": "string",
|
|
16
16
|
"pattern": "^(skill|skill-name|skill-parse|skill-runbook|skill-apply-note|skill-presence|statusline|schema|config|doctor-self|capability):[A-Za-z0-9][A-Za-z0-9._-]*$",
|
|
17
|
-
"description": "Stable check id. Known prefixes: skill:<name> (required skill present), skill-name:<dir> (directory matches declared name), skill-parse:<dir> (skill metadata parsed), skill-runbook:<name> (Default runbook section exists), skill-apply-note:<name> (destructive --apply lines carry an authorization/--dry-run note), skill-presence:<topic> (status of .peaks/.active-skill.json — current/freshness), statusline:<topic> (
|
|
17
|
+
"description": "Stable check id. Known prefixes: skill:<name> (required skill present), skill-name:<dir> (directory matches declared name), skill-parse:<dir> (skill metadata parsed), skill-runbook:<name> (Default runbook section exists), skill-apply-note:<name> (destructive --apply lines carry an authorization/--dry-run note), skill-presence:<topic> (status of .peaks/.active-skill.json — current/freshness/workspace), statusline:<topic> (out-of-band Claude Code statusLine — install/runtime), schema:<file> (schema file exists and is valid JSON), config:<scope> (optional config locations), doctor-self:<topic> (doctor validates its own output against this schema), capability:<name> (third-party capability is resolvable at the pinned version)."
|
|
18
18
|
},
|
|
19
19
|
"ok": { "type": "boolean" },
|
|
20
20
|
"message": { "type": "string", "minLength": 1 }
|