peer-ai 1.0.0-next.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/LICENSE +21 -0
- package/README.md +403 -0
- package/dist/assess.d.ts +102 -0
- package/dist/assess.js +545 -0
- package/dist/check.d.ts +30 -0
- package/dist/check.js +253 -0
- package/dist/checks.d.ts +15 -0
- package/dist/checks.js +15 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +231 -0
- package/dist/detect.d.ts +42 -0
- package/dist/detect.js +459 -0
- package/dist/doctor.d.ts +36 -0
- package/dist/doctor.js +297 -0
- package/dist/document.d.ts +30 -0
- package/dist/document.js +72 -0
- package/dist/enforcers.d.ts +6 -0
- package/dist/enforcers.js +307 -0
- package/dist/feedback.d.ts +67 -0
- package/dist/feedback.js +209 -0
- package/dist/files.d.ts +1 -0
- package/dist/files.js +72 -0
- package/dist/init.d.ts +31 -0
- package/dist/init.js +158 -0
- package/dist/mcp.d.ts +13 -0
- package/dist/mcp.js +247 -0
- package/dist/package-info.d.ts +4 -0
- package/dist/package-info.js +6 -0
- package/dist/pipeline.d.ts +82 -0
- package/dist/pipeline.js +265 -0
- package/dist/prompter.d.ts +23 -0
- package/dist/prompter.js +56 -0
- package/dist/render.d.ts +58 -0
- package/dist/render.js +557 -0
- package/dist/report.d.ts +3 -0
- package/dist/report.js +93 -0
- package/dist/routing.d.ts +24 -0
- package/dist/routing.js +121 -0
- package/dist/ruff.d.ts +19 -0
- package/dist/ruff.js +64 -0
- package/dist/standards.d.ts +46 -0
- package/dist/standards.js +130 -0
- package/dist/state.d.ts +22 -0
- package/dist/state.js +56 -0
- package/dist/test-helpers.d.ts +16 -0
- package/dist/test-helpers.js +62 -0
- package/dist/work.d.ts +147 -0
- package/dist/work.js +357 -0
- package/package.json +45 -0
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type ToolId } from "peer-ai-workflow";
|
|
2
|
+
import { type Detected, type DetectedTrack } from "./detect.ts";
|
|
3
|
+
import { type Prompter } from "./prompter.ts";
|
|
4
|
+
export declare const SCHEMA_URL = "https://raw.githubusercontent.com/AbuMahir980/peer-ai/main/packages/workflow/schemas/config.schema.json";
|
|
5
|
+
export type Stage = "prototype" | "mvp" | "production";
|
|
6
|
+
export type Team = "solo" | "team";
|
|
7
|
+
export interface InitOptions {
|
|
8
|
+
cwd: string;
|
|
9
|
+
yes: boolean;
|
|
10
|
+
dryRun: boolean;
|
|
11
|
+
name?: string;
|
|
12
|
+
stage?: Stage;
|
|
13
|
+
team?: Team;
|
|
14
|
+
tools?: ToolId[];
|
|
15
|
+
}
|
|
16
|
+
export interface Output {
|
|
17
|
+
log: (line: string) => void;
|
|
18
|
+
error: (line: string) => void;
|
|
19
|
+
}
|
|
20
|
+
interface Answers {
|
|
21
|
+
name: string;
|
|
22
|
+
description: string;
|
|
23
|
+
tracks: DetectedTrack[];
|
|
24
|
+
team: Team;
|
|
25
|
+
stage: Stage;
|
|
26
|
+
tools: ToolId[];
|
|
27
|
+
}
|
|
28
|
+
export declare function describeTrack(track: DetectedTrack): string;
|
|
29
|
+
export declare function buildConfig(detected: Detected, answers: Answers): Record<string, unknown>;
|
|
30
|
+
export declare function runInit(options: InitOptions, prompter: Prompter | undefined, out: Output): Promise<number>;
|
|
31
|
+
export {};
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// peer-ai init: works out what it can from the repository, asks about the rest, and writes
|
|
2
|
+
// peer-ai.config.json. It never overwrites an existing config, and it never assumes a stack:
|
|
3
|
+
// a project with nothing to detect gets a track of kind "other" until the user decides.
|
|
4
|
+
import { writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { TOOL_IDS, validateConfig } from "peer-ai-workflow";
|
|
7
|
+
import { CONFIG_FILE, detect } from "./detect.js";
|
|
8
|
+
import { Cancelled } from "./prompter.js";
|
|
9
|
+
export const SCHEMA_URL = "https://raw.githubusercontent.com/AbuMahir980/peer-ai/main/packages/workflow/schemas/config.schema.json";
|
|
10
|
+
const UNDECIDED_TRACK = { id: "app", kind: "other", stack: [] };
|
|
11
|
+
const KIND_CHOICES = [
|
|
12
|
+
{ value: "web", label: "A web app" },
|
|
13
|
+
{ value: "mobile", label: "A mobile app" },
|
|
14
|
+
{ value: "backend", label: "A backend or API" },
|
|
15
|
+
{ value: "desktop", label: "A desktop app" },
|
|
16
|
+
{ value: "cli", label: "A command-line tool" },
|
|
17
|
+
{ value: "library", label: "A library or SDK" },
|
|
18
|
+
{ value: "other", label: "Not decided yet" },
|
|
19
|
+
];
|
|
20
|
+
const STAGE_CHOICES = [
|
|
21
|
+
{ value: "prototype", label: "Prototype", hint: "trying an idea; only the essentials are checked" },
|
|
22
|
+
{ value: "mvp", label: "MVP", hint: "real users soon; critical paths are tested" },
|
|
23
|
+
{ value: "production", label: "Production", hint: "live; everything is checked" },
|
|
24
|
+
];
|
|
25
|
+
const TOOL_LABELS = {
|
|
26
|
+
"claude-code": "Claude Code",
|
|
27
|
+
codex: "Codex",
|
|
28
|
+
cursor: "Cursor",
|
|
29
|
+
copilot: "GitHub Copilot",
|
|
30
|
+
"gemini-cli": "Gemini CLI",
|
|
31
|
+
other: "Another tool",
|
|
32
|
+
};
|
|
33
|
+
export function describeTrack(track) {
|
|
34
|
+
const where = track.path ?? "repository root";
|
|
35
|
+
const stack = track.stack.length === 0 ? "" : `: ${track.stack.join(", ")}`;
|
|
36
|
+
const deploy = track.deploy === undefined ? "" : `, deploys to ${track.deploy}`;
|
|
37
|
+
return `${track.id} (${track.kind}, ${where}${stack}${deploy})`;
|
|
38
|
+
}
|
|
39
|
+
function parseStack(text) {
|
|
40
|
+
return text
|
|
41
|
+
.split(/[,\s]+/)
|
|
42
|
+
.map((part) => part.trim().toLowerCase())
|
|
43
|
+
.filter((part) => part !== "");
|
|
44
|
+
}
|
|
45
|
+
async function ask(detected, prompter) {
|
|
46
|
+
prompter.intro("peer-ai init");
|
|
47
|
+
if (detected.tracks.length > 0) {
|
|
48
|
+
prompter.note(detected.tracks.map(describeTrack).join("\n"), "Found in this repository");
|
|
49
|
+
}
|
|
50
|
+
const name = await prompter.text("What are you building? Give it a name.", {
|
|
51
|
+
initial: detected.name,
|
|
52
|
+
required: true,
|
|
53
|
+
});
|
|
54
|
+
const description = await prompter.text("Describe it in one line. Optional.", {
|
|
55
|
+
initial: detected.description ?? "",
|
|
56
|
+
});
|
|
57
|
+
let tracks = detected.tracks;
|
|
58
|
+
if (tracks.length > 0 && !(await prompter.confirm("Use the parts found above?", true)))
|
|
59
|
+
tracks = [];
|
|
60
|
+
if (tracks.length === 0) {
|
|
61
|
+
const kind = await prompter.select("What kind of thing is it?", KIND_CHOICES);
|
|
62
|
+
const stack = await prompter.text("Which stack? For example: typescript, react. Leave empty to decide later.", {
|
|
63
|
+
placeholder: "decide later",
|
|
64
|
+
});
|
|
65
|
+
tracks = [{ id: "app", kind, stack: parseStack(stack) }];
|
|
66
|
+
}
|
|
67
|
+
const team = await prompter.select("Who's working on it?", [
|
|
68
|
+
{ value: "solo", label: "Just me" },
|
|
69
|
+
{ value: "team", label: "A team" },
|
|
70
|
+
], "solo");
|
|
71
|
+
const stage = await prompter.select("What stage is it at?", STAGE_CHOICES, "mvp");
|
|
72
|
+
let tools = detected.tools;
|
|
73
|
+
if (tools.length === 0) {
|
|
74
|
+
tools = await prompter.multiselect("Which AI tools do you use?", TOOL_IDS.map((id) => ({ value: id, label: TOOL_LABELS[id] })));
|
|
75
|
+
}
|
|
76
|
+
return { name, description, tracks, team, stage, tools };
|
|
77
|
+
}
|
|
78
|
+
function defaults(detected, options) {
|
|
79
|
+
return {
|
|
80
|
+
name: options.name ?? detected.name,
|
|
81
|
+
description: detected.description ?? "",
|
|
82
|
+
tracks: detected.tracks.length > 0 ? detected.tracks : [UNDECIDED_TRACK],
|
|
83
|
+
team: options.team ?? "solo",
|
|
84
|
+
stage: options.stage ?? "mvp",
|
|
85
|
+
tools: options.tools ?? detected.tools,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export function buildConfig(detected, answers) {
|
|
89
|
+
return {
|
|
90
|
+
$schema: SCHEMA_URL,
|
|
91
|
+
version: 1,
|
|
92
|
+
project: {
|
|
93
|
+
name: answers.name,
|
|
94
|
+
...(answers.description === "" ? {} : { description: answers.description }),
|
|
95
|
+
stage: answers.stage,
|
|
96
|
+
origin: detected.origin,
|
|
97
|
+
team: answers.team,
|
|
98
|
+
},
|
|
99
|
+
...(answers.tools.length === 0 ? {} : { tools: answers.tools }),
|
|
100
|
+
tracks: answers.tracks.map((track) => ({
|
|
101
|
+
id: track.id,
|
|
102
|
+
kind: track.kind,
|
|
103
|
+
...(track.path === undefined ? {} : { path: track.path }),
|
|
104
|
+
...(track.stack.length === 0 ? {} : { stack: track.stack }),
|
|
105
|
+
...(track.deploy === undefined ? {} : { deploy: { target: track.deploy } }),
|
|
106
|
+
status: "active",
|
|
107
|
+
...(track.kind === "other" && track.stack.length === 0
|
|
108
|
+
? { note: "Set the kind and stack once they are decided." }
|
|
109
|
+
: {}),
|
|
110
|
+
})),
|
|
111
|
+
repo: { ...(detected.repo.host === undefined ? {} : { host: detected.repo.host }), remote: detected.repo.remote },
|
|
112
|
+
...(detected.delivery === undefined ? {} : { delivery: detected.delivery }),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
export async function runInit(options, prompter, out) {
|
|
116
|
+
const detected = detect(options.cwd);
|
|
117
|
+
if (detected.hasConfig) {
|
|
118
|
+
out.error(`${CONFIG_FILE} already exists. init never overwrites it: edit it, or delete it and run init again.`);
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
if (!options.yes && prompter === undefined) {
|
|
122
|
+
out.error("init asks a few questions, so it needs a terminal. To accept what it detects instead, pass --yes.");
|
|
123
|
+
return 2;
|
|
124
|
+
}
|
|
125
|
+
let answers;
|
|
126
|
+
try {
|
|
127
|
+
answers = options.yes || prompter === undefined ? defaults(detected, options) : await ask(detected, prompter);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (error instanceof Cancelled) {
|
|
131
|
+
out.error("Cancelled. Nothing was written.");
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
const config = buildConfig(detected, answers);
|
|
137
|
+
const result = validateConfig(config);
|
|
138
|
+
if (!result.ok) {
|
|
139
|
+
out.error("The config init built is not valid. This is a bug in peer-ai; please report it with these details:");
|
|
140
|
+
for (const error of result.errors)
|
|
141
|
+
out.error(` ${error}`);
|
|
142
|
+
return 2;
|
|
143
|
+
}
|
|
144
|
+
const json = `${JSON.stringify(config, null, 2)}\n`;
|
|
145
|
+
if (options.dryRun) {
|
|
146
|
+
out.log(json.trimEnd());
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
writeFileSync(join(options.cwd, CONFIG_FILE), json, { flag: "wx" });
|
|
150
|
+
const summary = `Wrote ${CONFIG_FILE}: ${String(answers.tracks.length)} ${answers.tracks.length === 1 ? "part" : "parts"}, ${answers.stage} stage.`;
|
|
151
|
+
if (prompter !== undefined && !options.yes)
|
|
152
|
+
prompter.outro(summary);
|
|
153
|
+
else
|
|
154
|
+
out.log(summary);
|
|
155
|
+
out.log("Edit it any time. Your editor checks it against the schema as you type.");
|
|
156
|
+
out.log("Next: peer-ai assess to map the project, then peer-ai render to set up your AI tools.");
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { type CommandRunner } from "./work.ts";
|
|
3
|
+
export interface ServerOptions {
|
|
4
|
+
/** Where the AI tool started the server: the project's folder or one inside it. */
|
|
5
|
+
cwd: string;
|
|
6
|
+
now?: () => Date;
|
|
7
|
+
run?: CommandRunner;
|
|
8
|
+
}
|
|
9
|
+
/** The nearest folder at or above `start` with a config, or `start` itself when there is none. */
|
|
10
|
+
export declare function findRoot(start: string): string;
|
|
11
|
+
export declare function createServer(options: ServerOptions): McpServer;
|
|
12
|
+
/** Serves over stdio until the AI tool closes the connection. Nothing else may write to stdout. */
|
|
13
|
+
export declare function serveStdio(cwd: string): Promise<void>;
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// The Peer AI MCP server. Every AI tool that speaks the Model Context Protocol reaches the same
|
|
2
|
+
// project map, work items and gates through it, so a project behaves the same whichever tool a
|
|
3
|
+
// person uses. It runs over stdio from the project's folder: `peer-ai mcp`.
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { ACTIVITY_IDS, MapItemIdSchema, SKILL_IDS, WorkItemSchema } from "peer-ai-workflow";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { NEXT_STAGE, assess, gaps, loadConfig } from "./assess.js";
|
|
11
|
+
import { CONFIG_FILE } from "./detect.js";
|
|
12
|
+
import { checkDocumentFile } from "./document.js";
|
|
13
|
+
import { draftFeedback } from "./feedback.js";
|
|
14
|
+
import { VERSION } from "./package-info.js";
|
|
15
|
+
import { standardsFor } from "./standards.js";
|
|
16
|
+
import { mapChanges, readMap } from "./state.js";
|
|
17
|
+
import { advanceWorkItem, checkReport, createWorkItem, nextWork, recordReview, runCommand, runVerify, updateWorkItem, } from "./work.js";
|
|
18
|
+
const INSTRUCTIONS = `Peer AI keeps this project's map, its work items and the gates work must pass.
|
|
19
|
+
Start a session with next_work: it returns the work item for the current git branch and where it stopped.
|
|
20
|
+
Before editing a file, call standards_for_file and follow what it returns.
|
|
21
|
+
Record progress with update_work_item. Run verification with run_verify rather than reporting a result yourself.
|
|
22
|
+
Record each review with record_review, passing the path of its report, including failed and incomplete reviews.
|
|
23
|
+
Check each document a Peer AI skill writes with check_document, and fix what it names.
|
|
24
|
+
Move work with advance_work_item: build before changing code, verify once the change is complete, ship when it is verified, reviewed and ready to merge, done once merged or released.
|
|
25
|
+
Moving to ship or done passes the same gates as CI; when it refuses, fix what it lists.
|
|
26
|
+
When next_work reports setup problems, fix what you can, such as running npx peer-ai render, before other work, and tell the person in plain words about anything only they can decide.
|
|
27
|
+
When Peer AI gets something wrong, call draft_feedback. At a natural stopping point, show the person each draft in a few words and ask whether to send it.`;
|
|
28
|
+
/** The nearest folder at or above `start` with a config, or `start` itself when there is none. */
|
|
29
|
+
export function findRoot(start) {
|
|
30
|
+
let dir = start;
|
|
31
|
+
for (;;) {
|
|
32
|
+
if (existsSync(join(dir, CONFIG_FILE)))
|
|
33
|
+
return dir;
|
|
34
|
+
const parent = dirname(dir);
|
|
35
|
+
if (parent === dir)
|
|
36
|
+
return start;
|
|
37
|
+
dir = parent;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const reply = (value) => ({
|
|
41
|
+
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
42
|
+
});
|
|
43
|
+
const refuse = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
|
|
44
|
+
const fromResult = (result) => (result.ok ? reply(result.value) : refuse(result.error));
|
|
45
|
+
const READ_ONLY = { readOnlyHint: true, openWorldHint: false };
|
|
46
|
+
const WRITES = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
|
|
47
|
+
const itemId = z.string().min(1).describe("The work item's id, such as ITEM-3 or PROJ-14.");
|
|
48
|
+
export function createServer(options) {
|
|
49
|
+
const now = options.now ?? (() => new Date());
|
|
50
|
+
const run = options.run ?? runCommand;
|
|
51
|
+
const server = new McpServer({ name: "peer-ai", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
52
|
+
/** Every tool works from the project's config; without one it says how to create it. */
|
|
53
|
+
const withProject = (handle) => (args) => {
|
|
54
|
+
const root = findRoot(options.cwd);
|
|
55
|
+
const { config, errors } = loadConfig(root);
|
|
56
|
+
if (config !== undefined)
|
|
57
|
+
return handle(root, config, args);
|
|
58
|
+
return refuse(errors === undefined
|
|
59
|
+
? `There is no ${CONFIG_FILE} in ${root} or above it. Run npx peer-ai init in the project first.`
|
|
60
|
+
: `${CONFIG_FILE} is not valid:\n${errors.map((error) => `- ${error}`).join("\n")}`);
|
|
61
|
+
};
|
|
62
|
+
server.registerTool("project_map", {
|
|
63
|
+
title: "Project map",
|
|
64
|
+
description: "Where the project stands: each item on the map (requirements, architecture, API contract, tests, CI, infrastructure and more) as present, partial, missing or not applicable, with the files that prove it; what the project's stage still needs; and compliance signals such as personal data found in the schema.",
|
|
65
|
+
annotations: READ_ONLY,
|
|
66
|
+
}, withProject((root, config) => {
|
|
67
|
+
const stage = config.project.stage ?? "mvp";
|
|
68
|
+
const assessment = assess(root, config, stage);
|
|
69
|
+
const next = NEXT_STAGE[stage];
|
|
70
|
+
const needed = gaps(assessment, stage);
|
|
71
|
+
const stored = readMap(root);
|
|
72
|
+
return reply({
|
|
73
|
+
project: assessment.name,
|
|
74
|
+
stage,
|
|
75
|
+
items: assessment.items,
|
|
76
|
+
neededFor: { [stage]: needed },
|
|
77
|
+
...(next === undefined
|
|
78
|
+
? {}
|
|
79
|
+
: { laterFor: { [next]: gaps(assessment, next).filter((id) => !needed.includes(id)) } }),
|
|
80
|
+
signals: assessment.signals,
|
|
81
|
+
suggestedTraits: assessment.suggestedTraits,
|
|
82
|
+
suggestedProfiles: assessment.suggestedProfiles,
|
|
83
|
+
suggestedStacks: assessment.suggestedStacks,
|
|
84
|
+
storedMap: stored === undefined
|
|
85
|
+
? "There is no .peer-ai/map.json yet. Run npx peer-ai assess to write it."
|
|
86
|
+
: stored.ok
|
|
87
|
+
? { assessedAt: stored.value.assessedAt, changedSince: mapChanges(stored.value, assessment) }
|
|
88
|
+
: `.peer-ai/map.json ${stored.error}`,
|
|
89
|
+
});
|
|
90
|
+
}));
|
|
91
|
+
server.registerTool("next_work", {
|
|
92
|
+
title: "Next work",
|
|
93
|
+
description: "The work to continue: the open work item for the current git branch, with where it stopped, its next action and the reviews it needs, and every other open item, with the items each is waiting for before it can ship. When nothing is open, the gaps the project's stage needs, to start as work items, with the Peer AI skill to use for each (useSkill).",
|
|
94
|
+
annotations: READ_ONLY,
|
|
95
|
+
}, withProject((root, config) => reply(nextWork(root, config))));
|
|
96
|
+
server.registerTool("standards_for_file", {
|
|
97
|
+
title: "Standards for a file",
|
|
98
|
+
description: "The standards that govern a file: the track it belongs to, the stack profiles, and the project's own standards documents and rules for that track. Call it before editing a file, then read and follow the documents it lists.",
|
|
99
|
+
inputSchema: { file: z.string().min(1).describe("The file's path, relative to the project root.") },
|
|
100
|
+
annotations: READ_ONLY,
|
|
101
|
+
}, withProject((root, config, { file }) => {
|
|
102
|
+
const standards = standardsFor(config, root, file);
|
|
103
|
+
return standards === undefined ? refuse(`${file} is outside the project.`) : reply(standards);
|
|
104
|
+
}));
|
|
105
|
+
server.registerTool("create_work_item", {
|
|
106
|
+
title: "Create a work item",
|
|
107
|
+
description: "Start a piece of work: a feature, bug, refactor, migration, discovery, chore, or a gap on the project map. It starts at prepare. The id comes from the tracker's ticket prefix unless you give one, such as a tracker key. Give it a goal, acceptance criteria, the sources it implements and the items it depends on when you know them: it can't ship before those items have.",
|
|
108
|
+
inputSchema: {
|
|
109
|
+
title: z.string().min(1),
|
|
110
|
+
kind: WorkItemSchema.shape.kind,
|
|
111
|
+
track: z.string().min(1).optional().describe("The track it changes. Needed when the project has several."),
|
|
112
|
+
gap: MapItemIdSchema.optional().describe("For kind gap: the map item this work fills, such as threat-model."),
|
|
113
|
+
id: z.string().min(1).optional().describe("A tracker key such as PROJ-14, or GH-42 for a GitHub issue."),
|
|
114
|
+
branch: z.string().min(1).optional().describe("Its git branch. Filled from the repo's naming pattern if set."),
|
|
115
|
+
next: z.string().min(1).max(200).optional().describe("One line: the first action."),
|
|
116
|
+
goal: WorkItemSchema.shape.goal,
|
|
117
|
+
acceptance: WorkItemSchema.shape.acceptance,
|
|
118
|
+
sources: WorkItemSchema.shape.sources,
|
|
119
|
+
dependsOn: WorkItemSchema.shape.dependsOn,
|
|
120
|
+
},
|
|
121
|
+
annotations: WRITES,
|
|
122
|
+
}, withProject((root, config, input) => fromResult(createWorkItem(root, config, input, now()))));
|
|
123
|
+
server.registerTool("update_work_item", {
|
|
124
|
+
title: "Update a work item",
|
|
125
|
+
description: "Record where work stands, so the next session resumes exactly there: the next action, the activity and step it stopped at, its branch or its title. It also sets the item's goal, acceptance criteria, sources and dependencies, replacing any given before.",
|
|
126
|
+
inputSchema: {
|
|
127
|
+
id: itemId,
|
|
128
|
+
next: z.string().min(1).max(200).optional().describe("One line: the next action."),
|
|
129
|
+
position: z
|
|
130
|
+
.object({ activity: z.enum(ACTIVITY_IDS), step: z.number().int().positive() })
|
|
131
|
+
.optional()
|
|
132
|
+
.describe("The activity and step where work stopped."),
|
|
133
|
+
branch: z.string().min(1).optional(),
|
|
134
|
+
title: z.string().min(1).optional(),
|
|
135
|
+
goal: WorkItemSchema.shape.goal,
|
|
136
|
+
acceptance: WorkItemSchema.shape.acceptance,
|
|
137
|
+
sources: WorkItemSchema.shape.sources,
|
|
138
|
+
dependsOn: WorkItemSchema.shape.dependsOn,
|
|
139
|
+
},
|
|
140
|
+
annotations: { ...WRITES, idempotentHint: true },
|
|
141
|
+
}, withProject((root, config, { id, ...changes }) => fromResult(updateWorkItem(root, config, id, changes, now()))));
|
|
142
|
+
server.registerTool("run_verify", {
|
|
143
|
+
title: "Run verify",
|
|
144
|
+
description: "Run the project's verify command (commands.verify in peer-ai.config.json) and record the result on the work item, with the end of its output. A work item can't move to ship or done without a passing verify, and only this tool records one.",
|
|
145
|
+
inputSchema: { id: itemId },
|
|
146
|
+
annotations: { ...WRITES, openWorldHint: true },
|
|
147
|
+
}, withProject(async (root, config, { id }) => {
|
|
148
|
+
const outcome = await runVerify(root, config, id, now, run);
|
|
149
|
+
if (!outcome.ok)
|
|
150
|
+
return refuse(outcome.error);
|
|
151
|
+
const { command, result, output } = outcome.value;
|
|
152
|
+
return reply({ id, command, result, output });
|
|
153
|
+
}));
|
|
154
|
+
server.registerTool("record_review", {
|
|
155
|
+
title: "Record a review",
|
|
156
|
+
description: "Record a review of a work item. Write the review's report first (.peer-ai/reports/<work item>/<skill>-<time>.json, in the review-report format) and pass its path: Peer AI checks the report, including that every rule the skill answers for has a coverage line, and works out pass, fail or incomplete from it. If it refuses, fix what it names and call it again. A review recorded without a report is marked unproven. Record failed and incomplete reviews too. For a review of the whole project, which has no work item, leave out the id: Peer AI checks the report the same way and gives its result, without recording it anywhere.",
|
|
157
|
+
inputSchema: {
|
|
158
|
+
id: itemId.optional().describe("The work item reviewed. Leave it out for a review of the whole project."),
|
|
159
|
+
skill: z.enum(SKILL_IDS),
|
|
160
|
+
report: z.string().min(1).optional().describe("The review report's path, relative to the project root."),
|
|
161
|
+
result: z
|
|
162
|
+
.enum(["pass", "fail", "incomplete"])
|
|
163
|
+
.optional()
|
|
164
|
+
.describe("Needed only without a report. With one, it must match the result the report supports."),
|
|
165
|
+
summary: z.string().min(1).max(500).optional().describe("A short summary for people."),
|
|
166
|
+
},
|
|
167
|
+
annotations: WRITES,
|
|
168
|
+
}, withProject((root, config, { id, ...review }) => {
|
|
169
|
+
if (id !== undefined)
|
|
170
|
+
return fromResult(recordReview(root, config, id, review, now()));
|
|
171
|
+
if (review.report === undefined) {
|
|
172
|
+
return refuse("A review of the whole project needs its report: give the report's path.");
|
|
173
|
+
}
|
|
174
|
+
const checked = checkReport(root, config, { ...review, report: review.report });
|
|
175
|
+
return fromResult(checked.ok
|
|
176
|
+
? {
|
|
177
|
+
ok: true,
|
|
178
|
+
value: {
|
|
179
|
+
...checked.value,
|
|
180
|
+
recorded: false,
|
|
181
|
+
note: "The report is valid. A review of the whole project has no work item, so it isn't recorded; tell the person its result.",
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
: checked);
|
|
185
|
+
}));
|
|
186
|
+
server.registerTool("check_document", {
|
|
187
|
+
title: "Check a document",
|
|
188
|
+
description: "Check a document a Peer AI document skill wrote, such as requirements or a threat model, against the skill's template: every required part present and filled in, no template text left in, and only rule ids that exist. Write the document first and pass its path. When it isn't ready, it lists what to change: fix it and call check_document again, until it's ready.",
|
|
189
|
+
inputSchema: {
|
|
190
|
+
skill: z.enum(SKILL_IDS).describe("The document skill that wrote it, such as requirements-analysis."),
|
|
191
|
+
path: z.string().min(1).describe("The document's path, relative to the project root."),
|
|
192
|
+
template: z
|
|
193
|
+
.string()
|
|
194
|
+
.min(1)
|
|
195
|
+
.optional()
|
|
196
|
+
.describe("Which of the skill's templates it follows, when it has several. Defaults to the main one."),
|
|
197
|
+
},
|
|
198
|
+
annotations: READ_ONLY,
|
|
199
|
+
}, withProject((root, _config, input) => {
|
|
200
|
+
const checked = checkDocumentFile(root, input);
|
|
201
|
+
if (!checked.ok)
|
|
202
|
+
return refuse(checked.error);
|
|
203
|
+
if (checked.value.ready)
|
|
204
|
+
return reply(checked.value);
|
|
205
|
+
return refuse(`${input.path} isn't ready yet. Fix these, then call check_document again:\n${checked.value.problems.map((problem) => `- ${problem}`).join("\n")}`);
|
|
206
|
+
}));
|
|
207
|
+
server.registerTool("advance_work_item", {
|
|
208
|
+
title: "Advance a work item",
|
|
209
|
+
description: "Move a work item to its next stage (prepare, build, verify, ship, done), to an earlier stage to reopen it, or to cancelled. Moving to verify works out the reviews the change needs from the files it touched. Moving to ship or done passes the same gates as CI: a passing verify, the required reviews passing, and for a gap, the gap filled. When it refuses, it lists what to fix.",
|
|
210
|
+
inputSchema: {
|
|
211
|
+
id: itemId,
|
|
212
|
+
to: WorkItemSchema.shape.stage.optional().describe("The stage to move to. Omit it for the next one."),
|
|
213
|
+
},
|
|
214
|
+
annotations: WRITES,
|
|
215
|
+
}, withProject((root, config, { id, to }) => fromResult(advanceWorkItem(root, config, id, to, now()))));
|
|
216
|
+
server.registerTool("draft_feedback", {
|
|
217
|
+
title: "Draft feedback",
|
|
218
|
+
description: "Draft a report for Peer AI's maintainers when Peer AI itself gets something wrong: a review misses a problem or reports one that isn't there, a check blocks work by mistake, a skill's step can't be followed, or a command fails or misleads. Not for problems in the project. The draft stays in .peer-ai/feedback/ until the person decides; never send it yourself. Describe everything in plain words: never include the project's code, file contents, names of people, companies or products, secrets, or URLs and hosts. Peer AI refuses a draft holding code, a key or token, or an email address.",
|
|
219
|
+
inputSchema: {
|
|
220
|
+
title: z
|
|
221
|
+
.string()
|
|
222
|
+
.min(1)
|
|
223
|
+
.max(120)
|
|
224
|
+
.describe('One line, such as "security-review flagged a test file as production code".'),
|
|
225
|
+
what: z.string().min(1).max(4000).describe("What happened, in plain words."),
|
|
226
|
+
expected: z.string().min(1).max(2000).describe("What should have happened."),
|
|
227
|
+
skill: z.enum(SKILL_IDS).optional().describe("The Peer AI skill involved, when there is one."),
|
|
228
|
+
command: z
|
|
229
|
+
.string()
|
|
230
|
+
.min(1)
|
|
231
|
+
.max(120)
|
|
232
|
+
.optional()
|
|
233
|
+
.describe("The peer-ai command or MCP tool involved, when there is one."),
|
|
234
|
+
},
|
|
235
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
236
|
+
}, (input) => {
|
|
237
|
+
const root = findRoot(options.cwd);
|
|
238
|
+
const { config } = loadConfig(root);
|
|
239
|
+
const client = server.server.getClientVersion()?.name;
|
|
240
|
+
return fromResult(draftFeedback(root, config, input, client === undefined ? {} : { tool: client }, now()));
|
|
241
|
+
});
|
|
242
|
+
return server;
|
|
243
|
+
}
|
|
244
|
+
/** Serves over stdio until the AI tool closes the connection. Nothing else may write to stdout. */
|
|
245
|
+
export async function serveStdio(cwd) {
|
|
246
|
+
await createServer({ cwd }).connect(new StdioServerTransport());
|
|
247
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
3
|
+
/** This package's version. */
|
|
4
|
+
export const VERSION = pkg.version;
|
|
5
|
+
/** The oldest Node.js major version this package runs on, from `engines.node` (">=24"). */
|
|
6
|
+
export const MIN_NODE_MAJOR = Number(/^>=(\d+)/.exec(pkg.engines.node)?.[1] ?? 0);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { type PipelineJob } from "peer-ai-standards";
|
|
2
|
+
import type { PeerAiConfig } from "peer-ai-workflow";
|
|
3
|
+
export declare const WORKFLOW_FILE = ".github/workflows/peer-ai-security.yml";
|
|
4
|
+
/** A container image, pinned to its digest: Docker uses the digest, and the tag says which release it is. */
|
|
5
|
+
export interface Image {
|
|
6
|
+
name: string;
|
|
7
|
+
tag: string;
|
|
8
|
+
digest: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const IMAGES: {
|
|
11
|
+
zizmor: {
|
|
12
|
+
name: string;
|
|
13
|
+
tag: string;
|
|
14
|
+
digest: string;
|
|
15
|
+
};
|
|
16
|
+
semgrep: {
|
|
17
|
+
name: string;
|
|
18
|
+
tag: string;
|
|
19
|
+
digest: string;
|
|
20
|
+
};
|
|
21
|
+
sslyze: {
|
|
22
|
+
name: string;
|
|
23
|
+
tag: string;
|
|
24
|
+
digest: string;
|
|
25
|
+
};
|
|
26
|
+
zap: {
|
|
27
|
+
name: string;
|
|
28
|
+
tag: string;
|
|
29
|
+
digest: string;
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
export declare const imageRef: (image: Image) => string;
|
|
33
|
+
/** How a job installs its tool and runs it on the repository, as shell lines. */
|
|
34
|
+
export interface JobScript {
|
|
35
|
+
/** The job's name, which a project makes a required check. It never changes. */
|
|
36
|
+
name: string;
|
|
37
|
+
/** The tool and its version, for the steps' names. */
|
|
38
|
+
tool: string;
|
|
39
|
+
/** Lines that install the tool into $RUNNER_TEMP/peer-ai-tools, which is on the PATH afterwards. */
|
|
40
|
+
install: string[];
|
|
41
|
+
/** Lines that run the tool on the repository at the current folder, failing the job on a finding. */
|
|
42
|
+
scan: string[];
|
|
43
|
+
/** The whole history, for a tool that reads it. */
|
|
44
|
+
history?: boolean;
|
|
45
|
+
/** Environment variables the scan reads, from GitHub's contexts: never pasted into the script. */
|
|
46
|
+
env?: Record<string, string>;
|
|
47
|
+
}
|
|
48
|
+
export declare const JOBS: Record<PipelineJob, JobScript>;
|
|
49
|
+
/** An environment with an address the pipeline can check. */
|
|
50
|
+
interface Address {
|
|
51
|
+
id: string;
|
|
52
|
+
url: string;
|
|
53
|
+
host: string;
|
|
54
|
+
production: boolean | undefined;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The environments' addresses, split into those the pipeline can use and those it can't: an
|
|
58
|
+
* address that isn't a full http or https URL, or that holds a user name or password, which would
|
|
59
|
+
* be written into the committed workflow.
|
|
60
|
+
*/
|
|
61
|
+
export declare function environmentAddresses(config: PeerAiConfig): {
|
|
62
|
+
usable: Address[];
|
|
63
|
+
unusable: {
|
|
64
|
+
id: string;
|
|
65
|
+
url: string;
|
|
66
|
+
}[];
|
|
67
|
+
};
|
|
68
|
+
/** The pipeline rules that apply at the project's stage, leaving out the ones it set aside. */
|
|
69
|
+
export declare function pipelineRules(config: PeerAiConfig): import("peer-ai-standards").AppliedRule[];
|
|
70
|
+
/** The workflow's body, below its header, or undefined when no job applies. */
|
|
71
|
+
export declare function workflowBody(config: PeerAiConfig): string | undefined;
|
|
72
|
+
/** The jobs the workflow render writes would run: the keys under jobs:. */
|
|
73
|
+
export declare function workflowJobs(config: PeerAiConfig): string[];
|
|
74
|
+
/** The whole file render writes: a header recording the body's hash, then the body. */
|
|
75
|
+
export declare function workflowFile(config: PeerAiConfig): string | undefined;
|
|
76
|
+
/** Whether a file was written by render: it records a hash in its header. */
|
|
77
|
+
export declare const writtenByRender: (content: string) => boolean;
|
|
78
|
+
/** Whether a file is as render wrote it: its body still has the hash its header records. */
|
|
79
|
+
export declare function unchangedSinceRender(content: string): boolean;
|
|
80
|
+
/** Whether two files are the same apart from their line endings. */
|
|
81
|
+
export declare const sameFile: (a: string, b: string) => boolean;
|
|
82
|
+
export {};
|