diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eaab1a..279baa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +### Improved + +- Rank focused implementation and test files ahead of broad historical acceptance harnesses for + ordinary task briefs. +- End initialization with an accurate created, updated, and reused setup summary plus the next local + action. +- Distinguish learning that was not assessed from an approved assessment with no reusable result. +- Expose prepared independent-review packages through the read-only `review` command. + ## 0.1.1 — 2026-09-04 This patch release strengthens the existing Noxroot workflow without adding a new command or diff --git a/README.md b/README.md index ccfa5da..1e0e47c 100644 --- a/README.md +++ b/README.md @@ -80,13 +80,9 @@ Commit the reviewed setup before your first code-changing task; `start` requires baseline. This is a local commit; no push is needed. Still evaluating? `preview` and `context` work without initialization or a setup commit. -Then keep talking to your coding agent normally. For code changes, compatible agents are instructed -to run the pinned `start` before editing and `finish` afterward. - -Finish all edits, run `finish`, address any failures or required review, then commit. Rerun `finish` -if you edit again. Approve real project commands during setup; see the -[first-task guide](docs/getting-started.md) for missing checks, timeouts, and package-age -restrictions. +Then keep talking to your coding agent normally; compatible agents are instructed to record the +baseline before a code change, run the approved checks afterward, and surface any required review. +See the [first-task guide](docs/getting-started.md) when you want to inspect that flow yourself. `init` preserves existing documentation and pins the Noxroot version. `npx` downloads from [npm](https://www.npmjs.com/package/noxroot) into its cache; no global installation or clone is @@ -98,10 +94,8 @@ ownership of lifecycle, review, and learning; Noxroot can supply context and ver it. A coordination ledger is adjacent, not a development coordinator. Noxroot does not import its log. -Read-only work creates no task. In the same repository, branch, and worktree, a repeated `start` -continues the active baseline. `finish` infers a single matching task; several matches require -`--task `. Commands remain available for manual use when an agent does not follow the -instructions. +Read-only work creates no task. A repeated `start` continues the same task on the same branch and +worktree. Commands remain available for manual use when an agent does not follow the instructions. When upgrading, inspect the managed instruction changes with `npx noxroot@latest sync --dry-run --diff`. Apply them with `npx noxroot@latest sync` after review. @@ -127,7 +121,8 @@ Existing `.git/noxroot` records stay in place, without a second store. If an age state, it must stop and request access before continuing. `SKILL.md` files are portable instructions for verification and review. `AGENTS.md`, the knowledge -index, and routes guide context loading. `finish` and `learn` handle learning proposals. +index, and routes guide context loading. `review` exposes the prepared package when a fresh review +is required. Only an approved review of the unchanged diff can produce a learning proposal. Skills are instructions, not test evidence. Incomplete work cannot become approved. Noxroot does not push, merge, publish, or deploy. @@ -210,8 +205,8 @@ Python, Go, Rust, and other stacks. CI covers Windows, macOS, and Linux. `context` explains file selection. `verify --plan` shows approved commands without running them. `verify --changed` runs applicable checks. `run --dry-run` shows a connected execution plan. `status` shows active work and the next action. `doctor` explains configuration problems. -`learn --task ID` shows confirmable durable proposals. Data commands support `--json`, with progress -and diagnostics on standard error. +`review --task ID` prepares a fresh-review handoff. `learn --task ID` shows confirmable durable +proposals. Data commands support `--json`, with progress and diagnostics on standard error. Read [Getting started](docs/getting-started.md), the [command reference](docs/commands.md), [configuration](docs/configuration.md), [architecture](docs/architecture.md), diff --git a/docs/commands.md b/docs/commands.md index d4c94af..44ab95f 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -172,6 +172,18 @@ tracked, oversized, mismatched, and malformed evidence is rejected; rejected con in task state. If a check or reviewer changes the repository, prior verification and review evidence becomes stale and `finish` must be run again. +## `review` + +`review [--task ID]` exposes the reviewer package already prepared by `finish` for a +`review-pending` task. Human output explains what review is required, why, and how to continue. +`--json` emits the complete bounded package for a fresh coding-agent reviewer, including the task, +change ID, changed paths, diff, verification evidence, review reasons, and strict response contract. +The command is read-only and does not invoke a reviewer. + +Save the reviewer's one-object JSON response as an untracked file under `.noxroot/local/`, then pass +that relative path to `finish --review-file`. Noxroot validates the task and full-change IDs before +accepting the decision. Editing the repository makes the earlier package and response stale. + ## `learn` `learn --task ID` accepts structured reviewer candidates of kind `knowledge`, `decision`, @@ -186,3 +198,7 @@ sessions, user data, secrets, and external human docs are not converted into kno carry a confirmation date and source task id. Noxroot refuses another entry when the destination would exceed `context.documentWarningBytes`; existing knowledge must then be consolidated or superseded deliberately. + +Human output distinguishes `not-assessed` from `no-candidate`. The former means there is no approved +review of the current unchanged diff; the latter means that review ran and identified no reusable +project knowledge. Neither state changes project memory. diff --git a/docs/getting-started.md b/docs/getting-started.md index ab81a0b..8368616 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -72,9 +72,14 @@ The handoff should name the changed files, commands that ran, failures, and anyt Passing checks do not satisfy a required review. A task with incomplete verification is not approved. -Useful lessons can be proposed for documentation after the task. Review those proposals before -applying them. No learning candidate is a valid outcome; every change does not need another -document. +If finish reports `review-pending`, run `noxroot review --task ID`. Give its JSON package to a fresh +coding-agent reviewer, save the strict response under `.noxroot/local/`, and pass that file back to +`finish --review-file`. Noxroot checks that the response belongs to the current unchanged diff. + +Useful lessons can be proposed for documentation only after an approved review of the current +unchanged diff. Review those proposals before applying them. `Not assessed` means that review did +not happen; `no candidate` means it did and found nothing reusable. Every change does not need +another document. An accepted lesson must also be eligible for the task's context route. Fresh setups include `.noxroot/knowledge/**`; relevance and size limits still apply. If an older setup includes only diff --git a/src/cli.ts b/src/cli.ts index e97b9ef..cc627ab 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -39,9 +39,11 @@ import { orchestrateRun, type RunRecord } from "./orchestration/run.js"; import { renderContext, renderGuidedFinish, + renderInitApplied, renderInitMark, renderLearning, renderPreview, + renderReviewHandoff, renderVerification, renderVerificationPlan, renderWelcome, @@ -568,10 +570,13 @@ export function createProgram(customIo?: Partial): Command { io.stderr("Noxroot is already initialized; no files were proposed.\n"); return; } + const writableChanges = preview.proposedFiles.filter( + (proposal) => proposal.action !== "reference", + ).length; if ( !(await confirm( io, - `Create exactly ${preview.proposedFiles.length} proposed file(s)?`, + `Apply exactly ${writableChanges} displayed setup change(s)?`, options.yes, )) ) { @@ -583,10 +588,7 @@ export function createProgram(customIo?: Partial): Command { } const result = await applyProposals(preview); if (common.json) writeJson(io, { preview, applied: result }); - else - io.stdout( - `Created ${result.created.length} file(s):\n${result.created.map((file) => `- ${file}`).join("\n")}\n`, - ); + else io.stdout(renderInitApplied(result, renderOptions(io, common))); }, ); @@ -684,6 +686,30 @@ export function createProgram(customIo?: Partial): Command { emit(io, common.json, result, renderTaskStatus(result)); }); + program + .command("review") + .description("show the prepared package for a required independent review") + .option("--task ", "guided task id; inferred when exactly one task is active") + .action(async (options: { task?: string }, command: Command) => { + const common = globals(command); + const root = path.resolve(common.root); + const config = await loadConfig(root); + if (refuseDisabledModule(io, common.json, config, "orchestration")) return; + const taskId = await inferGuidedTaskId(root, options.task); + const record = await readRunRecord(root, taskId); + if (record.status !== "review-pending" || !record.reviewerPackage) { + throw new Error( + `Task ${taskId} has no pending review package. Run ${cliCommand(`finish --task ${taskId}`)} first.`, + ); + } + emit( + io, + common.json, + record.reviewerPackage, + renderReviewHandoff(record, renderOptions(io, common)), + ); + }); + program .command("context") .description("produce bounded context for a task without invoking an agent") @@ -1095,8 +1121,7 @@ export function createProgram(customIo?: Partial): Command { reason: "No deterministic documentation signal was produced.", }, learning: { - status: - learning.proposals.length > 0 ? ("proposed" as const) : ("no-candidate" as const), + status: learning.status, proposals: learning.proposals.length, }, }; @@ -1105,12 +1130,7 @@ export function createProgram(customIo?: Partial): Command { io, common.json, { record: finished, recordPath, completion, learning, retention }, - renderGuidedFinish( - finished, - learning.proposals.length, - recordPath, - renderOptions(io, common), - ), + renderGuidedFinish(finished, learning, recordPath, renderOptions(io, common)), ); if (controller.signal.aborted) process.exitCode = EXIT.interrupted; else if (finished.status === "incomplete" || finished.status === "failed") diff --git a/src/core/context.ts b/src/core/context.ts index 1e1cc74..f4a8983 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -27,6 +27,7 @@ const STOP_WORDS = new Set([ "do", "existing", "fix", + "first", "for", "from", "has", @@ -43,6 +44,7 @@ const STOP_WORDS = new Set([ "pattern", "patterns", "reuse", + "run", "safe", "safety", "that", @@ -81,6 +83,7 @@ const TEST_PATH = const DOCUMENT_PATH = /(?:^|\/)(?:docs?|adr|adrs)(?:\/|$)|\.(?:md|mdx)$/; const NON_AUTHORITATIVE_PATH = /(?:^|\/)(?:expected|fixtures?|golden|snapshots?|examples?|generated|vendor|cassettes?|recordings?|testdata|canary|payloads?)(?:\/|$)|[.-]min\.(?:js|css)$/i; +const ACCEPTANCE_HARNESS_PATH = /(?:^|\/)tests?\/acceptance(?:\/|$)/i; type Category = "entrypoint" | "manifest" | "source" | "test" | "document" | "other"; @@ -468,6 +471,9 @@ export async function buildContext(task: string, root = process.cwd()): Promise< "testdata", ].includes(term), ); + const acceptanceHarnessRequested = taskTerms.some((term) => + ["acceptance", "adoption", "corpus", "harness", "release", "workflow"].includes(term), + ); const activeRoutes = (routes?.routes ?? []).filter((route) => route.match.some((pattern) => routeMatches(pattern, taskTerms)), ); @@ -510,6 +516,10 @@ export async function buildContext(task: string, root = process.cwd()): Promise< } const candidate = baseScore(file, rankingTerms, activeRouteIds); candidate.bytes = profile.fileSizes[file] ?? 0; + if (ACCEPTANCE_HARNESS_PATH.test(file) && !acceptanceHarnessRequested) { + candidate.score -= 60; + candidate.reasons.push("acceptance-harness penalty outside an acceptance task"); + } if (adoption.referencedPaths.includes(file)) { candidate.score += 26; candidate.reasons.push("explicitly referenced by repository instructions"); @@ -579,9 +589,9 @@ export async function buildContext(task: string, root = process.cwd()): Promise< const priority = [ topOwner, ...topPathOwners, + topTest, topProcedure, ...candidates.filter((item) => isAlwaysContext(item.file)), - topTest, topDocument, ] .filter((item): item is RankedCandidate => item !== undefined) diff --git a/src/core/proposals.ts b/src/core/proposals.ts index f6e06d1..65112ec 100644 --- a/src/core/proposals.ts +++ b/src/core/proposals.ts @@ -27,6 +27,8 @@ If start fails, stop before editing and report the error. If finish fails, do no For unfinished work, use \`${cliCommand("status")}\` before opening raw task records. Even when status lists an active task, repeat start with that task's text before resuming edits: status is read-only and does not check write access. Keep routine output brief; use \`--verbose\` or \`--json\` when supporting detail is needed. +If finish reports \`review-pending\`, run \`${cliCommand("review --task ")}\` to expose the prepared package. Give its \`--json\` output to a fresh reviewer, save the strict response under \`.noxroot/local/\`, and pass that file back to finish. + When \`.noxroot/skills/\` exists, load only the task-relevant \`SKILL.md\`: verification for changed-code checks, independent review for fresh review, and product/UX review only for applicable user-facing work.` : mode === "companion" ? `The existing repository coordinator remains authoritative for code-changing work. Noxroot does not add a second task lifecycle, reviewer, or learning loop. diff --git a/src/knowledge/learn.ts b/src/knowledge/learn.ts index 015357a..d99dcd5 100644 --- a/src/knowledge/learn.ts +++ b/src/knowledge/learn.ts @@ -25,6 +25,7 @@ export interface LearningProposal { export interface LearnResult { taskId: string; + status: "not-assessed" | "no-candidate" | "proposed"; proposals: LearningProposal[]; rejected: Array<{ reason: string; destination: string }>; message?: string; @@ -217,6 +218,7 @@ export async function proposeLearnings(root: string, run: RunRecord): Promise 0 ? "proposed" : "no-candidate", proposals, rejected, ...(proposals.length === 0 ? { message: "No durable learning identified" } : {}), diff --git a/src/orchestration/guided.ts b/src/orchestration/guided.ts index 2426ccd..9530c54 100644 --- a/src/orchestration/guided.ts +++ b/src/orchestration/guided.ts @@ -201,7 +201,9 @@ function guidedHandoff( "LEARNING", record.learningCandidates?.length ? `${record.learningCandidates.length} documentation candidate${record.learningCandidates.length === 1 ? "" : "s"} identified by review; duplication and ownership still need checking.` - : "No reusable project-knowledge candidate identified.", + : record.status === "approved" + ? "The approved review identified no reusable project-knowledge candidate." + : "Not assessed; reusable learning requires an approved review of the unchanged diff.", "", "NEXT", record.status === "review-pending" diff --git a/src/output.ts b/src/output.ts index ca70888..ea10657 100644 --- a/src/output.ts +++ b/src/output.ts @@ -5,18 +5,19 @@ import type { VerificationResult, } from "./model.js"; import type { LearnResult } from "./knowledge/learn.js"; +import type { ApplyResult } from "./core/init.js"; import { cliCommand, VERSION } from "./invocation.js"; import type { GuidedRunRecord } from "./orchestration/guided.js"; import { failureDetail, TIMEOUT_NEXT } from "./verification/diagnostics.js"; export function renderGuidedFinish( record: GuidedRunRecord, - proposals: number, + learning: LearnResult, recordPath: string, options: RenderOptions, ): string { if (options.verbose) - return `${record.handoff}\n\nDocumentation: not assessed automatically.\nLearning: ${proposals} reusable proposal(s).\nLocal record: ${recordPath}\n`; + return `${record.handoff}\n\nDocumentation: not assessed automatically.\nLearning: ${learning.status}${learning.proposals.length ? `; ${learning.proposals.length} reusable proposal(s)` : ""}.\nLocal record: ${recordPath}\n`; const checks = record.verification.at(-1) ?? []; // Calls are historical. Only review-result states without a verification gap // can use the latest call as the current completion attempt's decision. @@ -33,7 +34,7 @@ export function renderGuidedFinish( if (record.status === "incomplete" && checks.some((check) => check.status === "unavailable")) next = `Make the approved check runnable, then rerun ${cliCommand("finish")}.`; if (record.status === "review-pending") - next = `Provide a fresh review with ${cliCommand("finish --review-file ")}.`; + next = `Prepare the fresh-review handoff with ${cliCommand(`review --task ${record.id}`)}.`; if (record.status === "completed" || record.status === "approved") next = "Review the change before committing."; return [ @@ -47,7 +48,13 @@ export function renderGuidedFinish( ...record.verificationGaps.map((gap) => `Gap ${gap}`), `Review ${reviewResult ? `${reviewResult.review?.decision ?? reviewResult.reviewDecision ?? reviewResult.status}: ${reviewResult.summary}` : record.reviewAssessment?.required ? `Pending ${record.reviewAssessment.kinds.join("/")} review` : "Not required for this change"}`, "Docs Not assessed automatically", - `Learning ${proposals ? `${proposals} proposal(s); inspect with ${cliCommand(`learn --task ${record.id}`)}` : "No reusable update proposed"}`, + `Learning ${ + learning.status === "proposed" + ? `${learning.proposals.length} proposal(s); inspect with ${cliCommand(`learn --task ${record.id}`)}` + : learning.status === "not-assessed" + ? "Not assessed; an independent review is required" + : "Assessed; no reusable update identified" + }`, `Next ${next}`, `Evidence ${recordPath}`, "", @@ -152,6 +159,85 @@ export function renderInitMark(options: RenderOptions = {}): string { return `${style("NOXROOT", ANSI.violet, options)} ${style("◆", ANSI.blue, options)} ${style("setup", ANSI.dim, options)}\n\n`; } +export function renderInitApplied(result: ApplyResult, options: RenderOptions = {}): string { + const lines = [ + title("setup ready", options), + "", + ...section( + "Changed", + [ + ...(result.created.length + ? [`Created ${result.created.length} file${result.created.length === 1 ? "" : "s"}`] + : []), + ...(result.patched.length + ? [ + `Updated ${result.patched.length} existing file${result.patched.length === 1 ? "" : "s"}`, + ] + : []), + ], + options, + ANSI.green, + ), + ...section( + "Reused", + result.referenced.length + ? [`${result.referenced.length} existing file${result.referenced.length === 1 ? "" : "s"}`] + : [], + options, + ANSI.blue, + ), + ]; + if (options.verbose) { + lines.push( + ...section( + "Files", + [ + ...result.created.map((file) => `created ${file}`), + ...result.patched.map((file) => `updated ${file}`), + ...result.referenced.map((file) => `reused ${file}`), + ], + options, + ), + ); + } + lines.push( + ...section( + "Next", + [ + "Review and commit this setup locally; no push is required.", + "Then give your coding agent a real code-changing task.", + ], + options, + ANSI.blue, + ), + ); + return `${lines.join("\n").trimEnd()}\n`; +} + +export function renderReviewHandoff(record: GuidedRunRecord, options: RenderOptions = {}): string { + const checks = record.verification.at(-1) ?? []; + return `${[ + title("review handoff", options), + "", + `Task ${record.id}`, + `Change ${record.changeIdentity?.changeId ?? "not established"}`, + `Review ${record.reviewAssessment?.kinds.join("/") || "independent"}`, + ...(record.reviewAssessment?.reasons ?? []).map((reason) => `Why ${reason}`), + `Files ${record.changedPaths?.length ?? 0}`, + `Checks ${checks.filter((check) => check.status === "passed").length}/${checks.length} passed`, + "", + "Reviewer input", + ` ${cliCommand(`review --task ${record.id} --json`)}`, + "", + "Reviewer response", + " Save the strict JSON response as an untracked file under .noxroot/local/.", + "", + "Continue", + ` ${cliCommand(`finish --task ${record.id} --review-file .noxroot/local/review.json`)}`, + "", + ].join("\n")}`; +} + export function renderPreview( result: PreviewResult, options: RenderOptions & { diff?: boolean; next?: string } = {}, @@ -574,14 +660,23 @@ export function renderVerification( export function renderLearning(result: LearnResult, options: RenderOptions = {}): string { const lines = [title("learning", options), ""]; - if (result.proposals.length === 0) { + if (result.status === "not-assessed") { + lines.push( + ...section( + "Not assessed", + ["An approved independent review of the current unchanged diff is required."], + options, + ANSI.yellow, + ), + ); + } else if (result.proposals.length === 0) { lines.push( ...section( "No update", [ result.rejected.length ? "No learning candidate was safe to propose." - : "No reusable project knowledge was identified.", + : "The approved review identified no reusable project knowledge.", ], options, ANSI.green, @@ -614,7 +709,12 @@ export function renderLearning(result: LearnResult, options: RenderOptions = {}) "Next", result.proposals.length ? [cliCommand(`learn --task ${result.taskId} --apply`)] - : ["Project memory was not changed."], + : result.status === "not-assessed" + ? [ + `Run ${cliCommand(`finish --task ${result.taskId}`)} for the current diff and complete any required review.`, + "Project memory was not changed.", + ] + : ["Project memory was not changed."], options, ANSI.blue, ), diff --git a/tests/autonomy-guided.test.ts b/tests/autonomy-guided.test.ts index bb31e96..4e38a86 100644 --- a/tests/autonomy-guided.test.ts +++ b/tests/autonomy-guided.test.ts @@ -195,7 +195,7 @@ commands: expect(pendingValue.record.status).toBe("completed"); expect(pendingValue.record.calls).toEqual([]); expect(pendingValue.completion.documentation.status).toBe("not-assessed"); - expect(pendingValue.completion.learning.status).toBe("no-candidate"); + expect(pendingValue.completion.learning.status).toBe("not-assessed"); const reviewPath = path.join(root, ".noxroot", "local", "external-review.json"); await writeFile( @@ -441,10 +441,23 @@ commands: await cli(["start", "review the panel accessibility", "--json", "--root", root]); await writeFile(path.join(root, "web", "components", "panel.tsx"), "export const Panel = 2;\n"); const finished = JSON.parse((await cli(["finish", "--json", "--root", root])).stdout) as { - record: { status: string }; + record: { id: string; status: string }; }; expect(finished.record.status).toBe("review-pending"); + const reviewHandoff = await cli(["review", "--task", finished.record.id, "--root", root]); + expect(reviewHandoff.stdout).toContain("NOXROOT review handoff"); + expect(reviewHandoff.stdout).toContain(`review --task ${finished.record.id} --json`); + expect(reviewHandoff.stdout).toContain("Save the strict JSON response"); + const reviewPackage = JSON.parse( + (await cli(["review", "--task", finished.record.id, "--json", "--root", root])).stdout, + ) as { schemaVersion: number; taskId: string; changedPaths: string[] }; + expect(reviewPackage).toMatchObject({ + schemaVersion: 2, + taskId: finished.record.id, + changedPaths: ["web/components/panel.tsx"], + }); + const current = JSON.parse( (await cli(["start", "review the panel accessibility", "--json", "--root", root])).stdout, ) as { @@ -934,7 +947,12 @@ agents: {default: manual, adapters: {manual: {type: manual}}} const checks = finished.verification.at(-1)!; for (const output of [ finished.handoff, - renderGuidedFinish(finished, 0, "record.json", {}), + renderGuidedFinish( + finished, + { taskId: finished.id, status: "not-assessed", proposals: [], rejected: [] }, + "record.json", + {}, + ), renderVerification(checks, {}), ]) { expect(output).toContain("limit 1000ms"); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 5a94af4..09c1fc7 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -62,6 +62,11 @@ describe("CLI contracts", () => { isTTY: true, }); expect(initialized.stdout).toContain("NOXROOT ◆ setup"); + expect(initialized.stdout).toContain("NOXROOT setup ready"); + expect(initialized.stdout).toContain("Changed\n Created"); + expect(initialized.stdout).toContain( + "Review and commit this setup locally; no push is required.", + ); const preview = await run(["preview", "--no-color", "--root", root], { isTTY: true }); expect(preview.stdout).not.toContain("◆"); @@ -110,6 +115,7 @@ describe("CLI contracts", () => { "init", "sync", "doctor", + "review", "context", "verify", "start", @@ -322,13 +328,15 @@ describe("CLI contracts", () => { const human = await run(["learn", "--task", "completed-task", "--root", root]); expect(human.stdout).toContain("NOXROOT learning"); - expect(human.stdout).toContain("No reusable project knowledge was identified."); + expect(human.stdout).toContain("Not assessed"); + expect(human.stdout).toContain("An approved independent review"); expect(human.stdout).toContain("Project memory was not changed."); expect(human.stdout.trimStart()).not.toMatch(/^\{/); const machine = await run(["learn", "--task", "completed-task", "--json", "--root", root]); expect(JSON.parse(machine.stdout)).toMatchObject({ taskId: "completed-task", + status: "not-assessed", proposals: [], message: "Learning requires an approved review of the current unchanged diff", }); diff --git a/tests/context-routing.test.ts b/tests/context-routing.test.ts index 90c1b9a..ddfab80 100644 --- a/tests/context-routing.test.ts +++ b/tests/context-routing.test.ts @@ -22,6 +22,19 @@ describe("bounded relevance routing", () => { ).toContain("content contains task terms “review”, “decision”"); }); + it("keeps routine setup work on implementation and focused tests instead of acceptance history", async () => { + const context = await buildContext( + "Improve the first-run setup summary and readiness guidance", + path.resolve("."), + ); + const selected = context.selected.map((item) => item.path); + + expect(selected).toContain("src/output.ts"); + expect(selected).toContain("src/cli.ts"); + expect(context.likelyTests).toContain("tests/cli.test.ts"); + expect(selected.some((item) => item.startsWith("tests/acceptance/"))).toBe(false); + }); + it("treats route includes as eligibility without blanket relevance", async () => { const context = await buildContext("document package release", path.resolve(".")); const unrelated = context.selected.filter( diff --git a/tests/documentation.test.ts b/tests/documentation.test.ts index f35aa4b..fd6e793 100644 --- a/tests/documentation.test.ts +++ b/tests/documentation.test.ts @@ -20,7 +20,7 @@ describe("documentation examples", () => { expect(readme).toContain( "A CLI for project memory, focused task briefs, approved checks, and reusable documentation.", ); - expect(readme).toContain("Then keep talking to your coding agent normally."); + expect(readme).toContain("Then keep talking to your coding agent normally;"); expect(readme).toContain("actions/workflows/ci.yml/badge.svg?branch=main"); expect(readme).toContain("license-Apache--2.0-blue.svg"); const output = renderPreview(await previewRepository(path.join(fixtures, "typescript"))); diff --git a/tests/init-context-doctor.test.ts b/tests/init-context-doctor.test.ts index 310506e..371aa0e 100644 --- a/tests/init-context-doctor.test.ts +++ b/tests/init-context-doctor.test.ts @@ -120,6 +120,8 @@ describe("initialization, sync safety, context, and doctor", () => { "Even when status lists an active task, repeat start with that task's text before resuming edits", ); expect(agents).toContain("status is read-only and does not check write access"); + expect(agents).toContain(`noxroot@${VERSION} review --task `); + expect(agents).toContain("Give its `--json` output to a fresh reviewer"); expect(agents).toContain(".noxroot/knowledge/INDEX.md"); expect( await readFile(path.join(fixture.root, ".noxroot", "knowledge", "INDEX.md"), "utf8"), diff --git a/tests/output-contract.test.ts b/tests/output-contract.test.ts index caff16a..a60216b 100644 --- a/tests/output-contract.test.ts +++ b/tests/output-contract.test.ts @@ -1,7 +1,13 @@ import { expect, it } from "vitest"; import path from "node:path"; import { buildContext } from "../src/core/context.js"; -import { renderContext, renderGuidedFinish } from "../src/output.js"; +import { + renderContext, + renderGuidedFinish, + renderInitApplied, + renderLearning, +} from "../src/output.js"; +import type { LearnResult } from "../src/knowledge/learn.js"; import type { GuidedRunRecord } from "../src/orchestration/guided.js"; it.each([false, true])("uses plain task-brief labels (verbose: %s)", async (verbose) => { @@ -48,10 +54,51 @@ function record(status: GuidedRunRecord["status"]): GuidedRunRecord { } as unknown as GuidedRunRecord; } +function learning(proposals = 0): LearnResult { + return { + taskId: "one", + status: proposals ? "proposed" : "not-assessed", + proposals: Array.from({ length: proposals }, () => ({}) as never), + rejected: [], + }; +} + +it("distinguishes unassessed learning from an assessed empty result", () => { + const unassessed = renderLearning(learning(), {}); + const assessed = renderLearning({ ...learning(), status: "no-candidate" }, {}); + + expect(unassessed).toContain("Not assessed"); + expect(unassessed).toContain("approved independent review"); + expect(assessed).toContain("No update"); + expect(assessed).toContain("approved review identified no reusable project knowledge"); + expect(assessed).not.toContain("Not assessed"); +}); + +it("summarizes setup writes and reuse without dumping paths by default", () => { + const result = { + created: [".noxroot/config.yml", ".noxroot/knowledge/INDEX.md"], + patched: ["AGENTS.md"], + referenced: ["README.md"], + }; + const concise = renderInitApplied(result, {}); + const verbose = renderInitApplied(result, { verbose: true }); + + expect(concise).toContain("Created 2 files"); + expect(concise).toContain("Updated 1 existing file"); + expect(concise).toContain("1 existing file"); + expect(concise).not.toContain(".noxroot/config.yml"); + expect(verbose).toContain("created .noxroot/config.yml"); + expect(verbose).toContain("updated AGENTS.md"); + expect(verbose).toContain("reused README.md"); +}); + it.each([80, 120])("keeps a routine finish short at %i columns without losing status", (width) => { - const plain = renderGuidedFinish(record("completed"), 0, ".noxroot/local/runs/one.json", { - width, - }); + const plain = renderGuidedFinish( + record("completed"), + learning(), + ".noxroot/local/runs/one.json", + { width }, + ); expect(plain.trim().split("\n").length).toBeLessThanOrEqual(12); expect(plain).toContain("task completed"); expect(plain).toContain("Changed 1 file\n"); @@ -62,7 +109,7 @@ it.each([80, 120])("keeps a routine finish short at %i columns without losing st }); it("retains failure evidence and retry instructions in default output", () => { - const output = renderGuidedFinish(record("failed"), 0, "record.json", {}); + const output = renderGuidedFinish(record("failed"), learning(), "record.json", {}); expect(output).toContain("task failed"); expect(output).toContain("expected 2, received 1"); expect(output).toContain("Fix the failing check"); @@ -70,15 +117,17 @@ it("retains failure evidence and retry instructions in default output", () => { }); it("does not turn passing checks into review approval", () => { - const output = renderGuidedFinish(record("review-pending"), 0, "record.json", {}); + const output = renderGuidedFinish(record("review-pending"), learning(), "record.json", {}); expect(output).toContain("task review-pending"); expect(output).toContain("Pending ux review"); - expect(output).toContain("--review-file"); + expect(output).toContain("review --task one"); expect(output).not.toContain("task completed"); }); it("keeps detailed handoff evidence available and adds no repeated banner", () => { - const output = renderGuidedFinish(record("completed"), 1, "record.json", { verbose: true }); + const output = renderGuidedFinish(record("completed"), learning(1), "record.json", { + verbose: true, + }); expect(output).toContain("Full handoff evidence"); expect(output).toContain("Local record: record.json"); expect(output).not.toContain("█"); @@ -99,7 +148,7 @@ it("shows why an invalid reviewer response blocked completion", () => { }, }, ]; - const output = renderGuidedFinish(blocked, 0, "record.json", {}); + const output = renderGuidedFinish(blocked, learning(), "record.json", {}); expect(output).toContain("Review blocked: Review response was not schema-valid JSON."); expect(output).not.toContain("Not required"); }); @@ -121,7 +170,7 @@ it.each(["review-pending", "failed", "incomplete", "completed"] as const)( }, }, ]; - const output = renderGuidedFinish(current, 0, "record.json", {}); + const output = renderGuidedFinish(current, learning(), "record.json", {}); expect(output).not.toContain("Approved previous diff"); expect(output).not.toContain("Review approved"); if (status === "review-pending") expect(output).toContain("Pending ux review"); @@ -144,7 +193,9 @@ it("does not show an old blocked review after a new verification gap", () => { }, }, ]; - expect(renderGuidedFinish(current, 0, "record.json", {})).not.toContain("Old invalid review"); + expect(renderGuidedFinish(current, learning(), "record.json", {})).not.toContain( + "Old invalid review", + ); }); it.each(["approved", "changes-requested", "blocked"] as const)( @@ -164,12 +215,12 @@ it.each(["approved", "changes-requested", "blocked"] as const)( }, }, ]; - expect(renderGuidedFinish(current, 0, "record.json", {})).toContain( + expect(renderGuidedFinish(current, learning(), "record.json", {})).toContain( `Review ${decision}: Current review result`, ); current.status = "review-pending"; current.reviewAssessment!.required = true; - const pending = renderGuidedFinish(current, 0, "record.json", {}); + const pending = renderGuidedFinish(current, learning(), "record.json", {}); expect(pending).toContain("Pending ux review"); expect(pending).not.toContain("Current review result"); },