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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +403 -0
  3. package/dist/assess.d.ts +102 -0
  4. package/dist/assess.js +545 -0
  5. package/dist/check.d.ts +30 -0
  6. package/dist/check.js +253 -0
  7. package/dist/checks.d.ts +15 -0
  8. package/dist/checks.js +15 -0
  9. package/dist/cli.d.ts +10 -0
  10. package/dist/cli.js +231 -0
  11. package/dist/detect.d.ts +42 -0
  12. package/dist/detect.js +459 -0
  13. package/dist/doctor.d.ts +36 -0
  14. package/dist/doctor.js +297 -0
  15. package/dist/document.d.ts +30 -0
  16. package/dist/document.js +72 -0
  17. package/dist/enforcers.d.ts +6 -0
  18. package/dist/enforcers.js +307 -0
  19. package/dist/feedback.d.ts +67 -0
  20. package/dist/feedback.js +209 -0
  21. package/dist/files.d.ts +1 -0
  22. package/dist/files.js +72 -0
  23. package/dist/init.d.ts +31 -0
  24. package/dist/init.js +158 -0
  25. package/dist/mcp.d.ts +13 -0
  26. package/dist/mcp.js +247 -0
  27. package/dist/package-info.d.ts +4 -0
  28. package/dist/package-info.js +6 -0
  29. package/dist/pipeline.d.ts +82 -0
  30. package/dist/pipeline.js +265 -0
  31. package/dist/prompter.d.ts +23 -0
  32. package/dist/prompter.js +56 -0
  33. package/dist/render.d.ts +58 -0
  34. package/dist/render.js +557 -0
  35. package/dist/report.d.ts +3 -0
  36. package/dist/report.js +93 -0
  37. package/dist/routing.d.ts +24 -0
  38. package/dist/routing.js +121 -0
  39. package/dist/ruff.d.ts +19 -0
  40. package/dist/ruff.js +64 -0
  41. package/dist/standards.d.ts +46 -0
  42. package/dist/standards.js +130 -0
  43. package/dist/state.d.ts +22 -0
  44. package/dist/state.js +56 -0
  45. package/dist/test-helpers.d.ts +16 -0
  46. package/dist/test-helpers.js +62 -0
  47. package/dist/work.d.ts +147 -0
  48. package/dist/work.js +357 -0
  49. package/package.json +45 -0
@@ -0,0 +1,307 @@
1
+ // Whether the stack profiles a project lists exist, and whether the tools that enforce their
2
+ // automatic rules are set up (RFC 0006): ESLint using Peer AI's settings, and each TypeScript
3
+ // part's tsconfig saying what the compiler rules need. An automatic rule nothing enforces is a
4
+ // warning, and a failure at production.
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { dirname, join, normalize, posix } from "node:path";
7
+ import { parse as parseJsonc } from "jsonc-parser";
8
+ import { parse as parseToml } from "smol-toml";
9
+ import { parse as parseYaml } from "yaml";
10
+ import { profile, profileRulesFor } from "peer-ai-standards";
11
+ import { fail, ok, plural, warn } from "./checks.js";
12
+ import { WORKFLOW_FILE, environmentAddresses, pipelineRules, sameFile, unchangedSinceRender, workflowFile, workflowJobs, writtenByRender, } from "./pipeline.js";
13
+ import { RUFF_FILE, ruffFile } from "./ruff.js";
14
+ const ESLINT_CONFIGS = ["js", "mjs", "cjs", "ts", "mts", "cts"].map((extension) => `eslint.config.${extension}`);
15
+ const ESLINT_PACKAGE = "peer-ai-eslint-config";
16
+ const list = (ids) => ids.join(", ");
17
+ /** The profiles the config lists that Peer AI has no rules for yet. */
18
+ export function checkProfiles(config) {
19
+ const listed = config.standards?.profiles ?? [];
20
+ if (listed.length === 0)
21
+ return [];
22
+ const unknown = listed.filter((id) => profile(id) === undefined);
23
+ if (unknown.length === 0)
24
+ return [ok("profiles", `Stack profiles: ${list(listed)}`)];
25
+ return [
26
+ warn("profiles", `Peer AI has no rules yet for ${list(unknown)}, so ${unknown.length === 1 ? "that profile adds" : "those profiles add"} nothing for now.`, "Keep it listed and its rules arrive with the profile, or check the id against the profiles in peer-ai-standards."),
27
+ ];
28
+ }
29
+ /** The automatic profile rules each part of the project gets, leaving out the ones it set aside. */
30
+ function automaticRules(config) {
31
+ const setAside = new Set((config.standards?.exceptions ?? []).map((exception) => exception.rule));
32
+ return config.tracks
33
+ .filter((track) => track.status !== "external")
34
+ .map((track) => ({
35
+ track,
36
+ rules: profileRulesFor({
37
+ listed: config.standards?.profiles ?? [],
38
+ stage: config.project.stage ?? "mvp",
39
+ traits: config.project.traits ?? [],
40
+ overrides: config.standards?.overrides ?? {},
41
+ ...(track.stack === undefined ? {} : { stack: track.stack }),
42
+ ...(track.architecture === undefined ? {} : { architecture: track.architecture }),
43
+ }).filter((rule) => rule.check === "auto" && !setAside.has(rule.id)),
44
+ }));
45
+ }
46
+ /** The nearest ESLint config for a part, from its folder up to the root: ESLint looks for it the same way. */
47
+ function eslintConfigFor(root, path) {
48
+ for (const folder of upFrom(path)) {
49
+ const found = ESLINT_CONFIGS.find((name) => existsSync(join(root, folder, name)));
50
+ if (found !== undefined)
51
+ return folder === "." ? found : join(folder, found);
52
+ }
53
+ return undefined;
54
+ }
55
+ /** The folders from a part's folder up to the root, nearest first. */
56
+ function upFrom(path) {
57
+ const folders = [];
58
+ for (let folder = path === undefined ? "." : normalize(path);; folder = dirname(folder)) {
59
+ folders.push(folder);
60
+ if (folder === "." || dirname(folder) === folder)
61
+ break;
62
+ }
63
+ return folders;
64
+ }
65
+ const table = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined;
66
+ const strings = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
67
+ /** The Ruff settings in a file, or undefined when it isn't valid TOML or holds none. */
68
+ function readRuff(root, file) {
69
+ let data;
70
+ try {
71
+ data = parseToml(readFileSync(join(root, file), "utf8"));
72
+ }
73
+ catch {
74
+ return undefined;
75
+ }
76
+ const ruff = file.endsWith("pyproject.toml") ? table(table(data.tool)?.ruff) : data;
77
+ if (ruff === undefined)
78
+ return undefined;
79
+ const lint = table(ruff.lint) ?? {};
80
+ const extend = ruff.extend;
81
+ return {
82
+ ...(typeof extend === "string" ? { extend: posix.normalize(posix.join(posix.dirname(file), extend)) } : {}),
83
+ select: "select" in lint || "select" in ruff,
84
+ ignored: [lint.ignore, lint["extend-ignore"], ruff.ignore, ruff["extend-ignore"]].flatMap(strings),
85
+ };
86
+ }
87
+ /**
88
+ * The Ruff settings nearest a part, found as Ruff finds them: in each folder from the part's up,
89
+ * .ruff.toml, then ruff.toml, then a pyproject.toml with a tool.ruff table, such as [tool.ruff.lint].
90
+ */
91
+ function ruffConfigFor(root, path) {
92
+ for (const folder of upFrom(path)) {
93
+ for (const name of [".ruff.toml", "ruff.toml", "pyproject.toml"]) {
94
+ const file = folder === "." ? name : posix.join(folder, name);
95
+ if (!existsSync(join(root, file)))
96
+ continue;
97
+ if (name !== "pyproject.toml" || readRuff(root, file) !== undefined)
98
+ return file;
99
+ }
100
+ }
101
+ return undefined;
102
+ }
103
+ /**
104
+ * The files a Ruff config reads, following each extend in turn: Ruff allows one per file, and the
105
+ * file it names can extend another. Stops at Peer AI's file, at a file it can't read, or at a loop.
106
+ */
107
+ function ruffChain(root, file) {
108
+ const files = [];
109
+ const settings = [];
110
+ for (let current = file; current !== undefined && !files.includes(current);) {
111
+ files.push(current);
112
+ if (current === RUFF_FILE || !existsSync(join(root, current)))
113
+ break;
114
+ const read = readRuff(root, current);
115
+ settings.push(read);
116
+ current = read?.extend;
117
+ }
118
+ return { files, settings };
119
+ }
120
+ /** The jobs a workflow file defines, or none when it isn't valid YAML. */
121
+ function jobsIn(content) {
122
+ try {
123
+ const parsed = parseYaml(content);
124
+ return Object.keys(table(table(parsed)?.jobs) ?? {});
125
+ }
126
+ catch {
127
+ return [];
128
+ }
129
+ }
130
+ /** A tsconfig's compiler options, read as TypeScript reads it: comments and trailing commas allowed. */
131
+ function readTsconfig(root, path) {
132
+ const parsed = parseJsonc(readFileSync(join(root, path), "utf8"), [], { allowTrailingComma: true });
133
+ const config = (typeof parsed === "object" && parsed !== null ? parsed : {});
134
+ const references = (config.references ?? [])
135
+ .map((reference) => reference.path)
136
+ .filter((reference) => typeof reference === "string")
137
+ .map((reference) => {
138
+ const target = join(dirname(path), reference);
139
+ return target.endsWith(".json") ? target : join(target, "tsconfig.json");
140
+ })
141
+ .filter((target) => existsSync(join(root, target)));
142
+ return { options: config.compilerOptions ?? {}, references };
143
+ }
144
+ /**
145
+ * The tsconfig files that set a part's compiler options: its own, or, when it only points to
146
+ * others, as a solution file does, the ones it references.
147
+ */
148
+ function tsconfigsFor(root, path, option) {
149
+ const { options, references } = readTsconfig(root, path);
150
+ return option in options || references.length === 0 ? [path] : references;
151
+ }
152
+ /** Each enforcing tool is set up to use Peer AI's settings. */
153
+ export function checkEnforcers(root, config) {
154
+ const production = config.project.stage === "production";
155
+ const missing = (message, fix) => production ? fail("enforcers", message, fix) : warn("enforcers", message, fix);
156
+ const parts = automaticRules(config);
157
+ const checks = [];
158
+ // Each part is linted by its nearest ESLint config, so each config is checked for the rules of
159
+ // the parts it covers.
160
+ const byConfig = new Map();
161
+ for (const { track, rules } of parts) {
162
+ const linted = rules.filter((rule) => rule.enforcer?.tool === "eslint").map((rule) => rule.id);
163
+ if (linted.length === 0)
164
+ continue;
165
+ const file = eslintConfigFor(root, track.path);
166
+ byConfig.set(file, new Set([...(byConfig.get(file) ?? []), ...linted]));
167
+ }
168
+ const addIt = `import peerAi from "${ESLINT_PACKAGE}", and spread ...peerAi() into the settings it exports, before your own.`;
169
+ for (const [file, ids] of byConfig) {
170
+ const listed = list([...ids]);
171
+ if (file === undefined) {
172
+ checks.push(missing(`There's no ESLint config, so nothing enforces ${listed}.`, `Add eslint.config.js: ${addIt}`));
173
+ }
174
+ else if (!readFileSync(join(root, file), "utf8").includes(ESLINT_PACKAGE)) {
175
+ checks.push(missing(`${file} doesn't use Peer AI's settings, so nothing enforces ${listed}.`, `In ${file}, ${addIt}`));
176
+ }
177
+ else {
178
+ checks.push(ok("enforcers", `${file} uses Peer AI's settings for ${plural(ids.size, "rule")}`));
179
+ }
180
+ }
181
+ // Each Python part is linted by its nearest Ruff settings, which must reach the file render
182
+ // writes through extend, without a select that replaces its rules or an ignore that drops them.
183
+ const byRuffConfig = new Map();
184
+ for (const { track, rules } of parts) {
185
+ const ruffRules = rules.filter((rule) => rule.enforcer?.tool === "ruff");
186
+ if (ruffRules.length === 0)
187
+ continue;
188
+ const file = ruffConfigFor(root, track.path);
189
+ const folder = track.path ?? ".";
190
+ const key = file ?? `none:${folder}`;
191
+ const entry = byRuffConfig.get(key) ?? { ...(file === undefined ? {} : { file }), folder, rules: [] };
192
+ for (const rule of ruffRules)
193
+ if (!entry.rules.some((known) => known.id === rule.id))
194
+ entry.rules.push(rule);
195
+ byRuffConfig.set(key, entry);
196
+ }
197
+ if (byRuffConfig.size > 0) {
198
+ const current = existsSync(join(root, RUFF_FILE)) ? readFileSync(join(root, RUFF_FILE), "utf8") : undefined;
199
+ if (current === undefined) {
200
+ checks.push(missing(`${RUFF_FILE} isn't there, so Ruff has no Peer AI settings to extend.`, "Run peer-ai render."));
201
+ }
202
+ else if (current !== ruffFile(config)) {
203
+ checks.push(missing(`${RUFF_FILE} is out of date with the config.`, "Run peer-ai render."));
204
+ }
205
+ }
206
+ const extendLine = (from) => `extend = "${posix.relative(from, RUFF_FILE)}"`;
207
+ for (const { file, folder, rules } of byRuffConfig.values()) {
208
+ const listed = list(rules.map((rule) => rule.id));
209
+ if (file === undefined) {
210
+ checks.push(missing(`There are no Ruff settings for ${folder}, so nothing enforces ${listed}.`, `Add [tool.ruff] to ${posix.join(folder, "pyproject.toml")}, with ${extendLine(folder)}.`));
211
+ continue;
212
+ }
213
+ const chain = ruffChain(root, file);
214
+ const unreadable = chain.files.find((_, i) => i < chain.settings.length && chain.settings[i] === undefined);
215
+ if (unreadable !== undefined) {
216
+ checks.push(missing(`${unreadable} can't be read as Ruff settings, so Ruff won't run.`, `Fix the TOML in ${unreadable}.`));
217
+ continue;
218
+ }
219
+ if (!chain.files.includes(RUFF_FILE)) {
220
+ const last = chain.files.at(-1) ?? file;
221
+ const table = last.endsWith("pyproject.toml") ? ", under [tool.ruff]," : "";
222
+ const where = last === file
223
+ ? `In ${file}${table}`
224
+ : `${file} extends ${last}, which extends nothing further. In ${last}${table}`;
225
+ checks.push(missing(`${file} doesn't extend Peer AI's Ruff settings, so nothing enforces ${listed}.`, `${where} add ${extendLine(posix.dirname(last))}. Ruff allows one extend in each file.`));
226
+ continue;
227
+ }
228
+ const before = chain.settings.filter((read) => read !== undefined);
229
+ const selecting = chain.files.filter((_, i) => before[i]?.select === true);
230
+ if (selecting.length > 0) {
231
+ checks.push(missing(`${list(selecting)} ${selecting.length === 1 ? "sets" : "set"} select, which replaces Peer AI's Ruff rules instead of adding to them, so nothing enforces ${listed}.`, `In ${list(selecting)}, rename select to extend-select.`));
232
+ continue;
233
+ }
234
+ const ignored = before.flatMap((read) => read.ignored);
235
+ const dropped = rules.filter((rule) => {
236
+ const code = rule.enforcer?.tool === "ruff" ? rule.enforcer.rule : "";
237
+ return ignored.some((entry) => entry === "ALL" || code.startsWith(entry));
238
+ });
239
+ if (dropped.length > 0) {
240
+ checks.push(missing(`${file}'s Ruff settings ignore the codes of ${list(dropped.map((rule) => rule.id))}, which switches them off without a recorded reason.`, "Take those codes out of ignore, and set any rule that doesn't fit aside in standards.exceptions, with the reason."));
241
+ continue;
242
+ }
243
+ checks.push(ok("enforcers", `${file} extends Peer AI's Ruff settings for ${plural(rules.length, "rule")}`));
244
+ }
245
+ // The pipeline's checks run from the workflow render writes.
246
+ const pipeline = pipelineRules(config);
247
+ const existing = existsSync(join(root, WORKFLOW_FILE)) ? readFileSync(join(root, WORKFLOW_FILE), "utf8") : undefined;
248
+ const expected = workflowFile(config);
249
+ if (expected === undefined) {
250
+ if (existing !== undefined && writtenByRender(existing)) {
251
+ checks.push(warn("enforcers", `${WORKFLOW_FILE} is still there, but the config asks for none of its checks, so render no longer updates it.`, "Delete it, or list the github-actions profile in standards.profiles."));
252
+ }
253
+ }
254
+ else {
255
+ const ids = list(pipeline.filter((rule) => rule.check === "auto").map((rule) => rule.id));
256
+ if (existing === undefined) {
257
+ checks.push(missing(`${WORKFLOW_FILE} isn't there, so the pipeline doesn't run ${ids}.`, "Run peer-ai render."));
258
+ }
259
+ else if (unchangedSinceRender(existing)) {
260
+ if (sameFile(existing, expected))
261
+ checks.push(ok("enforcers", `${WORKFLOW_FILE} runs ${ids}`));
262
+ else
263
+ checks.push(missing(`${WORKFLOW_FILE} is out of date with the config.`, "Run peer-ai render."));
264
+ }
265
+ else {
266
+ // Changed by hand: the jobs Peer AI's would have must still be there, under their names.
267
+ const found = jobsIn(existing);
268
+ const gone = workflowJobs(config).filter((job) => !found.includes(job));
269
+ checks.push(gone.length === 0
270
+ ? warn("enforcers", `${WORKFLOW_FILE} was changed by hand. It still has every job, but doctor can't tell whether each still runs its check.`, "Keep it up to date with the config yourself, or delete it and run peer-ai render to go back to Peer AI's.")
271
+ : missing(`${WORKFLOW_FILE} was changed by hand, and no longer has the ${gone.join(", ")} ${gone.length === 1 ? "job" : "jobs"}.`, "Add them back, or delete the file and run peer-ai render to go back to Peer AI's."));
272
+ }
273
+ const unusable = environmentAddresses(config).unusable;
274
+ if (unusable.length > 0 && pipeline.some((rule) => rule.id === "GHA-06" || rule.id === "GHA-07")) {
275
+ checks.push(warn("enforcers", `The pipeline leaves out ${list(unusable.map((environment) => `${environment.id} (${environment.url})`))}: an address must be a full http or https URL, without a user name or password.`, "Correct the url in the environments of peer-ai.config.json."));
276
+ }
277
+ }
278
+ for (const { track, rules } of parts) {
279
+ const compiled = rules.filter((rule) => rule.enforcer?.tool === "typescript");
280
+ if (compiled.length === 0)
281
+ continue;
282
+ const path = [track.path, undefined]
283
+ .map((folder) => (folder === undefined ? "tsconfig.json" : join(folder, "tsconfig.json")))
284
+ .find((candidate) => existsSync(join(root, candidate)));
285
+ if (path === undefined) {
286
+ checks.push(missing(`${track.id} has no tsconfig.json, so the compiler doesn't enforce ${list(compiled.map((r) => r.id))}.`, `Add a tsconfig.json to ${track.path ?? "the repository"} that turns on what they need.`));
287
+ continue;
288
+ }
289
+ for (const rule of compiled) {
290
+ if (rule.enforcer?.tool !== "typescript")
291
+ continue;
292
+ const { option, value } = rule.enforcer;
293
+ for (const file of tsconfigsFor(root, path, option)) {
294
+ const set = readTsconfig(root, file).options[option];
295
+ const wanted = JSON.stringify(value);
296
+ if (set === value) {
297
+ checks.push(ok("enforcers", `${file} sets ${option} to ${wanted} (${rule.id})`));
298
+ continue;
299
+ }
300
+ checks.push(missing(set === undefined
301
+ ? `${file} doesn't say "${option}": ${wanted}, which ${rule.id} needs.`
302
+ : `${file} sets "${option}" to ${JSON.stringify(set)}, but ${rule.id} needs ${wanted}.`, `Set "${option}": ${wanted} in the compilerOptions of ${file}, in the file itself, so it holds whatever it extends.`));
303
+ }
304
+ }
305
+ }
306
+ return checks;
307
+ }
@@ -0,0 +1,67 @@
1
+ import type { PeerAiConfig } from "peer-ai-workflow";
2
+ import type { Output } from "./init.ts";
3
+ import type { Result } from "./work.ts";
4
+ export declare const FEEDBACK_DIR = ".peer-ai/feedback";
5
+ /** Where reports go. */
6
+ export declare const FEEDBACK_REPO = "AbuMahir980/peer-ai";
7
+ export interface FeedbackInput {
8
+ /** One line, such as "security-review flagged a test file as production code". */
9
+ title: string;
10
+ /** What happened, in plain words. */
11
+ what: string;
12
+ /** What should have happened. */
13
+ expected: string;
14
+ /** The skill involved, when there is one. */
15
+ skill?: string | undefined;
16
+ /** The command or tool involved, when there is one. */
17
+ command?: string | undefined;
18
+ }
19
+ /** What Peer AI knows itself, and adds to every report. */
20
+ export interface FeedbackContext {
21
+ /** The AI tool, as it named itself when it connected. */
22
+ tool?: string;
23
+ node?: string;
24
+ os?: string;
25
+ }
26
+ /** Why a draft can't be written as it is: empty when it can. */
27
+ export declare function draftProblems(input: FeedbackInput): string[];
28
+ /** The report as a person reads it and as the issue shows it. */
29
+ export declare function formatReport(input: FeedbackInput, config: PeerAiConfig | undefined, context: FeedbackContext): string;
30
+ /** Writes a draft to .peer-ai/feedback/, or refuses it with what to take out. */
31
+ export declare function draftFeedback(root: string, config: PeerAiConfig | undefined, input: FeedbackInput, context: FeedbackContext, now: Date): Result<{
32
+ draft: string;
33
+ }>;
34
+ export interface Draft {
35
+ file: string;
36
+ title: string;
37
+ }
38
+ /** The drafts waiting for a person's decision, oldest first. */
39
+ export declare function listDrafts(root: string): Draft[];
40
+ /** Runs a command and returns what it printed, or undefined when it failed. */
41
+ export type Runner = (command: string, args: string[]) => string | undefined;
42
+ export declare const runQuietly: Runner;
43
+ /** A link that opens a new issue with the report filled in, for a person to submit. */
44
+ export declare function issueLink(title: string, body: string): string;
45
+ export type Sent = {
46
+ issue: string;
47
+ } | {
48
+ link: string;
49
+ };
50
+ /**
51
+ * Opens a draft as an issue on Peer AI's repository, through the GitHub CLI when it's signed in,
52
+ * under the person's own account, then moves the draft to sent/. Without the CLI, returns a link
53
+ * for the person to open and submit, and keeps the draft.
54
+ */
55
+ export declare function sendDraft(root: string, draft: string, run?: Runner): Result<Sent>;
56
+ /** Deletes a draft the person decided not to send. */
57
+ export declare function dropDraft(root: string, draft: string): Result<{
58
+ dropped: string;
59
+ }>;
60
+ export interface FeedbackOptions {
61
+ cwd: string;
62
+ action?: string | undefined;
63
+ draft?: string | undefined;
64
+ run?: Runner;
65
+ }
66
+ /** `peer-ai feedback [send|drop <draft>]`. Exit code 0 done, 1 refused, 2 a usage error. */
67
+ export declare function runFeedback(options: FeedbackOptions, out: Output): number;
@@ -0,0 +1,209 @@
1
+ // The feedback route (RFC 0007). When Peer AI gets something wrong in a project, the AI tool drafts
2
+ // a report with the draft_feedback tool, and a person decides whether it's sent: `peer-ai feedback`
3
+ // lists the drafts, `send` opens one as an issue on Peer AI's repository, `drop` deletes it. A
4
+ // report leaves the machine only when a person has read it and said yes.
5
+ import { execFileSync } from "node:child_process";
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
7
+ import { arch, platform } from "node:os";
8
+ import { basename, join } from "node:path";
9
+ import { VERSION } from "./package-info.js";
10
+ export const FEEDBACK_DIR = ".peer-ai/feedback";
11
+ const SENT_DIR = join(FEEDBACK_DIR, "sent");
12
+ /** Where reports go. */
13
+ export const FEEDBACK_REPO = "AbuMahir980/peer-ai";
14
+ /**
15
+ * What a report must never hold, with what to do instead. Project code, secrets and people's
16
+ * details stay out of a report that becomes a public issue.
17
+ */
18
+ const NEVER = [
19
+ { pattern: /```|~~~/, problem: "It holds a block of code. Describe what the code does in words instead." },
20
+ {
21
+ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{8,}|\bgh[pousr]_[A-Za-z0-9]{20,}|\bgithub_pat_[A-Za-z0-9_]{20,}|\bAKIA[0-9A-Z]{16}\b|\bxox[abprs]-[A-Za-z0-9-]{10,}|\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}|[A-Za-z0-9+/_=-]{40,}/,
22
+ problem: "It holds something that looks like a key or a token. Take it out.",
23
+ },
24
+ {
25
+ pattern: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/,
26
+ problem: "It holds an email address. Take it out, and any other detail about a person.",
27
+ },
28
+ ];
29
+ /** Why a draft can't be written as it is: empty when it can. */
30
+ export function draftProblems(input) {
31
+ const text = [input.title, input.what, input.expected, input.skill ?? "", input.command ?? ""].join("\n");
32
+ const problems = NEVER.filter(({ pattern }) => pattern.test(text)).map(({ problem }) => problem);
33
+ if (input.title.includes("\n"))
34
+ problems.push("The title is more than one line.");
35
+ return problems;
36
+ }
37
+ const slug = (title) => title
38
+ .toLowerCase()
39
+ .replace(/[^a-z0-9]+/g, "-")
40
+ .replace(/^-|-$/g, "")
41
+ .slice(0, 50)
42
+ .replace(/-$/, "") || "feedback";
43
+ /** The report as a person reads it and as the issue shows it. */
44
+ export function formatReport(input, config, context) {
45
+ const facts = [
46
+ ["Peer AI", VERSION],
47
+ ["AI tool", context.tool],
48
+ ["Skill", input.skill],
49
+ ["Command or tool", input.command],
50
+ ["Operating system", context.os ?? `${platform()} ${arch()}`],
51
+ ["Node", context.node ?? process.versions.node],
52
+ ["Stage", config?.project.stage],
53
+ ["Stack profiles", config?.standards?.profiles?.join(", ")],
54
+ ];
55
+ return [
56
+ `# ${input.title.trim()}`,
57
+ "",
58
+ "## What happened",
59
+ "",
60
+ input.what.trim(),
61
+ "",
62
+ "## What should have happened",
63
+ "",
64
+ input.expected.trim(),
65
+ "",
66
+ "## Where",
67
+ "",
68
+ "| | |",
69
+ "|-|-|",
70
+ ...facts.flatMap(([name, value]) => (value === undefined || value === "" ? [] : [`| ${name} | ${value} |`])),
71
+ "",
72
+ "Drafted by an AI tool with Peer AI's draft_feedback, and sent by a person who read it.",
73
+ "",
74
+ ].join("\n");
75
+ }
76
+ /** Writes a draft to .peer-ai/feedback/, or refuses it with what to take out. */
77
+ export function draftFeedback(root, config, input, context, now) {
78
+ const problems = draftProblems(input);
79
+ if (problems.length > 0) {
80
+ return {
81
+ ok: false,
82
+ error: `Peer AI won't write this draft, since a report may become public. ${problems.join(" ")}`,
83
+ };
84
+ }
85
+ const dir = join(root, FEEDBACK_DIR);
86
+ mkdirSync(dir, { recursive: true });
87
+ const stem = `${now.toISOString().slice(0, 10)}-${slug(input.title)}`;
88
+ let name = `${stem}.md`;
89
+ for (let n = 2; existsSync(join(dir, name)); n++)
90
+ name = `${stem}-${String(n)}.md`;
91
+ writeFileSync(join(dir, name), formatReport(input, config, context));
92
+ return { ok: true, value: { draft: join(FEEDBACK_DIR, name) } };
93
+ }
94
+ /** The drafts waiting for a person's decision, oldest first. */
95
+ export function listDrafts(root) {
96
+ const dir = join(root, FEEDBACK_DIR);
97
+ if (!existsSync(dir))
98
+ return [];
99
+ return readdirSync(dir)
100
+ .filter((file) => file.endsWith(".md"))
101
+ .sort()
102
+ .map((file) => ({
103
+ file,
104
+ title: /^# (.+)$/m.exec(readFileSync(join(dir, file), "utf8"))?.[1] ?? file,
105
+ }));
106
+ }
107
+ /** A draft by its file name or its path, never a file outside the drafts folder. */
108
+ function findDraft(root, draft) {
109
+ const file = basename(draft);
110
+ const path = join(root, FEEDBACK_DIR, file);
111
+ if (!file.endsWith(".md") || !existsSync(path)) {
112
+ const waiting = listDrafts(root).map((d) => d.file);
113
+ return {
114
+ ok: false,
115
+ error: `There's no draft ${file}.${waiting.length === 0 ? " No drafts are waiting." : ` Waiting: ${waiting.join(", ")}.`}`,
116
+ };
117
+ }
118
+ return { ok: true, value: path };
119
+ }
120
+ export const runQuietly = (command, args) => {
121
+ try {
122
+ return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
123
+ }
124
+ catch {
125
+ return undefined;
126
+ }
127
+ };
128
+ /** A link that opens a new issue with the report filled in, for a person to submit. */
129
+ export function issueLink(title, body) {
130
+ const params = new URLSearchParams({ title, body, labels: "feedback" });
131
+ return `https://github.com/${FEEDBACK_REPO}/issues/new?${params.toString()}`;
132
+ }
133
+ /**
134
+ * Opens a draft as an issue on Peer AI's repository, through the GitHub CLI when it's signed in,
135
+ * under the person's own account, then moves the draft to sent/. Without the CLI, returns a link
136
+ * for the person to open and submit, and keeps the draft.
137
+ */
138
+ export function sendDraft(root, draft, run = runQuietly) {
139
+ const found = findDraft(root, draft);
140
+ if (!found.ok)
141
+ return found;
142
+ const report = readFileSync(found.value, "utf8");
143
+ const title = /^# (.+)$/m.exec(report)?.[1] ?? basename(found.value, ".md");
144
+ const body = report.replace(/^# .+\n+/, "");
145
+ if (run("gh", ["auth", "status"]) !== undefined) {
146
+ const create = ["issue", "create", "--repo", FEEDBACK_REPO, "--title", title, "--body", body];
147
+ // The label helps the maintainer sort reports; an issue without it is still a report.
148
+ const printed = run("gh", [...create, "--label", "feedback"]) ?? run("gh", create);
149
+ const issue = printed?.trim().split("\n").at(-1);
150
+ if (issue?.startsWith("https://") === true) {
151
+ mkdirSync(join(root, SENT_DIR), { recursive: true });
152
+ writeFileSync(found.value, `${report.trimEnd()}\n\nSent as ${issue}\n`);
153
+ renameSync(found.value, join(root, SENT_DIR, basename(found.value)));
154
+ return { ok: true, value: { issue } };
155
+ }
156
+ }
157
+ return { ok: true, value: { link: issueLink(title, body) } };
158
+ }
159
+ /** Deletes a draft the person decided not to send. */
160
+ export function dropDraft(root, draft) {
161
+ const found = findDraft(root, draft);
162
+ if (!found.ok)
163
+ return found;
164
+ rmSync(found.value);
165
+ return { ok: true, value: { dropped: basename(found.value) } };
166
+ }
167
+ /** `peer-ai feedback [send|drop <draft>]`. Exit code 0 done, 1 refused, 2 a usage error. */
168
+ export function runFeedback(options, out) {
169
+ const { cwd, action, draft } = options;
170
+ if (action === undefined) {
171
+ const drafts = listDrafts(cwd);
172
+ if (drafts.length === 0) {
173
+ out.log("No feedback drafts are waiting.");
174
+ return 0;
175
+ }
176
+ out.log(`Feedback drafts waiting for your decision, in ${FEEDBACK_DIR}/:`);
177
+ for (const { file, title } of drafts)
178
+ out.log(` ${file}: ${title}`);
179
+ out.log("");
180
+ out.log("Read one, then run peer-ai feedback send <draft> or peer-ai feedback drop <draft>.");
181
+ return 0;
182
+ }
183
+ if ((action !== "send" && action !== "drop") || draft === undefined) {
184
+ out.error("Use peer-ai feedback, peer-ai feedback send <draft> or peer-ai feedback drop <draft>.");
185
+ return 2;
186
+ }
187
+ if (action === "drop") {
188
+ const dropped = dropDraft(cwd, draft);
189
+ if (!dropped.ok) {
190
+ out.error(dropped.error);
191
+ return 1;
192
+ }
193
+ out.log(`Dropped ${dropped.value.dropped}.`);
194
+ return 0;
195
+ }
196
+ const sent = sendDraft(cwd, draft, options.run);
197
+ if (!sent.ok) {
198
+ out.error(sent.error);
199
+ return 1;
200
+ }
201
+ if ("issue" in sent.value)
202
+ out.log(`Sent: ${sent.value.issue}`);
203
+ else {
204
+ out.log("The GitHub CLI isn't signed in here, so open this link to submit the report yourself:");
205
+ out.log(sent.value.link);
206
+ out.log(`Once it's submitted, run peer-ai feedback drop ${basename(draft)}.`);
207
+ }
208
+ return 0;
209
+ }
@@ -0,0 +1 @@
1
+ export declare function listRepoFiles(root: string): string[];
package/dist/files.js ADDED
@@ -0,0 +1,72 @@
1
+ // Lists a repository's files as forward-slash paths relative to its root. In a git repository
2
+ // it asks git, so .gitignore is respected; otherwise it walks the folder. Dependency and build
3
+ // folders are always skipped, as are Peer AI's own rendered skills, and symlinks are never followed.
4
+ import { execFileSync } from "node:child_process";
5
+ import { existsSync, lstatSync, readdirSync } from "node:fs";
6
+ import { join, relative, sep } from "node:path";
7
+ const SKIP_DIRS = new Set([
8
+ "node_modules",
9
+ ".git",
10
+ "dist",
11
+ "build",
12
+ "out",
13
+ ".next",
14
+ ".nuxt",
15
+ ".turbo",
16
+ ".expo",
17
+ "coverage",
18
+ "vendor",
19
+ "Pods",
20
+ ".venv",
21
+ "venv",
22
+ "__pycache__",
23
+ ".dart_tool",
24
+ ".gradle",
25
+ "target",
26
+ "obj",
27
+ ".build",
28
+ "_build",
29
+ ]);
30
+ const MAX_FILES = 50_000;
31
+ /** Peer AI's own skills, wherever render wrote them. They describe Peer AI, not the project. */
32
+ const PEER_AI_SKILL = /(^|\/)\.(claude|agents)\/skills\/peer-ai-[^/]+\//;
33
+ const skipped = (path) => path.split("/").some((part) => SKIP_DIRS.has(part)) || PEER_AI_SKILL.test(path);
34
+ export function listRepoFiles(root) {
35
+ if (existsSync(join(root, ".git"))) {
36
+ try {
37
+ const listed = execFileSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
38
+ cwd: root,
39
+ encoding: "utf8",
40
+ stdio: ["ignore", "pipe", "pipe"],
41
+ maxBuffer: 256 * 1024 * 1024,
42
+ });
43
+ return listed
44
+ .split("\0")
45
+ .filter((file) => file !== "" && !skipped(file))
46
+ .slice(0, MAX_FILES);
47
+ }
48
+ catch {
49
+ // Not usable as a git repository after all: fall back to walking the folder.
50
+ }
51
+ }
52
+ const files = [];
53
+ const walk = (dir) => {
54
+ for (const entry of readdirSync(dir).sort()) {
55
+ if (files.length >= MAX_FILES)
56
+ return;
57
+ if (SKIP_DIRS.has(entry))
58
+ continue;
59
+ const full = join(dir, entry);
60
+ const stats = lstatSync(full);
61
+ if (stats.isDirectory())
62
+ walk(full);
63
+ else if (stats.isFile()) {
64
+ const path = relative(root, full).split(sep).join("/");
65
+ if (!skipped(path))
66
+ files.push(path);
67
+ }
68
+ }
69
+ };
70
+ walk(root);
71
+ return files;
72
+ }