From 4c9852f964444808da59b07f2db500088e90f628 Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 07:41:07 -0400 Subject: [PATCH 01/24] fix: keep new task records inside the writable worktree --- src/adapters/vcs.ts | 4 +- src/cli.ts | 11 ++++ src/core/proposals.ts | 2 + src/detection/scan.ts | 2 +- src/state/local.ts | 116 +++++++++++++++++++++++++++------ tests/autonomy-guided.test.ts | 10 +-- tests/state-location.test.ts | 118 ++++++++++++++++++++++++++++++++++ 7 files changed, 235 insertions(+), 28 deletions(-) create mode 100644 tests/state-location.test.ts diff --git a/src/adapters/vcs.ts b/src/adapters/vcs.ts index f166009..5c84405 100644 --- a/src/adapters/vcs.ts +++ b/src/adapters/vcs.ts @@ -1,7 +1,7 @@ import { lstat, mkdir, readFile, realpath } from "node:fs/promises"; import path from "node:path"; import { runProcess } from "./process.js"; -import { localStateRoot } from "../state/local.js"; +import { prepareStateRoot } from "../state/local.js"; import { isSuspectedSecret, resolveWithin } from "../security/paths.js"; export interface IsolatedWorktree { @@ -166,7 +166,7 @@ export async function prepareIsolatedWorktree( } const status = await git(root, ["status", "--porcelain=v1"]); if (status.exitCode !== 0) throw new Error("Git status could not be inspected safely."); - const stateRoot = await localStateRoot(root); + const stateRoot = await prepareStateRoot(root); const worktreesRoot = path.join(stateRoot, "worktrees"); await mkdir(worktreesRoot, { recursive: true }); const worktreePath = path.join(worktreesRoot, id); diff --git a/src/cli.ts b/src/cli.ts index 19521f7..cb7a063 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -46,6 +46,8 @@ import { type RenderOptions, } from "./output.js"; import { + assertTaskStateWritable, + TaskStateError, enforceRunRetention, listRunRecords, localStateRoot, @@ -733,6 +735,7 @@ export function createProgram(customIo?: Partial): Command { if (refuseDisabledModule(io, common.json, config, "orchestration")) return; const continuation = await findGuidedContinuation(root, task); if (continuation) { + await assertTaskStateWritable(root); const continuationState = await inspectGuidedContinuation( root, continuation, @@ -1016,6 +1019,7 @@ export function createProgram(customIo?: Partial): Command { if (refuseDisabledModule(io, common.json, config, "orchestration")) return; const taskId = await inferGuidedTaskId(root, options.task); const record = await readRunRecord(root, taskId); + await assertTaskStateWritable(root); const autonomy = effectiveAutonomy(config); const adapter = configuredAgent(config); const controller = new AbortController(); @@ -1121,6 +1125,13 @@ export async function main(argv = process.argv): Promise { return; } const message = error instanceof Error ? error.message : String(error); + if (error instanceof TaskStateError) { + if (argv.includes("--json")) + process.stdout.write(`${JSON.stringify({ error: "task-state-unavailable", message })}\n`); + else process.stderr.write(`NOXROOT task incomplete\n${message}\n`); + process.exitCode = EXIT.refused; + return; + } process.stderr.write( `Noxroot could not complete the request: ${message}\nWhy it matters: the requested operation stopped before unsafe assumptions were made.\nNext: correct the reported input or run noxroot doctor.\n`, ); diff --git a/src/core/proposals.ts b/src/core/proposals.ts index a9237f9..ebe0e18 100644 --- a/src/core/proposals.ts +++ b/src/core/proposals.ts @@ -23,6 +23,8 @@ function managedBlock(mode: WorkflowMode, hasKnowledgeIndex = true): string { mode === "full" ? `For a code-changing task, run \`${cliCommand('start ""')}\` before editing and \`${cliCommand("finish")}\` when the change is ready to check. A repeated start for the same active task continues its existing baseline. Do not start a task for questions, explanations, reviews, or other read-only work. +If start fails, stop before editing and report the error. If finish fails, do not report the task complete. Request only the access needed to retry; do not disable the sandbox or create a second task-state store. + 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/detection/scan.ts b/src/detection/scan.ts index 96aab45..928b7db 100644 --- a/src/detection/scan.ts +++ b/src/detection/scan.ts @@ -834,7 +834,7 @@ export async function scanRepository( continue; } if (entry.isDirectory()) { - if (EXCLUDED_DIRECTORIES.has(entry.name)) continue; + if (EXCLUDED_DIRECTORIES.has(entry.name) || relative === ".noxroot/local") continue; if (current.depth + 1 > limits.maxDepth) { incompleteReasons.push(`depth limit reached at ${relative}`); continue; diff --git a/src/state/local.ts b/src/state/local.ts index ae04231..a9e2aaf 100644 --- a/src/state/local.ts +++ b/src/state/local.ts @@ -1,7 +1,10 @@ -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { homedir } from "node:os"; import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; +import { setupDestination } from "../security/paths.js"; + +export class TaskStateError extends Error {} async function pathType(candidate: string): Promise<"file" | "directory" | undefined> { try { @@ -13,7 +16,7 @@ async function pathType(candidate: string): Promise<"file" | "directory" | undef } } -export async function localStateRoot(root: string): Promise { +async function legacyStateRoot(root: string): Promise { const gitMarker = path.join(root, ".git"); const type = await pathType(gitMarker); if (type === "directory") return path.join(gitMarker, "noxroot"); @@ -38,17 +41,82 @@ export async function localStateRoot(root: string): Promise { return path.join(appData, "noxroot", "repositories", digest); } +export async function localStateRoot(root: string): Promise { + const legacy = await legacyStateRoot(root); + if (!(await pathType(path.join(root, ".git")))) return legacy; + const local = await setupDestination(root, ".noxroot/local"); + if (await pathType(legacy)) { + if (await pathType(local)) { + throw new TaskStateError( + "Two task-state directories exist. Stop and reconcile the existing records before retrying; neither directory was changed.", + ); + } + return legacy; + } + return local; +} + +function stateError(error: unknown, directory: string): never { + const code = (error as NodeJS.ErrnoException).code; + if (["EACCES", "EPERM", "EROFS"].includes(code ?? "")) { + throw new TaskStateError( + `Task state is not writable: ${directory}\nNext: request write access to this directory and retry. Do not edit after a failed start or report completion after a failed finish. Existing records have not been moved.`, + ); + } + throw error; +} + +export async function prepareStateRoot(root: string): Promise { + const directory = await localStateRoot(root); + try { + await mkdir(directory, { recursive: true }); + if (directory === path.join(root, ".noxroot", "local")) { + const ignore = await setupDestination(root, ".noxroot/local/.gitignore"); + try { + await writeFile(ignore, "*\n", { flag: "wx", mode: 0o600 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + if ((await readFile(ignore, "utf8")) !== "*\n") { + throw new TaskStateError( + "Local task-state ignore policy differs from the managed '*' rule. Restore that rule before recording tasks; the existing file was not changed.", + ); + } + } + } + return directory; + } catch (error) { + stateError(error, directory); + } +} + +export async function assertTaskStateWritable(root: string): Promise { + const directory = await prepareStateRoot(root); + const probe = await setupDestination(directory, `runs/.write-check-${randomUUID()}`); + try { + await mkdir(path.dirname(probe), { recursive: true }); + await writeFile(probe, "", { flag: "wx", mode: 0o600 }); + await unlink(probe); + } catch (error) { + stateError(error, directory); + } +} + export async function writeRunRecord(root: string, id: string, value: unknown): Promise { - const stateRoot = await localStateRoot(root); + if (!/^[a-z0-9-]+$/i.test(id)) throw new Error("Task id contains unsupported characters."); + const stateRoot = await prepareStateRoot(root); const directory = path.join(stateRoot, "runs"); - await mkdir(directory, { recursive: true }); - const target = path.join(directory, `${id}.json`); - await writeFile(target, `${JSON.stringify(value, null, 2)}\n`, { - encoding: "utf8", - flag: "wx", - mode: 0o600, - }); - return target; + const target = await setupDestination(stateRoot, `runs/${id}.json`); + try { + await mkdir(directory, { recursive: true }); + await writeFile(target, `${JSON.stringify(value, null, 2)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + return target; + } catch (error) { + stateError(error, directory); + } } export async function readRunRecord(root: string, id: string): Promise { @@ -60,16 +128,24 @@ export async function readRunRecord(root: string, id: string): Promise { export async function replaceRunRecord(root: string, id: string, value: unknown): Promise { if (!/^[a-z0-9-]+$/i.test(id)) throw new Error("Task id contains unsupported characters."); const stateRoot = await localStateRoot(root); - const target = path.join(stateRoot, "runs", `${id}.json`); + const target = await setupDestination(stateRoot, `runs/${id}.json`); await stat(target); - const temporary = `${target}.tmp-${process.pid}`; - await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { - encoding: "utf8", - flag: "wx", - mode: 0o600, - }); - await rename(temporary, target); - return target; + const temporary = `${target}.tmp-${randomUUID()}`; + try { + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + await rename(temporary, target); + return target; + } catch (error) { + stateError(error, stateRoot); + } finally { + await unlink(temporary).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + } } export async function listRunRecords(root: string): Promise { diff --git a/tests/autonomy-guided.test.ts b/tests/autonomy-guided.test.ts index 12b3965..cad3ee6 100644 --- a/tests/autonomy-guided.test.ts +++ b/tests/autonomy-guided.test.ts @@ -135,7 +135,7 @@ commands: expect(pendingValue.completion.documentation.status).toBe("not-assessed"); expect(pendingValue.completion.learning.status).toBe("no-candidate"); - const reviewPath = path.join(root, ".git", "noxroot", "external-review.json"); + const reviewPath = path.join(root, ".noxroot", "local", "external-review.json"); await writeFile( reviewPath, JSON.stringify({ @@ -159,7 +159,7 @@ commands: "--task", startValue.record.id, "--review-file", - ".git/noxroot/external-review.json", + ".noxroot/local/external-review.json", "--json", "--root", root, @@ -197,7 +197,7 @@ commands: (await cli(["context", "change another value under src", "--json", "--root", root])).stdout, ) as { selected: Array<{ path: string }> }; expect(later.selected.map((item) => item.path)).toContain(".noxroot/knowledge/learnings.md"); - expect(later.selected.some((item) => item.path.includes(".git/noxroot/runs"))).toBe(false); + expect(later.selected.some((item) => item.path.includes(".noxroot/local/"))).toBe(false); }); it("requires an explicit id when multiple guided tasks are active", async () => { @@ -268,7 +268,7 @@ agents: {default: manual, adapters: {manual: {type: manual}}} ); const records = await ( await import("node:fs/promises") - ).readdir(path.join(root, ".git", "noxroot", "runs")); + ).readdir(path.join(root, ".noxroot", "local", "runs")); expect(records.filter((name) => name.endsWith(".json"))).toHaveLength(1); }); @@ -423,7 +423,7 @@ agents: {default: manual, adapters: {manual: {type: manual}}} const started = JSON.parse( (await cli(["start", "change value", "--json", "--root", root])).stdout, ) as { record: { id: string } }; - const recordPath = path.join(root, ".git", "noxroot", "runs", `${started.record.id}.json`); + const recordPath = path.join(root, ".noxroot", "local", "runs", `${started.record.id}.json`); const persisted = JSON.parse(await readFile(recordPath, "utf8")) as { baseline: { revision: string }; }; diff --git a/tests/state-location.test.ts b/tests/state-location.test.ts new file mode 100644 index 0000000..6a92bc7 --- /dev/null +++ b/tests/state-location.test.ts @@ -0,0 +1,118 @@ +import { chmod, mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { + assertTaskStateWritable, + localStateRoot, + readRunRecord, + replaceRunRecord, + writeRunRecord, +} from "../src/state/local.js"; +import { scanRepository } from "../src/detection/scan.js"; +import { temporaryDirectory } from "./helpers.js"; + +const cleanup: string[] = []; +afterEach(async () => { + await Promise.all(cleanup.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); +async function repository() { + const root = await temporaryDirectory("noxroot-state-location-"); + cleanup.push(root); + await mkdir(path.join(root, ".git")); + return root; +} + +it("selects workspace-local state without writing during inspection", async () => { + const root = await repository(); + expect(await localStateRoot(root)).toBe(path.join(root, ".noxroot", "local")); + expect(await readdir(root)).toEqual([".git"]); + await writeRunRecord(root, "one", { status: "running" }); + await replaceRunRecord(root, "one", { status: "completed" }); + expect(await readRunRecord(root, "one")).toEqual({ status: "completed" }); + expect(await readFile(path.join(root, ".noxroot/local/.gitignore"), "utf8")).toBe("*\n"); + expect(await readdir(path.join(root, ".git"))).toEqual([]); + const profile = await scanRepository(root); + expect(profile.files.some((file) => file.startsWith(".noxroot/local/"))).toBe(false); +}); + +it("preserves legacy storage and refuses a second store", async () => { + const root = await repository(); + const legacy = path.join(root, ".git/noxroot"); + await mkdir(legacy); + await writeRunRecord(root, "existing", { status: "running" }); + expect(await localStateRoot(root)).toBe(legacy); + await mkdir(path.join(root, ".noxroot/local"), { recursive: true }); + await expect(localStateRoot(root)).rejects.toThrow("Two task-state directories"); + expect(await readFile(path.join(legacy, "runs/existing.json"), "utf8")).toContain("running"); +}); + +it("rejects linked local state without modifying the target", async () => { + const root = await repository(); + const outside = await temporaryDirectory("noxroot-state-outside-"); + cleanup.push(outside); + await mkdir(path.join(root, ".noxroot")); + await symlink( + outside, + path.join(root, ".noxroot/local"), + process.platform === "win32" ? "junction" : "dir", + ); + await expect(writeRunRecord(root, "one", {})).rejects.toThrow(/symbolic link/); + expect(await readdir(outside)).toEqual([]); +}); + +it("does not overwrite an existing local ignore policy", async () => { + const root = await repository(); + await mkdir(path.join(root, ".noxroot/local"), { recursive: true }); + await writeFile(path.join(root, ".noxroot/local/.gitignore"), "!runs/\n"); + await expect(writeRunRecord(root, "one", {})).rejects.toThrow(/ignore/); + expect(await readFile(path.join(root, ".noxroot/local/.gitignore"), "utf8")).toBe("!runs/\n"); +}); + +it("isolates new worktree state but still discovers shared legacy records", async () => { + const root = await repository(); + const worktree = await temporaryDirectory("noxroot-state-worktree-"); + cleanup.push(worktree); + const metadata = path.join(root, ".git/worktrees/second"); + await mkdir(metadata, { recursive: true }); + await writeFile(path.join(metadata, "commondir"), "../..\n"); + await writeFile(path.join(worktree, ".git"), `gitdir: ${metadata}\n`); + expect(await localStateRoot(worktree)).toBe(path.join(worktree, ".noxroot/local")); + await mkdir(path.join(root, ".git/noxroot")); + await writeRunRecord(root, "existing", { status: "running" }); + expect(await readRunRecord(worktree, "existing")).toEqual({ status: "running" }); +}); + +it.skipIf(process.platform === "win32")( + "keeps Git metadata read-only throughout a new lifecycle", + async () => { + const root = await repository(); + await chmod(path.join(root, ".git"), 0o555); + try { + await writeRunRecord(root, "one", { status: "running" }); + await assertTaskStateWritable(root); + await replaceRunRecord(root, "one", { status: "completed" }); + expect(await readdir(path.join(root, ".git"))).toEqual([]); + } finally { + await chmod(path.join(root, ".git"), 0o755); + } + }, +); + +it.skipIf(process.platform === "win32")( + "reports blocked legacy storage without creating a fallback", + async () => { + const root = await repository(); + const legacy = path.join(root, ".git/noxroot"); + await mkdir(legacy); + await chmod(legacy, 0o555); + try { + await expect(writeRunRecord(root, "one", {})).rejects.toThrow("request write access"); + await expect(assertTaskStateWritable(root)).rejects.toThrow( + "Do not edit after a failed start", + ); + expect(await readdir(root)).toEqual([".git"]); + } finally { + await chmod(legacy, 0o755); + } + }, +); From fd54c52f6cf6705b18bfa214be287b1e7fe9946c Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 07:50:29 -0400 Subject: [PATCH 02/24] feat: shorten routine CLI summaries without hiding failures --- src/cli.ts | 80 ++++++++++++++++++++----------- src/output.ts | 90 +++++++++++++++++++++++++++++++++++ tests/autonomy-guided.test.ts | 4 +- tests/cli.test.ts | 10 ++-- tests/output-contract.test.ts | 68 ++++++++++++++++++++++++++ 5 files changed, 219 insertions(+), 33 deletions(-) create mode 100644 tests/output-contract.test.ts diff --git a/src/cli.ts b/src/cli.ts index cb7a063..97c7ba3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -37,6 +37,7 @@ import { import { orchestrateRun, type RunRecord } from "./orchestration/run.js"; import { renderContext, + renderGuidedFinish, renderInitMark, renderLearning, renderPreview, @@ -313,6 +314,7 @@ function renderContinuation( record: GuidedRunRecord, recordPath: string, continuation: GuidedContinuationState, + verbose = false, ): string { const changed = continuation.changedPaths.length; const changedSummary = changed @@ -321,13 +323,13 @@ function renderContinuation( return `${[ "Continuing active task", ` Outcome: ${record.context.intent.requiredOutcomes[0] ?? record.context.interpretation}`, - ` Task: ${record.id}`, - ` Baseline: ${record.baseline.revision.slice(0, 12)}`, + ...(verbose + ? [` Task: ${record.id}`, ` Baseline: ${record.baseline.revision.slice(0, 12)}`] + : []), ` Changed: ${changedSummary}`, ` Verification: ${continuation.verification.summary}`, - " No duplicate task was created.", `Next: ${continuation.nextAction}`, - `Local record: ${recordPath}`, + ...(verbose ? [`Local record: ${recordPath}`] : ["Details: use --verbose or --json."]), ].join("\n")}\n`; } @@ -336,20 +338,20 @@ function renderStart( context: Awaited>, checks: Awaited>, recordPath: string, + verbose = false, ): string { return `${[ - "Preparing", + "NOXROOT task started", ` Outcome: ${context.intent.requiredOutcomes[0] ?? context.interpretation}`, - ` Exclusions: ${context.intent.explicitExclusions.join("; ") || "none"}`, + ...(context.intent.explicitExclusions.length + ? [` Exclusions: ${context.intent.explicitExclusions.join("; ")}`] + : []), ` Context: ${context.selected.length} relevant files · ~${context.budget.estimatedTokens.toLocaleString("en-US")} tokens`, ` Likely area: ${context.applicableAreas.join(", ") || "not yet established"}`, ` Checks: ${checks.map((check) => check.id).join(", ") || "none approved yet"}`, - " Coding agent: not invoked (manual mode)", - "", - "Ready for your coding agent.", - `Task: ${id}`, + ...(verbose ? [" Coding agent: not invoked (manual mode)", `Task: ${id}`] : []), `Next: make the change, then run ${cliCommand("finish")}.`, - `Local record: ${recordPath}`, + ...(verbose ? [`Local record: ${recordPath}`] : ["Details: use --verbose or --json."]), ].join("\n")}\n`; } @@ -534,7 +536,7 @@ export function createProgram(customIo?: Partial): Command { renderPreview(preview, { ...renderOptions(io, common), diff: !options.dryRun, - verbose: common.verbose || !options.dryRun, + verbose: common.verbose === true, }), ); } @@ -598,7 +600,7 @@ export function createProgram(customIo?: Partial): Command { `${renderSyncSummary(summary)}${renderPreview(preview, { ...renderOptions(io, common), diff: options.diff || !options.dryRun, - verbose: common.verbose || options.diff || !options.dryRun, + verbose: common.verbose === true, next: options.dryRun ? options.diff ? cliCommand("sync --yes") @@ -753,7 +755,7 @@ export function createProgram(customIo?: Partial): Command { continued: true, continuation: continuationState, }, - renderContinuation(continuation, recordPath, continuationState), + renderContinuation(continuation, recordPath, continuationState, common.verbose), ); return; } @@ -779,7 +781,7 @@ export function createProgram(customIo?: Partial): Command { io, common.json, { context, record, recordPath, agentInvoked: false }, - renderStart(id, context, checks, recordPath), + renderStart(id, context, checks, recordPath, common.verbose), ); }); @@ -851,13 +853,32 @@ export function createProgram(customIo?: Partial): Command { autonomy.worker.authorized, }; + const humanPlan = common.verbose + ? `NOXROOT RUN PLAN\n${JSON.stringify(plan, null, 2)}\n\n${renderContext(context, renderOptions(io, common))}` + : [ + "NOXROOT run plan", + "", + `Task ${task}`, + `Agent ${plan.adapter}`, + `Calls ${Object.entries(plan.calls) + .map(([role, count]) => `${role}: ${count}`) + .join(" · ")}`, + `Context ${context.selected.length} files · ~${context.budget.estimatedTokens} tokens`, + `Scope ${plan.writableScope}`, + ...checks.map( + (check) => + `Check ${[check.executable, ...check.args].join(" ")} · cwd ${check.cwd}`, + ), + `Effects ${plan.sideEffects.join("; ") || "none"}`, + `Prohibited ${plan.prohibited.join(", ")}`, + ...context.intent.explicitExclusions.map((exclusion) => `Do not ${exclusion}`), + ...context.conflicts.map((conflict) => `Conflict ${conflict}`), + "Details Use --verbose for the full plan; --json for structured output.", + "", + ].join("\n"); + if (options.dryRun) { - emit( - io, - common.json, - { plan, context }, - `NOXROOT RUN PLAN\n${JSON.stringify(plan, null, 2)}\n\n${renderContext(context)}`, - ); + emit(io, common.json, { plan, context }, humanPlan); return; } if (options.guided) { @@ -884,7 +905,7 @@ export function createProgram(customIo?: Partial): Command { io, common.json, { plan, context, record, recordPath }, - `NOXROOT GUIDED TASK\nTask id: ${id}\nSelected context: ${context.selected.length} files (~${context.budget.estimatedTokens} tokens)\nApproved checks captured: ${checks.length}\nNo agent was invoked.\nNext: ${cliCommand(`finish --task ${id}`)}\nEvidence: ${recordPath}\n`, + renderStart(id, context, checks, recordPath, common.verbose), ); return; } @@ -893,7 +914,7 @@ export function createProgram(customIo?: Partial): Command { io, common.json, { plan, context }, - `NOXROOT RUN PLAN\n${JSON.stringify(plan, null, 2)}\n\n${renderContext(context)}Next: rerun with --guided to record a completable task.\n`, + `${humanPlan}Next: rerun with --guided to record a completable task.\n`, ); return; } @@ -908,11 +929,9 @@ export function createProgram(customIo?: Partial): Command { ); } if (!common.json) { - io.stdout( - `NOXROOT RUN PLAN\n${JSON.stringify(plan, null, 2)}\n\n${renderContext(context)}`, - ); + io.stdout(humanPlan); } else { - io.stderr(`NOXROOT RUN PLAN ${JSON.stringify(plan)}\n`); + io.stderr("Starting the confirmed delegated run.\n"); } if (!(await confirm(io, "Start this delegated run?", options.yes))) { process.exitCode = EXIT.refused; @@ -1062,7 +1081,12 @@ export function createProgram(customIo?: Partial): Command { io, common.json, { record: finished, recordPath, completion, learning, retention }, - `${finished.handoff}\n\nDocumentation\n Not assessed automatically; no deterministic documentation signal was produced.\n\nLearning\n ${learning.proposals.length ? `${learning.proposals.length} reusable proposal(s) available; inspect with ${cliCommand(`learn --task ${taskId}`)}.` : "No reusable project-knowledge candidate identified."}\n\nLocal record: ${recordPath}\n`, + renderGuidedFinish( + finished, + learning.proposals.length, + recordPath, + renderOptions(io, common), + ), ); if (controller.signal.aborted) process.exitCode = EXIT.interrupted; else if (finished.status === "incomplete") process.exitCode = EXIT.verification; diff --git a/src/output.ts b/src/output.ts index 16e71fd..1af426f 100644 --- a/src/output.ts +++ b/src/output.ts @@ -6,6 +6,46 @@ import type { } from "./model.js"; import type { LearnResult } from "./knowledge/learn.js"; import { cliCommand, VERSION } from "./invocation.js"; +import type { GuidedRunRecord } from "./orchestration/guided.js"; + +export function renderGuidedFinish( + record: GuidedRunRecord, + proposals: number, + 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`; + const checks = record.verification.at(-1) ?? []; + const review = record.calls + .flatMap((call) => (call.result.review ? [call.result.review] : [])) + .at(-1); + let next = "Resolve the reported gap or review finding, then retry finish."; + if (record.status === "failed") + next = `Fix the failing check, then rerun ${cliCommand("finish")}.`; + 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 ")}.`; + if (record.status === "completed" || record.status === "approved") + next = "Review the change before committing."; + return [ + title(`task ${record.status}`, options), + "", + `Changed ${record.changedPaths?.length ?? 0} file${record.changedPaths?.length === 1 ? "" : "s"}`, + ...checks.map( + (check) => + `Checks ${commandText(check.command)} · cwd ${check.command.cwd} · ${check.status}${check.status === "passed" ? "" : `: ${(check.evidence.stderr || check.evidence.stdout).replace(/\s+/g, " ").trim().slice(0, 240)}`}`, + ), + ...record.verificationGaps.map((gap) => `Gap ${gap}`), + `Review ${review ? `${review.decision}: ${review.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"}`, + `Next ${next}`, + `Evidence ${recordPath}`, + "", + ].join("\n"); +} export interface RenderOptions { color?: boolean; @@ -275,6 +315,56 @@ export function renderPreview( } export function renderContext(context: ContextPackage, options: RenderOptions = {}): string { + if (!options.verbose) { + const owners = context.likelyOwningSource.slice(0, 3); + const tests = context.likelyTests.filter((file) => !owners.includes(file)).slice(0, 2); + const guidance = context.selected + .map((item) => item.path) + .filter((file) => !owners.includes(file) && !tests.includes(file)) + .slice(0, 3); + return ( + [ + title("task brief", options), + "", + ...section( + "Outcome", + context.intent.requiredOutcomes.length + ? context.intent.requiredOutcomes + : [context.interpretation], + options, + ), + ...section( + "Task context", + [ + `${context.selected.length} files · ~${context.budget.estimatedTokens.toLocaleString("en-US")} tokens`, + ], + options, + ), + ...section("Likely owner", owners.length ? owners : ["Not established"], options), + ...section("Likely tests", tests.length ? tests : ["Not established"], options), + ...section("Also selected", guidance, options), + ...section( + "Checks", + context.requiredVerification.length + ? context.requiredVerification.map( + (check) => `${commandText(check)} · cwd ${check.cwd}`, + ) + : ["No approved command is available."], + options, + ), + ...section("Do not", context.intent.explicitExclusions, options, ANSI.yellow), + ...section("Conflicts", context.conflicts, options, ANSI.yellow), + ...(context.confidence === "high" + ? [] + : [`Confidence ${sentenceCase(context.confidence)}`]), + ...section("Excluded", [`${context.excluded.length} files left out`], options), + `Next ${context.conflicts.length ? "Resolve the reported conflicts before editing." : "Inspect the relevant files, then build the requested change."}`, + "Details Use --verbose for all selected paths and reasons; --json for structured context.", + ] + .filter((line, index) => line !== "" || index === 1) + .join("\n") + "\n" + ); + } const selectedPaths = new Set(context.selected.map((item) => item.path)); const candidatePath = (pathname: string): string => selectedPaths.has(pathname) ? pathname : `${pathname} (path match; inspect selectively)`; diff --git a/tests/autonomy-guided.test.ts b/tests/autonomy-guided.test.ts index cad3ee6..b07915f 100644 --- a/tests/autonomy-guided.test.ts +++ b/tests/autonomy-guided.test.ts @@ -656,10 +656,10 @@ commands: const result = await cli(["finish", "--root", root]); expect(result.stdout).toContain( - "missing-check: unavailable | definitely-not-installed-noxroot-check --verify | cwd . | exit not started", + "definitely-not-installed-noxroot-check --verify · cwd . · unavailable", ); expect(result.stdout).toContain("Make the approved check runnable"); - expect(result.stdout).toContain("rerun npx --yes noxroot@0.1.0 finish --task"); + expect(result.stdout).toContain("rerun npx --yes noxroot@0.1.0 finish."); expect(result.stderr).toContain("Inspecting changed files and running affected checks"); expect(result.stderr).toContain("Assessing reusable learning"); expect(result.stderr).toContain("Preparing handoff"); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index dd5ad11..fe564fa 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -203,6 +203,9 @@ describe("CLI contracts", () => { expect(concise.stdout).toContain("Checks"); expect(concise.stdout).toMatch(/Excluded\n {2}\d+ files left out/); expect(concise.stdout).not.toContain("outside the active route candidate pool"); + expect(concise.stdout.trim().split("\n").length).toBeLessThanOrEqual(24); + expect(concise.stdout).toContain("--verbose"); + expect(concise.stdout.match(/src\/index\.ts/g)?.length ?? 0).toBeLessThanOrEqual(1); const verbose = await run(["context", "change greeting", "--verbose", "--root", fixture.root]); expect(verbose.stdout).toMatch(/\d+ of \d+ files/); @@ -265,7 +268,7 @@ describe("CLI contracts", () => { ); const { stdout, stderr } = await run(["init", "--yes", "--root", root]); - expect(stdout).toContain("Initialization: allowed"); + expect(stdout).toContain("Exact proposed changes"); expect(stdout).toContain("Mode\n Companion"); expect(stderr).toBe(""); const initialized = await hashTree(root); @@ -294,8 +297,9 @@ describe("CLI contracts", () => { cleanup.push(fixture.cleanup); const before = await hashTree(fixture.root); const { stdout } = await run(["run", "change greeting", "--dry-run", "--root", fixture.root]); - expect(stdout).toContain("NOXROOT RUN PLAN"); - expect(stdout).toContain('"executes": false'); + expect(stdout).toContain("NOXROOT run plan"); + expect(stdout).not.toContain('"sideEffects"'); + expect(stdout).toContain("Effects none"); expect(await hashTree(fixture.root)).toBe(before); }); diff --git a/tests/output-contract.test.ts b/tests/output-contract.test.ts new file mode 100644 index 0000000..ac6e7b3 --- /dev/null +++ b/tests/output-contract.test.ts @@ -0,0 +1,68 @@ +import { expect, it } from "vitest"; +import { renderGuidedFinish } from "../src/output.js"; +import type { GuidedRunRecord } from "../src/orchestration/guided.js"; + +function record(status: GuidedRunRecord["status"]): GuidedRunRecord { + return { + id: "one", + status, + changedPaths: ["src/value.ts"], + calls: [], + verificationGaps: [], + verification: [ + [ + { + command: { id: "unit", executable: "npm", args: ["test"], cwd: "." }, + status: status === "failed" ? "failed" : "passed", + evidence: { + stdout: "expected 2, received 1", + stderr: "", + exitCode: status === "failed" ? 1 : 0, + }, + }, + ], + ], + reviewAssessment: { + required: status === "review-pending", + kinds: ["product-ux"], + reasons: ["changed navigation"], + }, + handoff: "Full handoff evidence", + } as unknown as GuidedRunRecord; +} + +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, + }); + expect(plain.trim().split("\n").length).toBeLessThanOrEqual(12); + expect(plain).toContain("task completed"); + expect(plain).toContain("Changed 1 file\n"); + expect(plain).toContain("npm test · cwd . · passed"); + expect(plain).toContain("Not assessed automatically"); + expect(plain).not.toContain("\u001b["); + expect(plain).not.toContain("approved"); +}); + +it("retains failure evidence and retry instructions in default output", () => { + const output = renderGuidedFinish(record("failed"), 0, "record.json", {}); + expect(output).toContain("task failed"); + expect(output).toContain("expected 2, received 1"); + expect(output).toContain("Fix the failing check"); + expect(output).not.toContain("task completed"); +}); + +it("does not turn passing checks into review approval", () => { + const output = renderGuidedFinish(record("review-pending"), 0, "record.json", {}); + expect(output).toContain("task review-pending"); + expect(output).toContain("Pending product-ux review"); + expect(output).toContain("--review-file"); + 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 }); + expect(output).toContain("Full handoff evidence"); + expect(output).toContain("Local record: record.json"); + expect(output).not.toContain("█"); +}); From 2284a2c46ba51e9e624925d8bc10642b7b087fca Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 07:51:35 -0400 Subject: [PATCH 03/24] docs: explain local task storage and concise output --- README.md | 21 ++++++++++--------- docs/architecture.md | 8 ++++++-- docs/commands.md | 40 ++++++++++++++++++++++++++----------- tests/documentation.test.ts | 2 +- 4 files changed, 47 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 3dd6551..8f442c6 100644 --- a/README.md +++ b/README.md @@ -109,19 +109,22 @@ Instruction discovery varies by coding tool, so the commands remain available fo ### What setup can add -| Surface | Actual path or command | Purpose | -| --------------------------------- | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| Agent entrypoint and config | `AGENTS.md`, `.noxroot/config.yml` | Connect compatible agents to the project workflow | -| Project-memory index | `.noxroot/knowledge/INDEX.md` | Route agents to relevant existing documentation | -| Task-context routes | `.noxroot/routes.yml` | Select relevant files, rules, tests, decisions, and skills | -| Verification policy and skill | `.noxroot/verification.yml`, `.noxroot/skills/verify-change/SKILL.md` | Define approved checks and the procedure for checking a change | -| Review skills | `.noxroot/skills/independent-review/SKILL.md`, `.noxroot/skills/product-ux-review/SKILL.md` when applicable | Provide fresh review procedures when the change requires them | -| Learning procedure after finish | `finish`, then `learn` through the pinned `npx` command | Propose a small knowledge update when something reusable was validated | -| Local task state created by start | `.git/noxroot/runs/*.json` in a standard checkout | Store baselines and results without treating them as project documentation | +| Surface | Actual path or command | Purpose | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| Agent entrypoint and config | `AGENTS.md`, `.noxroot/config.yml` | Connect compatible agents to the project workflow | +| Project-memory index | `.noxroot/knowledge/INDEX.md` | Route agents to relevant existing documentation | +| Task-context routes | `.noxroot/routes.yml` | Select relevant files, rules, tests, decisions, and skills | +| Verification policy and skill | `.noxroot/verification.yml`, `.noxroot/skills/verify-change/SKILL.md` | Define approved checks and the procedure for checking a change | +| Review skills | `.noxroot/skills/independent-review/SKILL.md`, `.noxroot/skills/product-ux-review/SKILL.md` when applicable | Provide fresh review procedures when the change requires them | +| Learning procedure after finish | `finish`, then `learn` through the pinned `npx` command | Propose a small knowledge update when something reusable was validated | +| Local task state created by start | `.noxroot/local/runs/*.json` in a new Git checkout | Store ignored baselines and results, separate from project documentation | Only missing capabilities are proposed. Mature repositories may need nothing. Existing documentation remains discoverable without being copied. +Existing `.git/noxroot` records stay in place, without a second store. If an agent cannot write task +state, it must stop and request access before continuing. + `SKILL.md` files are portable, on-demand instructions. The generated verification skill tells an agent how to check a change; the independent-review and optional product/UX skills describe their reviews. Context loading comes from `AGENTS.md`, the knowledge index, and context routes, not a diff --git a/docs/architecture.md b/docs/architecture.md index 32006a7..b9ce4d2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,8 +35,12 @@ evidence. The orchestrator accepts adapter, verification, and diff interfaces. This permits a deterministic fake in tests and keeps worker/reviewer invocations distinct. Delegated Git runs create `noxroot/*` -branches and worktrees; local evidence is stored under the Git common directory, not -`.noxroot/knowledge/`. +branches and worktrees. New Git repositories store local evidence under `.noxroot/local/`, with a +self-contained ignore rule. Detection excludes this directory even if Git ignores are bypassed. New +worktrees have separate local state; repository, branch, and baseline checks still apply. Existing +Git-common-directory state is preserved, including shared worktree records. If both stores exist, +Noxroot refuses to choose. Non-Git repositories retain their application-state location. No task +evidence belongs in `.noxroot/knowledge/`. Guided orchestration is a two-command lifecycle. Start persists repository identity, clean revision, bounded context, effective autonomy, and a hash of the approved verification policy. Finish derives diff --git a/docs/commands.md b/docs/commands.md index 4a7c790..88a5859 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -10,14 +10,14 @@ without changing JSON. Exit codes: -| Code | Meaning | -| ---- | ------------------------------------------------- | -| 0 | Requested operation completed | -| 2 | Usage, configuration, or validation error | -| 3 | Required confirmation was refused or unavailable | -| 4 | Verification failed, timed out, or is incomplete | -| 5 | Connected agent or required review did not finish | -| 130 | Interrupted | +| Code | Meaning | +| ---- | ------------------------------------------------------ | +| 0 | Requested operation completed | +| 2 | Usage, configuration, or validation error | +| 3 | Required confirmation or task-state access unavailable | +| 4 | Verification failed, timed out, or is incomplete | +| 5 | Connected agent or required review did not finish | +| 130 | Interrupted | ## `preview` @@ -70,10 +70,26 @@ does not broadly rewrite the repository. ## `context` -`context "task"` shows the outcome, selected paths, likely source and tests, approved checks, an -exclusion count, and estimated tokens. `--verbose` adds selection reasons, individual exclusions, -constraints, conflicts, unknowns, and byte counts. It stores paths and evidence, not copied source -files. +`context "task"` shows the outcome, a bounded selection of paths, likely source and tests, approved +checks with their working directories, an exclusion count, and estimated tokens. Exclusions and +conflicts remain visible. `--verbose` adds every selected path, selection reasons, individual +exclusions, unknowns, and byte counts. JSON retains the complete bounded context package. + +Routine `start`, continuation, and `finish` output separates the result from supporting evidence. +The short finish view still shows failures, verification gaps, pending review, and a path to the +full local record. Passing tests alone never turn a pending review into approval. + +### Local task-state access + +New Git repositories use `.noxroot/local/runs/`, inside the writable worktree rather than Git's +metadata. Its managed `.gitignore` contains `*`; never force-add task records to Git. Inspection and +read-only conversation create no state. Retention rules are unchanged. + +Existing `.git/noxroot` state remains authoritative. Noxroot does not move active tasks during an +upgrade. If this legacy directory is sandbox-protected, approve access only to the reported state +directory, or run the lifecycle command yourself in a trusted terminal. Do not disable the sandbox +or create another store. A blocked start means stop before editing; a blocked finish means the task +is not complete. Sync updates the managed instructions with these rules after you review its diff. ## `status` diff --git a/tests/documentation.test.ts b/tests/documentation.test.ts index 5023e88..8a7f2e7 100644 --- a/tests/documentation.test.ts +++ b/tests/documentation.test.ts @@ -108,7 +108,7 @@ describe("documentation examples", () => { expect(readme).toContain('src="docs/assets/noxroot-terminal.png"'); expect(readme).toContain('width="594"'); expect(readme).toContain(".noxroot/skills/verify-change/SKILL.md"); - expect(readme).toContain(".git/noxroot/runs/*.json"); + expect(readme).toContain(".noxroot/local/runs/*.json"); expect(readme).not.toContain("—"); expect(readme).not.toMatch( /auto-documenting|self-training|autonomous team|Obsidian integration|vault system|self-improving AI/i, From 617040ca6951cf6aaad0e93eaa01c2df01339a1b Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 07:51:35 -0400 Subject: [PATCH 04/24] test: add opt-in three-session Codex acceptance --- tests/acceptance/live-codex.mjs | 277 ++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 tests/acceptance/live-codex.mjs diff --git a/tests/acceptance/live-codex.mjs b/tests/acceptance/live-codex.mjs new file mode 100644 index 0000000..bb90662 --- /dev/null +++ b/tests/acceptance/live-codex.mjs @@ -0,0 +1,277 @@ +// Opt-in acceptance, not part of CI. Requires an existing Codex login and built dist/. +// Uses three real sessions in one synthetic repository; never changes an external project. +import { spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, readdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const source = path.resolve(import.meta.dirname, "../.."); +const scratch = await mkdtemp(path.join(tmpdir(), "noxroot-lifecycle-")); +const root = path.join(scratch, "project-dashboard"); +const env = { + ...process.env, + npm_config_cache: path.join(scratch, "npm-cache"), + npm_config_audit: "false", + npm_config_fund: "false", +}; +delete env.OPENAI_API_KEY; +delete env.CODEX_API_KEY; +const report = { phase: "setup", sourceCommit: "", package: {}, checks: {}, sessions: [], root }; +async function save() { + await writeFile(path.join(scratch, "report.json"), JSON.stringify(report, null, 2)); +} +function run(bin, args, cwd = root, allowFailure = false) { + const result = spawnSync(bin, args, { + cwd, + env, + encoding: "utf8", + timeout: 180000, + maxBuffer: 16000000, + }); + if (result.error) throw result.error; + if (result.status !== 0 && !allowFailure) + throw new Error(`${bin} failed (${result.status}): ${result.stderr}`); + return result; +} +async function files(directory = root, prefix = "") { + const result = {}; + for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => + a.name.localeCompare(b.name), + )) { + const relative = prefix + entry.name; + if ([".git", "node_modules"].includes(entry.name) || relative === ".noxroot/local") continue; + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) Object.assign(result, await files(absolute, `${relative}/`)); + else if (entry.isFile()) + result[relative] = createHash("sha256") + .update(await readFile(absolute)) + .digest("hex"); + } + return result; +} +async function records() { + const directory = path.join(root, ".noxroot/local/runs"); + const names = await readdir(directory).catch(() => []); + return Promise.all( + names + .filter((name) => name.endsWith(".json")) + .sort() + .map(async (name) => JSON.parse(await readFile(path.join(directory, name), "utf8"))), + ); +} +async function agent(label, prompt) { + report.phase = label; + await save(); + console.log(`\nCodex: ${label} (fresh session, workspace-write sandbox)`); + const evidence = { label, exitCode: null, commands: [], summary: "" }; + const args = [ + "-a", + "never", + "exec", + "--ephemeral", + "--ignore-user-config", + "--sandbox", + "workspace-write", + "--json", + "-C", + root, + `${prompt}\nWork only in this disposable repository. Do not commit, push, publish, install dependencies, read credentials, access unrelated directories, or use additional agents.`, + ]; + await new Promise((resolve, reject) => { + const child = spawn("codex", args, { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] }); + let pending = ""; + let diagnostic = ""; + const timer = setTimeout(() => child.kill("SIGTERM"), 300000); + child.stderr.on("data", (data) => { + diagnostic = (diagnostic + data).slice(-2000); + }); + child.stdout.on("data", (data) => { + pending += data; + const lines = pending.split("\n"); + pending = lines.pop(); + for (const line of lines) { + let event; + try { + event = JSON.parse(line); + } catch { + continue; + } + const item = event.item; + if (event.type === "item.completed" && item?.type === "command_execution") { + // Retain only bounded product-command evidence, not raw agent transcripts. + if (/noxroot|npm test/.test(item.command)) + evidence.commands.push({ + command: item.command, + exitCode: item.exit_code, + output: item.aggregated_output?.slice(0, 12000), + }); + } + if (event.type === "item.completed" && item?.type === "agent_message") + evidence.summary = item.text; + } + }); + child.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.on("close", (code) => { + clearTimeout(timer); + evidence.exitCode = code; + if (code !== 0) reject(new Error(`Codex ${label} exited ${code}: ${diagnostic}`)); + else resolve(); + }); + }); + evidence.records = (await records()).map((r) => ({ + id: r.id, + task: r.task, + status: r.status, + baseline: r.baseline, + })); + report.sessions.push(evidence); + await save(); + console.log(evidence.summary); +} + +console.log(`Acceptance workspace: ${scratch}`); +try { + await mkdir(root); + report.sourceCommit = run("git", ["rev-parse", "HEAD"], source).stdout.trim(); + const packages = []; + for (const directory of [ + source, + ...["commander", "yaml", "zod"].map((name) => path.join(source, "node_modules", name)), + ]) { + const packed = JSON.parse( + run( + "npm", + ["pack", directory, "--pack-destination", scratch, "--json", "--ignore-scripts"], + scratch, + ).stdout, + )[0]; + packages.push(path.join(scratch, packed.filename)); + if (directory === source) + report.package = { + size: packed.size, + unpackedSize: packed.unpackedSize, + integrity: packed.integrity, + }; + } + for (const directory of ["src", "tests", "docs"]) await mkdir(path.join(root, directory)); + await writeFile( + path.join(root, "package.json"), + JSON.stringify( + { + name: "project-dashboard-demo", + private: true, + type: "module", + scripts: { test: "node --test tests/*.test.mjs" }, + }, + null, + 2, + ) + "\n", + ); + await writeFile(path.join(root, ".gitignore"), "node_modules/\n"); + await writeFile( + path.join(root, "docs/architecture.md"), + "# Navigation\n\nProject filters belong in the URL query string. Preserve that query when building return links. Do not add localStorage or a parallel state store.\n", + ); + await writeFile( + path.join(root, "src/project-return-url.mjs"), + 'export function projectReturnUrl(listUrl) {\n const url = new URL(listUrl, "https://dashboard.example");\n return url.pathname;\n}\n', + ); + await writeFile( + path.join(root, "tests/project-return-url.test.mjs"), + 'import test from "node:test";\nimport assert from "node:assert/strict";\nimport { projectReturnUrl } from "../src/project-return-url.mjs";\ntest("returns the project-list path", () => {\n assert.equal(projectReturnUrl("/projects"), "/projects");\n});\n', + ); + run("npm", [ + "install", + "--offline", + "--no-save", + "--package-lock=false", + "--ignore-scripts", + ...packages, + ]); + env.npm_config_cache = path.join(root, "node_modules/.cache/npm"); + env.npm_config_offline = "true"; + run("git", ["init", "-b", "agent/live-demo"]); + run("git", ["config", "user.name", "Noxroot Demo"]); + run("git", ["config", "user.email", "demo@example.invalid"]); + const cli = path.join(root, "node_modules/noxroot/dist/cli.js"); + const nox = (...args) => run("node", [cli, ...args]); + const before = await files(); + report.preview = nox("preview", "--json").stdout; + report.checks.previewReadOnly = JSON.stringify(before) === JSON.stringify(await files()); + nox("init", "--yes", "--json"); + const firstInit = await files(); + nox("init", "--yes", "--json"); + report.checks.initIdempotent = JSON.stringify(firstInit) === JSON.stringify(await files()); + // Explicit fixture approval, not a claim that discovered scripts are trusted automatically. + await writeFile( + path.join(root, ".noxroot/verification.yml"), + 'version: 1\ncommands:\n - id: unit-tests\n executable: npm\n args: [test]\n cwd: .\n timeoutMs: 30000\n appliesTo: ["src/**", "tests/**"]\n', + ); + run("git", ["add", "."]); + run("git", ["commit", "-m", "Synthetic baseline with approved one-time setup"]); + report.initialFiles = await files(); + console.log( + "Packed CLI installed. Preview unchanged; setup completed once. Ready for everyday use.", + ); + await agent( + "question", + "What does projectReturnUrl do, and where are this repository's navigation conventions documented? Explain briefly without changing anything.", + ); + report.checks.questionNoTask = (await records()).length === 0; + report.checks.questionNoChanges = + JSON.stringify(report.initialFiles) === JSON.stringify(await files()); + await agent( + "regression", + "Preserve project filters on back navigation. First add a regression test for /projects?status=active&sort=name losing its query string. Run the test to reproduce the bug, then stop before implementing the fix; we will continue this same change in a new conversation.", + ); + const first = await records(); + report.checks.firstTaskCount = first.length; + report.checks.regressionExit = run("npm", ["test"], root, true).status; + await agent( + "continuation", + "Preserve project filters on back navigation. Continue the unfinished change already in this repository. Inspect the existing test and local task state, implement the smallest fix consistent with the documented convention, verify it, and report the result.", + ); + const final = await records(); + report.checks.finalTaskCount = final.length; + report.checks.sameTask = + first.length === 1 && + final.length === 1 && + first[0].id === final[0].id && + first[0].baseline.revision === final[0].baseline.revision; + report.checks.completed = final.length === 1 && final[0].status === "completed"; + report.checks.finalTestExit = run("npm", ["test"], root, true).status; + report.checks.diffCheckExit = run("git", ["diff", "--check"], root, true).status; + report.finalFiles = await files(); + report.checks.documentationChanged = Object.keys(report.finalFiles).filter( + (name) => + (name.startsWith("docs/") || name.startsWith(".noxroot/knowledge/")) && + report.finalFiles[name] !== report.initialFiles[name], + ); + report.finalRecords = final; + report.finalDiff = run("git", ["diff"]).stdout; + report.context = nox( + "context", + "Preserve project filters on back navigation", + "--no-color", + ).stdout; + report.status = nox("status", "--json").stdout; + report.gitStatus = run("git", ["status", "--short"]).stdout; + report.phase = "finished"; + await save(); + console.log("\nAcceptance:", JSON.stringify(report.checks, null, 2)); + console.log(`Evidence: ${path.join(scratch, "report.json")}`); + if (!report.checks.sameTask || !report.checks.completed || report.checks.finalTestExit !== 0) + process.exitCode = 1; +} catch (error) { + report.phase = "blocked"; + report.error = error.message; + await save(); + console.error(error.message); + process.exitCode = 1; +} +// Preserve the dirty synthetic repository for inspection. The operator removes installed +// dependencies and archives the small recovery evidence after evaluating the result. From bc98b6364d844b3a3507765d8b1b5ada26a9f822 Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 07:56:14 -0400 Subject: [PATCH 05/24] fix: guide continuation through concise status output --- src/core/proposals.ts | 2 ++ src/output.ts | 4 +++- src/state/local.ts | 13 ++++++++----- tests/init-context-doctor.test.ts | 3 +++ 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/core/proposals.ts b/src/core/proposals.ts index ebe0e18..93500e9 100644 --- a/src/core/proposals.ts +++ b/src/core/proposals.ts @@ -25,6 +25,8 @@ function managedBlock(mode: WorkflowMode, hasKnowledgeIndex = true): string { If start fails, stop before editing and report the error. If finish fails, do not report the task complete. Request only the access needed to retry; do not disable the sandbox or create a second task-state store. +For unfinished work, use \`${cliCommand("status")}\` before opening raw task records. Keep routine output brief; use \`--verbose\` or \`--json\` when supporting detail is needed. + 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/output.ts b/src/output.ts index 1af426f..909c037 100644 --- a/src/output.ts +++ b/src/output.ts @@ -320,7 +320,9 @@ export function renderContext(context: ContextPackage, options: RenderOptions = const tests = context.likelyTests.filter((file) => !owners.includes(file)).slice(0, 2); const guidance = context.selected .map((item) => item.path) - .filter((file) => !owners.includes(file) && !tests.includes(file)) + .filter( + (file) => file !== ".noxroot/config.yml" && !owners.includes(file) && !tests.includes(file), + ) .slice(0, 3); return ( [ diff --git a/src/state/local.ts b/src/state/local.ts index a9e2aaf..4637662 100644 --- a/src/state/local.ts +++ b/src/state/local.ts @@ -70,7 +70,7 @@ export async function prepareStateRoot(root: string): Promise { const directory = await localStateRoot(root); try { await mkdir(directory, { recursive: true }); - if (directory === path.join(root, ".noxroot", "local")) { + if (directory === path.resolve(root, ".noxroot", "local")) { const ignore = await setupDestination(root, ".noxroot/local/.gitignore"); try { await writeFile(ignore, "*\n", { flag: "wx", mode: 0o600 }); @@ -131,20 +131,23 @@ export async function replaceRunRecord(root: string, id: string, value: unknown) const target = await setupDestination(stateRoot, `runs/${id}.json`); await stat(target); const temporary = `${target}.tmp-${randomUUID()}`; + let created = false; try { await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600, }); + created = true; await rename(temporary, target); return target; } catch (error) { - stateError(error, stateRoot); + return stateError(error, stateRoot); } finally { - await unlink(temporary).catch((error: NodeJS.ErrnoException) => { - if (error.code !== "ENOENT") throw error; - }); + if (created) + await unlink(temporary).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); } } diff --git a/tests/init-context-doctor.test.ts b/tests/init-context-doctor.test.ts index e86caf6..c9f5f83 100644 --- a/tests/init-context-doctor.test.ts +++ b/tests/init-context-doctor.test.ts @@ -78,6 +78,9 @@ describe("initialization, sync safety, context, and doctor", () => { expect(agents).toContain('run `npx --yes noxroot@0.1.0 start ""` before editing'); expect(agents).toContain("`npx --yes noxroot@0.1.0 finish` when the change is ready to check"); expect(agents).toContain("Do not start a task for questions, explanations, reviews"); + expect(agents).toContain("If start fails, stop before editing"); + expect(agents).toContain("If finish fails, do not report the task complete"); + expect(agents).toContain("status` before opening raw task records"); expect(agents).toContain(".noxroot/knowledge/INDEX.md"); expect( await readFile(path.join(fixture.root, ".noxroot", "knowledge", "INDEX.md"), "utf8"), From 0ff25098c1ea9358511cf6538389dad967a48397 Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:04:58 -0400 Subject: [PATCH 06/24] fix: harden task failure and retention boundaries --- src/cli.ts | 3 +- src/core/doctor.ts | 8 ++--- src/state/local.ts | 21 ++++++++---- tests/package-smoke.mjs | 64 +++++++++++++++++++++++++++++++++++- tests/state-location.test.ts | 23 +++++++++++++ 5 files changed, 107 insertions(+), 12 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 97c7ba3..c82c962 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1089,7 +1089,8 @@ export function createProgram(customIo?: Partial): Command { ), ); if (controller.signal.aborted) process.exitCode = EXIT.interrupted; - else if (finished.status === "incomplete") process.exitCode = EXIT.verification; + else if (finished.status === "incomplete" || finished.status === "failed") + process.exitCode = EXIT.verification; else if (!["approved", "completed", "review-pending"].includes(finished.status)) process.exitCode = EXIT.agent; } finally { diff --git a/src/core/doctor.ts b/src/core/doctor.ts index bb59874..a12b26b 100644 --- a/src/core/doctor.ts +++ b/src/core/doctor.ts @@ -6,8 +6,8 @@ import { inspectRepositoryAdoption } from "../detection/adoption.js"; import { scanRepository } from "../detection/scan.js"; import { resolvePlatformCommand } from "../adapters/process.js"; import { cliCommand } from "../invocation.js"; -import { isWithin } from "../security/paths.js"; -import { localStateRoot } from "../state/local.js"; +import { isWithin, setupDestination } from "../security/paths.js"; +import { localRunDirectory } from "../state/local.js"; export interface DoctorFinding { severity: "error" | "warning" | "info"; @@ -307,7 +307,7 @@ export async function doctorRepository(root = process.cwd()): Promise file.endsWith(".json")); if (config && runFiles.length > config.retention.maximumRuns) { findings.push( @@ -320,7 +320,7 @@ export async function doctorRepository(root = process.cwd()): Promise 256_000) continue; const record = JSON.parse(await readFile(absolute, "utf8")) as { status?: string }; diff --git a/src/state/local.ts b/src/state/local.ts index 4637662..346e06d 100644 --- a/src/state/local.ts +++ b/src/state/local.ts @@ -121,8 +121,8 @@ export async function writeRunRecord(root: string, id: string, value: unknown): export async function readRunRecord(root: string, id: string): Promise { if (!/^[a-z0-9-]+$/i.test(id)) throw new Error("Task id contains unsupported characters."); - const stateRoot = await localStateRoot(root); - return JSON.parse(await readFile(path.join(stateRoot, "runs", `${id}.json`), "utf8")) as T; + const directory = await localRunDirectory(root); + return JSON.parse(await readFile(await setupDestination(directory, `${id}.json`), "utf8")) as T; } export async function replaceRunRecord(root: string, id: string, value: unknown): Promise { @@ -151,8 +151,15 @@ export async function replaceRunRecord(root: string, id: string, value: unknown) } } +export async function localRunDirectory(root: string): Promise { + const stateRoot = await localStateRoot(root); + return (await pathType(stateRoot)) + ? setupDestination(stateRoot, "runs") + : path.join(stateRoot, "runs"); +} + export async function listRunRecords(root: string): Promise { - const directory = path.join(await localStateRoot(root), "runs"); + const directory = await localRunDirectory(root); let names: string[]; try { names = (await readdir(directory)).filter((name) => name.endsWith(".json")).sort(); @@ -163,7 +170,9 @@ export async function listRunRecords(root: string): Promise { const records: T[] = []; for (const name of names) { try { - records.push(JSON.parse(await readFile(path.join(directory, name), "utf8")) as T); + records.push( + JSON.parse(await readFile(await setupDestination(directory, name), "utf8")) as T, + ); } catch { // A malformed or concurrently replaced record is not eligible for implicit selection. } @@ -203,7 +212,7 @@ export async function enforceRunRetention( now = Date.now(), preserveIds: readonly string[] = [], ): Promise { - const directory = path.join(await localStateRoot(root), "runs"); + const directory = await localRunDirectory(root); let names: string[]; try { names = (await readdir(directory)).filter((name) => /^[a-z0-9-]+\.json$/i.test(name)).sort(); @@ -220,7 +229,7 @@ export async function enforceRunRetention( for (const name of names) { try { const record = JSON.parse( - await readFile(path.join(directory, name), "utf8"), + await readFile(await setupDestination(directory, name), "utf8"), ) as RetentionRecord; if (preservedNames.has(name)) { protectedCount += 1; diff --git a/tests/package-smoke.mjs b/tests/package-smoke.mjs index 98e994b..564061b 100644 --- a/tests/package-smoke.mjs +++ b/tests/package-smoke.mjs @@ -229,8 +229,70 @@ try { ); assert.deepEqual(await snapshot(linkedRoot), linkedBefore); assert.deepEqual(await snapshot(outside), {}); + + const guidedRoot = path.join(temporaryRoot, "guided-repository"); + await mkdir(path.join(guidedRoot, "src"), { recursive: true }); + await writeFile(path.join(guidedRoot, "src/value.mjs"), "export const value = 1;\n"); + invokeBinary(binary, ["init", "--yes", "--root", guidedRoot], installRoot); + await writeFile( + path.join(guidedRoot, ".noxroot/config.yml"), + "version: 1\nmodules: [repository-profile, agent-routing, verification, orchestration]\nautonomy: {implementation: 1}\n", + ); + await writeFile( + path.join(guidedRoot, ".noxroot/verification.yml"), + JSON.stringify({ + version: 1, + commands: [ + { + id: "syntax", + executable: process.execPath, + args: ["--check", "src/value.mjs"], + cwd: ".", + timeoutMs: 10000, + appliesTo: ["src/**"], + }, + ], + }), + ); + run("git", ["init"], { cwd: guidedRoot }); + run("git", ["add", "."], { cwd: guidedRoot }); + run( + "git", + [ + "-c", + "user.name=Noxroot Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-m", + "Synthetic baseline", + ], + { cwd: guidedRoot }, + ); + const guided = (...args) => + JSON.parse(invokeBinary(binary, [...args, "--root", guidedRoot, "--json"], installRoot)); + const started = guided("start", "change value"); + assert.ok(started.recordPath.includes(path.join(".noxroot", "local", "runs"))); + assert.equal(run("git", ["status", "--porcelain"], { cwd: guidedRoot }).trim(), ""); + await writeFile(path.join(guidedRoot, "src/value.mjs"), "export const value = ;\n"); + assert.throws(() => guided("finish"), /failed with 4/); + const failedRecord = JSON.parse(await readFile(started.recordPath, "utf8")); + assert.equal(failedRecord.status, "failed"); + const continued = guided("start", "change value"); + assert.equal(continued.record.id, started.record.id); + assert.equal(continued.continued, true); + await writeFile(path.join(guidedRoot, "src/value.mjs"), "export const value = 2;\n"); + const finished = guided("finish"); + assert.equal(finished.record.status, "completed"); + assert.equal(finished.record.id, started.record.id); + assert.equal(finished.record.baseline.revision, started.record.baseline.revision); + assert.deepEqual(finished.record.changedPaths, ["src/value.mjs"]); + assert.deepEqual( + (await readdir(path.dirname(started.recordPath))).filter((name) => name.endsWith(".json")), + [path.basename(started.recordPath)], + ); process.stdout.write( - `Packed CLI smoke passed on ${process.platform}: real tarball install, repeated init, managed-pin upgrade, user-content preservation, and linked-destination refusal.\n`, + `Packed CLI smoke passed on ${process.platform}: install, repeated init, managed-pin upgrade, preservation, linked-destination refusal, and start/fail/continue/finish.\n`, ); } finally { await rm(temporaryRoot, { recursive: true, force: true }); diff --git a/tests/state-location.test.ts b/tests/state-location.test.ts index 6a92bc7..0055fe2 100644 --- a/tests/state-location.test.ts +++ b/tests/state-location.test.ts @@ -3,6 +3,8 @@ import path from "node:path"; import { afterEach, expect, it } from "vitest"; import { assertTaskStateWritable, + enforceRunRetention, + listRunRecords, localStateRoot, readRunRecord, replaceRunRecord, @@ -60,6 +62,27 @@ it("rejects linked local state without modifying the target", async () => { expect(await readdir(outside)).toEqual([]); }); +it("refuses linked run directories before inspection or retention", async () => { + const root = await repository(); + const outside = await temporaryDirectory("noxroot-state-retention-outside-"); + cleanup.push(outside); + await mkdir(path.join(root, ".noxroot/local"), { recursive: true }); + await writeFile( + path.join(outside, "old.json"), + '{"id":"old","status":"completed","finishedAt":"2000-01-01"}\n', + ); + await symlink( + outside, + path.join(root, ".noxroot/local/runs"), + process.platform === "win32" ? "junction" : "dir", + ); + await expect(listRunRecords(root)).rejects.toThrow("symbolic link"); + await expect(enforceRunRetention(root, { evidenceDays: 1, maximumRuns: 1 })).rejects.toThrow( + "symbolic link", + ); + expect(await readdir(outside)).toEqual(["old.json"]); +}); + it("does not overwrite an existing local ignore policy", async () => { const root = await repository(); await mkdir(path.join(root, ".noxroot/local"), { recursive: true }); From 9a18ac6827ca984afbbfcfb83cd9673f2f364b36 Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:06:20 -0400 Subject: [PATCH 07/24] fix: keep blocked review reasons visible in summaries --- src/output.ts | 6 ++---- tests/output-contract.test.ts | 24 ++++++++++++++++++++++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/output.ts b/src/output.ts index 909c037..f581886 100644 --- a/src/output.ts +++ b/src/output.ts @@ -17,9 +17,7 @@ export function renderGuidedFinish( if (options.verbose) return `${record.handoff}\n\nDocumentation: not assessed automatically.\nLearning: ${proposals} reusable proposal(s).\nLocal record: ${recordPath}\n`; const checks = record.verification.at(-1) ?? []; - const review = record.calls - .flatMap((call) => (call.result.review ? [call.result.review] : [])) - .at(-1); + const reviewResult = record.calls.filter((call) => call.role === "reviewer").at(-1)?.result; let next = "Resolve the reported gap or review finding, then retry finish."; if (record.status === "failed") next = `Fix the failing check, then rerun ${cliCommand("finish")}.`; @@ -38,7 +36,7 @@ export function renderGuidedFinish( `Checks ${commandText(check.command)} · cwd ${check.command.cwd} · ${check.status}${check.status === "passed" ? "" : `: ${(check.evidence.stderr || check.evidence.stdout).replace(/\s+/g, " ").trim().slice(0, 240)}`}`, ), ...record.verificationGaps.map((gap) => `Gap ${gap}`), - `Review ${review ? `${review.decision}: ${review.summary}` : record.reviewAssessment?.required ? `Pending ${record.reviewAssessment.kinds.join("/")} review` : "Not required for this change"}`, + `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"}`, `Next ${next}`, diff --git a/tests/output-contract.test.ts b/tests/output-contract.test.ts index ac6e7b3..fe2b583 100644 --- a/tests/output-contract.test.ts +++ b/tests/output-contract.test.ts @@ -24,7 +24,7 @@ function record(status: GuidedRunRecord["status"]): GuidedRunRecord { ], reviewAssessment: { required: status === "review-pending", - kinds: ["product-ux"], + kinds: ["ux"], reasons: ["changed navigation"], }, handoff: "Full handoff evidence", @@ -55,7 +55,7 @@ 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", {}); expect(output).toContain("task review-pending"); - expect(output).toContain("Pending product-ux review"); + expect(output).toContain("Pending ux review"); expect(output).toContain("--review-file"); expect(output).not.toContain("task completed"); }); @@ -66,3 +66,23 @@ it("keeps detailed handoff evidence available and adds no repeated banner", () = expect(output).toContain("Local record: record.json"); expect(output).not.toContain("█"); }); + +it("shows why an invalid reviewer response blocked completion", () => { + const blocked = record("blocked"); + blocked.calls = [ + { + role: "reviewer", + result: { + invoked: false, + status: "failed", + summary: "Review response was not schema-valid JSON.", + output: "", + exitCode: null, + reviewDecision: "blocked", + }, + }, + ]; + const output = renderGuidedFinish(blocked, 0, "record.json", {}); + expect(output).toContain("Review blocked: Review response was not schema-valid JSON."); + expect(output).not.toContain("Not required"); +}); From 9602dcdfe6ec98956f740adeeb20a0b2ace516bb Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:11:11 -0400 Subject: [PATCH 08/24] docs: record sandbox lifecycle and terminal acceptance --- tests/acceptance/LIFECYCLE-UX-2026-09-04.md | 102 ++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tests/acceptance/LIFECYCLE-UX-2026-09-04.md diff --git a/tests/acceptance/LIFECYCLE-UX-2026-09-04.md b/tests/acceptance/LIFECYCLE-UX-2026-09-04.md new file mode 100644 index 0000000..192f694 --- /dev/null +++ b/tests/acceptance/LIFECYCLE-UX-2026-09-04.md @@ -0,0 +1,102 @@ +# Lifecycle and terminal acceptance + +September 4, 2026. Baseline: `c32241b`. Product code validated through `9a18ac6`. No runtime +dependencies, external-repository changes, client hooks, publication, or deployment. + +## What changed + +1. New Git repositories keep ignored task records in `.noxroot/local/runs/`, outside protected Git + metadata. Read-only inspection creates nothing. Existing `.git/noxroot` records remain + authoritative; competing stores are refused. New worktrees have separate state; legacy shared + records remain discoverable. Existing identity, branch, baseline, and retention checks still + apply. +2. Continuation and finish check state write access. A denied write explains the required access. + Generated instructions tell agents to stop after a failed start and not claim a failed finish as + complete. State reads, writes, doctor inspection, and retention refuse linked run directories. +3. Routine start, continuation, context, and finish output is shorter. Context avoids repeated + paths; commands retain their working directories. Failures, unavailable checks, and blocked or + pending reviews stay visible. Full evidence remains in the local record, verbose view, and + structured JSON. Mutating setup still shows exact patches before confirmation. Verification + failures return exit 4. +4. Generated instructions recommend `status` before raw task records. This guides compatible agents; + it does not control their narration or guarantee identical behavior across clients. + +## Actual agent journey + +Two packed installations were exercised with three fresh Codex sessions each, using an existing +ChatGPT login, `--ephemeral --ignore-user-config --sandbox workspace-write`, and approval policy +`never`. No API key, permission bypass, global installation, or model-based continuation logic. + +The synthetic JavaScript repository contains a URL helper, native Node tests, and an existing +navigation convention. It is not a real Next.js application or a browser acceptance test. The +harness explicitly approved `npm test` at repository root; discovery alone did not authorize +execution. + +| Check | Result in both runs | +| --------------------------- | ------------------------------------------------------ | +| Preview | No file changes | +| Repeated initialization | Byte-identical project files | +| Ordinary question | No task and no edits | +| First code-changing session | One task; failing regression reproduced | +| Fresh-session continuation | Same task ID and baseline, no duplicate | +| Finish without `--task` | One applicable task inferred; persisted as `completed` | +| Native verification | Two tests passed; `git diff --check` passed | +| Documentation | Existing convention reused; zero documentation changes | + +The implementation changed one return expression from `url.pathname` to `url.pathname + url.search` +and added one regression test. No review was required by the current applicability rules. The final +live task was `20260904-c8d17cd6`, baseline `ed4804101e879df08656294babbd3de597bae2d3`. + +The first fresh agent read raw task JSON. After the guidance adjustment, the repeat used `status` +before continuing and did not dump the task record. Agents still performed their own source reads, +test commands, and narration. The harness displays agent summaries separately from installation; it +is not a claim that the complete agent terminal contains only Noxroot's summary lines. + +The live runs preceded the final linked-retention and blocked-review hardening. Final product code +was then repacked and exercised through deterministic start/fail/continue/finish on both platforms. + +## Output and validation + +- Observed Noxroot output: start 7 lines, continuation 6, finish 12 including three progress lines. + Counts exclude the trailing empty split element. Long paths may wrap in narrower terminals. +- Same TypeScript fixture and request: context decreased from 25 to 19 logical lines, a 24% + reduction. +- Output regressions cover 80/120-column options, no-color/piped output, JSON, failed checks, + unavailable commands, pending reviews, invalid reviewer responses, and verbose evidence. +- Complete `npm run check` passed on Windows and WSL/Linux: formatting, lint, types, 196 passing + tests and two platform-specific skips on each, build, compiled read-only safety, and packed + install. There are 198 tests total, 14 more than the baseline. The skipped tests differ by + platform. +- Packed smoke now tests a failing check, persisted failure, same-task continuation, automatic + finish inference, one record, unchanged baseline, and ignored runtime state. +- Existing 600-record retention and 30-task cross-stack context regressions passed. These are + synthetic regression tests, not 600 agent sessions or 30 newly cloned repositories. +- Linux dependency installation reported zero audit vulnerabilities. No macOS or Claude Code live + run was performed in this slice. + +## Size and documentation + +Product source: 280 added / 62 removed lines, net +218 across seven files. Tests and the opt-in +acceptance driver account for most additional repository lines. No dependencies were added. + +Packed size: 120,983 to 124,049 bytes, +3,066 bytes (about 2.5%). Unpacked size: 387,801 to 399,204 +bytes. README: local-state table entry updated plus one legacy-access paragraph. Whitespace word +count: 1,436 to 1,461, net +25. Intro, tagline, logo, and screenshot are unchanged. Architecture and +command references explain the new location, legacy behavior, failure contract, and concise output. + +## Limits and handoff + +Legacy repositories can still require narrowly scoped approval to write `.git/noxroot`. This slice +does not migrate active records or weaken a sandbox. The reliable default is demonstrated for new +setups, not a universal migration claim. Read-only client policies still require user approval for +any code-changing work. + +Live recovery evidence is retained under `/tmp/noxroot-lifecycle-zaBA7M` and +`/tmp/noxroot-lifecycle-muMmu5`. Each synthetic repository has two uncommitted source/test changes; +these must not be silently deleted. Installed packages, caches, and disposable build copies are +removed separately. The earlier `/tmp/noxroot-live-SND14p` recovery evidence was not changed. + +Working repository: `C:/Users/lione/Documents/ChatGPT/noxroot`. Branch: +`agent/sandbox-lifecycle-quiet-output`. No additional implementation worktrees or files in +workspace-parent directories. Push, merge, npm publication, and fresh independent review remain +separate release actions. From da002def7be78934aac2bc8ce3e7e4885e74ea6c Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:36:33 -0400 Subject: [PATCH 09/24] fix: keep historical reviews out of current finish summaries --- src/output.ts | 8 +++- tests/output-contract.test.ts | 71 +++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/output.ts b/src/output.ts index f581886..7f239e8 100644 --- a/src/output.ts +++ b/src/output.ts @@ -17,7 +17,13 @@ export function renderGuidedFinish( if (options.verbose) return `${record.handoff}\n\nDocumentation: not assessed automatically.\nLearning: ${proposals} reusable proposal(s).\nLocal record: ${recordPath}\n`; const checks = record.verification.at(-1) ?? []; - const reviewResult = record.calls.filter((call) => call.role === "reviewer").at(-1)?.result; + // Calls are historical. Only review-result states without a verification gap + // can use the latest call as the current completion attempt's decision. + const reviewResult = + ["approved", "changes-requested", "blocked"].includes(record.status) && + record.verificationGaps.length === 0 + ? record.calls.filter((call) => call.role === "reviewer").at(-1)?.result + : undefined; let next = "Resolve the reported gap or review finding, then retry finish."; if (record.status === "failed") next = `Fix the failing check, then rerun ${cliCommand("finish")}.`; diff --git a/tests/output-contract.test.ts b/tests/output-contract.test.ts index fe2b583..fc08fb7 100644 --- a/tests/output-contract.test.ts +++ b/tests/output-contract.test.ts @@ -86,3 +86,74 @@ it("shows why an invalid reviewer response blocked completion", () => { expect(output).toContain("Review blocked: Review response was not schema-valid JSON."); expect(output).not.toContain("Not required"); }); + +it.each(["review-pending", "failed", "incomplete", "completed"] as const)( + "does not present a historical review as current when %s", + (status) => { + const current = record(status); + current.calls = [ + { + role: "reviewer", + result: { + invoked: false, + status: "completed", + summary: "Approved previous diff", + output: "", + exitCode: 0, + reviewDecision: "approved", + }, + }, + ]; + const output = renderGuidedFinish(current, 0, "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"); + }, +); + +it("does not show an old blocked review after a new verification gap", () => { + const current = record("blocked"); + current.verificationGaps = ["No repository change was detected from the recorded baseline."]; + current.calls = [ + { + role: "reviewer", + result: { + invoked: false, + status: "failed", + summary: "Old invalid review", + output: "", + exitCode: null, + reviewDecision: "blocked", + }, + }, + ]; + expect(renderGuidedFinish(current, 0, "record.json", {})).not.toContain("Old invalid review"); +}); + +it.each(["approved", "changes-requested", "blocked"] as const)( + "keeps a current %s decision, but not after another pending attempt", + (decision) => { + const current = record(decision); + current.calls = [ + { + role: "reviewer", + result: { + invoked: false, + status: "completed", + summary: "Current review result", + output: "", + exitCode: 0, + reviewDecision: decision, + }, + }, + ]; + expect(renderGuidedFinish(current, 0, "record.json", {})).toContain( + `Review ${decision}: Current review result`, + ); + current.status = "review-pending"; + current.reviewAssessment!.required = true; + const pending = renderGuidedFinish(current, 0, "record.json", {}); + expect(pending).toContain("Pending ux review"); + expect(pending).not.toContain("Current review result"); + }, +); From ce579711d6ce41a9700430ee733cf82711b988ed Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:40:10 -0400 Subject: [PATCH 10/24] fix: require writable task continuation before resumed edits --- docs/commands.md | 3 +++ src/cli.ts | 3 ++- src/core/proposals.ts | 2 +- tests/autonomy-guided.test.ts | 18 +++++++++++++++--- tests/init-context-doctor.test.ts | 4 ++++ 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 88a5859..74d32c3 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -98,6 +98,9 @@ working-tree state, active Noxroot tasks, changed paths since each baseline, whe matches the current diff, and the next applicable action. It does not invoke an agent or restore a chat session. +Before resuming edits, repeat `start` with the active task's text. `status` does not check whether +task state is writable and is not a substitute for `start`. + ## `verify` `verify --plan` displays the confirmed policy without running it. `verify --changed` reads Git diff --git a/src/cli.ts b/src/cli.ts index c82c962..7ca877d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -400,10 +400,11 @@ function renderTaskStatus(result: Awaited line.startsWith(`${started.record.id} `))! + .slice(started.record.id.length + 2); + const resumed = JSON.parse((await cli(["start", displayed, "--json", "--root", root])).stdout); + expect(resumed.continued).toBe(true); + expect(resumed.record.id).toBe(started.record.id); }); it("reports current and stale verification deterministically when continuing", async () => { diff --git a/tests/init-context-doctor.test.ts b/tests/init-context-doctor.test.ts index c9f5f83..5931752 100644 --- a/tests/init-context-doctor.test.ts +++ b/tests/init-context-doctor.test.ts @@ -81,6 +81,10 @@ describe("initialization, sync safety, context, and doctor", () => { expect(agents).toContain("If start fails, stop before editing"); expect(agents).toContain("If finish fails, do not report the task complete"); expect(agents).toContain("status` before opening raw task records"); + expect(agents).toContain( + "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/knowledge/INDEX.md"); expect( await readFile(path.join(fixture.root, ".noxroot", "knowledge", "INDEX.md"), "utf8"), From dfa7939c462735cb100b160f7241903bf33f9185 Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:40:10 -0400 Subject: [PATCH 11/24] test: rehearse legacy upgrades and isolated repository workflows --- tests/acceptance/legacy-workflows.mjs | 386 ++++++++++++++++++++++++ tests/acceptance/live-legacy-denial.mjs | 136 +++++++++ 2 files changed, 522 insertions(+) create mode 100644 tests/acceptance/legacy-workflows.mjs create mode 100644 tests/acceptance/live-legacy-denial.mjs diff --git a/tests/acceptance/legacy-workflows.mjs b/tests/acceptance/legacy-workflows.mjs new file mode 100644 index 0000000..1bac044 --- /dev/null +++ b/tests/acceptance/legacy-workflows.mjs @@ -0,0 +1,386 @@ +// Opt-in Linux acceptance. Input contains inspected, pinned underscore/ and bottle/ +// checkouts plus a built old-source/. Never commits or pushes external repositories. +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { appendFile, chmod, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const source = path.resolve(import.meta.dirname, "../.."); +const scratch = path.resolve(process.argv[2] ?? "."); +if (process.platform === "win32" || !/^\/tmp\/noxroot-legacy-acceptance-[\w-]+$/.test(scratch)) + throw Error("Use Linux and an explicitly prepared /tmp/noxroot-legacy-acceptance-* directory."); +const env = Object.fromEntries( + ["PATH", "HOME", "LANG"].filter((k) => process.env[k]).map((k) => [k, process.env[k]]), +); +Object.assign(env, { + npm_config_cache: path.join(scratch, "cache"), + npm_config_audit: "false", + npm_config_fund: "false", + PYTHONDONTWRITEBYTECODE: "1", + GIT_TERMINAL_PROMPT: "0", +}); +const report = { + method: + "Packed CLIs, separate command processes, operator-driven workflows. Not autonomous agent sessions or registry upgrades.", + results: [], +}; +async function save() { + await writeFile(path.join(scratch, "report.json"), JSON.stringify(report, null, 2) + "\n"); +} +function run(bin, args, cwd, expected = 0) { + const r = spawnSync(bin, args, { + cwd, + env, + encoding: "utf8", + timeout: 120000, + maxBuffer: 8000000, + }); + const result = { + code: r.status, + stdout: r.stdout ?? "", + stderr: r.stderr ?? r.error?.message ?? "", + }; + if (expected !== null) + assert.equal( + result.code, + expected, + `${bin} ${args.join(" ")}: ${result.stderr || result.stdout}`, + ); + return result; +} +function git(root, args) { + return run("git", ["-c", "core.hooksPath=/dev/null", ...args], root).stdout.trim(); +} +function nox(cli, root, args, expected = 0) { + const r = run("node", [cli, ...args, "--root", root, "--json"], root, expected); + return { ...r, value: JSON.parse(r.stdout) }; +} +async function snapshot(root, prefix = "") { + const result = {}; + for (const e of (await readdir(path.join(root, prefix), { withFileTypes: true })).sort((a, b) => + a.name.localeCompare(b.name), + )) { + const p = prefix + e.name; + if ([".git", "node_modules", "__pycache__"].includes(e.name) || p === ".noxroot/local") + continue; + if (e.isDirectory()) Object.assign(result, await snapshot(root, p + "/")); + else if (e.isFile()) + result[p] = createHash("sha256") + .update(await readFile(path.join(root, p))) + .digest("hex"); + } + return result; +} +async function install(directory, name, deps) { + const destination = path.join(scratch, name); + await mkdir(destination); + const packed = JSON.parse( + run( + "npm", + ["pack", directory, "--ignore-scripts", "--json", "--pack-destination", destination], + scratch, + ).stdout, + )[0]; + await writeFile( + path.join(destination, "package.json"), + '{"name":"acceptance-only","private":true}\n', + ); + run( + "npm", + [ + "install", + "--offline", + "--ignore-scripts", + "--no-save", + "--package-lock=false", + path.join(destination, packed.filename), + ...deps, + ], + destination, + ); + return { + cli: path.join(destination, "node_modules/noxroot/dist/cli.js"), + size: packed.size, + integrity: packed.integrity, + }; +} +async function policy(root, executable, args, cwd = ".") { + const command = { + id: "approved-regression", + executable, + args, + cwd, + timeoutMs: 30000, + appliesTo: ["**/*"], + }; + await writeFile( + path.join(root, ".noxroot/verification.yml"), + JSON.stringify({ version: 1, commands: [command] }, null, 2) + "\n", + ); + return command; +} +async function setup(cli, root) { + const before = await snapshot(root); + const preview = nox(cli, root, ["preview"]).value; + assert.deepEqual(await snapshot(root), before); + assert.equal(preview.initializationAllowed, true); + nox(cli, root, ["init", "--yes"]); + const initialized = await snapshot(root); + nox(cli, root, ["init", "--yes"]); + assert.deepEqual(await snapshot(root), initialized); + return { + previewReadOnly: true, + initIdempotent: true, + capabilities: preview.capabilities, + proposedFiles: preview.proposedFiles.map(({ path, action }) => ({ path, action })), + discoveredCommands: preview.profile.candidateCommands, + }; +} +function contextEvidence(value) { + return { + selected: value.context.selected.map((f) => f.path), + budget: value.context.budget, + confidence: value.context.confidence, + }; +} +async function cycle(cli, root, task, target, failing, passing, stateDirectory) { + const started = nox(cli, root, ["start", task]).value; + await writeFile(path.join(root, target), failing); + const failure = nox(cli, root, ["finish"], 4).value; + assert.equal(failure.record.status, "failed"); + const continued = nox(cli, root, ["start", task]).value; + assert.equal(continued.continued, true); + assert.equal(continued.record.id, started.record.id); + assert.equal(continued.continuation.verification.status, "current-failed"); + await writeFile(path.join(root, target), passing); + const stale = nox(cli, root, ["start", task]).value; + assert.equal(stale.continuation.verification.status, "stale"); + const done = nox(cli, root, ["finish"]).value; + assert.equal(done.record.id, started.record.id); + assert.deepEqual(done.record.baseline, started.record.baseline); + assert.equal(done.record.status, "completed"); + assert.equal((await readdir(stateDirectory)).filter((n) => n.endsWith(".json")).length, 1); + assert.equal(done.learning.proposals.length, 0); + return { + context: contextEvidence(started), + taskId: started.record.id, + baseline: started.record.baseline, + failedExit: 4, + failureEvidence: failure.record.verification.at(-1), + continuedSameTask: true, + verificationInvalidatedAfterEdit: true, + finishInferred: true, + finalStatus: done.record.status, + checks: done.record.verification.at(-1), + documentationAssessment: done.completion, + modelCalls: done.record.calls.length, + }; +} + +try { + const deps = []; + for (const name of ["commander", "yaml", "zod"]) { + const p = JSON.parse( + run( + "npm", + [ + "pack", + path.join(source, "node_modules", name), + "--ignore-scripts", + "--json", + "--pack-destination", + scratch, + ], + scratch, + ).stdout, + )[0]; + deps.push(path.join(scratch, p.filename)); + } + const current = await install(source, "current-install", deps); + const old = await install(path.join(scratch, "old-source"), "old-install", deps); + report.package = current; + report.sourceCommit = git(source, ["rev-parse", "HEAD"]); + report.sourceDiff = git(source, ["diff", "--stat"]); + + const legacy = path.join(scratch, "legacy-project"); + await mkdir(legacy); + for (const dir of ["src", "test", "docs"]) await mkdir(path.join(legacy, dir)); + await writeFile( + path.join(legacy, "package.json"), + '{"name":"legacy-demo","private":true,"scripts":{"test":"node --test test/value.cjs"}}\n', + ); + await writeFile( + path.join(legacy, "src/value.cjs"), + "exports.normalize = value => value.trim();\n", + ); + await writeFile( + path.join(legacy, "test/value.cjs"), + 'const assert = require("node:assert/strict");\nassert.equal(require("../src/value.cjs").normalize(" x "), "x");\n', + ); + await writeFile( + path.join(legacy, "docs/architecture.md"), + "# Normalization\n\nTrim surrounding whitespace without changing internal spacing. Keep the existing CommonJS API.\n", + ); + await writeFile( + path.join(legacy, "AGENTS.md"), + "# Team instructions\n\nRead docs/architecture.md before changing normalization.\n", + ); + await writeFile( + path.join(legacy, "CLAUDE.md"), + "# Client instructions\n\nFollow AGENTS.md and preserve the CommonJS API.\n", + ); + git(legacy, ["init", "-b", "agent/legacy-demo"]); + const row = { name: "actual-old-CLI-upgrade", setup: await setup(old.cli, legacy) }; + row.approvedCommand = await policy(legacy, "npm", ["test"]); + git(legacy, ["add", "."]); + git(legacy, [ + "-c", + "user.name=Noxroot Acceptance", + "-c", + "user.email=test@example.invalid", + "commit", + "-m", + "Synthetic legacy baseline", + ]); + const task = "test normalization preserves internal spaces"; + const started = nox(old.cli, legacy, ["start", task]).value; + const before = await snapshot(legacy); + const recordBefore = await readFile(started.recordPath, "utf8"); + const preview = nox(current.cli, legacy, ["sync", "--dry-run", "--diff"]).value; + row.syncProposed = preview.preview.proposedFiles.filter((f) => f.action !== "reference"); + assert.deepEqual(await snapshot(legacy), before); + nox(current.cli, legacy, ["sync", "--yes"]); + const synced = await snapshot(legacy); + row.syncChanged = Object.keys(synced).filter((p) => synced[p] !== before[p]); + assert.deepEqual(row.syncChanged, ["AGENTS.md"]); + assert.ok( + (await readFile(path.join(legacy, "AGENTS.md"), "utf8")).startsWith( + "# Team instructions\n\nRead docs/architecture.md before changing normalization.", + ), + ); + assert.equal(await readFile(started.recordPath, "utf8"), recordBefore); + assert.equal(nox(current.cli, legacy, ["sync", "--dry-run"]).value.summary.managedChanges, 0); + const runs = path.join(legacy, ".git/noxroot/runs"); + await chmod(runs, 0o555); + try { + nox(current.cli, legacy, ["status"]); + for (const args of [["start", task], ["finish"]]) { + const denied = nox(current.cli, legacy, args, 3); + assert.equal(denied.value.error, "task-state-unavailable"); + assert.match(denied.value.message, /request write access/); + } + assert.equal(await readFile(started.recordPath, "utf8"), recordBefore); + assert.deepEqual(await snapshot(legacy), synced); + } finally { + await chmod(runs, 0o755); + } + row.blockedAccess = "status readable; start/finish exit 3; records unchanged; no second store"; + const resumed = nox(current.cli, legacy, ["start", task]).value; + assert.equal(resumed.record.id, started.record.id); + assert.equal(resumed.continued, true); + const originalTest = await readFile(path.join(legacy, "test/value.cjs"), "utf8"); + const result = await cycle( + current.cli, + legacy, + task, + "test/value.cjs", + originalTest + 'assert.equal(require("../src/value.cjs").normalize(" a b "), "a b");\n', + originalTest + 'assert.equal(require("../src/value.cjs").normalize(" a b "), "a b");\n', + runs, + ); + Object.assign(row, result, { userDocsPreserved: true, legacyRecordPreserved: true }); + assert.equal((await readdir(path.join(legacy, ".noxroot"))).includes("local"), false); + row.diff = git(legacy, ["diff"]); + report.results.push(row); + await save(); + console.log( + "Legacy upgrade: same task and baseline; denied access stopped safely; recovery completed.", + ); + + for (const name of ["underscore", "bottle"]) { + const root = path.join(scratch, name); + assert.equal(git(root, ["status", "--porcelain"]), ""); + const revision = git(root, ["rev-parse", "HEAD"]); + const row = { name, revision, setup: await setup(current.cli, root) }; + // Test-only setup stays local; do not create any upstream commit. + await appendFile(path.join(root, ".git/info/exclude"), "\n/.noxroot/\n/AGENTS.md\n"); + let target, failing, passing, task; + if (name === "underscore") { + row.baselineCheck = run( + "node", + [ + "-e", + 'require("node:assert/strict").deepEqual(require("./underscore").groupBy([1,2,3], x => x % 2), {0:[2],1:[1,3]})', + ], + root, + ); + row.approvedCommand = await policy(root, "node", ["noxroot-acceptance.cjs"], "test"); + target = "test/noxroot-acceptance.cjs"; + task = "test groupBy preserves input order within groups"; + const prefix = + 'const assert = require("node:assert/strict");\nconst _ = require("../underscore");\nassert.deepEqual(_.groupBy([3, 2, 1], value => value % 2), '; + failing = prefix + "{0:[2],1:[1,3]});\n"; + passing = prefix + "{0:[2],1:[3,1]});\n"; + row.testScope = + "Focused native Node assertions against the actual library, not its obsolete full QUnit/lint/browser toolchain."; + } else { + row.baselineCheck = run("python3", ["-B", "-m", "unittest", "test.test_router"], root); + row.approvedCommand = await policy(root, "python3", [ + "-B", + "-m", + "unittest", + "test.test_router", + ]); + target = "test/test_router.py"; + task = "test integer router parameters with leading zeros"; + const original = await readFile(path.join(root, target), "utf8"); + const addition = + '\n def testAcceptanceLeadingZeroInteger(self):\n self.assertMatches("/object/", "/object/007", id=7)\n'; + passing = original.replace( + " def testIntFilter(self):", + addition + "\n def testIntFilter(self):", + ); + assert.notEqual(passing, original); + failing = passing.replace('"/object/007", id=7)', '"/object/007", id=8)'); + row.testScope = + "Existing 32 router tests plus one new regression; not the whole web-framework suite."; + } + const before = await snapshot(root); + assert.equal(git(root, ["status", "--porcelain"]), ""); + Object.assign( + row, + await cycle( + current.cli, + root, + task, + target, + failing, + passing, + path.join(root, ".noxroot/local/runs"), + ), + ); + const after = await snapshot(root); + row.changed = Object.keys(after).filter((p) => after[p] !== before[p]); + assert.deepEqual(row.changed, [target]); + row.diff = git(root, ["diff"]); + row.gitStatus = git(root, ["status", "--short"]); + assert.equal(git(root, ["rev-parse", "HEAD"]), revision); + git(root, ["diff", "--check"]); + report.results.push(row); + await save(); + console.log( + `${name}: failed check surfaced; same-task continuation; inferred finish completed; no documentation growth.`, + ); + } + report.passed = true; +} catch (error) { + report.error = error.stack; + process.exitCode = 1; +} finally { + await save(); + console.log(`Evidence: ${path.join(scratch, "report.json")}`); + console.log(report.error ?? "All three workflows passed."); +} +// Keep changed checkouts for inspection. Remove only package/build/cache artifacts +// after validation; never remove dirty worktrees as part of automatic cleanup. diff --git a/tests/acceptance/live-legacy-denial.mjs b/tests/acceptance/live-legacy-denial.mjs new file mode 100644 index 0000000..392f1b5 --- /dev/null +++ b/tests/acceptance/live-legacy-denial.mjs @@ -0,0 +1,136 @@ +// Opt-in follow-up to legacy-workflows.mjs. Uses the user's existing Codex login. +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { appendFile, readFile, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const scratch = path.resolve(process.argv[2] ?? "."); +if (process.platform === "win32" || !/^\/tmp\/noxroot-legacy-acceptance-[\w-]+$/.test(scratch)) + throw Error("Use the prepared Linux legacy acceptance directory."); +const name = process.argv[3] ?? "live-legacy"; +if (!/^live-legacy(?:-[a-z0-9]+)?$/.test(name)) throw Error("Use a live-legacy-* fixture name."); +const root = path.join(scratch, name); +const env = { + ...process.env, + npm_config_offline: "true", + npm_config_audit: "false", + npm_config_fund: "false", + npm_config_cache: path.join(root, "node_modules/.cache/npm"), +}; +delete env.OPENAI_API_KEY; +delete env.CODEX_API_KEY; +function run(bin, args, cwd = root) { + const r = spawnSync(bin, args, { cwd, env, encoding: "utf8", timeout: 120000 }); + assert.equal(r.status, 0, r.stderr || r.stdout); + return r.stdout; +} +run( + "git", + [ + "-c", + "core.hooksPath=/dev/null", + "clone", + "--no-hardlinks", + path.join(scratch, "legacy-project"), + root, + ], + scratch, +); +await appendFile(path.join(root, ".git/info/exclude"), "\n/node_modules/\n"); +const packages = (await readdir(scratch)) + .filter((p) => p.endsWith(".tgz")) + .map((p) => path.join(scratch, p)); +packages.push(path.join(scratch, "current-install/noxroot-0.1.0.tgz")); +run("npm", [ + "install", + "--offline", + "--no-save", + "--package-lock=false", + "--ignore-scripts", + ...packages, +]); +const task = "test normalization preserves internal spaces"; +const oldCli = path.join(scratch, "old-install/node_modules/noxroot/dist/cli.js"); +const currentCli = path.join(root, "node_modules/noxroot/dist/cli.js"); +const started = JSON.parse(run("node", [oldCli, "start", task, "--json"])); +run("node", [currentCli, "sync", "--yes", "--json"]); +const beforeDiff = run("git", ["diff"]); +const beforeStatus = run("git", ["status", "--porcelain"]); +const beforeRecord = await readFile(started.recordPath, "utf8"); +const report = { root, taskId: started.record.id, commands: [], summary: "", exitCode: null }; +await new Promise((resolve, reject) => { + const prompt = + "Continue the unfinished task to test normalization preserves internal spaces. Add a regression for surrounding whitespace while keeping two internal spaces. Follow this repository's instructions and report what you could complete. Work only here. Do not commit, push, publish, install dependencies, read credentials, access unrelated projects, or use additional agents."; + const child = spawn( + "codex", + [ + "-a", + "never", + "exec", + "--ephemeral", + "--ignore-user-config", + "--sandbox", + "workspace-write", + "--json", + "-C", + root, + prompt, + ], + { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] }, + ); + let pending = "", + diagnostic = ""; + const timer = setTimeout(() => child.kill("SIGTERM"), 300000); + child.stderr.on("data", (data) => { + diagnostic = (diagnostic + data).slice(-2000); + }); + child.stdout.on("data", (data) => { + pending += data; + const lines = pending.split("\n"); + pending = lines.pop(); + for (const line of lines) { + let event; + try { + event = JSON.parse(line); + } catch { + continue; + } + if (event.type !== "item.completed") continue; + const item = event.item; + if (item?.type === "command_execution" && /noxroot/.test(item.command)) + report.commands.push({ + command: item.command, + exitCode: item.exit_code, + output: item.aggregated_output?.slice(0, 6000), + }); + if (item?.type === "agent_message") report.summary = item.text; + } + }); + child.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.on("close", (code) => { + clearTimeout(timer); + report.exitCode = code; + if (code !== 0) reject(Error(`Codex exited ${code}: ${diagnostic}`)); + else resolve(); + }); +}); +report.checks = { + noEdits: + run("git", ["diff"]) === beforeDiff && run("git", ["status", "--porcelain"]) === beforeStatus, + recordUnchanged: (await readFile(started.recordPath, "utf8")) === beforeRecord, + noSecondStore: !(await readdir(path.join(root, ".noxroot"))).includes("local"), + startDenied: report.commands.some( + (c) => + /start/.test(c.command) && c.exitCode === 3 && /Task state is not writable/.test(c.output), + ), +}; +await writeFile(path.join(scratch, `${name}-denial.json`), JSON.stringify(report, null, 2) + "\n"); +console.log(report.summary); +console.log(JSON.stringify(report.checks, null, 2)); +assert.ok( + Object.values(report.checks).every(Boolean), + "Live legacy denial acceptance failed; inspect retained evidence.", +); From cbb158f17f0e7159333d216710cdd67aafc9e68f Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:40:10 -0400 Subject: [PATCH 12/24] docs: record legacy acceptance and context limitations --- README.md | 20 +-- .../LEGACY-ACCEPTANCE-2026-09-04.md | 125 ++++++++++++++++++ .../acceptance/legacy-review-2026-09-04.json | 6 + 3 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md create mode 100644 tests/acceptance/legacy-review-2026-09-04.json diff --git a/README.md b/README.md index 8f442c6..769d2af 100644 --- a/README.md +++ b/README.md @@ -54,15 +54,15 @@ documentation current. If no reusable lesson exists, nothing is added. Completed expires after the configured retention window and is capped by count. Active and incomplete work is preserved. -Because project knowledge is plain Markdown, you can inspect it in GitHub, your editor, or -optionally Obsidian. Raw prompts, application sessions, credentials, and user data do not become -project memory. +Read project knowledge in GitHub, your editor, or Obsidian. It excludes raw prompts, application +sessions, credentials, and user data. ## Checks that match the change -During setup, Noxroot finds existing lint, type-check, test, build, and native eval commands. You -approve which may run. `finish` applies the relevant checks to the changed paths. Wider or sensitive -changes can require independent review. +During setup, Noxroot looks for existing lint, type-check, test, build, and native eval commands. +Legacy or custom commands may need explicit configuration. You approve which may run. `finish` +applies the relevant checks to the changed paths. Wider or sensitive changes can require independent +review. Noxroot shows which files changed, which commands ran, what passed or failed, and anything it could not verify. A missing relevant check produces `incomplete`, never `approved`. Inspect the exact plan @@ -130,14 +130,16 @@ agent how to check a change; the independent-review and optional product/UX skil reviews. Context loading comes from `AGENTS.md`, the knowledge index, and context routes, not a generated context skill. Learning comes from `finish` and `learn`, not a generated learning skill. -Skills do not prove that code works. The actual tests, type checks, builds, evals, and review -results do. An incomplete result can be handed off locally, but it cannot become approved or qualify -for a future automatic merge. Noxroot does not push, merge, publish, or deploy. +Skills are instructions, not test evidence. Incomplete work cannot become approved. Noxroot does not +push, merge, publish, or deploy. `context ""` is read-only. It does not start a task or run checks. Selection is advisory, not permission to edit. "Do not deploy" remains an exclusion; it never activates deployment work. Use `start` to record the task baseline and `finish` to check the resulting change. +Large source files can fall outside the brief's budget. Use `--verbose` to inspect selection and +read relevant source directly. + ## Try the read-only diagnosis Noxroot is not published to npm yet. From source, use Node.js `>=22.12 <27`: diff --git a/tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md b/tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md new file mode 100644 index 0000000..65089a0 --- /dev/null +++ b/tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md @@ -0,0 +1,125 @@ +# Legacy workflow acceptance + +Baseline: `9602dcd`. This pass adds targeted legacy workflows and a fresh independent review, not +another broad repository survey. No upstream commits, npm publication, or deployment. + +## Findings and corrections + +The independent reviewer found that short finish output could display an earlier reviewer approval +after a newer diff failed verification or awaited fresh review. Five regressions failed before the +fix. Eight added cases now cover historical decisions and current approval, rejection, and blocked +review. History remains available, but the short view reports the current completion attempt. + +A real Codex continuation initially skipped `start` after reading `status`, edited a test, then +correctly reported that finish was blocked by protected legacy state. This was a failed acceptance +run, not a pass. Generated instructions and human status now explicitly require repeating `start` +before resumed edits. Two existing tests were extended and failed before the correction. + +Review also caught that status showed a shortened outcome rather than the full task text. This could +drop exclusions and prevent matching continuation. Status now shows the original text. A regression +extracts the displayed `change the value; do not deploy` task and resumes the same record on a dirty +tree. It failed before the correction. + +The reviewer approved the corrections after 54 targeted tests, typecheck, and diff checks. The +strict response is retained in [legacy-review-2026-09-04.json](legacy-review-2026-09-04.json). + +## Three operator-driven workflows + +Both old and current CLIs were packed and installed offline with locally packed dependencies and +installation scripts disabled. The old CLI was built from actual commit `c32241b`, not simulated by +moving a current task record. Both packages still identify as unpublished `0.1.0`; this is a source +upgrade rehearsal, not an npm registry version upgrade. + +| Repository | Change and verification | Result | +| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| Synthetic legacy CommonJS project | Preserve internal spaces in normalization; `npm test`, cwd `.` | Old task survived sync, denied access, failure, continuation, and inferred finish | +| Underscore 1.8.3, `e4743ab712b8ab42ad4ccb48b155034d02394e4d` | Add a groupBy ordering regression; `node noxroot-acceptance.cjs`, cwd `test` | Failure surfaced, same task resumed, corrected regression completed | +| Bottle 0.12.25, `40aec5d4cca6ff4fbd73f4080554580fe4f5c212` | Add leading-zero integer route regression; `python3 -B -m unittest test.test_router`, cwd `.` | Existing 32 tests passed; added inherited regression produced 34 passing tests | + +Failures were deliberately incorrect test expectations, not discovered upstream defects. Underscore +used focused Node assertions against the real library, not its obsolete QUnit, lint, or browser +toolchain. Bottle exercised its router suite, not the whole framework. These are real CLI and native +test processes, but operator-driven test additions rather than autonomous feature implementations. + +Each workflow preserved one task ID and baseline, reported stale verification after another edit, +inferred finish without an ID, and recorded a completed result. No model calls were needed for the +CLI lifecycle. Checks were explicitly approved by the operator; automatic discovery returned none +for these cases and is not credited with finding them. + +## Upgrade and ownership evidence + +- Preview was read-only; repeated initialization was unchanged in all three cases. +- Legacy sync proposed and changed only the managed block in `AGENTS.md`. It added four lines; + surrounding team instructions, `CLAUDE.md`, architecture documentation, and task record stayed + unchanged. A second sync proposed no changes. The later continuation clarification extends one + existing managed line, without adding another file. +- With legacy `runs/` made read-only, status still worked; start and finish exited 3. Neither task + record nor repository content changed, and no second store was created. +- Restoring access allowed continuation of the original task. This is manual permission recovery, + not an automatic state migration or a claim that every agent can obtain narrow write access. +- Subsequent task work added no knowledge documents or learning proposals. Documentation remained + `not-assessed`; zero proposals is not proof that no useful lesson could exist. +- External checkouts changed only their stated regression test. Their Git revisions are unchanged. + +Initial setup proposed `AGENTS.md`, config, knowledge index, routes, and independent-review skill +for each external repository. Underscore's existing `CONTRIBUTING.md` was referenced, not copied. +Neither external setup proposed a verification policy or verification skill without evidence; the +operator explicitly configured one approved command afterward. + +## Context quality: weaker than lifecycle reliability + +- Legacy project: seven selected files, 1,951 bytes, about 488 tokens. Included existing AGENTS, + CLAUDE instructions, architecture, source, and regression test. +- Underscore: nine selected files, 12,835 bytes, about 3,209 tokens; confidence `insufficient`. The + main implementation was absent. This was not a useful standalone implementation brief. +- Bottle: ten selected files, 13,599 bytes, about 3,400 tokens. The router test was selected, but + `bottle.py` was absent and unrelated plugin tests were included. Reported `high` confidence is too + reassuring for this example. + +All remained within the default 16,000-byte budget. That proves bounded size, not relevance. Large +single-file implementations and confidence calibration remain limitations. README now states that +large files may be omitted and legacy/custom checks may require explicit configuration. No parser, +routing system, dependency, or model-based retrieval feature was added to this slice. + +## Live agent denial and retest + +One fresh Codex session used the user's existing ChatGPT login with `workspace-write`, approvals +disabled, and no sandbox bypass. The first attempt failed the no-edits assertion because it skipped +start. Its task record was unchanged and finish reported the restriction honestly. + +After the instruction/status correction, the same scenario in a fresh fixture passed all four +assertions: start exited 3 with an actionable write-access error; no agent edits; unchanged record; +no second store. The agent stopped without running tests or claiming completion. This demonstrates +one successful instruction-following case, not guaranteed behavior from every client or future run. +The later full-task status-text correction was covered by the deterministic continuation regression. + +The sandbox settings follow +[official OpenAI documentation](https://learn.chatgpt.com/docs/agent-approvals-security). No +credentials or raw agent transcripts were stored in repository knowledge. Only bounded product +command output and final summaries were retained in the isolated test directory. + +## Reproduction and retained evidence + +`legacy-workflows.mjs` takes an explicitly prepared `/tmp/noxroot-legacy-acceptance-*` directory +containing inspected pinned `underscore/`, `bottle/`, and built `old-source/` directories. It +installs the packed CLIs and runs the three workflows. `live-legacy-denial.mjs` takes that directory +and an optional fresh fixture name such as `live-legacy-retry`. It requires an existing Codex login +and the latest packed tarball in `current-install/`. Neither script is part of normal CI or +publishes. + +The first scripts ran with the reviewed finish-output fix. The live retest used the additional +continuation guidance. Final cross-platform validation and package smoke cover the completed branch; +the three original workflows are not claimed as rerun after every wording refinement. + +Scratch evidence root: `/tmp/noxroot-legacy-acceptance-4KcWWC`. Preserve dirty repositories: + +- `legacy-project`: managed instruction update and normalization regression. +- `underscore`: untracked `test/noxroot-acceptance.cjs`. +- `bottle`: modified `test/test_router.py`. +- `live-legacy`: managed instruction update and agent-added regression from the failed acceptance. +- `live-legacy-retry`: managed instruction update only; no edits by the agent. + +No worktrees were added to the Noxroot project and no workspace-parent artifacts were created. +Package installs, caches, tarballs, and the extracted old source are disposable. Dirty checkouts and +small evidence reports must remain until their removal is explicitly reconciled with workspace +policy. diff --git a/tests/acceptance/legacy-review-2026-09-04.json b/tests/acceptance/legacy-review-2026-09-04.json new file mode 100644 index 0000000..e04a31b --- /dev/null +++ b/tests/acceptance/legacy-review-2026-09-04.json @@ -0,0 +1,6 @@ +{ + "decision": "approved", + "summary": "Independently re-reviewed the uncommitted CLI, generated instructions, output correction, tests, README, and command documentation. Status now exposes the complete original task text; the regression extracts that displayed text and verifies same-record continuation on a dirty tree with an explicit exclusion. Instructions correctly require repeating start before resumed edits and distinguish status from write-access validation. Historical reviewer decisions no longer masquerade as current results. Documentation acknowledges advisory context and explicit configuration for legacy/custom checks without claiming universal agent compliance. All 54 targeted guided-lifecycle, initialization, documentation, and output-contract tests passed; typecheck and git diff --check passed. No reviewer edits or live acceptance executions were performed. Previously reported actionable findings are resolved; no new findings identified.", + "findings": [], + "learningCandidates": [] +} From 3b8b20a96035ebf51a5ab0e1ea200ade12c1c0ea Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:41:39 -0400 Subject: [PATCH 13/24] test: type the continuation response assertion --- tests/autonomy-guided.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/autonomy-guided.test.ts b/tests/autonomy-guided.test.ts index c6e5dd9..94b32ad 100644 --- a/tests/autonomy-guided.test.ts +++ b/tests/autonomy-guided.test.ts @@ -317,7 +317,12 @@ agents: {default: manual, adapters: {manual: {type: manual}}} .split("\n") .find((line) => line.startsWith(`${started.record.id} `))! .slice(started.record.id.length + 2); - const resumed = JSON.parse((await cli(["start", displayed, "--json", "--root", root])).stdout); + const resumed = JSON.parse( + (await cli(["start", displayed, "--json", "--root", root])).stdout, + ) as { + continued: boolean; + record: { id: string }; + }; expect(resumed.continued).toBe(true); expect(resumed.record.id).toBe(started.record.id); }); From e6735fce96b3be119452d7ce967c1ada1a77b281 Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:45:59 -0400 Subject: [PATCH 14/24] docs: record final validation and cleanup evidence --- .../LEGACY-ACCEPTANCE-2026-09-04.md | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md b/tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md index 65089a0..da506e7 100644 --- a/tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md +++ b/tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md @@ -100,6 +100,22 @@ command output and final summaries were retained in the isolated test directory. ## Reproduction and retained evidence +Final local validation at `3b8b20a`: full `npm run check` passed on Windows and in a clean Linux +copy. Each ran 204 tests with two platform-specific skips, formatting, lint, typecheck, build, +permission-confined preview, and installed-package smoke. The 600-record retention regression also +passed. An intermediate test-only lint failure was corrected before these final runs. + +Product changes since `9602dcd`: three source files, ten added lines and three removed. Eight tests +were added and existing continuation/initialization assertions extended. No runtime dependencies +were added. The README changed from 1,461 to 1,451 whitespace-separated words, preserving its intro +and existing visual assets. Final package size: 124,307 bytes, up 258 bytes from the preceding +124,049-byte candidate; unpacked size 399,858 bytes. Reproduction scripts and reports are not +shipped in the npm package. + +The validated branch is pushed as [PR #9](https://github.com/liolevx/noxroot/pull/9). Its checks are +the source of truth for GitHub Windows/macOS/Linux, Node 22/24/26, and package validation. This pass +does not authorize merging or npm publication. + `legacy-workflows.mjs` takes an explicitly prepared `/tmp/noxroot-legacy-acceptance-*` directory containing inspected pinned `underscore/`, `bottle/`, and built `old-source/` directories. It installs the packed CLIs and runs the three workflows. `live-legacy-denial.mjs` takes that directory @@ -120,6 +136,9 @@ Scratch evidence root: `/tmp/noxroot-legacy-acceptance-4KcWWC`. Preserve dirty r - `live-legacy-retry`: managed instruction update only; no edits by the agent. No worktrees were added to the Noxroot project and no workspace-parent artifacts were created. -Package installs, caches, tarballs, and the extracted old source are disposable. Dirty checkouts and -small evidence reports must remain until their removal is explicitly reconciled with workspace -policy. +Package installs, caches, tarballs, and the extracted old source were removed. The isolated Linux +validation trees were removed by the runner. The five dirty checkouts and small reports remain in +the single 4.7 MB evidence directory until their removal is reconciled with workspace policy. + +Noxroot repository: `C:/Users/lione/Documents/ChatGPT/noxroot`. Branch: +`agent/sandbox-lifecycle-quiet-output`; kept for PR review, not merged. From 9ae45f5b9c3acfdbfc3978f785ce6bc9c7ba97e9 Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:53:05 -0400 Subject: [PATCH 15/24] fix: canonicalize CLI roots before lifecycle operations --- src/cli.ts | 5 +++- tests/autonomy-guided.test.ts | 49 ++++++++++++++++++++++++++++++++++- tests/helpers.ts | 4 +-- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 7ca877d..7fe25bd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -126,7 +126,10 @@ function refuseDisabledModule( } function globals(command: Command): GlobalOptions { - return command.optsWithGlobals(); + const options = command.optsWithGlobals(); + // Resolve the user-selected root once at the CLI boundary, as preview does. + // Destination checks still reject later redirects and nested writable links. + return { ...options, root: realpathSync(options.root) }; } function renderOptions(io: Io, options: GlobalOptions): RenderOptions { diff --git a/tests/autonomy-guided.test.ts b/tests/autonomy-guided.test.ts index 94b32ad..3deaafe 100644 --- a/tests/autonomy-guided.test.ts +++ b/tests/autonomy-guided.test.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { CommanderError } from "commander"; import { afterEach, describe, expect, it } from "vitest"; @@ -80,6 +80,53 @@ const context = { }; describe("enforced autonomy and guided completion", () => { + it("uses one canonical repository through an aliased CLI root", async () => { + const root = await repository(); + const holder = await temporaryDirectory("noxroot-alias-"); + cleanup.push(holder); + const alias = path.join(holder, "repository"); + await symlink(root, alias, process.platform === "win32" ? "junction" : "dir"); + await cli(["init", "--yes", "--root", alias]); + await writeFile(path.join(root, "src/value.cjs"), "exports.value = 1;\n"); + await writeFile( + path.join(root, ".noxroot/verification.yml"), + JSON.stringify({ + version: 1, + commands: [ + { + id: "syntax", + executable: process.execPath, + args: ["--check", "src/value.cjs"], + cwd: ".", + timeoutMs: 10000, + appliesTo: ["src/**"], + }, + ], + }), + ); + await git(root, ["add", "."]); + await git(root, ["commit", "-m", "configured alias fixture"]); + const started = JSON.parse( + (await cli(["start", "change value", "--root", alias, "--json"])).stdout, + ) as { record: { id: string; repository: { root: string } } }; + expect(started.record.repository.root).toBe(await realpath(root)); + const status = JSON.parse((await cli(["status", "--root", alias, "--json"])).stdout) as { + active: Array<{ record: { id: string } }>; + }; + expect(status.active.map((item) => item.record.id)).toEqual([started.record.id]); + await writeFile(path.join(root, "src/value.cjs"), "exports.value = 2;\n"); + const continued = JSON.parse( + (await cli(["start", "change value", "--root", alias, "--json"])).stdout, + ) as { continued: boolean; record: { id: string } }; + expect(continued.continued).toBe(true); + expect(continued.record.id).toBe(started.record.id); + const finished = JSON.parse((await cli(["finish", "--root", alias, "--json"])).stdout) as { + record: { id: string; status: string }; + }; + expect(finished.record.id).toBe(started.record.id); + expect(finished.record.status).toBe("completed"); + expect(await readFile(path.join(root, ".noxroot/local/.gitignore"), "utf8")).toBe("*\n"); + }); it("caps effective authority and permanently disables merge and delivery", () => { const autonomy = effectiveAutonomy({ autonomy: { default: 5, implementation: 5, review: 5, merge: 3, delivery: 3 }, diff --git a/tests/helpers.ts b/tests/helpers.ts index e653132..e089c08 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,12 +1,12 @@ import { createHash } from "node:crypto"; -import { cp, mkdtemp, mkdir, readdir, readFile, rm, stat } from "node:fs/promises"; +import { cp, mkdtemp, mkdir, readdir, readFile, realpath, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; export const fixtures = path.resolve(import.meta.dirname, "fixtures"); export async function temporaryDirectory(prefix = "noxroot-test-"): Promise { - return mkdtemp(path.join(tmpdir(), prefix)); + return realpath(await mkdtemp(path.join(tmpdir(), prefix))); } export async function fixtureCopy( From 2f050acefc5e24dd80443e344dafc99cd6481633 Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:53:05 -0400 Subject: [PATCH 16/24] docs: record cross-platform alias-root review --- .../LEGACY-ACCEPTANCE-2026-09-04.md | 29 +++++++++++++++---- .../alias-root-review-2026-09-04.json | 6 ++++ 2 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 tests/acceptance/alias-root-review-2026-09-04.json diff --git a/tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md b/tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md index da506e7..21ff258 100644 --- a/tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md +++ b/tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md @@ -105,17 +105,34 @@ copy. Each ran 204 tests with two platform-specific skips, formatting, lint, typ permission-confined preview, and installed-package smoke. The 600-record retention regression also passed. An intermediate test-only lint failure was corrected before these final runs. -Product changes since `9602dcd`: three source files, ten added lines and three removed. Eight tests -were added and existing continuation/initialization assertions extended. No runtime dependencies -were added. The README changed from 1,461 to 1,451 whitespace-separated words, preserving its intro -and existing visual assets. Final package size: 124,307 bytes, up 258 bytes from the preceding -124,049-byte candidate; unpacked size 399,858 bytes. Reproduction scripts and reports are not -shipped in the npm package. +Before the CI correction below, changes since `9602dcd` covered three source files, ten added lines +and three removed. Eight tests were added and existing continuation/initialization assertions +extended. No runtime dependencies were added. The README changed from 1,461 to 1,451 +whitespace-separated words, preserving its intro and existing visual assets. Final package size: +124,307 bytes, up 258 bytes from the preceding 124,049-byte candidate; unpacked size 399,858 bytes. +Reproduction scripts and reports are not shipped in the npm package. The validated branch is pushed as [PR #9](https://github.com/liolevx/noxroot/pull/9). Its checks are the source of truth for GitHub Windows/macOS/Linux, Node 22/24/26, and package validation. This pass does not authorize merging or npm publication. +### Cross-platform CI follow-up + +The first PR run passed Linux, Node 22/26 smoke, and package checks, but failed Windows and macOS +tests on aliased temporary-directory roots. Review confirmed a real CLI issue as well: preview/init +accepted an aliased `--root`, while lifecycle commands passed the alias to a canonical-root safety +check and failed. Merely changing the test helper would have hidden that public CLI case. + +A new regression failed locally before the correction. It now exercises init, start, status, +same-task continuation, and inferred finish through a directory alias with an actual syntax check. +The task identity and ignored local store use the canonical repository. CLI options now resolve the +user-selected root once at the command boundary. Direct state-layer fixtures also use canonical +temporary paths. Nested-link and changed-root protections in `setupDestination` are unchanged. + +This adds one deterministic test, taking the suite to 207 cases. GitHub checks on the latest PR +commit determine whether the Windows/macOS correction is confirmed; earlier successful local runs +alone are not evidence of a passing CI result. + `legacy-workflows.mjs` takes an explicitly prepared `/tmp/noxroot-legacy-acceptance-*` directory containing inspected pinned `underscore/`, `bottle/`, and built `old-source/` directories. It installs the packed CLIs and runs the three workflows. `live-legacy-denial.mjs` takes that directory diff --git a/tests/acceptance/alias-root-review-2026-09-04.json b/tests/acceptance/alias-root-review-2026-09-04.json new file mode 100644 index 0000000..7d5c3df --- /dev/null +++ b/tests/acceptance/alias-root-review-2026-09-04.json @@ -0,0 +1,6 @@ +{ + "decision": "approved", + "summary": "Independently reviewed the three-file correction. CLI root canonicalization now occurs before lifecycle and state operations, resolving the real alias-root failure rather than merely masking it in fixtures. The new junction/symlink regression exercises init, start, status, dirty-tree continuation, and finish with consistent canonical identity. The temporary-directory helper now supplies canonical roots to direct state tests. Production setupDestination protections remain unchanged. Focused lifecycle, CLI, initialization, state, retention, and path-safety suites passed: 67 tests passed, 2 Windows-specific skips. These include rejection of nested links and roots replaced after preview. Typecheck and git diff --check passed. No reviewer edits were made; macOS CI remains necessary to confirm that platform directly.", + "findings": [], + "learningCandidates": [] +} From 7e58af7007723e0ababfbac4a4d928ab81797eef Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:54:18 -0400 Subject: [PATCH 17/24] docs: record alias-root validation results --- tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md b/tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md index 21ff258..4dded74 100644 --- a/tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md +++ b/tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md @@ -129,9 +129,12 @@ The task identity and ignored local store use the canonical repository. CLI opti user-selected root once at the command boundary. Direct state-layer fixtures also use canonical temporary paths. Nested-link and changed-root protections in `setupDestination` are unchanged. -This adds one deterministic test, taking the suite to 207 cases. GitHub checks on the latest PR -commit determine whether the Windows/macOS correction is confirmed; earlier successful local runs -alone are not evidence of a passing CI result. +This adds one deterministic test, taking the suite to 207 cases. Full Windows and clean Linux +validation passed after the correction: 205 passed and two platform-specific skips each. Final +package size is 124,396 bytes (347 bytes above the preceding candidate), unpacked 400,091 bytes. The +independent [alias-root review](alias-root-review-2026-09-04.json) approved the fix. GitHub checks +on the latest PR commit determine whether the Windows/macOS correction is confirmed; successful +local runs alone are not evidence of a passing CI result. `legacy-workflows.mjs` takes an explicitly prepared `/tmp/noxroot-legacy-acceptance-*` directory containing inspected pinned `underscore/`, `bottle/`, and built `old-source/` directories. It From e076c83c9efaeb8da99da323c2eb67df3daa6870 Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:04:58 -0400 Subject: [PATCH 18/24] fix: resolve Windows short paths consistently --- src/cli.ts | 2 +- tests/package-smoke.mjs | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 7fe25bd..fea52ab 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -129,7 +129,7 @@ function globals(command: Command): GlobalOptions { const options = command.optsWithGlobals(); // Resolve the user-selected root once at the CLI boundary, as preview does. // Destination checks still reject later redirects and nested writable links. - return { ...options, root: realpathSync(options.root) }; + return { ...options, root: realpathSync.native(options.root) }; } function renderOptions(io: Io, options: GlobalOptions): RenderOptions { diff --git a/tests/package-smoke.mjs b/tests/package-smoke.mjs index 564061b..f2d5be8 100644 --- a/tests/package-smoke.mjs +++ b/tests/package-smoke.mjs @@ -1,6 +1,16 @@ import { spawnSync } from "node:child_process"; import assert from "node:assert/strict"; -import { mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -272,6 +282,12 @@ try { const guided = (...args) => JSON.parse(invokeBinary(binary, [...args, "--root", guidedRoot, "--json"], installRoot)); const started = guided("start", "change value"); + // Keep the caller's raw temp path above: Windows CI may supply an 8.3 alias. + assert.equal(started.record.repository.root, await realpath(guidedRoot)); + assert.equal( + path.dirname(path.dirname(path.dirname(started.recordPath))), + path.join(await realpath(guidedRoot), ".noxroot"), + ); assert.ok(started.recordPath.includes(path.join(".noxroot", "local", "runs"))); assert.equal(run("git", ["status", "--porcelain"], { cwd: guidedRoot }).trim(), ""); await writeFile(path.join(guidedRoot, "src/value.mjs"), "export const value = ;\n"); From 3739d39abc6d21ca191d451f9cdf58372a9107c5 Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:04:58 -0400 Subject: [PATCH 19/24] docs: record Windows package-path regression and review --- tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md | 10 ++++++++++ tests/acceptance/short-path-review-2026-09-04.json | 6 ++++++ 2 files changed, 16 insertions(+) create mode 100644 tests/acceptance/short-path-review-2026-09-04.json diff --git a/tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md b/tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md index 4dded74..6dd89a6 100644 --- a/tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md +++ b/tests/acceptance/LEGACY-ACCEPTANCE-2026-09-04.md @@ -136,6 +136,16 @@ independent [alias-root review](alias-root-review-2026-09-04.json) approved the on the latest PR commit determine whether the Windows/macOS correction is confirmed; successful local runs alone are not evidence of a passing CI result. +The next CI run passed macOS and all Windows unit tests, but the Windows installed-package smoke +still failed on a `RUNNER~1` temporary path. Independent local probes confirmed that `realpathSync` +preserves Windows short names while `realpathSync.native` and the asynchronous destination validator +expand them. CLI root selection now uses the native resolver. The packed smoke keeps its raw +temporary path and asserts canonical repository and record-path identity. Destination guards remain +unchanged. Full local Windows validation passed again, including package smoke. The +[short-path review](short-path-review-2026-09-04.json) approved the correction; the latest PR checks +remain the cross-platform release gate. Package size after this correction is 124,395 bytes, up 346 +bytes from the preceding candidate; unpacked size is 400,098 bytes. + `legacy-workflows.mjs` takes an explicitly prepared `/tmp/noxroot-legacy-acceptance-*` directory containing inspected pinned `underscore/`, `bottle/`, and built `old-source/` directories. It installs the packed CLIs and runs the three workflows. `live-legacy-denial.mjs` takes that directory diff --git a/tests/acceptance/short-path-review-2026-09-04.json b/tests/acceptance/short-path-review-2026-09-04.json new file mode 100644 index 0000000..0b1b310 --- /dev/null +++ b/tests/acceptance/short-path-review-2026-09-04.json @@ -0,0 +1,6 @@ +{ + "decision": "approved", + "summary": "Final read-only review approves the native CLI canonicalization and both installed-package assertions. The smoke test retains the raw temporary path as input, checks canonical repository identity, and verifies that the record path resides beneath the canonical .noxroot directory. This preserves the Windows short-path reproducer rather than normalizing it away. No destination safety checks were weakened. node --check and git diff --check passed. Earlier independent focused tests passed; the full local check was reported passed by the implementing agent. Windows installed-package CI remains the final platform confirmation. No reviewer edits were made.", + "findings": [], + "learningCandidates": [] +} From f4108bff86e3988eb3c908ee72045fbb05a89aed Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:55:27 -0400 Subject: [PATCH 20/24] fix: select bounded source ranges and calibrate context confidence --- src/cli.ts | 5 + src/core/context.ts | 176 ++++++++++++++++++++++++------ src/core/proposals.ts | 12 ++ src/model.ts | 3 + src/output.ts | 30 +++-- tests/context-large-files.test.ts | 168 ++++++++++++++++++++++++++++ tests/context-routing.test.ts | 7 +- 7 files changed, 360 insertions(+), 41 deletions(-) create mode 100644 tests/context-large-files.test.ts diff --git a/src/cli.ts b/src/cli.ts index fea52ab..7aa489c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -350,6 +350,11 @@ function renderStart( ? [` Exclusions: ${context.intent.explicitExclusions.join("; ")}`] : []), ` Context: ${context.selected.length} relevant files · ~${context.budget.estimatedTokens.toLocaleString("en-US")} tokens`, + ...(context.confidence === "high" + ? [] + : [ + ` Confidence: ${context.confidence} · ${context.unknowns[0] ?? "ownership evidence needs inspection"}`, + ]), ` Likely area: ${context.applicableAreas.join(", ") || "not yet established"}`, ` Checks: ${checks.map((check) => check.id).join(", ") || "none approved yet"}`, ...(verbose ? [" Coding agent: not invoked (manual mode)", `Task: ${id}`] : []), diff --git a/src/core/context.ts b/src/core/context.ts index 4544195..b746a78 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -1,4 +1,4 @@ -import { readFile } from "node:fs/promises"; +import { open } from "node:fs/promises"; import path from "node:path"; import { loadConfig, loadRoutes, loadVerification } from "../config/load.js"; import { scanRepository } from "../detection/scan.js"; @@ -66,16 +66,19 @@ const TOKEN_ALIASES: Record = { reviews: "review", tests: "test", testing: "test", + integer: "int", + minified: "min", + minify: "min", verified: "verify", verification: "verify", }; const SOURCE_EXTENSION = /\.(?:ts|tsx|js|jsx|mjs|cjs|py|rs|go|java|kt|swift|cs|rb|php)$/; const TEST_PATH = - /(?:^|\/)(?:__tests__|tests?|e2e|specs?)(?:\/|$)|\.(?:test|spec)\.|(?:^|\/)(?:test_[^/]+|[^/]+_(?:test|spec))\.(?:go|py|rb)$/; + /(?:^|\/)(?:__tests__|tests?|e2e|specs?)(?:\/|$)|\.(?:test|spec)\.|(?:^|\/)(?:test_[^/]+|[^/]+_(?:test|spec))\.(?:go|py|rb)$|(?:^|\/)(?:test|spec)\.[^.]+$/; const DOCUMENT_PATH = /(?:^|\/)(?:docs?|adr|adrs)(?:\/|$)|\.(?:md|mdx)$/; const NON_AUTHORITATIVE_PATH = - /(?:^|\/)(?:expected|fixtures?|golden|snapshots?|examples?|generated|vendor|cassettes?|recordings?|testdata|canary|payloads?)(?:\/|$)/i; + /(?:^|\/)(?:expected|fixtures?|golden|snapshots?|examples?|generated|vendor|cassettes?|recordings?|testdata|canary|payloads?)(?:\/|$)|[.-]min\.(?:js|css)$/i; type Category = "entrypoint" | "manifest" | "source" | "test" | "document" | "other"; @@ -87,6 +90,7 @@ interface RankedCandidate { category: Category; matchedTerms: Set; pathMatchedTerms: Set; + excerpt?: Pick; } function isAlwaysContext(file: string): boolean { @@ -103,12 +107,15 @@ function normalizeToken(value: string): string { } function tokenList(value: string): string[] { - return ( - value + // Preserve explicit camelCase symbols as well as their component words. + const symbols = value.match(/\b[a-z][a-z0-9]*(?:[A-Z][a-z0-9]+)+\b/g) ?? []; + return [ + ...(value .replace(/([a-z0-9])([A-Z])/g, "$1 $2") .toLowerCase() - .match(/[a-z0-9]+/g) ?? [] - ) + .match(/[a-z0-9]+/g) ?? []), + ...symbols, + ] .map(normalizeToken) .filter((token) => token.length >= 3 && !STOP_WORDS.has(token)); } @@ -173,7 +180,7 @@ function baseScore(file: string, taskTerms: string[], activeRouteIds: string[]): reasons.push("authoritative project manifest"); } - for (const term of isAlwaysContext(file) ? [] : taskTerms) { + for (const term of isAlwaysContext(file) ? [] : taskTerms.filter((term) => term !== "test")) { if (stemTerms.includes(term)) { score += 50; matchedTerms.add(term); @@ -243,28 +250,95 @@ function baseScore(file: string, taskTerms: string[], activeRouteIds: string[]): }; } +// Navigation hints only: never persist source text or imply these windows are a full file. +function excerpt(source: string, taskTerms: string[], budget: number): RankedCandidate["excerpt"] { + const lines = source.match(/[^\n]*\n|[^\n]+$/g) ?? []; + const sizes = lines.map((line) => Buffer.byteLength(line)); + const lineTerms = lines.map(tokenList); + const hits = lineTerms + .flatMap((terms, index) => { + if (!taskTerms.some((term) => terms.includes(term))) return []; + const start = Math.max(0, index - 6); + const end = Math.min(lines.length - 1, index + 6); + const nearby = lineTerms.slice(start, end + 1).flat(); + const matches = taskTerms.filter((term) => nearby.includes(term)); + return [ + { + index, + start, + end, + matches, + score: matches.reduce( + (sum, term) => sum + Math.min(3, nearby.filter((word) => word === term).length), + 0, + ), + }, + ]; + }) + .sort((left, right) => right.score - left.score || left.index - right.index); + const ranges: Array<{ start: number; end: number }> = []; + const covered = new Set(); + let bytes = 0; + for (const hit of hits) { + if (ranges.length === 3) break; + const { start, end } = hit; + if (hit.matches.every((term) => covered.has(term))) continue; + if (ranges.some((range) => start + 1 <= range.end && end + 1 >= range.start)) continue; + const size = sizes.slice(start, end + 1).reduce((sum, value) => sum + value, 0); + if (bytes + size > budget) continue; + ranges.push({ start: start + 1, end: end + 1 }); + hit.matches.forEach((term) => covered.add(term)); + bytes += size; + } + return ranges.length + ? { bytes, lineRanges: ranges.sort((a, b) => a.start - b.start) } + : undefined; +} + async function addContentRelevance( root: string, candidates: RankedCandidate[], taskTerms: string[], -): Promise { + selectionFileLimit: number, +): Promise { let inspected = 0; let inspectedBytes = 0; + let incomplete = false; const inspectable = candidates .filter((item) => ["source", "test", "document"].includes(item.category)) - .filter((item) => item.bytes <= MAX_INSPECTED_FILE_BYTES) .sort((left, right) => right.score - left.score || left.file.localeCompare(right.file)); for (const item of inspectable) { - if (inspected >= MAX_CONTENT_INSPECTIONS || inspectedBytes + item.bytes > MAX_CONTENT_BYTES) - break; + const limit = Math.min(item.bytes, MAX_INSPECTED_FILE_BYTES); + if (inspected >= MAX_CONTENT_INSPECTIONS || inspectedBytes + limit > MAX_CONTENT_BYTES) { + incomplete = true; + continue; + } inspected += 1; - inspectedBytes += item.bytes; + inspectedBytes += limit; let source: string; try { - source = await readFile(resolveWithin(root, item.file), "utf8"); + const handle = await open(resolveWithin(root, item.file), "r"); + try { + const buffer = Buffer.alloc(limit); + const { bytesRead } = await handle.read(buffer, 0, limit, 0); + source = buffer.subarray(0, bytesRead).toString("utf8"); + if (item.bytes > bytesRead) { + incomplete = true; + // Discard a cut line (and any cut UTF-8 sequence), retaining exact line/byte ranges. + source = source.slice(0, source.lastIndexOf("\n") + 1); + } + } finally { + await handle.close(); + } } catch { + incomplete = true; continue; } + if (source.includes("\0")) continue; + if (item.bytes > selectionFileLimit && ["source", "test"].includes(item.category)) { + const ranges = excerpt(source, taskTerms, Math.min(3_000, selectionFileLimit)); + if (ranges) item.excerpt = ranges; + } const sourceTerms = tokenList(source); const matches = taskTerms.filter((term) => sourceTerms.includes(term)); if (matches.length > 0) { @@ -299,6 +373,7 @@ async function addContentRelevance( item.reasons.push(`matches ${item.matchedTerms.size} distinct task terms`); } } + return incomplete; } function addAdjacency(candidates: RankedCandidate[]): void { @@ -376,6 +451,7 @@ export async function buildContext(task: string, root = process.cwd()): Promise< "expected", "fixture", "golden", + "min", "payload", "recording", "sample", @@ -393,6 +469,7 @@ export async function buildContext(task: string, root = process.cwd()): Promise< .map((route) => route.id); const outsidePool: Array<{ path: string; reason: string }> = []; + let sourceOutsideRoutes = false; const scopeExcluded: Array<{ path: string; reason: string }> = []; const candidates: RankedCandidate[] = []; for (const file of profile.files) { @@ -416,6 +493,7 @@ export async function buildContext(task: string, root = process.cwd()): Promise< const activeRouteIds = routeIncludesFor(file); const fixed = isAlwaysContext(file) || MANIFESTS.has(path.posix.basename(file)); if (activeRoutes.length > 0 && !fixed && activeRouteIds.length === 0) { + if (category(file) === "source") sourceOutsideRoutes = true; if (outsidePool.length < 10) { outsidePool.push({ path: file, reason: "outside the active route candidate pool" }); } @@ -430,22 +508,32 @@ export async function buildContext(task: string, root = process.cwd()): Promise< candidates.push(candidate); } - await addContentRelevance(canonicalRoot, candidates, contentTerms); + const selectionFileLimit = Math.min(8_000, Math.floor(budget * 0.55)); + const inspectionIncomplete = await addContentRelevance( + canonicalRoot, + candidates, + contentTerms, + selectionFileLimit, + ); addAdjacency(candidates); candidates.sort((left, right) => right.score - left.score || left.file.localeCompare(right.file)); - const selectionFileLimit = Math.min(8_000, Math.floor(budget * 0.55)); + const hasTaskEvidence = (item: RankedCandidate): boolean => + item.pathMatchedTerms.size > 0 || + (contentTerms.length > 0 && item.matchedTerms.size >= Math.min(2, contentTerms.length)); + const selectedSize = (item: RankedCandidate): number => item.excerpt?.bytes ?? item.bytes; const directlyMatchedOwner = (item: RankedCandidate): boolean => item.category === "source" && item.reasons.some((reason) => reason.startsWith("basename matches task term")); const fitsSelection = (item: RankedCandidate): boolean => item.category === "entrypoint" ? item.bytes <= Math.min(selectionFileLimit, Math.floor(budget * 0.4)) - : item.bytes <= selectionFileLimit || (item.bytes <= budget && directlyMatchedOwner(item)); + : selectedSize(item) <= selectionFileLimit || + (item.bytes <= budget && directlyMatchedOwner(item)); const topOwner = candidates.find( (item) => item.category === "source" && - item.matchedTerms.size > 0 && + hasTaskEvidence(item) && item.score >= 20 && fitsSelection(item), ); @@ -466,10 +554,7 @@ export async function buildContext(task: string, root = process.cwd()): Promise< .slice(0, 3); const topTest = candidates.find( (item) => - item.category === "test" && - item.matchedTerms.size > 0 && - item.score >= 20 && - fitsSelection(item), + item.category === "test" && hasTaskEvidence(item) && item.score >= 20 && fitsSelection(item), ); const topProcedure = candidates.find( (item) => item.file.startsWith(".noxroot/skills/") && item.score >= 20 && fitsSelection(item), @@ -538,7 +623,7 @@ export async function buildContext(task: string, root = process.cwd()): Promise< } if ( (item.category === "source" || item.category === "test") && - item.matchedTerms.size === 0 && + !hasTaskEvidence(item) && !adjacentToPriority ) { if (excluded.length < 20) @@ -549,7 +634,8 @@ export async function buildContext(task: string, root = process.cwd()): Promise< (item.category === "source" || item.category === "test") && directPathMatch[item.category] && item.pathMatchedTerms.size === 0 && - !adjacentToPriority + !adjacentToPriority && + !(item.excerpt && priorityPaths.has(item.file) && item.matchedTerms.size >= 2) ) { if (excluded.length < 20) { excluded.push({ path: item.file, reason: "weaker than a direct task-path match" }); @@ -571,24 +657,28 @@ export async function buildContext(task: string, root = process.cwd()): Promise< excluded.push({ path: item.file, reason: `${item.category} category cap` }); continue; } - if (selectedBytes + item.bytes > budget) { + if (selectedBytes + selectedSize(item) > budget) { if (excluded.length < 20) excluded.push({ path: item.file, reason: "context byte budget" }); continue; } - if (!priorityPaths.has(item.file) && selectedBytes + item.bytes > curatedTargetBytes) { + if (!priorityPaths.has(item.file) && selectedBytes + selectedSize(item) > curatedTargetBytes) { if (excluded.length < 20) excluded.push({ path: item.file, reason: "curated context target" }); continue; } selected.push({ path: item.file, - bytes: item.bytes, - estimatedTokens: Math.ceil(item.bytes / 4), + bytes: selectedSize(item), + estimatedTokens: Math.ceil(selectedSize(item) / 4), + ...(item.excerpt ? { lineRanges: item.excerpt.lineRanges, sourceBytes: item.bytes } : {}), reasons: [ + ...(item.excerpt + ? ["partial file; inspect the selected line ranges and surrounding implementation"] + : []), ...new Set(item.reasons.length ? item.reasons : ["selected by bounded relevance ranking"]), ], }); - selectedBytes += item.bytes; + selectedBytes += selectedSize(item); categoryCounts[item.category] += 1; } @@ -597,7 +687,7 @@ export async function buildContext(task: string, root = process.cwd()): Promise< .filter( (item) => item.category === "source" && - (selectedSet.has(item.file) || (item.pathMatchedTerms.size > 0 && item.score >= 20)), + (selectedSet.has(item.file) || (hasTaskEvidence(item) && item.score >= 20)), ) .slice(0, 5) .map((item) => item.file); @@ -617,6 +707,21 @@ export async function buildContext(task: string, root = process.cwd()): Promise< .filter((item) => item.status === "conflicting") .map((item) => item.claim); const unknowns = [ + ...(!likelyOwningSource.length && sourceOutsideRoutes + ? [ + "Active routes exclude source files; review .noxroot/routes.yml before expanding the task scope.", + ] + : []), + ...likelyOwningSource + .filter((file) => !selectedSet.has(file)) + .map((file) => `Implementation not selected: ${file}`), + ...selected + .filter((item) => item.lineRanges && likelyOwningSource.includes(item.path)) + .map((item) => `Partial implementation context: ${item.path}`), + ...(inspectionIncomplete + ? ["Content inspection was bounded; uninspected content may contain other owners or tests."] + : []), + ...profile.stats.incompleteReasons, ...(likelyOwningSource.length ? [] : ["Owning source path"]), ...(likelyTests.length ? [] : ["Directly related test path"]), ...(requiredVerification.length ? [] : ["Applicable approved verification command"]), @@ -626,7 +731,16 @@ export async function buildContext(task: string, root = process.cwd()): Promise< likelyOwningSource.length > 0 && likelyTests.length > 0 && requiredVerification.length > 0 && - conflicts.length === 0 + conflicts.length === 0 && + unknowns.length === 0 && + likelyTests.some((file) => selectedSet.has(file)) && + !selected.some((item) => item.lineRanges) && + candidates.some( + (item) => + item.category === "source" && + selectedSet.has(item.file) && + (item.pathMatchedTerms.size > 0 || item.matchedTerms.size >= 2), + ) ? "high" : likelyOwningSource.length > 0 ? "partial" diff --git a/src/core/proposals.ts b/src/core/proposals.ts index 0c03d2d..3df8cbd 100644 --- a/src/core/proposals.ts +++ b/src/core/proposals.ts @@ -320,6 +320,17 @@ function routesContent( const testRoots = ["tests/**", "test/**", "e2e/**"].filter((glob) => profile.files.some((file) => file.startsWith(glob.replace("/**", "/"))), ); + const rootSourceGlobs = [ + ...new Set( + profile.files + .filter( + (file) => + !file.includes("/") && + /\.(?:ts|tsx|js|jsx|mjs|cjs|py|rs|go|java|kt|swift|cs|rb|php)$/.test(file), + ) + .map((file) => `*${path.posix.extname(file)}`), + ), + ]; return stringify({ version: 1, routes: [ @@ -335,6 +346,7 @@ function routesContent( ...projectRoots, ...sourceRoots, ...discoveredSourceRoots, + ...rootSourceGlobs, ...testRoots, ]), ], diff --git a/src/model.ts b/src/model.ts index d6b54bf..df3c557 100644 --- a/src/model.ts +++ b/src/model.ts @@ -146,6 +146,9 @@ export interface ContextSelection { bytes: number; estimatedTokens: number; reasons: string[]; + /** Present only for partial files; one-based inclusive ranges, not a full-file read. */ + lineRanges?: Array<{ start: number; end: number }>; + sourceBytes?: number; } export interface TaskIntent { diff --git a/src/output.ts b/src/output.ts index 7f239e8..e1ed9ae 100644 --- a/src/output.ts +++ b/src/output.ts @@ -319,6 +319,14 @@ export function renderPreview( } export function renderContext(context: ContextPackage, options: RenderOptions = {}): string { + const selectedPaths = new Map(context.selected.map((item) => [item.path, item])); + const candidatePath = (pathname: string): string => { + const item = selectedPaths.get(pathname); + if (!item) return `${pathname} (not selected; inspect selectively)`; + return item.lineRanges + ? `${pathname} (lines ${item.lineRanges.map((range) => `${range.start}-${range.end}`).join(", ")}; partial)` + : pathname; + }; if (!options.verbose) { const owners = context.likelyOwningSource.slice(0, 3); const tests = context.likelyTests.filter((file) => !owners.includes(file)).slice(0, 2); @@ -346,9 +354,17 @@ export function renderContext(context: ContextPackage, options: RenderOptions = ], options, ), - ...section("Likely owner", owners.length ? owners : ["Not established"], options), - ...section("Likely tests", tests.length ? tests : ["Not established"], options), - ...section("Also selected", guidance, options), + ...section( + "Likely owner", + owners.length ? owners.map(candidatePath) : ["Not established"], + options, + ), + ...section( + "Likely tests", + tests.length ? tests.map(candidatePath) : ["Not established"], + options, + ), + ...section("Also selected", guidance.map(candidatePath), options), ...section( "Checks", context.requiredVerification.length @@ -363,6 +379,7 @@ export function renderContext(context: ContextPackage, options: RenderOptions = ...(context.confidence === "high" ? [] : [`Confidence ${sentenceCase(context.confidence)}`]), + ...section("Missing evidence", context.unknowns.slice(0, 2), options), ...section("Excluded", [`${context.excluded.length} files left out`], options), `Next ${context.conflicts.length ? "Resolve the reported conflicts before editing." : "Inspect the relevant files, then build the requested change."}`, "Details Use --verbose for all selected paths and reasons; --json for structured context.", @@ -371,9 +388,6 @@ export function renderContext(context: ContextPackage, options: RenderOptions = .join("\n") + "\n" ); } - const selectedPaths = new Set(context.selected.map((item) => item.path)); - const candidatePath = (pathname: string): string => - selectedPaths.has(pathname) ? pathname : `${pathname} (path match; inspect selectively)`; const selectedSummary = options.verbose ? `${context.selected.length} of ${context.repositoryFileCount} files · ~${context.budget.estimatedTokens.toLocaleString("en-US")} tokens` : `${context.selected.length} files · ~${context.budget.estimatedTokens.toLocaleString("en-US")} tokens`; @@ -395,7 +409,7 @@ export function renderContext(context: ContextPackage, options: RenderOptions = : []), ...section( "Task context", - [selectedSummary, ...context.selected.map((item) => item.path)], + [selectedSummary, ...context.selected.map((item) => candidatePath(item.path))], options, ANSI.green, ), @@ -427,7 +441,7 @@ export function renderContext(context: ContextPackage, options: RenderOptions = ...section( "Selection reasons", context.selected.flatMap((item) => [ - `${item.path} (${item.bytes} bytes)`, + `${candidatePath(item.path)} (${item.bytes} selected bytes${item.sourceBytes === undefined ? "" : ` of ${item.sourceBytes}`})`, ...item.reasons.map((reason) => ` ${reason}`), ]), options, diff --git a/tests/context-large-files.test.ts b/tests/context-large-files.test.ts new file mode 100644 index 0000000..bbc1cfb --- /dev/null +++ b/tests/context-large-files.test.ts @@ -0,0 +1,168 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { buildContext } from "../src/core/context.js"; +import { renderContext } from "../src/output.js"; +import { previewRepository } from "../src/core/preview.js"; +import { applyProposals } from "../src/core/init.js"; +import { temporaryDirectory } from "./helpers.js"; + +const roots: string[] = []; +afterEach(async () => Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true })))); + +async function repository(files: Record): Promise { + const root = await temporaryDirectory("noxroot-large-context-"); + roots.push(root); + for (const [file, content] of Object.entries({ + ".noxroot/verification.yml": + "version: 1\ncommands:\n - id: syntax\n executable: node\n args: ['--check', 'core.js']\n cwd: .\n appliesTo: ['**/*']\n", + ...files, + })) { + await mkdir(path.dirname(path.join(root, file)), { recursive: true }); + await writeFile(path.join(root, file), content); + } + return root; +} + +it("finds a large generically named implementation and budgets exact line ranges", async () => { + const lines = [ + ...Array.from({ length: 700 }, () => "// unrelated padding é\r\n"), + "function parseIntegerRoute(value) {\r\n", + " return Number(value); // preserve leading zero integer route semantics\r\n", + "}\r\n", + ...Array.from({ length: 6000 }, () => "// unrelated padding é\r\n"), + ]; + const root = await repository({ + "core.js": lines.join(""), + "test/route.test.js": "// leading zero integer route regression\n", + "plugins/auth/test.py": "# route request authentication\n", + }); + const context = await buildContext("preserve leading zero integer route behavior", root); + expect(context).toEqual(await buildContext(context.task, root)); + expect(context.likelyOwningSource[0]).toBe("core.js"); + expect(context.likelyOwningSource).not.toContain("plugins/auth/test.py"); + const selected = context.selected.find((item) => item.path === "core.js"); + expect(selected).toMatchObject({ sourceBytes: Buffer.byteLength(lines.join("")) }); + expect(selected?.lineRanges?.some((range) => range.start <= 702 && range.end >= 702)).toBe(true); + expect(selected?.lineRanges?.length).toBeLessThanOrEqual(3); + const bytes = selected?.lineRanges?.reduce( + (total, range) => total + Buffer.byteLength(lines.slice(range.start - 1, range.end).join("")), + 0, + ); + expect(selected?.bytes).toBe(bytes); + expect(context.budget.selectedBytes).toBe( + context.selected.reduce((sum, item) => sum + item.bytes, 0), + ); + expect(context.budget.selectedBytes).toBeLessThanOrEqual(16_000); + expect(context.confidence).toBe("partial"); + expect(context.unknowns.join(" ")).toContain("Partial implementation context"); + for (const verbose of [false, true]) { + expect(renderContext(context, { verbose })).toMatch(/core\.js.*lines \d+-\d+.*partial/); + } +}); + +it("does not award high confidence to tests misclassified as implementation", async () => { + const root = await repository({ + "plugins/cache/test.py": "# integer route regression\n", + "plugins/cache/adapter.py": "# route route route route unrelated adapter\n", + "test/test_route.py": "# integer route regression\n", + }); + const context = await buildContext("integer route regression", root); + expect(context.likelyOwningSource).toEqual([]); + expect(context.confidence).toBe("insufficient"); +}); + +it("reports an omitted owner rather than high confidence when only a path matches", async () => { + const root = await repository({ + "core.js": "export const unrelated = true;\n", + "src/integer-route.js": "x".repeat(110_000), + "test/integer-route.test.js": "// integer route\n", + }); + const context = await buildContext("integer route", root); + expect(context.likelyOwningSource).toContain("src/integer-route.js"); + expect(context.confidence).not.toBe("high"); + expect(context.unknowns.join(" ")).toContain("Implementation not selected"); +}); + +it("keeps relevant text beyond the inspection limit unknown instead of pretending it was read", async () => { + const root = await repository({ + "core.js": `${"// filler\n".repeat(11_000)}function parseIntegerRoute() {}\n`, + "test/route.test.js": "// integer route regression\n", + }); + const context = await buildContext("integer route", root); + expect(context.selected.map((item) => item.path)).not.toContain("core.js"); + expect(context.confidence).toBe("insufficient"); + expect(context.unknowns.join(" ")).toContain("Content inspection was bounded"); +}); + +it("does not excerpt excluded routes or sensitive source files", async () => { + const root = await repository({ + ".noxroot/config.yml": "version: 1\nsensitivePaths: ['private/**']\n", + ".noxroot/routes.yml": + "version: 1\nroutes:\n - id: route\n match: ['route']\n include: ['src/**']\n exclude: ['src/blocked.js']\n", + "private/core.js": "function parseIntegerRoute() {}\n".repeat(4000), + "src/blocked.js": "function parseIntegerRoute() {}\n".repeat(4000), + "src/route.js": "function route() {}\n", + }); + const context = await buildContext("integer route", root); + expect(context.selected.map((item) => item.path)).not.toContain("private/core.js"); + expect(context.selected.map((item) => item.path)).not.toContain("src/blocked.js"); +}); + +it("keeps root-level implementation eligible after fresh initialization", async () => { + const root = await repository({ + "core.js": `function groupBy() {}\n${"// unused\n".repeat(6000)}`, + "test/collections.js": "// groupBy order regression\n", + }); + await applyProposals(await previewRepository(root)); + const context = await buildContext("test groupBy order", root); + expect(context.likelyOwningSource).toContain("core.js"); + expect( + context.selected.find((item) => item.path === "core.js")?.lineRanges?.length, + ).toBeGreaterThan(0); +}); + +it("preserves custom route boundaries and explains an excluded source pool", async () => { + const root = await repository({ + ".noxroot/routes.yml": + "version: 1\nroutes:\n - id: custom\n match: ['**/*']\n include: ['test/**']\n", + "core.js": "function groupBy() {}\n".repeat(5000), + "test/collections.js": "// groupBy order\n", + }); + const context = await buildContext("groupBy order", root); + expect(context.likelyOwningSource).toEqual([]); + expect(context.confidence).toBe("insufficient"); + expect(context.unknowns.join(" ")).toContain("Active routes exclude source files"); + expect( + (await previewRepository(root)).proposedFiles.some( + (file) => file.path === ".noxroot/routes.yml", + ), + ).toBe(false); +}); + +it("keeps minified duplicates out unless explicitly requested", async () => { + const root = await repository({ + "core.js": "function groupBy() {}\n", + "core-min.js": "function groupBy() {}\n", + "test/collections.js": "// groupBy\n", + }); + const ordinary = await buildContext("groupBy", root); + expect(ordinary.likelyOwningSource).toEqual(["core.js"]); + const explicit = await buildContext("inspect minified groupBy", root); + expect(explicit.likelyOwningSource).toContain("core-min.js"); + const filename = await buildContext("inspect core-min.js", root); + expect(filename.likelyOwningSource[0]).toBe("core-min.js"); +}); + +it("respects a smaller configured budget without hiding partial or omitted evidence", async () => { + const root = await repository({ + ".noxroot/config.yml": "version: 1\ncontext:\n budgetBytes: 500\n", + "core.js": `function groupBy() {}\n${"// é padding\n".repeat(10_000)}`, + "test/collections.js": "// groupBy\n", + }); + const context = await buildContext("groupBy", root); + expect(context.budget.maximumBytes).toBe(500); + expect(context.budget.selectedBytes).toBeLessThanOrEqual(500); + expect(context.confidence).not.toBe("high"); + expect(context.unknowns.length).toBeGreaterThan(0); +}); diff --git a/tests/context-routing.test.ts b/tests/context-routing.test.ts index e1cdc89..90c1b9a 100644 --- a/tests/context-routing.test.ts +++ b/tests/context-routing.test.ts @@ -87,7 +87,7 @@ describe("bounded relevance routing", () => { } }); - it("names an oversized direct owner without exceeding the selected context budget", async () => { + it("selects line ranges from an oversized direct owner without exceeding the context budget", async () => { const root = await temporaryDirectory("noxroot-context-oversized-owner-"); try { await mkdir(path.join(root, "src")); @@ -99,7 +99,10 @@ describe("bounded relevance routing", () => { const context = await buildContext("improve context ranking", root); expect(context.likelyOwningSource[0]).toBe("src/context-ranking.ts"); - expect(context.selected.map((item) => item.path)).not.toContain("src/context-ranking.ts"); + expect( + context.selected.find((item) => item.path === "src/context-ranking.ts")?.lineRanges?.length, + ).toBeGreaterThan(0); + expect(context.confidence).toBe("partial"); expect(context.budget.selectedBytes).toBeLessThanOrEqual(context.budget.maximumBytes); } finally { await rm(root, { recursive: true, force: true }); From d33d6c1c2ca14ca4e8fd5ab26dd63d1f2cf2637a Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:55:27 -0400 Subject: [PATCH 21/24] test: probe packed context on retained legacy source --- tests/acceptance/large-file-context.mjs | 123 ++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 tests/acceptance/large-file-context.mjs diff --git a/tests/acceptance/large-file-context.mjs b/tests/acceptance/large-file-context.mjs new file mode 100644 index 0000000..2141775 --- /dev/null +++ b/tests/acceptance/large-file-context.mjs @@ -0,0 +1,123 @@ +// Opt-in read-only context probes on two retained legacy checkouts. No upstream writes. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; + +if (process.platform === "win32") throw Error("Run in WSL/Linux."); +const retained = path.resolve(process.argv[2] ?? ""); +assert.match(retained, /^\/tmp\/noxroot-legacy-acceptance-[^/]+$/); +const source = path.resolve(import.meta.dirname, "../.."); +const scratch = await mkdtemp("/tmp/noxroot-large-context-"); +function run(executable, args, cwd = source) { + const result = spawnSync(executable, args, { + cwd, + encoding: "utf8", + maxBuffer: 8_000_000, + timeout: 120_000, + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + return result.stdout.trim(); +} +async function hashTree(root) { + const hash = createHash("sha256"); + async function walk(directory) { + for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => + a.name.localeCompare(b.name), + )) { + const file = path.join(directory, entry.name); + hash.update(path.relative(root, file)); + if (entry.isDirectory()) await walk(file); + else if (entry.isFile()) hash.update(await readFile(file)); + } + } + await walk(root); + return hash.digest("hex"); +} +try { + const packed = JSON.parse(run("npm", ["pack", "--pack-destination", scratch, "--json"]))[0]; + const install = path.join(scratch, "install"); + await mkdir(install); + run("npm", [ + "install", + "--prefix", + install, + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--cache", + path.join(scratch, "cache"), + path.join(scratch, packed.filename), + ]); + const cli = path.join(install, "node_modules/noxroot/dist/cli.js"); + const invoke = (root, ...args) => + JSON.parse(run("node", [cli, ...args, "--root", root, "--json"])); + const report = { packageBytes: packed.size, probes: [] }; + for (const [name, owner, task, symbol] of [ + ["underscore", "underscore.js", "test groupBy preserves input order within groups", "groupBy"], + ["bottle", "bottle.py", "test integer router parameters with leading zeros", "'int':"], + ]) { + const original = path.join(retained, name); + const status = run("git", ["status", "--porcelain"], original); + const originalHash = await hashTree(original); + const existing = invoke(original, "context", task); + assert.equal(await hashTree(original), originalHash); + assert.equal(existing.confidence, "insufficient"); + assert(existing.unknowns.some((item) => item.includes("Active routes exclude source files"))); + const root = path.join(scratch, name); + await mkdir(root); + // Archive committed upstream source into a disposable, non-Git source copy. + const archive = spawnSync("git", ["archive", "HEAD"], { cwd: original, maxBuffer: 16_000_000 }); + assert.equal(archive.status, 0); + assert.equal(spawnSync("tar", ["-x", "-C", root], { input: archive.stdout }).status, 0); + const preview = invoke(root, "preview"); + assert.equal(preview.initializationAllowed, true); + assert( + preview.proposedFiles.every( + (file) => + file.path === "AGENTS.md" || + file.path.startsWith(".noxroot/") || + file.action === "reference", + ), + ); + invoke(root, "init", "--yes"); + // Reuse the exact operator-approved check from the previous acceptance, not auto-discovery. + await writeFile( + path.join(root, ".noxroot/verification.yml"), + await readFile(path.join(original, ".noxroot/verification.yml")), + ); + const before = await hashTree(root); + const context = invoke(root, "context", task); + assert.deepEqual(context, invoke(root, "context", task)); + assert.equal(await hashTree(root), before); + assert.equal(run("git", ["status", "--porcelain"], original), status); + const selected = context.selected.find((item) => item.path === owner); + assert(selected?.lineRanges?.length, `${name}: missing implementation ranges`); + assert.equal(context.likelyOwningSource[0], owner); + assert.equal(context.confidence, "partial"); + const lines = (await readFile(path.join(root, owner), "utf8")).match(/[^\n]*\n|[^\n]+$/g); + const excerpt = selected.lineRanges + .map(({ start, end }) => lines.slice(start - 1, end).join("")) + .join(""); + assert(excerpt.includes(symbol), `${name}: selected windows miss ${symbol}`); + assert.equal(Buffer.byteLength(excerpt), selected.bytes); + assert(context.budget.selectedBytes <= 16_000); + report.probes.push({ + name, + revision: run("git", ["rev-parse", "HEAD"], original), + oldRoutes: "preserved; explicit recovery guidance", + confidence: context.confidence, + selected: context.selected, + owners: context.likelyOwningSource, + tests: context.likelyTests, + budget: context.budget, + unknowns: context.unknowns, + readOnly: true, + }); + } + console.log(JSON.stringify(report, null, 2)); +} finally { + await rm(scratch, { recursive: true, force: true }); + console.log("Removed disposable source copies, CLI install, package, and cache."); +} From e4b96b1e9ba718975fd1c2f92a5827590f91d7bc Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:55:27 -0400 Subject: [PATCH 22/24] docs: explain partial context and existing route boundaries --- README.md | 4 +- docs/commands.md | 12 +++ tests/acceptance/LARGE-CONTEXT-2026-09-04.md | 88 +++++++++++++++++++ .../large-context-review-2026-09-04.json | 6 ++ 4 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 tests/acceptance/LARGE-CONTEXT-2026-09-04.md create mode 100644 tests/acceptance/large-context-review-2026-09-04.json diff --git a/README.md b/README.md index 769d2af..87af616 100644 --- a/README.md +++ b/README.md @@ -137,8 +137,8 @@ push, merge, publish, or deploy. permission to edit. "Do not deploy" remains an exclusion; it never activates deployment work. Use `start` to record the task baseline and `finish` to check the resulting change. -Large source files can fall outside the brief's budget. Use `--verbose` to inspect selection and -read relevant source directly. +Large files get bounded line ranges when relevant text is found. Partial context is labelled; agents +still inspect the surrounding code. Existing routes stay unchanged. ## Try the read-only diagnosis diff --git a/docs/commands.md b/docs/commands.md index 74d32c3..610feed 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -75,6 +75,18 @@ checks with their working directories, an exclusion count, and estimated tokens. conflicts remain visible. `--verbose` adds every selected path, selection reasons, individual exclusions, unknowns, and byte counts. JSON retains the complete bounded context package. +Large source and test files can be selected as up to three line ranges rather than whole files. +Human output labels these as partial. JSON adds `lineRanges` (one-based, inclusive) and +`sourceBytes`; `bytes` counts only the selected ranges. These are reading hints, not embedded code +or complete functions. Inspect surrounding code and refresh context after edits move the lines. +Inspection remains capped at 96,000 bytes per file and 1,000,000 bytes across candidates. Missing +owners, partial files, and inspection limits prevent high confidence. + +Fresh setup includes root-level source extensions in its routes. Existing route files are not +rewritten by `init` or `sync`. If context reports excluded source files, review the includes in +`.noxroot/routes.yml` before widening scope; updating the CLI alone does not change those +boundaries. + Routine `start`, continuation, and `finish` output separates the result from supporting evidence. The short finish view still shows failures, verification gaps, pending review, and a path to the full local record. Passing tests alone never turn a pending review into approval. diff --git a/tests/acceptance/LARGE-CONTEXT-2026-09-04.md b/tests/acceptance/LARGE-CONTEXT-2026-09-04.md new file mode 100644 index 0000000..5036e72 --- /dev/null +++ b/tests/acceptance/LARGE-CONTEXT-2026-09-04.md @@ -0,0 +1,88 @@ +# Large-file context acceptance + +Baseline: `3739d39`. This slice addresses the context-selection failures recorded in +[legacy acceptance](LEGACY-ACCEPTANCE-2026-09-04.md), without adding repositories, model calls, +dependencies, source indexes, or persisted summaries. + +## Changes + +- Fresh setup includes root-level source extensions in its routes. Existing routes are preserved. +- Inspection reads at most 96,000 bytes per candidate and 1,000,000 bytes overall, rather than + skipping every file above the per-file inspection limit. Incomplete inspection is reported. +- Large source/test files can contribute up to three non-overlapping reading windows, at most 3,000 + bytes per file and always within the configured context budget. JSON stores line ranges and byte + counts, not source text. Human output labels partial selections. +- Standalone `test.py`-style files no longer count as implementation owners. Generic `test` path + matches and weak single-word content matches no longer fill the brief with unrelated tests or + adapters. Camel-case symbols retain their full name alongside component words; integer/int and + minified/minify/min terminology is normalized. +- Minified duplicates are excluded unless requested. Missing owners, omitted implementation, partial + files, bounded inspection, and missing check evidence prevent high confidence. + +## Test-first evidence + +Nine focused tests cover large generic implementation files, exact UTF-8/CRLF range bytes, +determinism, test classification, omitted owners, inspection limits, sensitive/custom routes, fresh +initialization, explicit minified targets, and a smaller configured budget. Four initial tests +failed before implementation. Both setup-route tests failed before the route correction. Additional +weak-match and explicit-minified assertions also failed before their corrections. The previous +oversized-owner regression now expects labelled ranges rather than complete omission. + +The independent reviewer requested a correction for explicit minified-file targeting. That failure +is fixed and covered by both descriptive and filename requests. The final +[independent review](large-context-review-2026-09-04.json) approved the slice after 59 focused +tests. + +Full local Windows `npm run check` passed: formatting, lint, typecheck, 214 passing tests with two +platform-specific skips, build, permission-confined preview, and installed-package smoke. The +existing 30-case synthetic routing benchmark and 600-record retention regression remain in the +suite. These are not 30 newly tested real repositories or 600 autonomous sessions. + +## Packed CLI on the same two legacy projects + +`large-file-context.mjs` packs and installs the real CLI with dependency install scripts disabled. +It reads the retained checkouts, archives their committed source into disposable non-Git copies, +reviews preview proposals, initializes only those copies, and reuses the earlier operator-approved +check configuration. It does not credit automatic discovery with finding those commands. + +| Project | Selected implementation | Related tests | Whole brief | Confidence | +| ---------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------- | ---------- | +| Underscore 1.8.3 | `underscore.js`: 400–412, 484–496, 1069–1081; 1,499 selected bytes of 52,919 | `test/collections.js`: 649–661; also `test/arrays.js` | 7,759 bytes, about 1,940 tokens | Partial | +| Bottle 0.12.25 | `bottle.py`: 292–304, 765–777, 1295–1307; 1,913 selected bytes of 151,993 | `test/test_router.py` | 10,899 bytes, about 2,725 tokens | Partial | + +Pinned revisions remain `e4743ab712b8ab42ad4ccb48b155034d02394e4d` and +`40aec5d4cca6ff4fbd73f4080554580fe4f5c212`. Assertions verify the selected source includes the +actual `groupBy` implementation and integer-conversion filter, that range bytes match the files, and +that repeated context is deterministic and below 16,000 bytes. Hashes prove context did not change +either retained repositories or disposable source copies. The probes do not execute project tests or +implement features; the preceding report covers those lifecycle workflows. + +Before this slice, the main implementation was missing from both briefs. The improved results above +use fresh routes on committed source copies, not an automatic migration of the previously +initialized working trees. Those retained trees still have their old route restrictions. The CLI now +reports insufficient context and points to reviewing `.noxroot/routes.yml`; it does not ignore or +silently replace those boundaries. No reinitialization is required to make a reviewed route edit. + +## Limits and release gate + +These are lexical reading hints, not AST-complete functions or proof that every relevant behavior +was found. Secondary ranges can still be less relevant. Code after the inspected prefix can be +missed, and line numbers must be refreshed after edits. Partial confidence is intentional, not an +acceptance failure or permission to skip surrounding source. Existing simple-repository behavior +remains covered by the full suite. + +The packed candidate is 126,481 bytes, 2,086 bytes above the preceding 124,395-byte candidate. No +runtime dependencies were added. README changes only the large-file limitation paragraph; the intro, +tagline, and visuals are unchanged. Command documentation explains the new metadata, inspection +limits, and existing-route behavior. Test harnesses and reports are not shipped. + +The final committed tree must also pass the clean Linux runner and the six checks on +[PR #9](https://github.com/liolevx/noxroot/pull/9). The PR is the source of truth for final +Windows/macOS/Linux and Node-version results. No merge, npm publication, or deployment is +authorized. + +All `/tmp/noxroot-large-context-*` source copies, installs, packages, and caches were removed by the +harness, including failed attempts. The prior 4.7 MB evidence directory +`/tmp/noxroot-legacy-acceptance-4KcWWC` remains unchanged. No worktrees or workspace-parent +artifacts were created. Repository: `C:/Users/lione/Documents/ChatGPT/noxroot`; branch: +`agent/sandbox-lifecycle-quiet-output`. diff --git a/tests/acceptance/large-context-review-2026-09-04.json b/tests/acceptance/large-context-review-2026-09-04.json new file mode 100644 index 0000000..8ac036d --- /dev/null +++ b/tests/acceptance/large-context-review-2026-09-04.json @@ -0,0 +1,6 @@ +{ + "decision": "approved", + "summary": "Independently re-reviewed the final bounded context changes. Minified targeting now uses consistent normalized tokens; descriptive and explicit filename regressions pass while ordinary requests exclude minified duplicates. Fresh routes include root-level source extensions without rewriting existing routes, and sensitive/custom-route boundaries remain enforced. Range selection retains bounded inspection, exact selected-byte accounting, explicit partial confidence, and metadata-only persistence. Documentation accurately describes reading hints and unchanged existing routes. The opt-in packed harness checks retained-tree hashes, repeated-context determinism, disposable-copy hashes, relevant symbols, and range bytes; approved checks are explicitly distinguished from automatic discovery. All 59 focused context, benchmark, routing, CLI, and initialization tests passed. Typecheck, harness syntax, and git diff --check passed. No reviewer edits or packed-harness execution occurred. The previous finding is resolved; no new actionable findings identified.", + "findings": [], + "learningCandidates": [] +} From f3a843e9b7740fe3885efb8c1e2ec3bba146d94c Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:10:27 -0400 Subject: [PATCH 23/24] test: pin and prepare Windows Corepack smoke offline --- .github/workflows/ci.yml | 5 ++++ tests/process-verification.test.ts | 44 ++++++++++++++++++++---------- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9b0ae7..a8098cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,11 @@ jobs: - run: npm run format:check - run: npm run lint - run: npm run typecheck + - name: Prepare pinned pnpm for the Windows adapter test + if: runner.os == 'Windows' + env: + COREPACK_DEFAULT_TO_LATEST: "0" + run: corepack prepare pnpm@10.0.0 - run: npm test - run: npm run build - run: npm run test:built diff --git a/tests/process-verification.test.ts b/tests/process-verification.test.ts index a56cb78..e0a9b3b 100644 --- a/tests/process-verification.test.ts +++ b/tests/process-verification.test.ts @@ -54,20 +54,36 @@ describe("safe process execution and verification trust", () => { existsSync( path.join(path.dirname(process.execPath), "node_modules", "corepack", "dist", "pnpm.js"), ), - )("invokes Corepack package managers without a command shell on Windows", async () => { - const root = await temporaryDirectory(); - cleanup.push(() => rm(root, { recursive: true, force: true })); - const result = await runProcess({ - executable: "pnpm", - args: ["--version"], - cwd: root, - repositoryRoot: root, - timeoutMs: 15_000, - }); - expect(result.exitCode).toBe(0); - expect(result.executable).toBe(process.execPath); - expect(result.args[0]).toMatch(/corepack[\\/]dist[\\/]pnpm\.js$/); - }); + )( + "invokes pinned Corepack package managers offline without a command shell on Windows", + async () => { + const root = await temporaryDirectory(); + cleanup.push(() => rm(root, { recursive: true, force: true })); + await writeFile( + path.join(root, "package.json"), + JSON.stringify({ packageManager: "pnpm@10.0.0" }), + ); + const result = await runProcess({ + executable: "pnpm", + args: ["--version"], + cwd: root, + repositoryRoot: root, + timeoutMs: 15_000, + env: { + COREPACK_ENABLE_NETWORK: "0", + COREPACK_DEFAULT_TO_LATEST: "0", + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", + ...(process.env.COREPACK_HOME ? { COREPACK_HOME: process.env.COREPACK_HOME } : {}), + }, + }); + expect(result.exitCode).toBe(0); + expect(result.timedOut).toBe(false); + expect(result.stdout.trim()).toBe("10.0.0"); + expect(result.executable).toBe(process.execPath); + expect(result.args[0]).toMatch(/corepack[\\/]dist[\\/]pnpm\.js$/); + }, + 30_000, + ); it("rejects process working-directory escapes", async () => { const root = await temporaryDirectory(); From 80b6cc19b6b85460eb30b4631378b2c7aac21b42 Mon Sep 17 00:00:00 2001 From: liolevx <312117550+liolevx@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:10:27 -0400 Subject: [PATCH 24/24] docs: record deterministic Corepack validation --- docs/development.md | 3 +++ tests/acceptance/LARGE-CONTEXT-2026-09-04.md | 17 +++++++++++++++++ .../acceptance/corepack-review-2026-09-04.json | 6 ++++++ 3 files changed, 26 insertions(+) create mode 100644 tests/acceptance/corepack-review-2026-09-04.json diff --git a/docs/development.md b/docs/development.md index 02a243c..bbfbb0e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -4,6 +4,9 @@ Requirements: Node.js `>=22.12 <27`, npm, and Git for lifecycle/worktree integra the full matrix on Node 24 across Linux, macOS, and Windows and installed-package smoke tests on Node 22 and 26 on Linux. +On Windows with Corepack installed, first run `corepack prepare pnpm@10.0.0`. This caches the pinned +test version. The adapter test then runs offline; CI performs preparation as a separate step. + ```bash npm ci npm run format:check diff --git a/tests/acceptance/LARGE-CONTEXT-2026-09-04.md b/tests/acceptance/LARGE-CONTEXT-2026-09-04.md index 5036e72..186fac0 100644 --- a/tests/acceptance/LARGE-CONTEXT-2026-09-04.md +++ b/tests/acceptance/LARGE-CONTEXT-2026-09-04.md @@ -81,6 +81,23 @@ The final committed tree must also pass the clean Linux runner and the six check Windows/macOS/Linux and Node-version results. No merge, npm publication, or deployment is authorized. +### Windows CI follow-up + +The first CI run passed five jobs and all nine new context tests, but the existing Windows Corepack +smoke hit its 15-second test deadline and cleanup encountered a locked directory. That test ran +unpinned `pnpm --version` from an empty directory, allowing Corepack registry lookup and download +inside the test. Its child and outer deadlines were also identical. + +CI now prepares `pnpm@10.0.0` separately, with automatic latest-version promotion disabled. The test +pins the same version and disables networking through the process adapter's explicit environment. It +still requires exit zero, exact version output, no timeout, and the Node/Corepack invocation path. +Only this test gets a 30-second outer deadline, leaving room for the unchanged 15-second child +deadline and termination. A missing cache failed promptly in the local test; after preparation in an +isolated cache, all ten process tests and full Windows validation passed. The +[review](corepack-review-2026-09-04.json) approved the correction. Its documentation caveat was +addressed by disabling promotion in CI and removing the unconditional default-preservation claim. No +production code or success criterion was weakened. The latest PR run remains the final gate. + All `/tmp/noxroot-large-context-*` source copies, installs, packages, and caches were removed by the harness, including failed attempts. The prior 4.7 MB evidence directory `/tmp/noxroot-legacy-acceptance-4KcWWC` remains unchanged. No worktrees or workspace-parent diff --git a/tests/acceptance/corepack-review-2026-09-04.json b/tests/acceptance/corepack-review-2026-09-04.json new file mode 100644 index 0000000..4917d68 --- /dev/null +++ b/tests/acceptance/corepack-review-2026-09-04.json @@ -0,0 +1,6 @@ +{ + "decision": "approved", + "summary": "Reviewed the three-file test/CI correction. The Windows test now pins pnpm@10.0.0, explicitly disables Corepack networking through runProcess request.env, preserves the optional cache location, and requires successful execution, exact version output, no timeout, and the existing Node/Corepack invocation evidence. The 30-second test deadline exceeds the 15-second child deadline and termination grace. CI preparation is separate from offline execution; cache misses are not treated as success. No production code changed. Typecheck, git diff --check, and four independently run process-safety tests passed. The implementing agent reported successful cold-cache failure and prepared-cache execution; Windows CI remains confirmation. One non-blocking documentation caveat: prepare without --activate avoids explicit activation, but disabling COREPACK_DEFAULT_TO_LATEST during preparation would also prevent Corepack's same-major automatic default promotion.", + "findings": [], + "learningCandidates": [] +}