nerdsystem 0.0.23 → 0.0.25
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/dist/api.d.ts +2 -0
- package/dist/cli.js +13 -3
- package/dist/commands/deploy.d.ts +2 -0
- package/dist/commands/deploy.js +9 -6
- package/dist/commands/init.js +1 -1
- package/dist/commands/org.js +11 -1
- package/dist/commands/platform-review.d.ts +11 -3
- package/dist/commands/platform-review.js +21 -11
- package/dist/commands/session-review.d.ts +104 -0
- package/dist/commands/session-review.js +262 -0
- package/dist/commands/status.d.ts +2 -2
- package/dist/commands/status.js +5 -5
- package/dist/commands/upgrade.js +1 -1
- package/dist/gates/review.d.ts +6 -4
- package/dist/gates/review.js +25 -11
- package/dist/gates/rules.js +1 -0
- package/dist/gates/text.d.ts +7 -0
- package/dist/gates/text.js +11 -0
- package/package.json +1 -1
- package/template/.claude/agents/nerd-reviewer.md +28 -0
- package/template/.claude/skills/nerdsystem/SKILL.md +17 -4
- package/template/.cursor/rules/nerdsystem.mdc +18 -5
- package/template/.github/copilot-instructions.md +18 -5
- package/template/AGENTS.md +18 -5
- package/template/CLAUDE.md +2 -1
- package/template/GEMINI.md +18 -5
package/dist/api.d.ts
CHANGED
|
@@ -53,6 +53,8 @@ export interface Project {
|
|
|
53
53
|
name: string;
|
|
54
54
|
scriptName: string;
|
|
55
55
|
url: string;
|
|
56
|
+
/** The address on the owner's own domain, once one is verified. Absent from an older platform. */
|
|
57
|
+
customUrl?: string | null;
|
|
56
58
|
}
|
|
57
59
|
export interface Deployment {
|
|
58
60
|
id: string;
|
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { cron, health, rollback, secretList, secretRemove, secretSet, transfer,
|
|
|
9
9
|
import { adminHelp, adminSuspend, adminUnsuspend } from "./commands/admin.js";
|
|
10
10
|
import { agentsSync } from "./commands/agents.js";
|
|
11
11
|
import { upgrade } from "./commands/upgrade.js";
|
|
12
|
+
import { reviewChecklist, reviewReport } from "./commands/session-review.js";
|
|
12
13
|
import { review, status } from "./commands/status.js";
|
|
13
14
|
import { billingPortal, billingShow, billingUpgrade } from "./commands/billing.js";
|
|
14
15
|
import { auditExport, clientsCreate, clientsList, clientsRelease, domainsAdd, domainsList, domainsRemove, logs, membersInvite, membersList, membersRemove } from "./commands/org.js";
|
|
@@ -61,7 +62,9 @@ const HELP = `nerd: start, check, and deploy a web app on nerdsystem
|
|
|
61
62
|
nerd agents sync Regenerate AGENTS.md, Cursor, Copilot, Gemini files from the skill
|
|
62
63
|
nerd upgrade Bring this app up to the current SDK, agent instructions, and test harness
|
|
63
64
|
nerd status What the platform has found for this app that needs attention (updates, review findings, vulnerable packages)
|
|
64
|
-
nerd review
|
|
65
|
+
nerd review What is open from the latest review of this app, with the fix for each
|
|
66
|
+
nerd review checklist What to look for when reviewing this app's code before a deploy, and which files changed
|
|
67
|
+
nerd review report [--agent name] Send the review written to .nerd/review.json (see the checklist for the format)
|
|
65
68
|
nerd billing [--org slug] Plan, limits, and usage for the org
|
|
66
69
|
nerd billing upgrade --plan pro|studio [--annual] Start checkout
|
|
67
70
|
nerd billing portal Manage cards, invoices, and plan
|
|
@@ -143,8 +146,15 @@ export async function main(argv) {
|
|
|
143
146
|
return status({ json: Boolean(values.json), hook: Boolean(values.hook) });
|
|
144
147
|
}
|
|
145
148
|
case "review": {
|
|
146
|
-
const { values } =
|
|
147
|
-
|
|
149
|
+
const { values, positionals } = parseArgs({ args: rest, options: { json: BOOL, file: STR, agent: STR }, allowPositionals: true });
|
|
150
|
+
const json = Boolean(values.json);
|
|
151
|
+
if (positionals[0] === "checklist")
|
|
152
|
+
return reviewChecklist({ json });
|
|
153
|
+
if (positionals[0] === "report")
|
|
154
|
+
return reviewReport({ json, ...(values.file ? { file: String(values.file) } : {}), ...(values.agent ? { agent: String(values.agent) } : {}) });
|
|
155
|
+
if (positionals.length)
|
|
156
|
+
throw new CliError("Usage: nerd review | nerd review checklist | nerd review report [--file path] [--agent name]");
|
|
157
|
+
return review({ json });
|
|
148
158
|
}
|
|
149
159
|
case "upgrade": {
|
|
150
160
|
const { values } = parse({}, false);
|
|
@@ -46,6 +46,8 @@ export declare function checksMetadata(results: GateResult[]): {
|
|
|
46
46
|
export interface ShipResult {
|
|
47
47
|
deployment: Deployment;
|
|
48
48
|
url: string;
|
|
49
|
+
/** The app's address on the owner's own domain, once one is verified; null otherwise. */
|
|
50
|
+
customUrl: string | null;
|
|
49
51
|
migrations: {
|
|
50
52
|
applied: string[];
|
|
51
53
|
skipped: string[];
|
package/dist/commands/deploy.js
CHANGED
|
@@ -185,11 +185,14 @@ export async function shipBuild(args) {
|
|
|
185
185
|
}
|
|
186
186
|
const { deployment } = await api.post(projectPath(nerd, "/deployments"), form);
|
|
187
187
|
const { project } = await api.get(projectPath(nerd));
|
|
188
|
-
log.ok(`Deployed ${deployment.id} (${deployment.status}) → ${project.url}`);
|
|
188
|
+
log.ok(`Deployed ${deployment.id} (${deployment.status}) → ${project.customUrl ?? project.url}`);
|
|
189
|
+
// The owner knows the app by their own domain; the platform address is the one that is probed, because it always resolves.
|
|
190
|
+
if (project.customUrl)
|
|
191
|
+
log.info(`Also at ${project.url}`);
|
|
189
192
|
if (!opts["skip-probe"] && deployment.status === "live")
|
|
190
193
|
log.step(`Probing ${project.url}`);
|
|
191
194
|
const probes = await probeAfterDeploy(root, deployment, project.url, Boolean(opts["skip-probe"]));
|
|
192
|
-
return { deployment, url: project.url, migrations: mig, probes, source, sourceSkipped, lineage, warnings: pack.warnings };
|
|
195
|
+
return { deployment, url: project.url, customUrl: project.customUrl ?? null, migrations: mig, probes, source, sourceSkipped, lineage, warnings: pack.warnings };
|
|
193
196
|
}
|
|
194
197
|
/** The one line about the code after a live deploy, keyed on what the API recorded, not on what we sent. */
|
|
195
198
|
export function sourceLine(r) {
|
|
@@ -225,12 +228,12 @@ export async function deploy(opts) {
|
|
|
225
228
|
if (!built.ok)
|
|
226
229
|
throw new CliError(`Blocked by: ${built.failed.map((g) => g.title).join(", ")}.`, 2);
|
|
227
230
|
const r = await shipBuild({ root, nerd, api, build, results: [...gates.results, ...built.results], opts });
|
|
228
|
-
// The app is live at this point. The
|
|
231
|
+
// The app is live at this point. The platform says what the review on file holds, and now and then reads the code itself.
|
|
229
232
|
const reviewPage = `${dashboardUrl(api.apiUrl)}/app/${nerd.org}/projects/${nerd.project}/review`;
|
|
230
233
|
const wantReview = r.deployment.status === "live" && Boolean(r.source);
|
|
231
234
|
if (opts.json) {
|
|
232
235
|
const review = wantReview ? await platformReview(api, `${projectPath(nerd)}/sources/${r.source.id}`, reviewPage) : null;
|
|
233
|
-
return printJson({ deployment: r.deployment, url: r.url, migrations: r.migrations, probes: r.probes, source: r.source, sourceSkipped: r.sourceSkipped, lineage: r.lineage.passed, warnings: r.warnings, review });
|
|
236
|
+
return printJson({ deployment: r.deployment, url: r.url, customUrl: r.customUrl, migrations: r.migrations, probes: r.probes, source: r.source, sourceSkipped: r.sourceSkipped, lineage: r.lineage.passed, warnings: r.warnings, review });
|
|
234
237
|
}
|
|
235
238
|
printProbe(r.url, r.probes);
|
|
236
239
|
if (r.deployment.status === "live") {
|
|
@@ -240,10 +243,10 @@ export async function deploy(opts) {
|
|
|
240
243
|
log.warn(sourceLine(r));
|
|
241
244
|
}
|
|
242
245
|
if (wantReview) {
|
|
243
|
-
log.step("Your app is live.
|
|
246
|
+
log.step("Your app is live. Checking in on its review (now and then nerdsystem reads the code itself, which takes a minute or two; safe to stop waiting)");
|
|
244
247
|
printReview(await platformReview(api, `${projectPath(nerd)}/sources/${r.source.id}`, reviewPage));
|
|
245
248
|
}
|
|
246
|
-
log.info(`First time? Sign up on your app at ${r.url}, then make yourself admin in the dashboard's Auth tab: ${dashboardUrl(api.apiUrl)}/app/${nerd.org}/projects/${nerd.project}/auth`);
|
|
249
|
+
log.info(`First time? Sign up on your app at ${r.customUrl ?? r.url}, then make yourself admin in the dashboard's Auth tab: ${dashboardUrl(api.apiUrl)}/app/${nerd.org}/projects/${nerd.project}/auth`);
|
|
247
250
|
}
|
|
248
251
|
export async function deployments(opts) {
|
|
249
252
|
const nerd = readNerdJson(findProjectRoot());
|
package/dist/commands/init.js
CHANGED
|
@@ -192,4 +192,4 @@ export async function init(dir, args) {
|
|
|
192
192
|
log.ok(`Ready. Next:\n cd ${dir}\n pnpm install\n nerd dev\n\nThen open the folder in your coding agent and ask it to build the first feature; the nerdsystem skill walks it through.\nDeploys go to ${project.url}`);
|
|
193
193
|
}
|
|
194
194
|
// Kept in sync with packages/sdk/package.json by the release script.
|
|
195
|
-
const SDK_VERSION = "0.0.
|
|
195
|
+
const SDK_VERSION = "0.0.25";
|
package/dist/commands/org.js
CHANGED
|
@@ -52,6 +52,16 @@ export function setupText(s) {
|
|
|
52
52
|
"If you are a coding agent: give the owner these steps as they are, numbered, and nothing else to decide. Do not ask which company they use (it was looked up) and do not offer other record types. When they say it is done, run `nerd domains` until the status is active, then open the address to confirm.",
|
|
53
53
|
].join("\n");
|
|
54
54
|
}
|
|
55
|
+
/** Whether the domain without www forwards to the app yet, in words an agent can pass on. */
|
|
56
|
+
function bareText(b) {
|
|
57
|
+
if (!b)
|
|
58
|
+
return "";
|
|
59
|
+
if (b.status === "forwarding")
|
|
60
|
+
return `${b.hostname} forwards here`;
|
|
61
|
+
if (b.status === "starting")
|
|
62
|
+
return `${b.hostname} is starting (certificate, under a minute)`;
|
|
63
|
+
return `${b.hostname} does not point here yet`;
|
|
64
|
+
}
|
|
55
65
|
export async function domainsList(opts) {
|
|
56
66
|
const nerd = readNerdJson(findProjectRoot());
|
|
57
67
|
const r = await ApiClient.fromConfig().get(projectPath(nerd, "/domains"));
|
|
@@ -59,7 +69,7 @@ export async function domainsList(opts) {
|
|
|
59
69
|
return printJson(r);
|
|
60
70
|
if (!r.domains.length)
|
|
61
71
|
return log.info(`No custom domains. Add one: nerd domains add example.com`);
|
|
62
|
-
table(r.domains.map((d) => ({ hostname: d.hostname, status: d.status, ssl: d.sslStatus ?? "", does: d.redirectTo ? `forwards to ${d.redirectTo}` : "the app", error: d.lastError ?? "", id: d.id })), ["hostname", "status", "ssl", "does", "error", "id"]);
|
|
72
|
+
table(r.domains.map((d) => ({ hostname: d.hostname, status: d.status, ssl: d.sslStatus ?? "", does: d.redirectTo ? `forwards to ${d.redirectTo}` : "the app", "bare name": bareText(d.bare), error: d.lastError ?? "", id: d.id })), ["hostname", "status", "ssl", "does", "bare name", "error", "id"]);
|
|
63
73
|
// Only addresses still waiting for their DNS records have steps left to do.
|
|
64
74
|
for (const s of r.setups ?? [])
|
|
65
75
|
log.info(`\n${setupText(s)}`);
|
|
@@ -12,16 +12,24 @@ export type ReviewOutcome = {
|
|
|
12
12
|
status: "done" | "declined";
|
|
13
13
|
findings: ReviewFindingOut[];
|
|
14
14
|
dashboard: string;
|
|
15
|
+
/** Whether the platform read the code itself just now. False means these are the findings already on file. */
|
|
16
|
+
platformRead: boolean;
|
|
17
|
+
/** Whose review the findings on file come from. */
|
|
18
|
+
kind: "platform" | "session";
|
|
15
19
|
} | {
|
|
16
20
|
status: "skipped";
|
|
17
21
|
reason: string;
|
|
18
22
|
};
|
|
19
23
|
/**
|
|
20
|
-
* After a deploy is live,
|
|
24
|
+
* After a deploy is live, checks in with the platform about the review of the stored code.
|
|
25
|
+
*
|
|
26
|
+
* The platform reads the code itself only now and then (an app's first deploy, and about weekly on paid
|
|
27
|
+
* plans when the code has changed), which takes a minute or two; otherwise it answers at once with the
|
|
28
|
+
* review on file, which is usually the one this app's agent reported with `nerd review report`.
|
|
21
29
|
*
|
|
22
30
|
* Only the findings come back into the terminal, and only as a rule id with a file and a line: the words
|
|
23
|
-
* printed are this CLI's own catalog, looked up by id, so nothing
|
|
24
|
-
* the agent reading this output. The
|
|
31
|
+
* printed are this CLI's own catalog, looked up by id, so nothing a model wrote is ever put in front of
|
|
32
|
+
* the agent reading this output. The plain-language summary is for the person and stays in the
|
|
25
33
|
* dashboard. Any failure, limit, or missing configuration is a skip with a reason; a review never changes
|
|
26
34
|
* whether the deploy succeeded.
|
|
27
35
|
*/
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { RULES } from "../gates/rules.js";
|
|
2
2
|
import { CliError, log } from "../util.js";
|
|
3
3
|
/**
|
|
4
|
-
* After a deploy is live,
|
|
4
|
+
* After a deploy is live, checks in with the platform about the review of the stored code.
|
|
5
|
+
*
|
|
6
|
+
* The platform reads the code itself only now and then (an app's first deploy, and about weekly on paid
|
|
7
|
+
* plans when the code has changed), which takes a minute or two; otherwise it answers at once with the
|
|
8
|
+
* review on file, which is usually the one this app's agent reported with `nerd review report`.
|
|
5
9
|
*
|
|
6
10
|
* Only the findings come back into the terminal, and only as a rule id with a file and a line: the words
|
|
7
|
-
* printed are this CLI's own catalog, looked up by id, so nothing
|
|
8
|
-
* the agent reading this output. The
|
|
11
|
+
* printed are this CLI's own catalog, looked up by id, so nothing a model wrote is ever put in front of
|
|
12
|
+
* the agent reading this output. The plain-language summary is for the person and stays in the
|
|
9
13
|
* dashboard. Any failure, limit, or missing configuration is a skip with a reason; a review never changes
|
|
10
14
|
* whether the deploy succeeded.
|
|
11
15
|
*/
|
|
@@ -13,18 +17,23 @@ export async function platformReview(api, sourcePath, dashboard) {
|
|
|
13
17
|
if (process.env.NERD_NO_REVIEW)
|
|
14
18
|
return { status: "skipped", reason: "turned off with NERD_NO_REVIEW" };
|
|
15
19
|
let review;
|
|
20
|
+
let platformRead = true;
|
|
16
21
|
try {
|
|
17
|
-
|
|
22
|
+
const got = await api.post(`${sourcePath}/review`);
|
|
23
|
+
review = got.review;
|
|
24
|
+
// A platform that does not say is an older one, which read the code on every call.
|
|
25
|
+
platformRead = got.read ? got.read.ran : true;
|
|
18
26
|
}
|
|
19
27
|
catch (err) {
|
|
20
28
|
const status = err instanceof CliError ? err.status : undefined;
|
|
21
29
|
// The 402's wording is the platform's own (plan and allowance), safe to repeat.
|
|
22
30
|
if (status === 402 && err instanceof Error)
|
|
23
31
|
return { status: "skipped", reason: err.message.replace(/\s*\[[^\]]*\]\s*$/, "") };
|
|
24
|
-
return { status: "skipped", reason: status === 503 ? "the
|
|
32
|
+
return { status: "skipped", reason: status === 503 ? "the platform's own review is not available here yet" : "the review could not be completed this time" };
|
|
25
33
|
}
|
|
34
|
+
const kind = review.kind === "session" ? "session" : "platform";
|
|
26
35
|
if (review.status !== "done")
|
|
27
|
-
return { status: "declined", findings: [], dashboard };
|
|
36
|
+
return { status: "declined", findings: [], dashboard, platformRead, kind };
|
|
28
37
|
const findings = [];
|
|
29
38
|
for (const f of review.findings ?? []) {
|
|
30
39
|
const rule = typeof f.rule_id === "string" && f.rule_id.startsWith("review/") ? RULES[f.rule_id] : undefined;
|
|
@@ -33,22 +42,23 @@ export async function platformReview(api, sourcePath, dashboard) {
|
|
|
33
42
|
continue;
|
|
34
43
|
findings.push({ rule_id: f.rule_id, severity: rule.severity, audience: rule.audience, file: f.file, line: f.line, risk: rule.risk, fix: rule.fix });
|
|
35
44
|
}
|
|
36
|
-
return { status: "done", findings, dashboard };
|
|
45
|
+
return { status: "done", findings, dashboard, platformRead, kind };
|
|
37
46
|
}
|
|
38
47
|
export function printReview(r) {
|
|
39
48
|
if (r.status === "skipped") {
|
|
40
|
-
log.info(`
|
|
49
|
+
log.info(`Review by nerdsystem: not run (${r.reason}). Your own review before each deploy (\`nerd review checklist\`) is not affected.`);
|
|
41
50
|
return;
|
|
42
51
|
}
|
|
43
52
|
if (r.status === "declined") {
|
|
44
|
-
log.info("
|
|
53
|
+
log.info("Review by nerdsystem: the reviewer gave no answer for this app.");
|
|
45
54
|
return;
|
|
46
55
|
}
|
|
56
|
+
const whose = r.platformRead ? "nerdsystem read the code itself after this deploy" : r.kind === "session" ? "from the review this app's agent last reported" : "from nerdsystem's last own read of the code";
|
|
47
57
|
if (!r.findings.length) {
|
|
48
|
-
log.ok(`
|
|
58
|
+
log.ok(`Review: nothing open (${whose}). A plain-language summary for the person is at ${r.dashboard}`);
|
|
49
59
|
return;
|
|
50
60
|
}
|
|
51
|
-
log.warn(`
|
|
61
|
+
log.warn(`Review: ${r.findings.length} thing(s) open (${whose}). A review is a second opinion and can be wrong, so check each one against the code before changing anything. Say what you did about them in the next \`nerd review report\`.`);
|
|
52
62
|
for (const f of r.findings) {
|
|
53
63
|
log.info(` ${f.severity === "info" ? "·" : "!"} ${f.rule_id} ${f.file}:${f.line}${f.audience === "human" ? " (for the person to decide)" : ""}`);
|
|
54
64
|
log.info(` ${f.risk}`);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session review: the app's coding agent reads its own changes against the platform's `review/`
|
|
3
|
+
* catalog before a deploy, and reports what it found.
|
|
4
|
+
*
|
|
5
|
+
* `nerd review checklist` prints what to look for, which files changed since the last report from this
|
|
6
|
+
* folder, the findings still open from earlier reviews, and the report's format. `nerd review report`
|
|
7
|
+
* stores a snapshot of the code as reviewed and sends the report with it. The platform checks every
|
|
8
|
+
* finding against that stored code and keeps the report as the agent's own word; it does its own
|
|
9
|
+
* independent read only now and then, and compares the two.
|
|
10
|
+
*
|
|
11
|
+
* What changed is worked out on this computer, from a list of file hashes kept in `.nerd/reviewed.json`
|
|
12
|
+
* at the last report. A fresh folder has no list, so its first review covers everything.
|
|
13
|
+
*/
|
|
14
|
+
/** Where the agent writes its report for `nerd review report` to send. */
|
|
15
|
+
export declare const REPORT_FILE = ".nerd/review.json";
|
|
16
|
+
export declare const REVIEW_RULES: string[];
|
|
17
|
+
type Hashes = Record<string, string>;
|
|
18
|
+
/** Every file a review reads in this folder, with a hash of its contents. */
|
|
19
|
+
export declare function reviewFiles(root: string): Hashes;
|
|
20
|
+
/** Records the code as it stands as reviewed. */
|
|
21
|
+
export declare function writeReviewed(root: string, files: Hashes): void;
|
|
22
|
+
export interface ReviewDue {
|
|
23
|
+
/** Whether code a review reads has changed since the last report from this folder (styles alone do not count). */
|
|
24
|
+
due: boolean;
|
|
25
|
+
/** No report has been made from this folder: everything is unreviewed as far as this computer knows. */
|
|
26
|
+
first: boolean;
|
|
27
|
+
/** Files to read: the ones added or changed since the last report, or all of them the first time. */
|
|
28
|
+
changed: string[];
|
|
29
|
+
}
|
|
30
|
+
export declare function reviewDue(root: string): ReviewDue;
|
|
31
|
+
interface OpenFinding {
|
|
32
|
+
rule_id: string;
|
|
33
|
+
file: string;
|
|
34
|
+
line: number;
|
|
35
|
+
origin: "platform" | "session";
|
|
36
|
+
}
|
|
37
|
+
declare const FORMAT: {
|
|
38
|
+
summary: string;
|
|
39
|
+
does: string[];
|
|
40
|
+
missing: string[];
|
|
41
|
+
findings: {
|
|
42
|
+
rule: string;
|
|
43
|
+
file: string;
|
|
44
|
+
line: number;
|
|
45
|
+
}[];
|
|
46
|
+
fixed: {
|
|
47
|
+
rule: string;
|
|
48
|
+
file: string;
|
|
49
|
+
}[];
|
|
50
|
+
dismissed: {
|
|
51
|
+
rule: string;
|
|
52
|
+
file: string;
|
|
53
|
+
}[];
|
|
54
|
+
};
|
|
55
|
+
export interface Checklist {
|
|
56
|
+
app: string;
|
|
57
|
+
first: boolean;
|
|
58
|
+
changed: string[];
|
|
59
|
+
open: OpenFinding[];
|
|
60
|
+
rules: {
|
|
61
|
+
rule_id: string;
|
|
62
|
+
title: string;
|
|
63
|
+
audience: "agent" | "human";
|
|
64
|
+
lookFor: string;
|
|
65
|
+
fix: string;
|
|
66
|
+
}[];
|
|
67
|
+
reportFile: string;
|
|
68
|
+
format: typeof FORMAT;
|
|
69
|
+
}
|
|
70
|
+
export declare function checklistText(c: Checklist): string;
|
|
71
|
+
/** `nerd review checklist`: what the agent reads before it reviews. */
|
|
72
|
+
export declare function reviewChecklist(opts: {
|
|
73
|
+
json: boolean;
|
|
74
|
+
}): Promise<void>;
|
|
75
|
+
export interface Report {
|
|
76
|
+
findings: {
|
|
77
|
+
rule: string;
|
|
78
|
+
file: string;
|
|
79
|
+
line: number;
|
|
80
|
+
}[];
|
|
81
|
+
fixed: {
|
|
82
|
+
rule: string;
|
|
83
|
+
file: string;
|
|
84
|
+
}[];
|
|
85
|
+
dismissed: {
|
|
86
|
+
rule: string;
|
|
87
|
+
file: string;
|
|
88
|
+
}[];
|
|
89
|
+
summary?: string;
|
|
90
|
+
does?: string[];
|
|
91
|
+
missing?: string[];
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Reads the agent's report strictly, so a slip is corrected here with a sentence that says what to change,
|
|
95
|
+
* instead of being dropped without a word by the platform. `files` is what a review reads in this folder.
|
|
96
|
+
*/
|
|
97
|
+
export declare function parseReport(text: string, files: Hashes, lineCount: (file: string) => number): Report;
|
|
98
|
+
/** `nerd review report`: store the code as reviewed, and send the agent's report with it. */
|
|
99
|
+
export declare function reviewReport(opts: {
|
|
100
|
+
json: boolean;
|
|
101
|
+
file?: string;
|
|
102
|
+
agent?: string;
|
|
103
|
+
}): Promise<void>;
|
|
104
|
+
export {};
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { ApiClient } from "../api.js";
|
|
5
|
+
import { RULES } from "../gates/rules.js";
|
|
6
|
+
import { isReviewFile } from "../gates/text.js";
|
|
7
|
+
import { findProjectRoot, projectPath, readNerdJson } from "../project.js";
|
|
8
|
+
import { listSnapshotFiles } from "../snapshot.js";
|
|
9
|
+
import { CliError, dashboardUrl, log, printJson } from "../util.js";
|
|
10
|
+
import { saveSnapshot } from "./save.js";
|
|
11
|
+
/**
|
|
12
|
+
* The session review: the app's coding agent reads its own changes against the platform's `review/`
|
|
13
|
+
* catalog before a deploy, and reports what it found.
|
|
14
|
+
*
|
|
15
|
+
* `nerd review checklist` prints what to look for, which files changed since the last report from this
|
|
16
|
+
* folder, the findings still open from earlier reviews, and the report's format. `nerd review report`
|
|
17
|
+
* stores a snapshot of the code as reviewed and sends the report with it. The platform checks every
|
|
18
|
+
* finding against that stored code and keeps the report as the agent's own word; it does its own
|
|
19
|
+
* independent read only now and then, and compares the two.
|
|
20
|
+
*
|
|
21
|
+
* What changed is worked out on this computer, from a list of file hashes kept in `.nerd/reviewed.json`
|
|
22
|
+
* at the last report. A fresh folder has no list, so its first review covers everything.
|
|
23
|
+
*/
|
|
24
|
+
/** Where the agent writes its report for `nerd review report` to send. */
|
|
25
|
+
export const REPORT_FILE = ".nerd/review.json";
|
|
26
|
+
const REVIEWED_FILE = ".nerd/reviewed.json";
|
|
27
|
+
const SAFE_FILE = /^[\w./@-]{1,200}$/;
|
|
28
|
+
export const REVIEW_RULES = Object.keys(RULES)
|
|
29
|
+
.filter((k) => k.startsWith("review/"))
|
|
30
|
+
.sort();
|
|
31
|
+
/** Every file a review reads in this folder, with a hash of its contents. */
|
|
32
|
+
export function reviewFiles(root) {
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const f of listSnapshotFiles(root).files) {
|
|
35
|
+
if (!isReviewFile(f.path))
|
|
36
|
+
continue;
|
|
37
|
+
try {
|
|
38
|
+
out[f.path] = createHash("sha256").update(readFileSync(f.abs)).digest("hex");
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// gone between the listing and the read: not part of the code any more
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
function readReviewed(root) {
|
|
47
|
+
try {
|
|
48
|
+
const v = JSON.parse(readFileSync(path.join(root, REVIEWED_FILE), "utf8"));
|
|
49
|
+
return v.files && typeof v.files === "object" ? v.files : null;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Records the code as it stands as reviewed. */
|
|
56
|
+
export function writeReviewed(root, files) {
|
|
57
|
+
mkdirSync(path.join(root, ".nerd"), { recursive: true });
|
|
58
|
+
writeFileSync(path.join(root, REVIEWED_FILE), `${JSON.stringify({ at: new Date().toISOString(), files }, null, 2)}\n`);
|
|
59
|
+
}
|
|
60
|
+
export function reviewDue(root) {
|
|
61
|
+
const now = reviewFiles(root);
|
|
62
|
+
const before = readReviewed(root);
|
|
63
|
+
if (!before)
|
|
64
|
+
return { due: Object.keys(now).length > 0, first: true, changed: Object.keys(now).sort() };
|
|
65
|
+
const changed = Object.keys(now).filter((p) => before[p] !== now[p]).sort();
|
|
66
|
+
const removed = Object.keys(before).filter((p) => !(p in now));
|
|
67
|
+
return { due: [...changed, ...removed].some((p) => !p.endsWith(".css")), first: false, changed };
|
|
68
|
+
}
|
|
69
|
+
/** What is open from earlier reviews, as rule ids and places only. Empty when the platform cannot be asked. */
|
|
70
|
+
async function openFindings(api, base) {
|
|
71
|
+
let got = null;
|
|
72
|
+
try {
|
|
73
|
+
got = await Promise.race([api.get(`${base}/review`), new Promise((r) => setTimeout(() => r(null), 5000))]);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const f of got?.review?.findings ?? []) {
|
|
80
|
+
if (typeof f.rule_id !== "string" || !REVIEW_RULES.includes(f.rule_id) || typeof f.file !== "string" || !SAFE_FILE.test(f.file) || !Number.isInteger(f.line))
|
|
81
|
+
continue;
|
|
82
|
+
out.push({ rule_id: f.rule_id, file: f.file, line: f.line, origin: f.origin === "session" ? "session" : "platform" });
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
const FORMAT = {
|
|
87
|
+
summary: "Two or three plain sentences for the app's owner: what the app is and the shape it is in. No code, no file names, no jargon.",
|
|
88
|
+
does: ["Up to six short phrases, each something a person can do in the app today"],
|
|
89
|
+
missing: ["Up to five short phrases, each something the owner asked for or the description promises that is not built yet"],
|
|
90
|
+
findings: [{ rule: "review/<id>", file: "src/server/index.ts", line: 42 }],
|
|
91
|
+
fixed: [{ rule: "review/<id>", file: "src/server/index.ts" }],
|
|
92
|
+
dismissed: [{ rule: "review/<id>", file: "src/server/index.ts" }],
|
|
93
|
+
};
|
|
94
|
+
export function checklistText(c) {
|
|
95
|
+
const lines = [
|
|
96
|
+
`Review of ${c.app} before it is deployed`,
|
|
97
|
+
"",
|
|
98
|
+
"This is the review of this app's own code for what tests cannot see. You do it, as part of the work. nerdsystem reads the code itself only now and then, and compares what it finds with what you report here.",
|
|
99
|
+
"",
|
|
100
|
+
"How to do it:",
|
|
101
|
+
"1. Use a reviewer with fresh eyes when you have one. In Claude Code, hand this whole output to the `nerd-reviewer` subagent that ships with the app (if it is not there yet, any read-only subagent). In another agent, use a subagent or a new task that has not seen this conversation. With neither, do it yourself and read the files again from disk, not from memory.",
|
|
102
|
+
c.first
|
|
103
|
+
? "2. No review has been reported from this folder yet, so read all of the app's own code: `src/server` and the database schema first, then `src/client`, plus README.md."
|
|
104
|
+
: c.changed.length
|
|
105
|
+
? "2. Read the files that changed since the last review, and the code they call or are called by when a finding depends on it:"
|
|
106
|
+
: "2. No file a review reads has changed since the last review. Check the open findings below, if any, and report.",
|
|
107
|
+
];
|
|
108
|
+
if (!c.first)
|
|
109
|
+
for (const f of c.changed.slice(0, 60))
|
|
110
|
+
lines.push(` - ${f}`);
|
|
111
|
+
if (!c.first && c.changed.length > 60)
|
|
112
|
+
lines.push(` … and ${c.changed.length - 60} more`);
|
|
113
|
+
lines.push("3. For each kind of problem under \"What to look for\", ask whether this code has it. Raise one only when you can point at the line that shows it. Finding nothing is a normal answer for a small change.", "4. Fix what is real and yours to fix. Anything marked [for the person to decide] is a plain question for them: ask it without file names or jargon, and wait for the answer.", `5. Write ${c.reportFile} in the format at the end and run \`nerd review report\`. Then \`nerd check\` and \`nerd deploy\` as usual.`, "");
|
|
114
|
+
if (c.open.length) {
|
|
115
|
+
lines.push("Open from earlier reviews. Check each against the code as it is now and answer it in the report:");
|
|
116
|
+
for (const f of c.open)
|
|
117
|
+
lines.push(`- ${f.rule_id} at ${f.file}:${f.line}${f.origin === "platform" ? " (from nerdsystem's own read)" : ""}`);
|
|
118
|
+
lines.push("");
|
|
119
|
+
}
|
|
120
|
+
lines.push("What to look for:");
|
|
121
|
+
for (const r of c.rules) {
|
|
122
|
+
lines.push(`- ${r.rule_id}: ${r.title}${r.audience === "human" ? " [for the person to decide]" : ""}`, ` Look for: ${r.lookFor}`, ` Fix: ${r.fix}`);
|
|
123
|
+
}
|
|
124
|
+
lines.push("", `The report, written to ${c.reportFile}:`, JSON.stringify(c.format, null, 2), "", "- findings: what is still in the code after your fixes, each with the rule, the file, and the line that shows it. Leave it empty when there is nothing.", "- fixed: what this review found and you then fixed, including findings from the open list above.", "- dismissed: only for a finding from nerdsystem's own read that you checked against the code and that does not apply. The owner sees this as your judgement, so use it when you are sure.", "- A finding from nerdsystem's own read that the report does not mention stays open.", "- summary, does, missing are for the owner and are shown on their dashboard as written. Plain words only.", "", "Report what you found, not what would look good. The report is stored as this agent's own review, it never decides whether a deploy goes out, and a clean report that nerdsystem's own read later contradicts is recorded as a miss.");
|
|
125
|
+
return lines.join("\n");
|
|
126
|
+
}
|
|
127
|
+
/** `nerd review checklist`: what the agent reads before it reviews. */
|
|
128
|
+
export async function reviewChecklist(opts) {
|
|
129
|
+
const root = findProjectRoot();
|
|
130
|
+
const nerd = readNerdJson(root);
|
|
131
|
+
const due = reviewDue(root);
|
|
132
|
+
let open = [];
|
|
133
|
+
try {
|
|
134
|
+
open = await openFindings(ApiClient.fromConfig(), projectPath(nerd));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// not signed in: the checklist is still worth printing
|
|
138
|
+
}
|
|
139
|
+
const c = {
|
|
140
|
+
app: `${nerd.org}/${nerd.project}`,
|
|
141
|
+
first: due.first,
|
|
142
|
+
changed: due.changed,
|
|
143
|
+
open,
|
|
144
|
+
rules: REVIEW_RULES.map((id) => ({ rule_id: id, title: RULES[id].title, audience: RULES[id].audience, lookFor: RULES[id].when ?? RULES[id].risk, fix: RULES[id].fix })),
|
|
145
|
+
reportFile: REPORT_FILE,
|
|
146
|
+
format: FORMAT,
|
|
147
|
+
};
|
|
148
|
+
if (opts.json)
|
|
149
|
+
return printJson(c);
|
|
150
|
+
process.stdout.write(`${checklistText(c)}\n`);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Reads the agent's report strictly, so a slip is corrected here with a sentence that says what to change,
|
|
154
|
+
* instead of being dropped without a word by the platform. `files` is what a review reads in this folder.
|
|
155
|
+
*/
|
|
156
|
+
export function parseReport(text, files, lineCount) {
|
|
157
|
+
let raw;
|
|
158
|
+
try {
|
|
159
|
+
raw = JSON.parse(text);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
throw new CliError(`${REPORT_FILE} is not valid JSON. Run \`nerd review checklist\` for the format.`);
|
|
163
|
+
}
|
|
164
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
165
|
+
throw new CliError(`${REPORT_FILE} must be a JSON object. Run \`nerd review checklist\` for the format.`);
|
|
166
|
+
const r = raw;
|
|
167
|
+
const problems = [];
|
|
168
|
+
const rule = (v, where) => {
|
|
169
|
+
const id = typeof v === "string" ? (v.startsWith("review/") ? v : `review/${v}`) : "";
|
|
170
|
+
if (REVIEW_RULES.includes(id))
|
|
171
|
+
return id;
|
|
172
|
+
problems.push(`${where}: "${typeof v === "string" ? v.slice(0, 60) : typeof v}" is not a rule in the checklist`);
|
|
173
|
+
return null;
|
|
174
|
+
};
|
|
175
|
+
const list = (key) => {
|
|
176
|
+
const v = r[key];
|
|
177
|
+
if (v === undefined || v === null)
|
|
178
|
+
return [];
|
|
179
|
+
if (!Array.isArray(v)) {
|
|
180
|
+
problems.push(`${key} must be a list`);
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
return v.map((x) => (x && typeof x === "object" ? x : {}));
|
|
184
|
+
};
|
|
185
|
+
const findings = [];
|
|
186
|
+
list("findings").forEach((f, i) => {
|
|
187
|
+
const where = `findings[${i}]`;
|
|
188
|
+
const id = rule(f.rule, where);
|
|
189
|
+
if (typeof f.file !== "string" || !(f.file in files))
|
|
190
|
+
return void problems.push(`${where}: "${String(f.file).slice(0, 80)}" is not a file a review reads (the app's own code under src/, README.md, package.json), or it does not exist`);
|
|
191
|
+
if (typeof f.line !== "number" || !Number.isInteger(f.line) || f.line < 1 || f.line > lineCount(f.file))
|
|
192
|
+
return void problems.push(`${where}: line ${String(f.line)} is not a line of ${f.file}`);
|
|
193
|
+
if (id)
|
|
194
|
+
findings.push({ rule: id, file: f.file, line: f.line });
|
|
195
|
+
});
|
|
196
|
+
const notes = (key) => {
|
|
197
|
+
const out = [];
|
|
198
|
+
list(key).forEach((f, i) => {
|
|
199
|
+
const id = rule(f.rule, `${key}[${i}]`);
|
|
200
|
+
if (typeof f.file !== "string" || !SAFE_FILE.test(f.file))
|
|
201
|
+
return void problems.push(`${key}[${i}]: give the file the finding was in`);
|
|
202
|
+
if (id)
|
|
203
|
+
out.push({ rule: id, file: f.file });
|
|
204
|
+
});
|
|
205
|
+
return out;
|
|
206
|
+
};
|
|
207
|
+
const fixed = notes("fixed");
|
|
208
|
+
const dismissed = notes("dismissed");
|
|
209
|
+
const words = (key) => {
|
|
210
|
+
const v = r[key];
|
|
211
|
+
if (v === undefined || v === null)
|
|
212
|
+
return undefined;
|
|
213
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) {
|
|
214
|
+
problems.push(`${key} must be a list of short phrases`);
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
return v.map((x) => x.slice(0, 400)).slice(0, 12);
|
|
218
|
+
};
|
|
219
|
+
const does = words("does");
|
|
220
|
+
const missing = words("missing");
|
|
221
|
+
if (r.summary !== undefined && r.summary !== null && typeof r.summary !== "string")
|
|
222
|
+
problems.push("summary must be a sentence or two of plain text");
|
|
223
|
+
if (problems.length)
|
|
224
|
+
throw new CliError(`${REPORT_FILE} needs ${problems.length === 1 ? "a correction" : "corrections"} before it can be sent:\n${problems.map((p) => ` - ${p}`).join("\n")}\nFix the file and run \`nerd review report\` again.`);
|
|
225
|
+
return { findings, fixed, dismissed, ...(typeof r.summary === "string" && r.summary.trim() ? { summary: r.summary.slice(0, 2000) } : {}), ...(does ? { does } : {}), ...(missing ? { missing } : {}) };
|
|
226
|
+
}
|
|
227
|
+
/** The name sent with a report. Only an agent this CLI can recognise for certain is named without being told. */
|
|
228
|
+
function agentName(given) {
|
|
229
|
+
if (given)
|
|
230
|
+
return /^[a-z0-9][a-z0-9-]{0,29}$/.test(given) ? given : undefined;
|
|
231
|
+
return process.env.CLAUDECODE ? "claude-code" : undefined;
|
|
232
|
+
}
|
|
233
|
+
/** `nerd review report`: store the code as reviewed, and send the agent's report with it. */
|
|
234
|
+
export async function reviewReport(opts) {
|
|
235
|
+
const root = findProjectRoot();
|
|
236
|
+
const nerd = readNerdJson(root);
|
|
237
|
+
const rel = opts.file ?? REPORT_FILE;
|
|
238
|
+
const file = path.resolve(root, rel);
|
|
239
|
+
if (!existsSync(file))
|
|
240
|
+
throw new CliError(`There is no review to report: ${rel} was not found. Run \`nerd review checklist\` for what to look for and the format to write.`);
|
|
241
|
+
const files = reviewFiles(root);
|
|
242
|
+
const report = parseReport(readFileSync(file, "utf8"), files, (f) => readFileSync(path.join(root, f), "utf8").split("\n").length);
|
|
243
|
+
const api = ApiClient.fromConfig();
|
|
244
|
+
const saved = await saveSnapshot(root, nerd, api, { quiet: true });
|
|
245
|
+
const agent = agentName(opts.agent);
|
|
246
|
+
const answer = await api.post(projectPath(nerd, `/sources/${saved.source.id}/review/report`), { ...report, ...(agent ? { agent } : {}) });
|
|
247
|
+
// From here the code as it stands counts as reviewed, and the report cannot be sent again by mistake against later code.
|
|
248
|
+
writeReviewed(root, files);
|
|
249
|
+
if (!opts.file)
|
|
250
|
+
rmSync(file, { force: true });
|
|
251
|
+
const page = `${dashboardUrl(api.apiUrl)}/app/${nerd.org}/projects/${nerd.project}/review`;
|
|
252
|
+
// Only the counts: the stored review carries a summary that may be a model's words, and those never go to a terminal.
|
|
253
|
+
if (opts.json)
|
|
254
|
+
return printJson({ accepted: answer.accepted, dropped: answer.dropped, carried: answer.carried, source: saved.source.id, dashboard: page });
|
|
255
|
+
const a = answer.accepted;
|
|
256
|
+
log.ok(`Review reported for ${nerd.org}/${nerd.project}: ${a.findings} open, ${a.fixed} fixed${a.dismissed ? `, ${a.dismissed} judged not to apply` : ""}.`);
|
|
257
|
+
if (answer.dropped)
|
|
258
|
+
log.warn(`${answer.dropped} finding(s) were left out because the rule, the file, or the line did not match the stored code.`);
|
|
259
|
+
if (answer.carried)
|
|
260
|
+
log.warn(`${answer.carried} finding(s) from nerdsystem's own read are still open because the report did not answer them. \`nerd review\` lists them.`);
|
|
261
|
+
log.info(`The owner's page for this review: ${page}`);
|
|
262
|
+
}
|
|
@@ -16,7 +16,7 @@ export interface Attention {
|
|
|
16
16
|
/** When the review the findings come from was made, so a finding already fixed since is not a surprise. */
|
|
17
17
|
reviewedAt?: string;
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
19
|
+
/** What is open from the latest review of this app (its agent's own, or the platform's read), mapped through this CLI's own catalog. */
|
|
20
20
|
export declare function reviewItems(api: ApiClient, base: string): Promise<{
|
|
21
21
|
items: AttentionItem[];
|
|
22
22
|
reviewedAt?: string;
|
|
@@ -41,7 +41,7 @@ export declare function status(opts: {
|
|
|
41
41
|
json: boolean;
|
|
42
42
|
hook: boolean;
|
|
43
43
|
}): Promise<void>;
|
|
44
|
-
/** `nerd review`: only the
|
|
44
|
+
/** `nerd review`: only what is open from the latest review, with every fix written out. */
|
|
45
45
|
export declare function review(opts: {
|
|
46
46
|
json: boolean;
|
|
47
47
|
}): Promise<void>;
|