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/render.js
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
// peer-ai render: writes what each AI tool in the config reads, so every tool works the project
|
|
2
|
+
// the same way. Instructions go in a marked block inside the project's own files (AGENTS.md,
|
|
3
|
+
// CLAUDE.md, GEMINI.md, Copilot's instructions), and only that block is ever rewritten. Cursor gets
|
|
4
|
+
// a rule file of its own. The MCP server is registered in each tool's project config, next to
|
|
5
|
+
// whatever else is there. Peer AI's skills are written where each tool reads them, under a peer-ai-
|
|
6
|
+
// prefix, and left out of git: they're rebuilt from the installed version (RFC 0004). So that cloud
|
|
7
|
+
// agents, which start from a fresh clone, have them too, each tool's setup step runs
|
|
8
|
+
// `peer-ai render --skills` before the agent starts. Running it twice changes nothing, and --check
|
|
9
|
+
// reports drift for CI.
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { SKILL_NAME_PREFIX, availableSkills, loadSkill, readSkillFiles, renderedName } from "peer-ai-skills";
|
|
13
|
+
import { loadConfig } from "./assess.js";
|
|
14
|
+
import { count, fail, formatChecks, ok, plural, skip } from "./checks.js";
|
|
15
|
+
import { CONFIG_FILE } from "./detect.js";
|
|
16
|
+
import { FEEDBACK_DIR } from "./feedback.js";
|
|
17
|
+
import { VERSION } from "./package-info.js";
|
|
18
|
+
import { WORKFLOW_FILE, sameFile, unchangedSinceRender, workflowFile } from "./pipeline.js";
|
|
19
|
+
import { RUFF_FILE, ruffFile } from "./ruff.js";
|
|
20
|
+
export const START = "<!-- peer-ai:start -->";
|
|
21
|
+
export const END = "<!-- peer-ai:end -->";
|
|
22
|
+
const GENERATED = `<!-- Generated by peer-ai render from ${CONFIG_FILE}. Change the config and run peer-ai render; edits inside this block are replaced. -->`;
|
|
23
|
+
const SERVER = "peer-ai";
|
|
24
|
+
const IMPORTS_AGENTS = /^@AGENTS\.md\s*$/m;
|
|
25
|
+
/** How AI tools start the server: the project's own pinned copy, or this exact version. */
|
|
26
|
+
export function serverCommand(root) {
|
|
27
|
+
const pkg = readJson(join(root, "package.json"));
|
|
28
|
+
const pinned = pkg?.devDependencies?.["peer-ai"] ?? pkg?.dependencies?.["peer-ai"];
|
|
29
|
+
return pinned === undefined
|
|
30
|
+
? { command: "npx", args: ["-y", `peer-ai@${VERSION}`, "mcp"] }
|
|
31
|
+
: { command: "npx", args: ["peer-ai", "mcp"] };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The command that writes only the skills, for a session or a cloud agent to run before it starts.
|
|
35
|
+
* It uses the same peer-ai the MCP server does: the project's pinned copy, or this exact version.
|
|
36
|
+
*/
|
|
37
|
+
export function skillsCommand(root) {
|
|
38
|
+
const { command, args } = serverCommand(root);
|
|
39
|
+
return [command, ...args.slice(0, -1), "render", "--skills", "--quiet"].join(" ");
|
|
40
|
+
}
|
|
41
|
+
/** Recognises the skills command in a setup file, whichever version it names. */
|
|
42
|
+
const SKILLS_COMMAND = /npx (?:-y )?peer-ai(?:@\S+)? render --skills(?: --quiet)?/;
|
|
43
|
+
function readJson(path) {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function readText(root, path) {
|
|
52
|
+
const full = join(root, path);
|
|
53
|
+
return existsSync(full) ? readFileSync(full, "utf8") : undefined;
|
|
54
|
+
}
|
|
55
|
+
/** The instructions every tool gets: short, because the MCP server serves the detail on demand. */
|
|
56
|
+
export function instructions(config) {
|
|
57
|
+
const { project } = config;
|
|
58
|
+
const lines = [
|
|
59
|
+
"## Peer AI",
|
|
60
|
+
"",
|
|
61
|
+
`This project uses Peer AI${project.stage === undefined ? "" : ` at the ${project.stage} stage`}. Its MCP server, \`${SERVER}\`, holds the project map, the work items, and the gates work must pass before it ships.`,
|
|
62
|
+
"",
|
|
63
|
+
"- Start each session with `next_work`. It returns the work item for the current branch and where it stopped. For new work, create one with `create_work_item`.",
|
|
64
|
+
"- Keep the item's stage current with `advance_work_item`: `build` before you change code, `verify` once the change is complete, `ship` when it is verified, reviewed and ready to merge, and `done` once it is merged or released. When it refuses, fix what it lists.",
|
|
65
|
+
"- Before editing a file, call `standards_for_file` and follow what it returns.",
|
|
66
|
+
"- Record progress with `update_work_item`, so the next session resumes where this one stopped.",
|
|
67
|
+
"- Verify with `run_verify`. Never report a verify result yourself.",
|
|
68
|
+
"- Peer AI's skills are named `peer-ai-…`, such as `peer-ai-security-review`. `next_work` names the one to use for a gap, and the reviews a work item needs; follow each skill step by step. If none are installed, run `npx peer-ai render --skills`.",
|
|
69
|
+
"- For every review, write its report in `.peer-ai/reports/`, then record it with `record_review` and the report's path. Record failed and incomplete reviews too.",
|
|
70
|
+
"- For every document a skill writes, such as the requirements, check it with `check_document` and fix what it names.",
|
|
71
|
+
"- Don't edit the files in `.peer-ai/` by hand. The tools keep them valid.",
|
|
72
|
+
"- 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.",
|
|
73
|
+
"- 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.",
|
|
74
|
+
];
|
|
75
|
+
const ownTracks = config.tracks.filter((track) => track.status !== "external");
|
|
76
|
+
if (config.tracks.length > 1) {
|
|
77
|
+
lines.push("", "Parts of the project:", "");
|
|
78
|
+
for (const track of config.tracks) {
|
|
79
|
+
const where = track.status === "external"
|
|
80
|
+
? `in ${track.repo ?? "another repository"}`
|
|
81
|
+
: (track.path ?? "the repository root");
|
|
82
|
+
lines.push(`- \`${track.id}\` (${track.kind}, ${track.status}): ${where}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else if (ownTracks[0] !== undefined) {
|
|
86
|
+
lines.push("", `The project is one ${ownTracks[0].kind} part, \`${ownTracks[0].id}\`.`);
|
|
87
|
+
}
|
|
88
|
+
const commands = Object.entries(config.commands ?? {}).filter((entry) => typeof entry[1] === "string");
|
|
89
|
+
if (commands.length > 0) {
|
|
90
|
+
lines.push("", `Commands: ${commands.map(([name, command]) => `${name} \`${command}\``).join(", ")}.`);
|
|
91
|
+
}
|
|
92
|
+
const compliance = config.compliance;
|
|
93
|
+
const packs = [...(compliance?.packs ?? [])];
|
|
94
|
+
if (packs.length + (compliance?.jurisdictions?.length ?? 0) > 0) {
|
|
95
|
+
const where = compliance?.jurisdictions === undefined ? "" : ` for ${compliance.jurisdictions.join(", ")}`;
|
|
96
|
+
lines.push("", `Compliance${where}: ${packs.length === 0 ? "rule packs to be chosen" : packs.join(", ")}.`);
|
|
97
|
+
}
|
|
98
|
+
if ((config.rules ?? []).length > 0) {
|
|
99
|
+
lines.push("", "Every change respects the project's own rules:", "");
|
|
100
|
+
for (const rule of config.rules ?? []) {
|
|
101
|
+
lines.push(`- \`${rule.path}\`${rule.description === undefined ? "" : `: ${rule.description}`}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
lines.push("", "If the peer-ai tools aren't available, run `npx peer-ai doctor`.");
|
|
105
|
+
return lines.join("\n");
|
|
106
|
+
}
|
|
107
|
+
const block = (body) => `${START}\n${GENERATED}\n\n${body}\n${END}`;
|
|
108
|
+
const IGNORE_START = "# peer-ai:start";
|
|
109
|
+
const IGNORE_END = "# peer-ai:end";
|
|
110
|
+
/**
|
|
111
|
+
* The file with its marked block written or replaced, and nothing outside the block touched. The
|
|
112
|
+
* markers default to the instructions block; .gitignore uses its own.
|
|
113
|
+
*/
|
|
114
|
+
function withBlock(path, existing, body, markers = { start: START, end: END, block: block(body) }) {
|
|
115
|
+
const wanted = markers.block;
|
|
116
|
+
if (existing === undefined)
|
|
117
|
+
return { path, action: "create", content: `${wanted}\n` };
|
|
118
|
+
const from = existing.indexOf(markers.start);
|
|
119
|
+
if (from === -1) {
|
|
120
|
+
const separator = existing === "" ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
121
|
+
return { path, action: "update", content: `${existing}${separator}${wanted}\n` };
|
|
122
|
+
}
|
|
123
|
+
const to = existing.indexOf(markers.end, from);
|
|
124
|
+
if (to === -1) {
|
|
125
|
+
return {
|
|
126
|
+
path,
|
|
127
|
+
action: "refused",
|
|
128
|
+
note: `It has ${markers.start} but no ${markers.end} after it. Add the end marker, or remove the start marker, then render again.`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const content = `${existing.slice(0, from)}${wanted}${existing.slice(to + markers.end.length)}`;
|
|
132
|
+
return { path, action: content === existing ? "unchanged" : "update", content };
|
|
133
|
+
}
|
|
134
|
+
/** Registers the server under `key` in a JSON config, keeping every other entry as it is. */
|
|
135
|
+
function withServer(root, path, key, entry) {
|
|
136
|
+
const existing = readText(root, path);
|
|
137
|
+
if (existing === undefined) {
|
|
138
|
+
return { path, action: "create", content: `${JSON.stringify({ [key]: { [SERVER]: entry } }, null, 2)}\n` };
|
|
139
|
+
}
|
|
140
|
+
const parsed = readJson(join(root, path));
|
|
141
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
142
|
+
return {
|
|
143
|
+
path,
|
|
144
|
+
action: "refused",
|
|
145
|
+
note: `It isn't plain JSON (comments aren't supported here), so add this under "${key}" by hand: "${SERVER}": ${JSON.stringify(entry)}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const config = parsed;
|
|
149
|
+
const servers = (typeof config[key] === "object" && config[key] !== null ? config[key] : {});
|
|
150
|
+
if (JSON.stringify(servers[SERVER]) === JSON.stringify(entry))
|
|
151
|
+
return { path, action: "unchanged" };
|
|
152
|
+
const content = `${JSON.stringify({ ...config, [key]: { ...servers, [SERVER]: entry } }, null, 2)}\n`;
|
|
153
|
+
return { path, action: "update", content };
|
|
154
|
+
}
|
|
155
|
+
function cursorRule(body) {
|
|
156
|
+
return [
|
|
157
|
+
"---",
|
|
158
|
+
"description: How to work in this project with Peer AI",
|
|
159
|
+
"alwaysApply: true",
|
|
160
|
+
"---",
|
|
161
|
+
"",
|
|
162
|
+
GENERATED.replace("edits inside this block are replaced", "this file is replaced"),
|
|
163
|
+
"",
|
|
164
|
+
body,
|
|
165
|
+
"",
|
|
166
|
+
].join("\n");
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* A tool's own instructions file. When AGENTS.md carries the block, a new file just imports it, so
|
|
170
|
+
* the tool doesn't read the instructions twice; an existing file that imports it is left alone.
|
|
171
|
+
*/
|
|
172
|
+
function toolFile(root, path, body, agentsRendered) {
|
|
173
|
+
const existing = readText(root, path);
|
|
174
|
+
if (existing === undefined && agentsRendered) {
|
|
175
|
+
return { path, action: "create", content: "@AGENTS.md\n", note: "It imports AGENTS.md." };
|
|
176
|
+
}
|
|
177
|
+
if (existing !== undefined && IMPORTS_AGENTS.test(existing) && !existing.includes(START)) {
|
|
178
|
+
return { path, action: "unchanged", note: "It imports AGENTS.md, which carries the instructions." };
|
|
179
|
+
}
|
|
180
|
+
return withBlock(path, existing, body);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Where the tools in the config read skills. Codex, Cursor, Copilot and Gemini CLI read the shared
|
|
184
|
+
* .agents/skills; Claude Code reads .claude/skills, which Cursor and Copilot also read. So each
|
|
185
|
+
* skill is written as few times as the tools allow, and not at all when no tool is listed.
|
|
186
|
+
*/
|
|
187
|
+
export function skillFolders(tools) {
|
|
188
|
+
const folders = tools.includes("claude-code") ? [".claude/skills"] : [];
|
|
189
|
+
const needsShared = tools.some((tool) => tool === "codex" || tool === "gemini-cli" || tool === "other");
|
|
190
|
+
const readsEither = tools.some((tool) => tool === "cursor" || tool === "copilot");
|
|
191
|
+
if (needsShared || (readsEither && folders.length === 0))
|
|
192
|
+
folders.push(".agents/skills");
|
|
193
|
+
return folders;
|
|
194
|
+
}
|
|
195
|
+
const SKILL_HOMES = [".claude/skills", ".agents/skills"];
|
|
196
|
+
const isDirectory = (path) => statSync(path, { throwIfNoEntry: false })?.isDirectory() === true;
|
|
197
|
+
/**
|
|
198
|
+
* Every Peer AI skill in each folder its tools read, and every Peer AI skill that shouldn't be
|
|
199
|
+
* there any more: one this version no longer has, or one in a folder no listed tool reads.
|
|
200
|
+
*/
|
|
201
|
+
function planSkills(root, tools) {
|
|
202
|
+
const folders = skillFolders(tools);
|
|
203
|
+
const planned = [];
|
|
204
|
+
for (const home of SKILL_HOMES) {
|
|
205
|
+
const wanted = folders.includes(home) ? availableSkills() : [];
|
|
206
|
+
for (const id of wanted) {
|
|
207
|
+
const name = renderedName(id);
|
|
208
|
+
const path = `${home}/${name}`;
|
|
209
|
+
const files = loadSkill(id, { name });
|
|
210
|
+
const existing = isDirectory(join(root, path)) ? readSkillFiles(join(root, path)) : undefined;
|
|
211
|
+
const stale = existing === undefined ? [] : [...existing.keys()].filter((file) => !files.has(file));
|
|
212
|
+
const same = existing !== undefined && stale.length === 0 && [...files].every(([file, text]) => existing.get(file) === text);
|
|
213
|
+
planned.push({ path, action: existing === undefined ? "create" : same ? "unchanged" : "update", files, stale });
|
|
214
|
+
}
|
|
215
|
+
const names = new Set(wanted.map(renderedName));
|
|
216
|
+
const present = isDirectory(join(root, home)) ? readdirSync(join(root, home)) : [];
|
|
217
|
+
for (const entry of present.filter((entry) => entry.startsWith(SKILL_NAME_PREFIX) && !names.has(entry))) {
|
|
218
|
+
planned.push({ path: `${home}/${entry}`, action: "remove", files: new Map(), stale: [] });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return planned;
|
|
222
|
+
}
|
|
223
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
224
|
+
/** A JSON file changed by `change`, or refused when it isn't plain JSON, with what to add by hand. */
|
|
225
|
+
function editJson(root, path, create, change, byHand) {
|
|
226
|
+
const existing = readText(root, path);
|
|
227
|
+
if (existing === undefined)
|
|
228
|
+
return { path, action: "create", content: `${JSON.stringify(create, null, 2)}\n` };
|
|
229
|
+
const parsed = readJson(join(root, path));
|
|
230
|
+
if (!isRecord(parsed)) {
|
|
231
|
+
return { path, action: "refused", note: `It isn't plain JSON (comments aren't supported here), so ${byHand}` };
|
|
232
|
+
}
|
|
233
|
+
const changed = change(parsed);
|
|
234
|
+
if (JSON.stringify(changed) === JSON.stringify(parsed))
|
|
235
|
+
return { path, action: "unchanged" };
|
|
236
|
+
return { path, action: "update", content: `${JSON.stringify(changed, null, 2)}\n` };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Claude Code runs a project's SessionStart hooks in every session, including cloud sessions,
|
|
240
|
+
* which start from a fresh clone. The hook writes the skills before the session begins.
|
|
241
|
+
*/
|
|
242
|
+
function claudeSessionHook(root, command) {
|
|
243
|
+
const hook = { type: "command", command };
|
|
244
|
+
const entry = { matcher: "startup", hooks: [hook] };
|
|
245
|
+
return editJson(root, ".claude/settings.json", { hooks: { SessionStart: [entry] } }, (settings) => {
|
|
246
|
+
const hooks = isRecord(settings.hooks) ? settings.hooks : {};
|
|
247
|
+
const groups = Array.isArray(hooks.SessionStart) ? hooks.SessionStart : [];
|
|
248
|
+
const isOurs = (item) => isRecord(item) && typeof item.command === "string" && SKILLS_COMMAND.test(item.command);
|
|
249
|
+
const inGroup = (group) => (isRecord(group) && Array.isArray(group.hooks) ? group.hooks : []);
|
|
250
|
+
const found = groups.some((group) => inGroup(group).some(isOurs));
|
|
251
|
+
const updated = groups.map((group) => isRecord(group) && Array.isArray(group.hooks)
|
|
252
|
+
? { ...group, hooks: group.hooks.map((item) => (isOurs(item) ? { ...item, command } : item)) }
|
|
253
|
+
: group);
|
|
254
|
+
return { ...settings, hooks: { ...hooks, SessionStart: found ? updated : [...groups, entry] } };
|
|
255
|
+
}, `add a SessionStart hook that runs: ${command}`);
|
|
256
|
+
}
|
|
257
|
+
/** Cursor's cloud agents run the environment's start command each time an agent boots. */
|
|
258
|
+
function cursorEnvironment(root, command) {
|
|
259
|
+
return editJson(root, ".cursor/environment.json", { start: command }, (environment) => {
|
|
260
|
+
const start = typeof environment.start === "string" ? environment.start : undefined;
|
|
261
|
+
const next = start === undefined || start === ""
|
|
262
|
+
? command
|
|
263
|
+
: SKILLS_COMMAND.test(start)
|
|
264
|
+
? start.replace(SKILLS_COMMAND, command)
|
|
265
|
+
: `${start} && ${command}`;
|
|
266
|
+
return { ...environment, start: next };
|
|
267
|
+
}, `add to its "start" command: ${command}`);
|
|
268
|
+
}
|
|
269
|
+
const CHECKOUT = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1";
|
|
270
|
+
const SETUP_NODE = "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0";
|
|
271
|
+
/**
|
|
272
|
+
* Copilot's cloud agent runs the copilot-setup-steps job before it starts. A workflow that already
|
|
273
|
+
* exists is left to its owner, with the step to add.
|
|
274
|
+
*/
|
|
275
|
+
function copilotSetupSteps(root, command) {
|
|
276
|
+
const path = ".github/workflows/copilot-setup-steps.yml";
|
|
277
|
+
const existing = readText(root, path);
|
|
278
|
+
if (existing !== undefined) {
|
|
279
|
+
if (!SKILLS_COMMAND.test(existing)) {
|
|
280
|
+
return {
|
|
281
|
+
path,
|
|
282
|
+
action: "refused",
|
|
283
|
+
note: `It exists, so add this step to its copilot-setup-steps job: - run: ${command}`,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const content = existing.replace(SKILLS_COMMAND, command);
|
|
287
|
+
return { path, action: content === existing ? "unchanged" : "update", content };
|
|
288
|
+
}
|
|
289
|
+
const content = [
|
|
290
|
+
"# Prepares Copilot's cloud agent before it starts. Peer AI's step writes its skills, which stay",
|
|
291
|
+
"# out of git. Generated by peer-ai render; add your own steps after Peer AI's.",
|
|
292
|
+
"name: Copilot setup steps",
|
|
293
|
+
"on: workflow_dispatch",
|
|
294
|
+
"permissions:",
|
|
295
|
+
" contents: read",
|
|
296
|
+
"jobs:",
|
|
297
|
+
" copilot-setup-steps:",
|
|
298
|
+
" runs-on: ubuntu-latest",
|
|
299
|
+
" steps:",
|
|
300
|
+
` - uses: ${CHECKOUT}`,
|
|
301
|
+
` - uses: ${SETUP_NODE}`,
|
|
302
|
+
" with:",
|
|
303
|
+
" node-version: 24",
|
|
304
|
+
` - run: ${command}`,
|
|
305
|
+
"",
|
|
306
|
+
].join("\n");
|
|
307
|
+
return { path, action: "create", content };
|
|
308
|
+
}
|
|
309
|
+
const ATTRIBUTES_START = "# peer-ai:start";
|
|
310
|
+
const ATTRIBUTES_END = "# peer-ai:end";
|
|
311
|
+
/** Committed skills are marked as generated, so pull requests fold them away. */
|
|
312
|
+
function markSkillsGenerated(root, folders) {
|
|
313
|
+
const lines = [
|
|
314
|
+
ATTRIBUTES_START,
|
|
315
|
+
"# Generated by peer-ai render: Peer AI's skills, rebuilt from the installed version.",
|
|
316
|
+
...folders.map((folder) => `/${folder}/${SKILL_NAME_PREFIX}*/** linguist-generated=true`),
|
|
317
|
+
ATTRIBUTES_END,
|
|
318
|
+
];
|
|
319
|
+
const markers = { start: ATTRIBUTES_START, end: ATTRIBUTES_END, block: lines.join("\n") };
|
|
320
|
+
return withBlock(".gitattributes", readText(root, ".gitattributes"), "", markers);
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Keeps out of git, in a marked block of .gitignore, what never belongs there: the rendered skills,
|
|
324
|
+
* unless the project commits them, and feedback drafts, which stay on the person's machine until
|
|
325
|
+
* they send them (RFC 0007).
|
|
326
|
+
*/
|
|
327
|
+
function ignoreBlock(root, folders) {
|
|
328
|
+
const lines = [
|
|
329
|
+
IGNORE_START,
|
|
330
|
+
...(folders.length === 0
|
|
331
|
+
? ["# Generated by peer-ai render."]
|
|
332
|
+
: [
|
|
333
|
+
"# Generated by peer-ai render. Peer AI's skills are rebuilt from the installed version: run peer-ai render after cloning.",
|
|
334
|
+
...folders.map((folder) => `/${folder}/${SKILL_NAME_PREFIX}*/`),
|
|
335
|
+
]),
|
|
336
|
+
"# Feedback drafts stay on this machine until a person sends them.",
|
|
337
|
+
`/${FEEDBACK_DIR}/`,
|
|
338
|
+
IGNORE_END,
|
|
339
|
+
];
|
|
340
|
+
const markers = { start: IGNORE_START, end: IGNORE_END, block: lines.join("\n") };
|
|
341
|
+
return withBlock(".gitignore", readText(root, ".gitignore"), "", markers);
|
|
342
|
+
}
|
|
343
|
+
export function planRender(root, config) {
|
|
344
|
+
const tools = config.tools ?? [];
|
|
345
|
+
const body = instructions(config);
|
|
346
|
+
const server = serverCommand(root);
|
|
347
|
+
const files = [];
|
|
348
|
+
const manual = [];
|
|
349
|
+
const uses = (tool) => tools.includes(tool);
|
|
350
|
+
const imports = (path) => IMPORTS_AGENTS.test(readText(root, path) ?? "");
|
|
351
|
+
// AGENTS.md is the file most tools read. A project that only uses Claude Code gets it when it
|
|
352
|
+
// already has one, or when CLAUDE.md or GEMINI.md imports it.
|
|
353
|
+
const agents = tools.length === 0 ||
|
|
354
|
+
tools.some((tool) => tool !== "claude-code") ||
|
|
355
|
+
existsSync(join(root, "AGENTS.md")) ||
|
|
356
|
+
imports("CLAUDE.md");
|
|
357
|
+
if (agents)
|
|
358
|
+
files.push(withBlock("AGENTS.md", readText(root, "AGENTS.md"), body));
|
|
359
|
+
if (uses("claude-code")) {
|
|
360
|
+
files.push(toolFile(root, "CLAUDE.md", body, agents));
|
|
361
|
+
files.push(withServer(root, ".mcp.json", "mcpServers", server));
|
|
362
|
+
}
|
|
363
|
+
if (uses("cursor")) {
|
|
364
|
+
const path = ".cursor/rules/peer-ai.mdc";
|
|
365
|
+
const content = cursorRule(body);
|
|
366
|
+
const existing = readText(root, path);
|
|
367
|
+
files.push({
|
|
368
|
+
path,
|
|
369
|
+
action: existing === undefined ? "create" : existing === content ? "unchanged" : "update",
|
|
370
|
+
content,
|
|
371
|
+
});
|
|
372
|
+
files.push(withServer(root, ".cursor/mcp.json", "mcpServers", server));
|
|
373
|
+
}
|
|
374
|
+
if (uses("copilot")) {
|
|
375
|
+
files.push(withBlock(".github/copilot-instructions.md", readText(root, ".github/copilot-instructions.md"), body));
|
|
376
|
+
files.push(withServer(root, ".vscode/mcp.json", "servers", { type: "stdio", ...server }));
|
|
377
|
+
}
|
|
378
|
+
if (uses("gemini-cli")) {
|
|
379
|
+
files.push(toolFile(root, "GEMINI.md", body, agents));
|
|
380
|
+
files.push(withServer(root, ".gemini/settings.json", "mcpServers", server));
|
|
381
|
+
}
|
|
382
|
+
const command = skillsCommand(root);
|
|
383
|
+
// Ruff reads settings from a file, not a package, so its settings for the project's stack
|
|
384
|
+
// profiles are written where the project's own Ruff settings can extend them (RFC 0006).
|
|
385
|
+
const ruff = ruffFile(config);
|
|
386
|
+
if (ruff !== undefined) {
|
|
387
|
+
const existing = readText(root, RUFF_FILE);
|
|
388
|
+
files.push({
|
|
389
|
+
path: RUFF_FILE,
|
|
390
|
+
action: existing === undefined ? "create" : existing === ruff ? "unchanged" : "update",
|
|
391
|
+
content: ruff,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
// The pipeline profile's checks, as one workflow render owns while nobody changes it by hand.
|
|
395
|
+
const workflow = workflowFile(config);
|
|
396
|
+
if (workflow !== undefined) {
|
|
397
|
+
const existing = readText(root, WORKFLOW_FILE);
|
|
398
|
+
if (existing === undefined)
|
|
399
|
+
files.push({ path: WORKFLOW_FILE, action: "create", content: workflow });
|
|
400
|
+
else if (unchangedSinceRender(existing)) {
|
|
401
|
+
const same = sameFile(existing, workflow);
|
|
402
|
+
files.push({
|
|
403
|
+
path: WORKFLOW_FILE,
|
|
404
|
+
action: same ? "unchanged" : "update",
|
|
405
|
+
...(same ? {} : { content: workflow }),
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
files.push({
|
|
410
|
+
path: WORKFLOW_FILE,
|
|
411
|
+
action: "kept",
|
|
412
|
+
note: "It was changed by hand, so keeping it up to date with the config is yours now; peer-ai doctor checks it still has every job. Delete it to have render write Peer AI's again.",
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (uses("claude-code"))
|
|
417
|
+
files.push(claudeSessionHook(root, command));
|
|
418
|
+
if (uses("cursor"))
|
|
419
|
+
files.push(cursorEnvironment(root, command));
|
|
420
|
+
if (uses("copilot"))
|
|
421
|
+
files.push(copilotSetupSteps(root, command));
|
|
422
|
+
if (uses("codex")) {
|
|
423
|
+
manual.push(`Codex cloud keeps its setup script in the environment's settings, not the project. Add this line to it, so the skills and the MCP server are ready before the agent starts (its internet is off while it works): ${command}`);
|
|
424
|
+
manual.push(`Codex keeps MCP servers in your own config, not the project's. Register it once: codex mcp add ${SERVER} -- ${server.command} ${server.args.join(" ")}`, `Codex asks before run_verify, because it runs the project's verify command. To allow it without asking, add to ~/.codex/config.toml: [mcp_servers.${SERVER}.tools.run_verify] approval_mode = "approve"`);
|
|
425
|
+
}
|
|
426
|
+
if (uses("other")) {
|
|
427
|
+
manual.push(`For another tool, point it at AGENTS.md, and register the MCP server as: ${server.command} ${server.args.join(" ")}`);
|
|
428
|
+
}
|
|
429
|
+
const skills = planSkills(root, tools);
|
|
430
|
+
const folders = skillFolders(tools);
|
|
431
|
+
const committed = config.skills?.commit === true;
|
|
432
|
+
if (committed && folders.length > 0)
|
|
433
|
+
files.push(markSkillsGenerated(root, folders));
|
|
434
|
+
const ignoredSkills = committed || !skills.some((skill) => skill.action !== "remove") ? [] : folders;
|
|
435
|
+
files.push(ignoreBlock(root, ignoredSkills));
|
|
436
|
+
return { files, skills, manual };
|
|
437
|
+
}
|
|
438
|
+
function write(root, planned) {
|
|
439
|
+
if (planned.content === undefined)
|
|
440
|
+
return;
|
|
441
|
+
const full = join(root, planned.path);
|
|
442
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
443
|
+
writeFileSync(full, planned.content);
|
|
444
|
+
}
|
|
445
|
+
function writeSkill(root, skill) {
|
|
446
|
+
const folder = join(root, skill.path);
|
|
447
|
+
if (skill.action === "remove") {
|
|
448
|
+
rmSync(folder, { recursive: true, force: true });
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
for (const file of skill.stale)
|
|
452
|
+
unlinkSync(join(folder, file));
|
|
453
|
+
for (const [file, text] of skill.files) {
|
|
454
|
+
mkdirSync(dirname(join(folder, file)), { recursive: true });
|
|
455
|
+
writeFileSync(join(folder, file), text);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/** One line per skills folder, rather than one per file. */
|
|
459
|
+
function skillChecks(skills) {
|
|
460
|
+
const homes = [...new Set(skills.map((skill) => skill.path.slice(0, skill.path.lastIndexOf("/"))))];
|
|
461
|
+
return homes.map((home) => {
|
|
462
|
+
const here = skills.filter((skill) => skill.path.startsWith(`${home}/`));
|
|
463
|
+
const changed = here.filter((skill) => skill.action !== "unchanged");
|
|
464
|
+
const names = (action) => here.filter((skill) => skill.action === action).map((skill) => skill.path.slice(home.length + 1));
|
|
465
|
+
if (changed.length === 0)
|
|
466
|
+
return ok("render", `${home}/: ${plural(here.length, "skill")}, up to date.`);
|
|
467
|
+
const parts = [
|
|
468
|
+
["written", [...names("create"), ...names("update")]],
|
|
469
|
+
["removed", names("remove")],
|
|
470
|
+
];
|
|
471
|
+
const summary = parts
|
|
472
|
+
.filter(([, list]) => list.length > 0)
|
|
473
|
+
.map(([verb, list]) => `${verb} ${list.join(", ")}`)
|
|
474
|
+
.join("; ");
|
|
475
|
+
return ok("render", `${home}/: ${summary}.`);
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
function toCheck(planned, checking) {
|
|
479
|
+
switch (planned.action) {
|
|
480
|
+
case "refused":
|
|
481
|
+
return fail("render", `${planned.path} wasn't changed.`, planned.note ?? "");
|
|
482
|
+
case "kept":
|
|
483
|
+
return skip("render", `${planned.path} left as it is. ${planned.note ?? ""}`.trim());
|
|
484
|
+
case "unchanged":
|
|
485
|
+
return ok("render", `${planned.path} is up to date.${planned.note === undefined ? "" : ` ${planned.note}`}`);
|
|
486
|
+
default:
|
|
487
|
+
return checking
|
|
488
|
+
? fail("render", `${planned.path} is out of date.`, "Run peer-ai render.")
|
|
489
|
+
: ok("render", `${planned.path} ${planned.action === "create" ? "created" : "updated"}.${planned.note === undefined ? "" : ` ${planned.note}`}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/** Exit code 0 when done or up to date; 1 when a file was refused, or out of date with --check; 2 without a valid config. */
|
|
493
|
+
export function runRender(options, out) {
|
|
494
|
+
const { config, errors } = loadConfig(options.cwd);
|
|
495
|
+
if (config === undefined) {
|
|
496
|
+
if (errors === undefined)
|
|
497
|
+
out.error(`There is no ${CONFIG_FILE}. Run peer-ai init first.`);
|
|
498
|
+
else {
|
|
499
|
+
out.error(`${CONFIG_FILE} is not valid:`);
|
|
500
|
+
for (const error of errors)
|
|
501
|
+
out.error(` ${error}`);
|
|
502
|
+
}
|
|
503
|
+
return 2;
|
|
504
|
+
}
|
|
505
|
+
const plan = planRender(options.cwd, config);
|
|
506
|
+
if (options.skills === true) {
|
|
507
|
+
for (const skill of plan.skills)
|
|
508
|
+
if (skill.action !== "unchanged")
|
|
509
|
+
writeSkill(options.cwd, skill);
|
|
510
|
+
if (options.quiet !== true)
|
|
511
|
+
for (const line of formatChecks(skillChecks(plan.skills)))
|
|
512
|
+
out.log(line);
|
|
513
|
+
return 0;
|
|
514
|
+
}
|
|
515
|
+
const committed = config.skills?.commit === true;
|
|
516
|
+
if (!options.check) {
|
|
517
|
+
for (const planned of plan.files)
|
|
518
|
+
if (planned.action !== "refused" && planned.action !== "kept")
|
|
519
|
+
write(options.cwd, planned);
|
|
520
|
+
for (const skill of plan.skills)
|
|
521
|
+
if (skill.action !== "unchanged")
|
|
522
|
+
writeSkill(options.cwd, skill);
|
|
523
|
+
}
|
|
524
|
+
// --check is for CI, which sees only what's committed. Skills usually aren't, so it leaves them
|
|
525
|
+
// out unless the project commits them; peer-ai doctor reports missing skills on a person's machine.
|
|
526
|
+
const skillLines = !options.check
|
|
527
|
+
? skillChecks(plan.skills)
|
|
528
|
+
: committed
|
|
529
|
+
? plan.skills
|
|
530
|
+
.filter((skill) => skill.action !== "unchanged")
|
|
531
|
+
.map((skill) => fail("render", `${skill.path}/ is out of date.`, "Run peer-ai render."))
|
|
532
|
+
: [];
|
|
533
|
+
const checks = [...plan.files.map((planned) => toCheck(planned, options.check)), ...skillLines];
|
|
534
|
+
if ((config.tools ?? []).length === 0) {
|
|
535
|
+
checks.push(skip("render", `No AI tools are listed in ${CONFIG_FILE}, so AGENTS.md is the only instructions file written.`));
|
|
536
|
+
}
|
|
537
|
+
const failures = count(checks, "fail");
|
|
538
|
+
if (options.quiet === true && failures === 0)
|
|
539
|
+
return 0;
|
|
540
|
+
out.log(`Peer AI render: ${config.project.name}`);
|
|
541
|
+
out.log("");
|
|
542
|
+
for (const line of formatChecks(checks))
|
|
543
|
+
out.log(line);
|
|
544
|
+
for (const step of plan.manual)
|
|
545
|
+
out.log(` ! ${step}`);
|
|
546
|
+
out.log("");
|
|
547
|
+
if (options.check)
|
|
548
|
+
out.log(failures === 0 ? "Everything is up to date." : "Out of date: run peer-ai render.");
|
|
549
|
+
else if (failures > 0)
|
|
550
|
+
out.log("Some files couldn't be changed; see above.");
|
|
551
|
+
else if (!committed && plan.skills.some((skill) => skill.action !== "remove")) {
|
|
552
|
+
out.log("Done. Commit these files, so every clone and every tool gets them. The skills stay out of git: run peer-ai render after cloning.");
|
|
553
|
+
}
|
|
554
|
+
else
|
|
555
|
+
out.log("Done. Commit these files, so every clone and every tool gets them.");
|
|
556
|
+
return failures === 0 ? 0 : 1;
|
|
557
|
+
}
|
package/dist/report.d.ts
ADDED
package/dist/report.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Turns an assessment into the report `peer-ai assess` prints: every item on the map, what the
|
|
2
|
+
// project's stage still needs, what was inferred and should be confirmed, and compliance signals.
|
|
3
|
+
import { MAP_ITEM_IDS } from "peer-ai-workflow";
|
|
4
|
+
import { NEXT_STAGE, gaps } from "./assess.js";
|
|
5
|
+
import { CONFIG_FILE } from "./detect.js";
|
|
6
|
+
const SYMBOL = { present: "✓", partial: "◐", missing: "✗", "not-applicable": "–" };
|
|
7
|
+
const WIDTH = Math.max(...MAP_ITEM_IDS.map((id) => id.length));
|
|
8
|
+
function detail(item) {
|
|
9
|
+
const text = item.note ?? item.evidence?.join(", ") ?? "";
|
|
10
|
+
return text.length <= 90 ? text : `${text.slice(0, 89)}…`;
|
|
11
|
+
}
|
|
12
|
+
function list(names) {
|
|
13
|
+
return names.length <= 6 ? names.join(", ") : `${names.slice(0, 6).join(", ")} and ${String(names.length - 6)} more`;
|
|
14
|
+
}
|
|
15
|
+
export function formatReport(assessment, stage) {
|
|
16
|
+
const lines = [];
|
|
17
|
+
lines.push(`Peer AI assessment: ${assessment.name} (stage: ${stage})`);
|
|
18
|
+
lines.push(assessment.tracks.length === 0
|
|
19
|
+
? "No parts found yet."
|
|
20
|
+
: `${String(assessment.tracks.length)} ${assessment.tracks.length === 1 ? "part" : "parts"}: ${list(assessment.tracks.map((track) => `${track.id} (${track.kind})`))}`);
|
|
21
|
+
lines.push("");
|
|
22
|
+
for (const id of MAP_ITEM_IDS) {
|
|
23
|
+
const item = assessment.items[id];
|
|
24
|
+
const text = detail(item);
|
|
25
|
+
lines.push(` ${SYMBOL[item.status]} ${id.padEnd(WIDTH)}${text === "" ? "" : ` ${text}`}`.trimEnd());
|
|
26
|
+
}
|
|
27
|
+
lines.push("");
|
|
28
|
+
const needed = gaps(assessment, stage);
|
|
29
|
+
if (stage === "prototype")
|
|
30
|
+
lines.push("Nothing is required at the prototype stage.");
|
|
31
|
+
else if (needed.length === 0)
|
|
32
|
+
lines.push(`Everything the ${stage} stage needs is in place.`);
|
|
33
|
+
else {
|
|
34
|
+
lines.push(`Needed for ${stage} (${String(needed.length)}):`);
|
|
35
|
+
for (const id of needed)
|
|
36
|
+
lines.push(` ${SYMBOL[assessment.items[id].status]} ${id}`);
|
|
37
|
+
}
|
|
38
|
+
const next = NEXT_STAGE[stage];
|
|
39
|
+
if (next !== undefined) {
|
|
40
|
+
const later = gaps(assessment, next).filter((id) => !needed.includes(id));
|
|
41
|
+
if (later.length > 0)
|
|
42
|
+
lines.push(`Later, for ${next}: ${later.join(", ")}.`);
|
|
43
|
+
}
|
|
44
|
+
if (assessment.legacyPlaybook) {
|
|
45
|
+
lines.push("");
|
|
46
|
+
lines.push("Left out: the peer-ai/ folder, a copy of the v0 playbook that 1.0 replaces.");
|
|
47
|
+
}
|
|
48
|
+
const inferred = MAP_ITEM_IDS.filter((id) => assessment.items[id].inferred === true);
|
|
49
|
+
if (inferred.length > 0) {
|
|
50
|
+
lines.push("");
|
|
51
|
+
lines.push(`Inferred from the code, not a document; check these: ${inferred.join(", ")}.`);
|
|
52
|
+
}
|
|
53
|
+
if (assessment.suggestedTraits.length > 0) {
|
|
54
|
+
lines.push("");
|
|
55
|
+
lines.push(`Traits to consider, each switching on extra rules. Add the ones that fit to project.traits in ${CONFIG_FILE}:`);
|
|
56
|
+
for (const { trait, evidence } of assessment.suggestedTraits)
|
|
57
|
+
lines.push(` ${trait}: ${evidence}`);
|
|
58
|
+
}
|
|
59
|
+
if (assessment.suggestedProfiles.length > 0) {
|
|
60
|
+
lines.push("");
|
|
61
|
+
lines.push(`Stack profiles to consider, each with the tools that enforce its rules. Add the ones that fit to standards.profiles in ${CONFIG_FILE}:`);
|
|
62
|
+
for (const { profile, evidence } of assessment.suggestedProfiles)
|
|
63
|
+
lines.push(` ${profile}: ${evidence}`);
|
|
64
|
+
}
|
|
65
|
+
if (assessment.suggestedStacks.length > 0) {
|
|
66
|
+
lines.push("");
|
|
67
|
+
lines.push(`Stacks to add to tracks in ${CONFIG_FILE}, so each stack profile applies only to the parts it fits:`);
|
|
68
|
+
for (const { track, stack } of assessment.suggestedStacks) {
|
|
69
|
+
lines.push(` ${track}: "stack": ${JSON.stringify(stack)}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const { personalData, cardData, paymentProviders } = assessment.signals;
|
|
73
|
+
if (personalData.length + cardData.length + paymentProviders.length > 0) {
|
|
74
|
+
lines.push("");
|
|
75
|
+
lines.push("Compliance signals:");
|
|
76
|
+
if (personalData.length > 0) {
|
|
77
|
+
const first = personalData[0]?.file ?? "";
|
|
78
|
+
lines.push(` Personal data in ${String(personalData.length)} fields: ${list(personalData.map((f) => f.name))} (first seen in ${first}).`);
|
|
79
|
+
}
|
|
80
|
+
if (cardData.length > 0) {
|
|
81
|
+
const first = cardData[0]?.file ?? "";
|
|
82
|
+
lines.push(` Card-related names in the schema: ${list(cardData.map((f) => f.name))} (first seen in ${first}). Check that only the last four digits and a payment provider's token are stored: never a full card number or a CVV.`);
|
|
83
|
+
}
|
|
84
|
+
if (paymentProviders.length > 0)
|
|
85
|
+
lines.push(` Payment providers: ${paymentProviders.join(", ")}.`);
|
|
86
|
+
const packs = [
|
|
87
|
+
...(cardData.length > 0 || paymentProviders.length > 0 ? ["pci-dss"] : []),
|
|
88
|
+
...(personalData.length > 0 ? ["the data protection law where you operate, such as ndpa or gdpr"] : []),
|
|
89
|
+
];
|
|
90
|
+
lines.push(` Rule packs to consider: ${packs.join("; ")}.`);
|
|
91
|
+
}
|
|
92
|
+
return lines;
|
|
93
|
+
}
|