nerdsystem 0.0.24 → 0.0.26

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.
@@ -0,0 +1,268 @@
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", leftAsIs: f.leftAsIs === true });
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
+ leftAsIs: [{ rule: "review/<id>", file: "src/server/index.ts", line: 42 }],
92
+ fixed: [{ rule: "review/<id>", file: "src/server/index.ts" }],
93
+ dismissed: [{ rule: "review/<id>", file: "src/server/index.ts" }],
94
+ };
95
+ export function checklistText(c) {
96
+ const lines = [
97
+ `Review of ${c.app} before it is deployed`,
98
+ "",
99
+ "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.",
100
+ "",
101
+ "How to do it:",
102
+ "1. Use a reviewer with fresh eyes when you have one. In Claude Code, give this output to the `nerd-reviewer` subagent that ships with the app, either pasted or saved to a file it can read (`nerd review checklist > .nerd/checklist.txt`). If the session says that subagent is not found, it was added after the session started: give a read-only subagent the file `.claude/agents/nerd-reviewer.md` as its instructions. 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.",
103
+ c.first
104
+ ? "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."
105
+ : c.changed.length
106
+ ? "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:"
107
+ : "2. No file a review reads has changed since the last review. Check the open findings below, if any, and report.",
108
+ ];
109
+ if (!c.first)
110
+ for (const f of c.changed.slice(0, 60))
111
+ lines.push(` - ${f}`);
112
+ if (!c.first && c.changed.length > 60)
113
+ lines.push(` … and ${c.changed.length - 60} more`);
114
+ 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.`, "");
115
+ if (c.open.length) {
116
+ lines.push("Open from earlier reviews. Check each against the code as it is now and answer it in the report:");
117
+ for (const f of c.open)
118
+ lines.push(`- ${f.rule_id} at ${f.file}:${f.line}${f.origin === "platform" ? " (from nerdsystem's own read)" : ""}${f.leftAsIs ? " (recorded as left as it is; it stays that way unless you report it differently)" : ""}`);
119
+ lines.push("");
120
+ }
121
+ lines.push("What to look for:");
122
+ for (const r of c.rules) {
123
+ lines.push(`- ${r.rule_id}: ${r.title}${r.audience === "human" ? " [for the person to decide]" : ""}`, ` Look for: ${r.lookFor}`, ` Fix: ${r.fix}`);
124
+ }
125
+ lines.push("", `The report, written to ${c.reportFile}:`, JSON.stringify(c.format, null, 2), "", "- findings: what is still in the code after your fixes and still needs someone to act or decide, each with the rule, the file, and the line that shows it. Leave it empty when there is nothing. A finding marked [for the person to decide] stays here until they have answered.", "- leftAsIs: what is real and deliberately left as it is, because the person decided so or because the rule's own advice is not to change working code for it. It is shown to the owner, kept on later reports, and not announced again to each new session.", "- fixed: what this review found and you then fixed, including findings from the open list above. If you fixed only part of one, list what remains under findings (or leftAsIs) at the line that still shows it, and do not list it as fixed.", "- 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.");
126
+ return lines.join("\n");
127
+ }
128
+ /** `nerd review checklist`: what the agent reads before it reviews. */
129
+ export async function reviewChecklist(opts) {
130
+ const root = findProjectRoot();
131
+ const nerd = readNerdJson(root);
132
+ const due = reviewDue(root);
133
+ let open = [];
134
+ try {
135
+ open = await openFindings(ApiClient.fromConfig(), projectPath(nerd));
136
+ }
137
+ catch {
138
+ // not signed in: the checklist is still worth printing
139
+ }
140
+ const c = {
141
+ app: `${nerd.org}/${nerd.project}`,
142
+ first: due.first,
143
+ changed: due.changed,
144
+ open,
145
+ 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 })),
146
+ reportFile: REPORT_FILE,
147
+ format: FORMAT,
148
+ };
149
+ if (opts.json)
150
+ return printJson(c);
151
+ process.stdout.write(`${checklistText(c)}\n`);
152
+ }
153
+ /**
154
+ * Reads the agent's report strictly, so a slip is corrected here with a sentence that says what to change,
155
+ * instead of being dropped without a word by the platform. `files` is what a review reads in this folder.
156
+ */
157
+ export function parseReport(text, files, lineCount) {
158
+ let raw;
159
+ try {
160
+ raw = JSON.parse(text);
161
+ }
162
+ catch {
163
+ throw new CliError(`${REPORT_FILE} is not valid JSON. Run \`nerd review checklist\` for the format.`);
164
+ }
165
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
166
+ throw new CliError(`${REPORT_FILE} must be a JSON object. Run \`nerd review checklist\` for the format.`);
167
+ const r = raw;
168
+ const problems = [];
169
+ const rule = (v, where) => {
170
+ const id = typeof v === "string" ? (v.startsWith("review/") ? v : `review/${v}`) : "";
171
+ if (REVIEW_RULES.includes(id))
172
+ return id;
173
+ problems.push(`${where}: "${typeof v === "string" ? v.slice(0, 60) : typeof v}" is not a rule in the checklist`);
174
+ return null;
175
+ };
176
+ const list = (key) => {
177
+ const v = r[key];
178
+ if (v === undefined || v === null)
179
+ return [];
180
+ if (!Array.isArray(v)) {
181
+ problems.push(`${key} must be a list`);
182
+ return [];
183
+ }
184
+ return v.map((x) => (x && typeof x === "object" ? x : {}));
185
+ };
186
+ const placed = (key) => {
187
+ const out = [];
188
+ list(key).forEach((f, i) => {
189
+ const where = `${key}[${i}]`;
190
+ const id = rule(f.rule, where);
191
+ if (typeof f.file !== "string" || !(f.file in files))
192
+ 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`);
193
+ if (typeof f.line !== "number" || !Number.isInteger(f.line) || f.line < 1 || f.line > lineCount(f.file))
194
+ return void problems.push(`${where}: line ${String(f.line)} is not a line of ${f.file}`);
195
+ if (id)
196
+ out.push({ rule: id, file: f.file, line: f.line });
197
+ });
198
+ return out;
199
+ };
200
+ const findings = placed("findings");
201
+ const leftAsIs = placed("leftAsIs");
202
+ const notes = (key) => {
203
+ const out = [];
204
+ list(key).forEach((f, i) => {
205
+ const id = rule(f.rule, `${key}[${i}]`);
206
+ if (typeof f.file !== "string" || !SAFE_FILE.test(f.file))
207
+ return void problems.push(`${key}[${i}]: give the file the finding was in`);
208
+ if (id)
209
+ out.push({ rule: id, file: f.file });
210
+ });
211
+ return out;
212
+ };
213
+ const fixed = notes("fixed");
214
+ const dismissed = notes("dismissed");
215
+ const words = (key) => {
216
+ const v = r[key];
217
+ if (v === undefined || v === null)
218
+ return undefined;
219
+ if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) {
220
+ problems.push(`${key} must be a list of short phrases`);
221
+ return undefined;
222
+ }
223
+ return v.map((x) => x.slice(0, 400)).slice(0, 12);
224
+ };
225
+ const does = words("does");
226
+ const missing = words("missing");
227
+ if (r.summary !== undefined && r.summary !== null && typeof r.summary !== "string")
228
+ problems.push("summary must be a sentence or two of plain text");
229
+ if (problems.length)
230
+ 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.`);
231
+ return { findings, leftAsIs, fixed, dismissed, ...(typeof r.summary === "string" && r.summary.trim() ? { summary: r.summary.slice(0, 2000) } : {}), ...(does ? { does } : {}), ...(missing ? { missing } : {}) };
232
+ }
233
+ /** The name sent with a report. Only an agent this CLI can recognise for certain is named without being told. */
234
+ function agentName(given) {
235
+ if (given)
236
+ return /^[a-z0-9][a-z0-9-]{0,29}$/.test(given) ? given : undefined;
237
+ return process.env.CLAUDECODE ? "claude-code" : undefined;
238
+ }
239
+ /** `nerd review report`: store the code as reviewed, and send the agent's report with it. */
240
+ export async function reviewReport(opts) {
241
+ const root = findProjectRoot();
242
+ const nerd = readNerdJson(root);
243
+ const rel = opts.file ?? REPORT_FILE;
244
+ const file = path.resolve(root, rel);
245
+ if (!existsSync(file))
246
+ 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.`);
247
+ const files = reviewFiles(root);
248
+ const report = parseReport(readFileSync(file, "utf8"), files, (f) => readFileSync(path.join(root, f), "utf8").split("\n").length);
249
+ const api = ApiClient.fromConfig();
250
+ const saved = await saveSnapshot(root, nerd, api, { quiet: true });
251
+ const agent = agentName(opts.agent);
252
+ const answer = await api.post(projectPath(nerd, `/sources/${saved.source.id}/review/report`), { ...report, ...(agent ? { agent } : {}) });
253
+ // From here the code as it stands counts as reviewed, and the report cannot be sent again by mistake against later code.
254
+ writeReviewed(root, files);
255
+ if (!opts.file)
256
+ rmSync(file, { force: true });
257
+ const page = `${dashboardUrl(api.apiUrl)}/app/${nerd.org}/projects/${nerd.project}/review`;
258
+ // Only the counts: the stored review carries a summary that may be a model's words, and those never go to a terminal.
259
+ if (opts.json)
260
+ return printJson({ accepted: answer.accepted, dropped: answer.dropped, carried: answer.carried, source: saved.source.id, dashboard: page });
261
+ const a = answer.accepted;
262
+ log.ok(`Review reported for ${nerd.org}/${nerd.project}: ${a.findings} open, ${a.fixed} fixed${a.leftAsIs ? `, ${a.leftAsIs} left as they are` : ""}${a.dismissed ? `, ${a.dismissed} judged not to apply` : ""}.`);
263
+ if (answer.dropped)
264
+ log.warn(`${answer.dropped} finding(s) were left out because the rule, the file, or the line did not match the stored code.`);
265
+ if (answer.carried)
266
+ 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.`);
267
+ log.info(`The owner's page for this review: ${page}`);
268
+ }
@@ -16,9 +16,10 @@ 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
- /** The latest model review's findings for this app, mapped through this CLI's own catalog. */
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
+ left: AttentionItem[];
22
23
  reviewedAt?: string;
23
24
  }>;
24
25
  /**
@@ -41,7 +42,7 @@ export declare function status(opts: {
41
42
  json: boolean;
42
43
  hook: boolean;
43
44
  }): Promise<void>;
44
- /** `nerd review`: only the model review's findings, with every fix written out. */
45
+ /** `nerd review`: only what is open from the latest review, with every fix written out. */
45
46
  export declare function review(opts: {
46
47
  json: boolean;
47
48
  }): Promise<void>;
@@ -4,28 +4,30 @@ import { ApiClient } from "../api.js";
4
4
  import { RULES } from "../gates/rules.js";
5
5
  import { currentVersions, pinned, versionFindings } from "../gates/versions.js";
6
6
  import { findProjectRoot, projectPath, readNerdJson } from "../project.js";
7
- import { log } from "../util.js";
7
+ import { localDay, log } from "../util.js";
8
8
  import { cliVersion } from "./source.js";
9
9
  const within = (p, ms) => Promise.race([p.catch(() => null), new Promise((r) => setTimeout(() => r(null), ms))]);
10
10
  const item = (rule_id, extra = {}) => {
11
11
  const r = RULES[rule_id];
12
12
  return r ? { rule_id, title: r.title, severity: r.severity, audience: r.audience, risk: r.risk, fix: extra.fix ?? r.fix, ...(extra.where ? { where: extra.where } : {}) } : null;
13
13
  };
14
- /** The latest model review's findings for this app, mapped through this CLI's own catalog. */
14
+ /** 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. */
15
15
  export async function reviewItems(api, base) {
16
16
  const got = await within(api.get(`${base}/review`), 5000);
17
17
  const review = got?.review;
18
18
  if (!review || review.status !== "done")
19
- return { items: [] };
19
+ return { items: [], left: [] };
20
20
  const items = [];
21
+ // Recorded as real and deliberately left as they are: kept for whoever asks, not announced to every new session.
22
+ const left = [];
21
23
  for (const f of review.findings ?? []) {
22
24
  if (typeof f.rule_id !== "string" || !f.rule_id.startsWith("review/") || typeof f.file !== "string" || !/^[\w./@-]{1,200}$/.test(f.file) || !Number.isInteger(f.line))
23
25
  continue;
24
26
  const it = item(f.rule_id, { where: `${f.file}:${f.line}` });
25
27
  if (it)
26
- items.push(it);
28
+ (f.leftAsIs === true ? left : items).push(it);
27
29
  }
28
- return { items, reviewedAt: review.createdAt };
30
+ return { items, left, reviewedAt: review.createdAt };
29
31
  }
30
32
  /**
31
33
  * Everything the platform has noticed about this app that someone should act on, gathered in a few
@@ -92,7 +94,7 @@ export function attentionText(a) {
92
94
  }
93
95
  }
94
96
  if (a.reviewedAt)
95
- lines.push("", `Findings with a file and a line come from a model's review of the version deployed on ${a.reviewedAt.slice(0, 10)}. It is a second opinion and can be wrong, and code changed since may already have fixed one: read the code at each place before changing anything.`);
97
+ lines.push("", `Findings with a file and a line come from the latest review of this app (${localDay(a.reviewedAt)}): the one its coding agent reported, or nerdsystem's own read of the code. A review is a second opinion and can be wrong, and code changed since may already have fixed one: read the code at each place before changing anything, and say what you did about each in the next \`nerd review report\`.`);
96
98
  lines.push("", "Before starting on what the person asked for, tell them about these in one or two plain sentences, with no file names or jargon, and offer to take care of them. If they say yes, fix what is real, run `nerd check`, and deploy. Anything marked for the person to decide, put to them as a plain question and wait for the answer. If they would rather get on with their own request, do that first and remind them once at the end.");
97
99
  return lines.join("\n");
98
100
  }
@@ -119,7 +121,7 @@ export async function status(opts) {
119
121
  else if (!opts.hook)
120
122
  log.ok(`Nothing needs attention${a.app ? ` for ${a.app}` : ""}.`);
121
123
  }
122
- /** `nerd review`: only the model review's findings, with every fix written out. */
124
+ /** `nerd review`: only what is open from the latest review, with every fix written out. */
123
125
  export async function review(opts) {
124
126
  const root = findProjectRoot();
125
127
  const nerd = readNerdJson(root);
@@ -129,12 +131,15 @@ export async function review(opts) {
129
131
  return;
130
132
  }
131
133
  if (!r.reviewedAt) {
132
- log.info("No model review of this app yet. One runs at the end of `nerd deploy`.");
134
+ log.info("No review of this app yet. Run `nerd review checklist` to do one before the next deploy.");
133
135
  return;
134
136
  }
137
+ const leftLines = r.left.length ? ["", "Known and deliberately left as they are (recorded in an earlier review; nothing to do unless the code around them changes):", ...r.left.map((it) => `- ${it.title} (${it.where})`)] : [];
135
138
  if (!r.items.length) {
136
- log.ok(`The review of the version deployed on ${r.reviewedAt.slice(0, 10)} found nothing to raise.`);
139
+ log.ok(`Nothing is open from the latest review (${localDay(r.reviewedAt)}).`);
140
+ if (leftLines.length)
141
+ process.stdout.write(`${leftLines.slice(1).join("\n")}\n`);
137
142
  return;
138
143
  }
139
- process.stdout.write(`${attentionText({ app: `${nerd.org}/${nerd.project}`, items: r.items, reviewedAt: r.reviewedAt })}\n`);
144
+ process.stdout.write(`${attentionText({ app: `${nerd.org}/${nerd.project}`, items: r.items, reviewedAt: r.reviewedAt })}${leftLines.length ? `\n${leftLines.join("\n")}` : ""}\n`);
140
145
  }
@@ -1,12 +1,14 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
3
  import path from "node:path";
3
4
  import { findProjectRoot } from "../project.js";
4
5
  import { log } from "../util.js";
5
6
  import { renderAgentFiles } from "./agents.js";
7
+ import { KNOWN_CLAUDE_MD } from "./claude-md-history.js";
6
8
  import { templateDir } from "./init.js";
7
9
  import { cliVersion } from "./source.js";
8
10
  /** Files that are the platform's, not the app's: replaced with the current version every time. */
9
- const OURS = [".claude/skills/nerdsystem/SKILL.md", "test/setup.ts", "test/global-setup.ts", "vitest.config.ts"];
11
+ const OURS = [".claude/skills/nerdsystem/SKILL.md", ".claude/agents/nerd-reviewer.md", "test/setup.ts", "test/global-setup.ts", "vitest.config.ts"];
10
12
  /** Files an app is expected to change: written only when the app has none. */
11
13
  const STARTERS = [
12
14
  ["test/access.test.ts", "test/access.test.ts"],
@@ -75,9 +77,13 @@ export function upgradeProject(root, tpl, version) {
75
77
  // purpose, so it is carried over word for word.
76
78
  const seeded = oldClaude ? /^(# [^\n]*\n\n[\s\S]*?\n\n)## This app runs on nerdsystem\n/.exec(oldClaude) : null;
77
79
  const newClaude = tplClaude !== null && seeded ? seeded[1] + tplClaude.replace(/^# This app runs on nerdsystem\n/, "## This app runs on nerdsystem\n") : tplClaude;
80
+ // The platform's part of the old file, as the template had it: when that is word for word a version this
81
+ // project shipped, nobody has added anything, and there is nothing to keep.
82
+ const oldPlatformText = oldClaude === null ? null : seeded ? oldClaude.slice(seeded[1].length).replace(/^## This app runs on nerdsystem\n/, "# This app runs on nerdsystem\n") : oldClaude;
83
+ const untouched = oldPlatformText !== null && KNOWN_CLAUDE_MD.has(createHash("sha256").update(oldPlatformText).digest("hex"));
78
84
  let keptOld = false;
79
85
  if (newClaude !== null && oldClaude !== newClaude) {
80
- if (oldClaude !== null && !existsSync(path.join(root, "CLAUDE.previous.md"))) {
86
+ if (oldClaude !== null && !untouched && !existsSync(path.join(root, "CLAUDE.previous.md"))) {
81
87
  renameSync(path.join(root, "CLAUDE.md"), path.join(root, "CLAUDE.previous.md"));
82
88
  changed.push("CLAUDE.previous.md");
83
89
  keptOld = true;
@@ -113,6 +119,8 @@ export function upgradeProject(root, tpl, version) {
113
119
  const next = ["Run `pnpm install`.", "Run `nerd check` and work through what it reports. It names what this app's own code should adopt (for example sign-in for every route, checked input, access tests)."];
114
120
  if (startersWritten.includes("test/access.test.ts"))
115
121
  next.push("`test/access.test.ts` is the starter's: keep the first test, and rewrite the owner test for this app's own routes. Then set `\"tests\": \"required\"` in nerd.json.");
122
+ if (changed.includes(".claude/agents/nerd-reviewer.md"))
123
+ next.push("A reviewer for Claude Code was added or updated at `.claude/agents/nerd-reviewer.md`. Claude Code loads it when a session starts, so a session that was already open will say it is not found: until the next session, give a read-only subagent that file as its instructions.");
116
124
  if (keptOld)
117
125
  next.push("Your earlier `CLAUDE.md` is now `CLAUDE.previous.md`. Move anything the person added into the new `CLAUDE.md`, then delete the old file.");
118
126
  next.push("Tell the person in plain words what changed, then run `nerd deploy` when the check passes.");
@@ -4,7 +4,7 @@ import { ApiClient } from "../api.js";
4
4
  import { BUILD_GATES, gateSummary, knownSecretsFrom, runGates, skippedGates, SOURCE_GATES } from "../gates/index.js";
5
5
  import { findProjectRoot, projectPath, readDevVars, readNerdJson } from "../project.js";
6
6
  import { CliError, log } from "../util.js";
7
- import { readBuildOutput } from "./deploy.js";
7
+ import { dropLocalSecretCopies, readBuildOutput } from "./deploy.js";
8
8
  /** Suppressions a human approved in the dashboard, so they stop being reported. Best effort. */
9
9
  export async function approvedSuppressions(root) {
10
10
  try {
@@ -28,6 +28,7 @@ export async function runVerify(root, opts) {
28
28
  let stop = source.capped;
29
29
  if (opts.withBuild && source.ok && existsSync(path.join(root, "dist"))) {
30
30
  const build = readBuildOutput(root);
31
+ dropLocalSecretCopies(build);
31
32
  const built = await runGates(BUILD_GATES.filter((g) => g.name !== "policy"), { ...ctxBase, build }, { stopOnFail: opts.stopOnFail, trackAttempts: false });
32
33
  results = [...results, ...built.results];
33
34
  failures = [...failures, ...built.failures];
@@ -108,6 +108,9 @@ export function designFindings(root, files, routes, authPath = "/api/auth") {
108
108
  for (const p of patterns) {
109
109
  if (SYSTEM.has(p.path))
110
110
  continue;
111
+ // A webhook or a callback is called by another service, never by a screen.
112
+ if (/\/(webhooks?|callbacks?)(\/|$)/.test(p.path))
113
+ continue;
111
114
  if (endings.some((e) => p.path.endsWith(e)))
112
115
  continue;
113
116
  // The route's name passed the same strict pattern as every other route name read from the report.
@@ -1,8 +1,10 @@
1
1
  import { type Gate } from "./types.js";
2
2
  /**
3
- * Brings the platform's latest model review of this app into the same list as every other check, so an
4
- * agent meets its findings in the loop it already runs and nobody has to go and look for them. Advice
5
- * only. The words are this CLI's catalog; the review itself contributes a rule id, a file, and a line.
6
- * Skipped without comment when there is no sign-in, no linked project, no review, or no network.
3
+ * Brings the review of this app into the same list as every other check, so an agent meets it in the
4
+ * loop it already runs and nobody has to go and look for it. Two things: the findings still open from
5
+ * the latest review (the agent's own or the platform's read), and a reminder when code a review reads
6
+ * has changed since the last one reported from this folder. Advice only. The words are this CLI's
7
+ * catalog; a review contributes a rule id, a file, and a line.
8
+ * Skipped without comment when there is no sign-in, no linked project, or no network.
7
9
  */
8
10
  export declare const reviewGate: Gate;
@@ -1,18 +1,23 @@
1
1
  import { ApiClient } from "../api.js";
2
+ import { reviewDue } from "../commands/session-review.js";
2
3
  import { reviewItems } from "../commands/status.js";
3
4
  import { projectPath, readNerdJson } from "../project.js";
5
+ import { finding } from "./secrets.js";
6
+ import { localDay } from "../util.js";
4
7
  import { counts, result, verdict } from "./types.js";
5
8
  /**
6
- * Brings the platform's latest model review of this app into the same list as every other check, so an
7
- * agent meets its findings in the loop it already runs and nobody has to go and look for them. Advice
8
- * only. The words are this CLI's catalog; the review itself contributes a rule id, a file, and a line.
9
- * Skipped without comment when there is no sign-in, no linked project, no review, or no network.
9
+ * Brings the review of this app into the same list as every other check, so an agent meets it in the
10
+ * loop it already runs and nobody has to go and look for it. Two things: the findings still open from
11
+ * the latest review (the agent's own or the platform's read), and a reminder when code a review reads
12
+ * has changed since the last one reported from this folder. Advice only. The words are this CLI's
13
+ * catalog; a review contributes a rule id, a file, and a line.
14
+ * Skipped without comment when there is no sign-in, no linked project, or no network.
10
15
  */
11
16
  export const reviewGate = {
12
17
  name: "review",
13
- title: "Model review of the deployed version",
18
+ title: "Review of how the app is built",
14
19
  blocking: false,
15
- rulePrefixes: ["review/"],
20
+ rulePrefixes: ["review/", "session/"],
16
21
  async run(ctx) {
17
22
  const t = Date.now();
18
23
  // A git build has no person's sign-in to ask with, and the review is of code already deployed.
@@ -25,13 +30,23 @@ export const reviewGate = {
25
30
  catch {
26
31
  return result(this, "skip", "No linked project or sign-in to ask with", [], t);
27
32
  }
28
- if (!got.reviewedAt)
29
- return result(this, "skip", "No review yet; one runs at the end of nerd deploy", [], t);
30
33
  const findings = got.items.map((i) => {
31
34
  const [file, line] = (i.where ?? "").split(":");
32
- return { rule_id: i.rule_id, severity: i.severity, audience: i.audience, file, line: Number(line) || undefined, risk: i.risk, fix: i.fix, verify: "nerd deploy (the code is reviewed again after the next deploy)", attempt: 1, fingerprint: `${i.rule_id}:${i.where}` };
35
+ return { rule_id: i.rule_id, severity: i.severity, audience: i.audience, file, line: Number(line) || undefined, risk: i.risk, fix: i.fix, verify: "nerd review report (say what you fixed), then nerd deploy", attempt: 1, fingerprint: `${i.rule_id}:${i.where}` };
33
36
  });
34
- const when = got.reviewedAt.slice(0, 10);
35
- return result(this, verdict(findings), findings.length ? `${counts(findings)}, from the review of the version deployed on ${when}; a second opinion that can be wrong, so read the code first` : `Nothing raised in the review of the version deployed on ${when}`, findings, t);
37
+ const open = findings.length;
38
+ let due = false;
39
+ let first = false;
40
+ try {
41
+ ({ due, first } = reviewDue(ctx.root));
42
+ }
43
+ catch {
44
+ // a folder that cannot be listed has nothing to say here
45
+ }
46
+ if (due)
47
+ findings.push(finding("session/review-due", { verify: "nerd review report" }));
48
+ const when = got.reviewedAt ? localDay(got.reviewedAt) : undefined;
49
+ const said = [open ? `${open} open from the review of ${when}; a second opinion that can be wrong, so read the code first` : when ? `Nothing open from the review of ${when}` : "No review yet", ...(due ? [first ? "no review has been reported from this folder: run nerd review checklist" : "code has changed since the last review: run nerd review checklist"] : [])];
50
+ return result(this, verdict(findings), findings.length ? `${counts(findings)}: ${said.join("; ")}` : said.join("; "), findings, t);
36
51
  },
37
52
  };
@@ -56,6 +56,7 @@ export const RULES = {
56
56
  "design/unused-table": { title: "A table nothing uses", severity: "warn", audience: "agent", risk: "A table left over from the starter or from a removed feature confuses the next change and may still hold people's data.", fix: "If the feature is gone, remove the table from the schema and write a migration that drops it; tell the person first if it may hold data they want. If it is meant to be used, connect it." },
57
57
  "design/long-file": { title: "A very long file", severity: "info", audience: "agent", risk: "Changes to a file this long are more likely to break something elsewhere in it, for a person or an agent.", fix: "Split it along its natural parts (one screen, one group of routes per file) the next time you change it. Do not reorganise working code just for this." },
58
58
  "design/unfinished": { title: "Something marked as unfinished", severity: "info", audience: "agent", risk: "A note to finish something later, or placeholder text, is about to go live.", fix: "Finish it, remove it, or tell the person it is not done yet so they can decide." },
59
+ "session/review-due": { title: "The code as it is now has not been reviewed", severity: "warn", audience: "agent", risk: "Tests show the app does what was checked; they cannot show that the server trusts a price the browser sent, or that two people can book the same slot. Nobody has looked for that in the code as it is now.", fix: "Run `nerd review checklist` and follow it: review the files it lists (in Claude Code, with the `nerd-reviewer` subagent), fix what is real, write the report it describes, and run `nerd review report`." },
59
60
  "platform/vulnerable-package": { title: "A package this app uses has a serious vulnerability reported since it was deployed", severity: "warn", audience: "agent", risk: "The app passed its checks when it went live. Since then a flaw has been made public in something it depends on, and the live app still carries it.", fix: "Run `nerd check`: it names the package and the version to move to. Update it, run the tests, and deploy." },
60
61
  "versions/cli-outdated": { title: "The nerd command is behind", severity: "warn", audience: "agent", risk: "An older nerd runs fewer checks and may not know about newer platform features.", fix: "Run `npm install -g nerdsystem@latest`." },
61
62
  "versions/sdk-outdated": { title: "The app's SDK is behind", severity: "warn", audience: "agent", risk: "The app is missing fixes and safeguards that newer versions of the platform's library and instructions carry.", fix: "Run `nerd upgrade`, then `pnpm install` and `nerd check`." },
@@ -68,6 +68,13 @@ export interface OsvVulnerability {
68
68
  export declare function osvSeverity(v: OsvVulnerability): AdvisorySeverity;
69
69
  /** CVSS v3.0/3.1 base score from a vector string, or null when the vector is malformed. */
70
70
  export declare function cvss3BaseScore(vector: string): number | null;
71
+ /**
72
+ * The files a review of an app reads: its own code and its description. Tests, generated files, and the
73
+ * instructions written for agents are left out. The control plane's model review and the session review an
74
+ * agent does itself (`nerd review checklist`) both go by this, so "what changed since the last review"
75
+ * means the same thing on both sides.
76
+ */
77
+ export declare function isReviewFile(posixPath: string): boolean;
71
78
  /** Name of the first entry in every snapshot tar. */
72
79
  export declare const SNAPSHOT_MANIFEST = ".nerd-snapshot.json";
73
80
  /** Most files one snapshot may hold. */
@@ -150,6 +150,17 @@ export function cvss3BaseScore(vector) {
150
150
  const roundUp = (x) => Math.ceil(x * 10 - 1e-9) / 10;
151
151
  return scopeChanged ? roundUp(Math.min(1.08 * (impact + exploitability), 10)) : roundUp(Math.min(impact + exploitability, 10));
152
152
  }
153
+ /**
154
+ * The files a review of an app reads: its own code and its description. Tests, generated files, and the
155
+ * instructions written for agents are left out. The control plane's model review and the session review an
156
+ * agent does itself (`nerd review checklist`) both go by this, so "what changed since the last review"
157
+ * means the same thing on both sides.
158
+ */
159
+ export function isReviewFile(posixPath) {
160
+ if (/\.d\.ts$/.test(posixPath))
161
+ return false;
162
+ return /^src\/.+\.(ts|tsx|js|jsx|mjs|css)$/.test(posixPath) || posixPath === "README.md" || posixPath === "package.json";
163
+ }
153
164
  /*
154
165
  * Source snapshots (docs/source-snapshots.md). The CLI packs them, the control plane validates
155
166
  * them while streaming, and both read the lists below, so a rule added here holds on both sides.
@@ -114,7 +114,9 @@ export const versionsGate = {
114
114
  // no package.json: the rules gate says so
115
115
  }
116
116
  const findings = versionFindings({ cli: installedCli(), sdk, versions, inBuilder: Boolean(ctx.committed) });
117
- const summary = findings.length ? findings.map((f) => f.rule_id.split("/")[1]).join(", ") : versions.latest ? `nerd and the SDK are current (${versions.latest})` : "Nothing older than the platform accepts";
117
+ // The platform's answer is kept for a day, so straight after an update this copy can be newer than the "latest" it remembers.
118
+ const shown = versions.latest && compareVersions(installedCli(), versions.latest) > 0 ? installedCli() : versions.latest;
119
+ const summary = findings.length ? findings.map((f) => f.rule_id.split("/")[1]).join(", ") : shown ? `nerd and the SDK are current (${shown})` : "Nothing older than the platform accepts";
118
120
  return result(this, verdict(findings), summary, findings, t);
119
121
  },
120
122
  };
package/dist/util.d.ts CHANGED
@@ -29,6 +29,8 @@ export declare function runOrThrow(cmd: string, args: string[], opts?: RunOption
29
29
  export declare function printJson(value: unknown): void;
30
30
  export declare function table(rows: Record<string, unknown>[], columns: string[]): void;
31
31
  /** The dashboard that goes with an API origin (https://api.nerdsystem.com -> https://nerdsystem.com). */
32
+ /** The calendar day of a timestamp where the person is, as YYYY-MM-DD. The platform's times are UTC, which is tomorrow for part of every evening in the Americas. */
33
+ export declare function localDay(iso: string): string;
32
34
  export declare function dashboardUrl(apiUrl: string): string;
33
35
  /** Stops with one sentence instead of npx noise when `pnpm install` has not been run. */
34
36
  export declare function ensureDeps(root: string): void;
package/dist/util.js CHANGED
@@ -49,6 +49,14 @@ export function table(rows, columns) {
49
49
  process.stdout.write(`${line(columns.map((c) => String(r[c] ?? "")))}\n`);
50
50
  }
51
51
  /** The dashboard that goes with an API origin (https://api.nerdsystem.com -> https://nerdsystem.com). */
52
+ /** The calendar day of a timestamp where the person is, as YYYY-MM-DD. The platform's times are UTC, which is tomorrow for part of every evening in the Americas. */
53
+ export function localDay(iso) {
54
+ const d = new Date(iso);
55
+ if (Number.isNaN(d.getTime()))
56
+ return iso.slice(0, 10);
57
+ const two = (n) => String(n).padStart(2, "0");
58
+ return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`;
59
+ }
52
60
  export function dashboardUrl(apiUrl) {
53
61
  return apiUrl.replace(/\/$/, "").replace(/^https?:\/\/api\./, "https://");
54
62
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nerdsystem",
3
- "version": "0.0.24",
3
+ "version": "0.0.26",
4
4
  "description": "nerd: start, check, and deploy a web app on nerdsystem from the terminal or a coding agent.",
5
5
  "type": "module",
6
6
  "bin": {