Skip to content

Commit 781dac0

Browse files
authored
Merge branch 'main' into codex/fix-e2e-pty-termios-retry
2 parents 7455c5a + 12e5bee commit 781dac0

97 files changed

Lines changed: 4573 additions & 6551 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/nemoclaw-maintainer-cut-release-tag/SKILL.md

Lines changed: 161 additions & 268 deletions
Large diffs are not rendered by default.

.agents/skills/nemoclaw-maintainer-cut-release-tag/references/candidate-evidence.md

Lines changed: 444 additions & 0 deletions
Large diffs are not rendered by default.

.agents/skills/nemoclaw-maintainer-day/PR-REVIEW-PRIORITIES.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,13 @@ The team follows a daily ship cycle. All maintainer skills operate within this r
5555

5656
1. **Morning** (`/nemoclaw-maintainer-morning`) — triage the backlog, pick items for the day, label them with the target version (e.g., `v0.0.8`).
5757
2. **During the day** (`/nemoclaw-maintainer-day`) — land PRs using the maintainer loop. Version labels make progress visible on dashboards.
58-
3. **Evening** (`/nemoclaw-maintainer-evening`) — Check shipped work and the pre-tag changelog PR.
59-
Confirm that the pre-tag changelog PR contains `docs/changelog/YYYY-MM-DD.mdx` for the release.
60-
Identify open items and prepare the QA summary. Record the candidate SHA and qualifying full manual `Release qualification` check that the release script accepts.
61-
Cut the tag after confirmation. Move open items to the next patch label and delete the released label.
62-
Prepare the Announcement.
58+
3. **Evening** (`/nemoclaw-maintainer-evening`) — check shipped work and the cumulative
59+
documentation PR. Confirm that it covers every merged change selected for the release and
60+
contains `docs/changelog/YYYY-MM-DD.mdx` for the release.
61+
Identify open items and prepare the Markdown release brief. Show the newest full E2E result and
62+
let the maintainer choose focused tests, the full suite, or the displayed status.
63+
Cut the tag after confirmation. Treat aliases, labels, publication, and the Announcement as work
64+
outside tag cutting; some share a workflow or depend on another post-tag state.
6365
4. **Overnight** — A QA team in another time zone validates the tag.
6466
Put new issues into the next morning's triage.
6567

.agents/skills/nemoclaw-maintainer-day/scripts/handoff-summary.ts

Lines changed: 231 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -2,39 +2,48 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
/**
5-
* Generate a QA handoff summary for the upcoming release tag.
5+
* Generate exact-range QA context for a release brief.
66
*
7-
* Lists commits since the last tag, identifies risky areas touched,
8-
* and suggests test focus areas. Output is JSON.
9-
*
10-
* Usage: node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/handoff-summary.ts [--repo OWNER/REPO]
7+
* Usage:
8+
* node --experimental-strip-types --no-warnings handoff-summary.ts \
9+
* --plan PATH --output PATH
1110
*/
1211

13-
import { isRiskyFile, run } from "./shared.ts";
12+
import { execFileSync } from "node:child_process";
13+
import fs from "node:fs";
14+
import path from "node:path";
15+
import { pathToFileURL } from "node:url";
1416

15-
interface CommitInfo {
16-
sha: string;
17-
subject: string;
18-
}
17+
import { isRiskyFile } from "./shared.ts";
1918

20-
interface HandoffOutput {
19+
export interface HandoffInput {
2120
previousTag: string;
21+
previousTagCommit: string;
2222
targetVersion: string;
23+
candidateCommit: string;
24+
}
25+
26+
export interface HandoffOutput extends HandoffInput {
2327
commitCount: number;
24-
commits: CommitInfo[];
25-
riskyFilesTouched: string[];
28+
riskyFileCount: number;
2629
riskyAreas: string[];
2730
suggestedTestFocus: string[];
2831
}
2932

33+
type CommandRunner = (command: string, args: string[]) => string;
34+
35+
const SEMVER = /^v\d+\.\d+\.\d+$/;
36+
const SHA = /^[0-9a-f]{40}$/;
37+
const INCOMPLETE = "TODO_RELEASE_BRIEF";
38+
3039
const AREA_LABELS: Record<string, RegExp[]> = {
3140
"Installer / bootstrap": [
3241
/^install\.sh$/,
3342
/^setup\.sh$/,
3443
/^brev-setup\.sh$/,
3544
/^scripts\/.*\.sh$/,
3645
],
37-
"Onboarding / host glue": [/^bin\/lib\/onboard\.js$/, /^bin\/.*\.js$/],
46+
"Onboarding / host glue": [/^bin\/lib\/onboard\.js$/, /^bin\/.*\.js$/, /^src\/lib\/onboard\//],
3847
"Sandbox / policy / SSRF": [
3948
/^nemoclaw\/src\/blueprint\//,
4049
/^nemoclaw-blueprint\//,
@@ -45,89 +54,231 @@ const AREA_LABELS: Record<string, RegExp[]> = {
4554
"Credentials / inference": [/credential/i, /inference/i],
4655
};
4756

48-
function getLatestTag(): string {
49-
const out = run("git", ["tag", "--sort=-v:refname"]);
50-
if (!out) return "v0.0.0";
51-
for (const line of out.split("\n")) {
52-
if (/^v\d+\.\d+\.\d+$/.test(line.trim())) return line.trim();
57+
function run(command: string, args: string[]): string {
58+
try {
59+
return execFileSync(command, args, {
60+
encoding: "utf8",
61+
maxBuffer: 10 * 1024 * 1024,
62+
stdio: ["ignore", "pipe", "pipe"],
63+
timeout: 120_000,
64+
}).trim();
65+
} catch (error) {
66+
const value = error as { stderr?: Buffer | string };
67+
const detail = value.stderr ? String(value.stderr).trim() : "";
68+
throw new Error(
69+
[`Command failed: ${command} ${args.join(" ")}`, detail].filter(Boolean).join("\n"),
70+
);
5371
}
54-
return "v0.0.0";
5572
}
5673

57-
function bumpPatch(tag: string): string {
58-
const match = tag.match(/^v(\d+)\.(\d+)\.(\d+)$/);
59-
if (!match) return "v0.0.1";
60-
return `v${match[1]}.${match[2]}.${parseInt(match[3], 10) + 1}`;
74+
function validateInput(input: HandoffInput): void {
75+
if (!SEMVER.test(input.previousTag)) throw new Error("previous tag must be vX.Y.Z");
76+
if (!SEMVER.test(input.targetVersion)) throw new Error("target version must be vX.Y.Z");
77+
if (!SHA.test(input.previousTagCommit)) {
78+
throw new Error("previous tag commit must be a lowercase 40-character Git SHA");
79+
}
80+
if (!SHA.test(input.candidateCommit)) {
81+
throw new Error("candidate commit must be a lowercase 40-character Git SHA");
82+
}
6183
}
6284

63-
function main(): void {
64-
run("git", ["fetch", "origin", "--tags", "--prune"]);
65-
66-
const previousTag = getLatestTag();
67-
const targetVersion = bumpPatch(previousTag);
68-
69-
// Commits since last tag
70-
const logOut = run("git", ["log", "--oneline", "--format=%h %s", `${previousTag}..origin/main`]);
71-
const commits: CommitInfo[] = [];
72-
if (logOut) {
73-
for (const line of logOut.split("\n")) {
74-
const spaceIdx = line.indexOf(" ");
75-
if (spaceIdx > 0) {
76-
commits.push({
77-
sha: line.slice(0, spaceIdx),
78-
subject: line.slice(spaceIdx + 1),
79-
});
80-
}
81-
}
85+
function suggestedFocus(areasHit: Set<string>, commitCount: number): string[] {
86+
const focus: string[] = [];
87+
if (areasHit.has("Installer / bootstrap")) focus.push("Fresh install and upgrade paths");
88+
if (areasHit.has("Onboarding / host glue")) {
89+
focus.push("Onboarding wizard and sandbox creation");
90+
}
91+
if (areasHit.has("Sandbox / policy / SSRF")) {
92+
focus.push("Policy enforcement, network egress, and SSRF protections");
8293
}
94+
if (areasHit.has("Workflow / enforcement")) {
95+
focus.push("CI checks, pre-commit hooks, and DCO declarations");
96+
}
97+
if (areasHit.has("Credentials / inference")) {
98+
focus.push("Credential storage and inference provider routing");
99+
}
100+
if (focus.length === 0 && commitCount > 0) {
101+
focus.push("General smoke test; no risky areas were detected");
102+
}
103+
return focus;
104+
}
105+
106+
export function buildHandoffSummary(
107+
input: HandoffInput,
108+
command: CommandRunner = run,
109+
): HandoffOutput {
110+
validateInput(input);
83111

84-
// Files changed since last tag
85-
const diffOut = run("git", ["diff", "--name-only", `${previousTag}..origin/main`]);
86-
const changedFiles = diffOut
87-
? diffOut
112+
const resolvedCandidate = command("git", ["rev-parse", `${input.candidateCommit}^{commit}`]);
113+
if (resolvedCandidate !== input.candidateCommit) {
114+
throw new Error(`candidate does not resolve to ${input.candidateCommit}`);
115+
}
116+
const mergeBase = command("git", ["merge-base", input.previousTagCommit, input.candidateCommit]);
117+
if (mergeBase !== input.previousTagCommit) {
118+
throw new Error("previous tag commit is not an ancestor of the candidate");
119+
}
120+
121+
const range = `${input.previousTagCommit}..${input.candidateCommit}`;
122+
const commitCountText = command("git", ["rev-list", "--count", range]);
123+
if (!/^\d+$/u.test(commitCountText)) throw new Error("git returned an invalid commit count");
124+
const commitCount = Number(commitCountText);
125+
if (!Number.isSafeInteger(commitCount)) throw new Error("release range is too large");
126+
127+
const changed = command("git", ["diff", "--name-only", range]);
128+
const changedFiles = changed
129+
? changed
88130
.split("\n")
89-
.map((f) => f.trim())
131+
.map((file) => file.trim())
90132
.filter(Boolean)
91133
: [];
92134
const riskyFilesTouched = changedFiles.filter(isRiskyFile);
93-
94-
// Map risky files to area labels
95135
const areasHit = new Set<string>();
96136
for (const file of riskyFilesTouched) {
97137
for (const [area, patterns] of Object.entries(AREA_LABELS)) {
98-
if (patterns.some((re) => re.test(file))) {
99-
areasHit.add(area);
100-
}
138+
if (patterns.some((pattern) => pattern.test(file))) areasHit.add(area);
101139
}
102140
}
103-
const riskyAreas = [...areasHit];
104-
105-
// Suggest test focus based on areas
106-
const suggestedTestFocus: string[] = [];
107-
if (areasHit.has("Installer / bootstrap"))
108-
suggestedTestFocus.push("Fresh install and upgrade paths");
109-
if (areasHit.has("Onboarding / host glue"))
110-
suggestedTestFocus.push("Onboarding wizard, sandbox creation");
111-
if (areasHit.has("Sandbox / policy / SSRF"))
112-
suggestedTestFocus.push("Policy enforcement, network egress, SSRF protections");
113-
if (areasHit.has("Workflow / enforcement"))
114-
suggestedTestFocus.push("CI checks, pre-commit hooks, DCO signing");
115-
if (areasHit.has("Credentials / inference"))
116-
suggestedTestFocus.push("Credential storage, inference provider routing");
117-
if (suggestedTestFocus.length === 0 && commits.length > 0)
118-
suggestedTestFocus.push("General smoke test — no risky areas touched");
119-
120-
const output: HandoffOutput = {
121-
previousTag,
122-
targetVersion,
123-
commitCount: commits.length,
124-
commits,
125-
riskyFilesTouched,
126-
riskyAreas,
127-
suggestedTestFocus,
141+
142+
return {
143+
...input,
144+
commitCount,
145+
riskyFileCount: riskyFilesTouched.length,
146+
riskyAreas: [...areasHit],
147+
suggestedTestFocus: suggestedFocus(areasHit, commitCount),
128148
};
149+
}
150+
151+
function text(value: string): string {
152+
return value.replace(/([\\`*_[\]<>#])/g, "\\$1");
153+
}
129154

130-
console.log(JSON.stringify(output, null, 2));
155+
function code(value: string): string {
156+
return `\`${value.replace(/`/g, "\\`")}\``;
157+
}
158+
159+
function list(values: string[], empty: string): string[] {
160+
return values.length ? values.map((value) => `- ${text(value)}`) : [`- ${empty}`];
161+
}
162+
163+
export function renderHandoffMarkdown(summary: HandoffOutput): string {
164+
const lines = [
165+
`# NemoClaw ${summary.targetVersion} release brief`,
166+
"",
167+
"## Release range",
168+
"",
169+
`- Previous release: ${code(summary.previousTag)} at ${code(summary.previousTagCommit)}`,
170+
`- Candidate: ${code(summary.candidateCommit)}`,
171+
`- Commits: ${summary.commitCount}`,
172+
`- Risky files detected: ${summary.riskyFileCount}`,
173+
"",
174+
"## QA context",
175+
"",
176+
"### Risky areas",
177+
"",
178+
...list(summary.riskyAreas, "None detected."),
179+
"",
180+
"### Suggested test focus",
181+
"",
182+
...list(summary.suggestedTestFocus, "No test focus was inferred."),
183+
"",
184+
"## Canonical release entry",
185+
"",
186+
`- Path: ${INCOMPLETE}`,
187+
"- Entry:",
188+
"",
189+
INCOMPLETE,
190+
"",
191+
"## Pi documentation evidence",
192+
"",
193+
`- Pi candidate: ${code(summary.candidateCommit)}`,
194+
`- Evidence: ${INCOMPLETE} (workflow and job URLs, artifact name, normalized approved-empty review, and managed-branch checks)`,
195+
"",
196+
"## Base and managed image evidence",
197+
"",
198+
`- Base-image candidate: ${code(summary.candidateCommit)}`,
199+
`- Evidence: ${INCOMPLETE}`,
200+
"",
201+
"## Exact staging Brev Launchable evidence",
202+
"",
203+
`- Launchable candidate: ${code(summary.candidateCommit)}`,
204+
`- Evidence: ${INCOMPLETE}`,
205+
"",
206+
"## General E2E decision",
207+
"",
208+
`- ${INCOMPLETE}: displayed run, requested runs, and maintainer choice.`,
209+
"",
210+
`Exceptions: ${INCOMPLETE}`,
211+
"",
212+
];
213+
return `${lines.join("\n")}\n`;
214+
}
215+
216+
function parseArguments(argv: string[]): { output: string; plan: string } {
217+
let output = "";
218+
let plan = "";
219+
for (let index = 0; index < argv.length; index += 1) {
220+
const argument = argv[index];
221+
if (argument === "--plan") {
222+
plan = argv[++index] ?? "";
223+
if (!plan || plan.startsWith("--")) throw new Error("--plan requires a path");
224+
} else if (argument === "--output") {
225+
output = argv[++index] ?? "";
226+
if (!output || output.startsWith("--")) throw new Error("--output requires a path");
227+
} else {
228+
throw new Error(`unknown argument: ${argument ?? "missing"}`);
229+
}
230+
}
231+
if (!plan || !output) throw new Error("usage: handoff-summary.ts --plan PATH --output PATH");
232+
return { output, plan };
131233
}
132234

133-
main();
235+
function readPlan(planPath: string): HandoffInput {
236+
const value = JSON.parse(fs.readFileSync(path.resolve(planPath), "utf8")) as Record<
237+
string,
238+
unknown
239+
>;
240+
const expectedKeys = [
241+
"nextTag",
242+
"originMainCommit",
243+
"originMainHeadline",
244+
"previousTag",
245+
"previousTagCommit",
246+
"previousTagObject",
247+
];
248+
if (JSON.stringify(Object.keys(value).sort()) !== JSON.stringify(expectedKeys)) {
249+
throw new Error("release plan must contain exactly the six supported fields");
250+
}
251+
if (typeof value.originMainHeadline !== "string" || !value.originMainHeadline) {
252+
throw new Error("release plan headline must be a nonempty string");
253+
}
254+
const input = {
255+
previousTag: String(value.previousTag),
256+
previousTagCommit: String(value.previousTagCommit),
257+
targetVersion: String(value.nextTag),
258+
candidateCommit: String(value.originMainCommit),
259+
};
260+
validateInput(input);
261+
return input;
262+
}
263+
264+
function main(): void {
265+
const options = parseArguments(process.argv.slice(2));
266+
const summary = buildHandoffSummary(readPlan(options.plan));
267+
const output = path.resolve(options.output);
268+
fs.mkdirSync(path.dirname(output), { recursive: true });
269+
fs.writeFileSync(output, renderHandoffMarkdown(summary), { encoding: "utf8", flag: "wx" });
270+
console.log(`Release brief written: ${output}`);
271+
}
272+
273+
const invoked = process.argv[1]
274+
? pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url
275+
: false;
276+
if (invoked) {
277+
try {
278+
main();
279+
} catch (error) {
280+
const message = error instanceof Error ? error.message : String(error);
281+
process.stderr.write(`handoff-summary: ${message}\n`);
282+
process.exitCode = 1;
283+
}
284+
}

.agents/skills/nemoclaw-maintainer-day/scripts/shared.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const RISKY_PATTERNS: RegExp[] = [
2121
/^scripts\/.*\.sh$/,
2222
/^bin\/lib\/onboard\.js$/,
2323
/^bin\/.*\.js$/,
24+
/^src\/lib\/onboard\//,
2425
/^nemoclaw\/src\/blueprint\//,
2526
/^nemoclaw-blueprint\//,
2627
/^\.github\/workflows\//,

0 commit comments

Comments
 (0)