Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 17 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,24 +77,26 @@ npx noxroot@latest init
```

Commit the reviewed setup before your first code-changing task; `start` requires a clean Git
baseline.
baseline. This is a local commit; no push is needed. Still evaluating? `preview` and `context` work
without initialization or a setup commit.

Then keep talking to your coding agent normally. Ask for your own feature, fix, or refactor. For
code-changing tasks, compatible agents are instructed to run `start` before editing and `finish`
when the change is ready to check, using the version pinned in your repository.
Then keep talking to your coding agent normally. For code changes, compatible agents are instructed
to run the pinned `start` before editing and `finish` afterward.

Run `init` once per repository. It previews a thin managed entrypoint, preserves existing
documentation, and pins the Noxroot version. `npx` downloads the package into npm's cache; no global
installation or clone of Noxroot is needed. The package is available on
[npm](https://www.npmjs.com/package/noxroot).
Finish all edits, run `finish`, address any failures or required review, then commit. Rerun `finish`
if you edit again. Approve real project commands during setup; see the
[first-task guide](docs/getting-started.md) for missing checks, timeouts, and package-age
restrictions.

Before setup, preview labels each capability `create`, `reuse`, `adjacent`, `conflict`, or
`not-assessed`. Noxroot creates only a confirmed gap. Existing systems stay in place. Missing
evidence means no change. If another tool already coordinates repository changes, Noxroot can add
non-overlapping context and verification support while that tool keeps ownership of task lifecycle,
review, and learning. A coordination ledger or session journal is reported as adjacent: it may
preserve work across sessions, but Noxroot does not import its log or treat it as a development
coordinator.
`init` preserves existing documentation and pins the Noxroot version. `npx` downloads from
[npm](https://www.npmjs.com/package/noxroot) into its cache; no global installation or clone is
needed.

Preview labels capabilities `create`, `reuse`, `adjacent`, `conflict`, or `not-assessed`. Noxroot
fills confirmed gaps; existing systems stay in place. Another development coordinator keeps
ownership of lifecycle, review, and learning; Noxroot can supply context and verification alongside
it. A coordination ledger is adjacent, not a development coordinator. Noxroot does not import its
log.

Read-only work creates no task. In the same repository, branch, and worktree, a repeated `start`
continues the active baseline. `finish` infers a single matching task; several matches require
Expand All @@ -106,9 +108,6 @@ When upgrading, inspect the managed instruction changes with
Starting a new chat does not require an upgrade or another initialization. See the
[command reference](docs/commands.md) for manual tasks and sync limits.

For the first change, continuation, and troubleshooting, read
[Getting started](docs/getting-started.md).

### What setup can add

| Surface | Actual path or command | Purpose |
Expand Down
6 changes: 6 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ then an unambiguous lockfile, then consistent CI evidence. npm, pnpm, Yarn, and
Missing or conflicting evidence produces no guessed command and preview never installs a manager or
runs Corepack.

Declared workspace members can inherit the root's confirmed manager when they have no local
declaration or lockfile. Unrelated nested projects do not inherit repository-wide CI commands.
Unsupported workspace patterns remain unconfirmed rather than guessed. Different root instruction
files must agree or explicitly forward to a canonical entrypoint; circular references remain a setup
conflict.

For Python, explicit `pytest`, Ruff, or mypy tool configuration can produce a scoped candidate using
the repository's `uv.lock` when present. Cargo and Go manifests produce their conventional native
test/check candidates. These remain proposals until accepted into verification policy; discovery
Expand Down
36 changes: 36 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ Check that it found your existing instructions and documentation. Look at propos
commands and their working directories. Missing evidence should remain visible, not become a guessed
command. For a repository of independent examples, select one project with `--root`.

You can also try `npx noxroot@latest context "<your actual task>"` before setup. This reads the
repository without committing anything. It shows a proposed brief, not completed verification.

## 2. Confirm setup once

```bash
Expand All @@ -28,10 +31,20 @@ npx noxroot@latest init
Review the patches and approve only what belongs in this repository. Existing documentation stays in
place. Compatible agent instructions receive a pinned Noxroot command.

Before accepting checks, inspect the executable, arguments, working directory, and affected paths in
`.noxroot/verification.yml`. A discovered test command is not proof that its dependencies are
installed or that it covers your change. For a missing command, use your project's documented check;
do not approve a placeholder that always succeeds. `verify --plan` shows the approved plan without
running it. See [verification configuration](configuration.md).

Review and commit the accepted setup changes before the first code-changing task. `start` requires a
clean committed Git baseline. Keep unrelated edits separate; do not discard them to make the working
tree clean.

This is a **local commit**, not a push or a commitment to keep Noxroot. It separates setup from the
task's actual code diff. If you are still evaluating it, stay with preview/context or use a
disposable copy before changing your working repository.

If another coordinator owns development work, Noxroot can offer companion setup for context and
verification. It does not take over that coordinator's lifecycle, reviews, or learning.

Expand All @@ -45,6 +58,8 @@ In full mode, compatible agents are instructed to:
1. Run `start` before editing to record a baseline and receive the task brief.
2. Inspect the relevant files and project rules, then make the change.
3. Run `finish` to check the actual diff and report remaining gaps or required review.
4. Address failures or review findings; rerun `finish` after any further edit, including formatting.
5. Review and commit the finished change before starting the next task.

Noxroot does not install native client hooks. Instructions guide agents; they cannot guarantee
compliance. For your first task, check the agent's command history for `start` and `finish`. If they
Expand All @@ -61,6 +76,12 @@ Useful lessons can be proposed for documentation after the task. Review those pr
applying them. No learning candidate is a valid outcome; every change does not need another
document.

An accepted lesson must also be eligible for the task's context route. Fresh setups include
`.noxroot/knowledge/**`; relevance and size limits still apply. If an older setup includes only
`.noxroot/knowledge/INDEX.md`, review `.noxroot/routes.yml` and add the lesson path or the knowledge
glob to the appropriate route. Existing routes are not widened automatically. Keep intentional
exclusions, and use `context "<your task>" --verbose` to inspect selection and exclusions.

For your first few tasks, ask:

- Did the brief surface a relevant rule or file without loading unrelated material?
Expand Down Expand Up @@ -89,5 +110,20 @@ active tasks and next actions. If task-state writes are blocked, stop before edi
only the required access. Do not disable the sandbox. If several tasks match, select the intended
task instead of guessing.

**A check timed out:** read the command, working directory, time limit, and last captured output.
Confirm the project's prerequisites and environment. A sandbox-dependent failure is still a failed
check; do not disable sandboxing, delete a check, or silently increase its limit to make it green.
After resolving the cause, rerun `finish`. An operator's separate diagnostic run is not an automatic
agent pass. Captured output is byte-capped; when truncated, Noxroot keeps the tail, not the full
log.

**npm/pnpm says the release is too new:** repositories can enforce `min-release-age` or
`minimumReleaseAge`. A fresh Noxroot release can be refused just like another dependency. Keep that
policy intact and retry the same install path after its waiting period, or use an already eligible
version approved for the project. `ERR_PNPM_NO_MATURE_MATCHING_VERSION` identifies this case;
`ETARGET` alone can have other causes. Inspect the full native error before assuming an age block.
Project-local installation and `npx` resolution are different paths; neither should be used as a
workaround to evade the project's policy.

For upgrades, review the [sync procedure](commands.md#init-and-sync). For Noxroot's own source
checkout, use the [development instructions](development.md).
35 changes: 22 additions & 13 deletions src/adapters/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,21 +94,30 @@ function appendBounded(
state: { bytes: number; truncated: boolean },
limit: number,
): void {
if (state.bytes >= limit) {
chunks.push(chunk);
state.bytes += chunk.byteLength;
while (state.bytes > limit) {
state.truncated = true;
return;
}
const remaining = limit - state.bytes;
if (chunk.byteLength > remaining) {
chunks.push(chunk.subarray(0, remaining));
state.bytes += remaining;
state.truncated = true;
} else {
chunks.push(chunk);
state.bytes += chunk.byteLength;
const first = chunks[0]!;
const excess = state.bytes - limit;
if (first.byteLength <= excess) {
chunks.shift();
state.bytes -= first.byteLength;
} else {
chunks[0] = first.subarray(excess);
state.bytes -= excess;
}
}
}

function decodeTail(chunks: Buffer[]): string {
const buffer = Buffer.concat(chunks);
let start = 0;
// A byte-capped tail can start inside a UTF-8 character. Drop only the cut prefix/suffix.
while (start < buffer.length && (buffer[start]! & 0xc0) === 0x80) start++;
return new TextDecoder().decode(buffer.subarray(start), { stream: true });
}

export async function runProcess(request: ProcessRequest): Promise<ProcessEvidence> {
if (request.signal?.aborted) throw new Error("Process cancelled before it started.");
const cwd = path.resolve(request.cwd);
Expand Down Expand Up @@ -181,8 +190,8 @@ export async function runProcess(request: ProcessRequest): Promise<ProcessEviden
exitCode,
signal,
timedOut,
stdout: Buffer.concat(stdout).toString("utf8"),
stderr: Buffer.concat(stderr).toString("utf8"),
stdout: decodeTail(stdout),
stderr: decodeTail(stderr),
outputTruncated: stdoutState.truncated || stderrState.truncated,
});
});
Expand Down
27 changes: 24 additions & 3 deletions src/core/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ const TOKEN_ALIASES: Record<string, string> = {
verification: "verify",
};

const SOURCE_EXTENSION = /\.(?:ts|tsx|js|jsx|mjs|cjs|py|rs|go|java|kt|swift|cs|rb|php)$/;
const SOURCE_EXTENSION =
/\.(?:ts|tsx|js|jsx|mjs|cjs|py|rs|go|java|kt|swift|cs|rb|php|c|cc|cpp|cxx)$/;
const DECLARATION_PATH = /(?:\.d\.(?:ts|mts|cts)|\.(?:h|hh|hpp|hxx))$/;
const TEST_PATH =
/(?:^|\/)(?:__tests__|tests?|e2e|specs?)(?:\/|$)|\.(?:test|spec)\.|(?:^|\/)(?:test_[^/]+|[^/]+_(?:test|spec))\.(?:go|py|rb)$|(?:^|\/)(?:test|spec)\.[^.]+$/;
const DOCUMENT_PATH = /(?:^|\/)(?:docs?|adr|adrs)(?:\/|$)|\.(?:md|mdx)$/;
Expand Down Expand Up @@ -143,6 +145,7 @@ function category(file: string): Category {
if (isAlwaysContext(file)) return "entrypoint";
if (MANIFESTS.has(path.posix.basename(file))) return "manifest";
if (TEST_PATH.test(file)) return "test";
if (DECLARATION_PATH.test(file)) return "document";
if (SOURCE_EXTENSION.test(file)) return "source";
if (DOCUMENT_PATH.test(file)) return "document";
return "other";
Expand Down Expand Up @@ -306,7 +309,13 @@ async function addContentRelevance(
let incomplete = false;
const inspectable = candidates
.filter((item) => ["source", "test", "document"].includes(item.category))
.sort((left, right) => right.score - left.score || left.file.localeCompare(right.file));
// Give implementations a chance before keyword-heavy declarations/docs exhaust the read budget.
.sort(
(left, right) =>
Number(right.category === "source") - Number(left.category === "source") ||
right.score - left.score ||
left.file.localeCompare(right.file),
);
for (const item of inspectable) {
const limit = Math.min(item.bytes, MAX_INSPECTED_FILE_BYTES);
if (inspected >= MAX_CONTENT_INSPECTIONS || inspectedBytes + limit > MAX_CONTENT_BYTES) {
Expand Down Expand Up @@ -613,6 +622,18 @@ export async function buildContext(task: string, root = process.cwd()): Promise<
const excluded: Array<{ path: string; reason: string }> = [...scopeExcluded, ...outsidePool];
let selectedBytes = 0;
for (const item of selectionOrder) {
if (
item.file.startsWith(".noxroot/knowledge/") &&
!isAlwaysContext(item.file) &&
!hasTaskEvidence(item)
) {
if (excluded.length < 20)
excluded.push({
path: item.file,
reason: "knowledge requires task relevance, not just an index reference",
});
continue;
}
const adjacentToPriority = item.reasons.some((reason) => {
const match = /^source\/test counterpart of (.+)$/.exec(reason);
return Boolean(match?.[1] && priorityPaths.has(match[1]));
Expand All @@ -635,7 +656,7 @@ export async function buildContext(task: string, root = process.cwd()): Promise<
directPathMatch[item.category] &&
item.pathMatchedTerms.size === 0 &&
!adjacentToPriority &&
!(item.excerpt && priorityPaths.has(item.file) && item.matchedTerms.size >= 2)
!(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" });
Expand Down
2 changes: 1 addition & 1 deletion src/core/proposals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ function routesContent(
include: [
...new Set([
"AGENTS.md",
".noxroot/knowledge/INDEX.md",
".noxroot/knowledge/**",
...usefulDocuments(profile, adoption).map((document) => document.path),
...skillPaths,
...projectRoots,
Expand Down
44 changes: 36 additions & 8 deletions src/detection/adoption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,25 +364,53 @@ export async function inspectRepositoryAdoption(
const source = sources.get(file);
if (!source || source.length > 400) continue;
const targets = references.filter(
(item) => item.from === file && INSTRUCTION_NAME.test(path.posix.basename(item.path)),
(item) =>
item.from === file &&
item.path !== file &&
INSTRUCTION_NAME.test(path.posix.basename(item.path)),
);
const containsIndependentDirective =
/\b(?:do not|don't|must not|never|override|ignore|instead of|only follow)\b/i.test(source);
if (targets.length === 1 && !containsIndependentDirective) {
const stub = source
.split(/\r?\n/)
.map((line) => line.trim())
.filter(
(line) =>
line &&
line.replace(/^#+\s*/, "").toLowerCase() !== path.posix.basename(file).toLowerCase(),
)
.join(" ");
const targetPath = targets[0]?.path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pureForward =
targetPath !== undefined &&
new RegExp(
`^(?:(?:see|read|follow|refer to)\\s+)?(?:@?${targetPath}|\\[[^\\]\\n]+\\]\\(${targetPath}\\))(?:\\s+for\\s+(?:(?:AI )?coding agent |repository |project )?(?:instructions|guidance))?\\.?$`,
"i",
).test(stub);
if (targets.length === 1 && !containsIndependentDirective && pureForward) {
forwarding.push({ from: file, to: targets[0]!.path });
}
}

const rootInstructions = instructionFiles.filter((file) => !file.includes("/"));
const normalizedInstruction = (file: string): string =>
(sources.get(file) ?? "").replace(/\s+/g, " ").trim().toLowerCase();
const canonicalInstruction = (file: string): string | undefined => {
const visited = new Set<string>();
let current = file;
while (!visited.has(current)) {
visited.add(current);
const target = forwarding.find((item) => item.from === current)?.to;
if (!target) return normalizedInstruction(current);
current = target;
}
return undefined; // A forwarding cycle cannot establish an authoritative entrypoint.
};
const canonicalInstructions = rootInstructions.map(canonicalInstruction);
const genuineInstructionConflict =
rootInstructions.length > 1 &&
!rootInstructions.every(
(file, index, all) =>
index === 0 ||
normalizedInstruction(file) === normalizedInstruction(all[0]!) ||
forwarding.some((item) => item.from === file && rootInstructions.includes(item.to)),
!canonicalInstructions.every(
(value) => value !== undefined && value === canonicalInstructions[0],
);

const referencedPaths = [...new Set(references.map((item) => item.path))].sort();
Expand Down Expand Up @@ -686,7 +714,7 @@ export async function inspectRepositoryAdoption(
...(projectCollection ? collectionGap : []),
...(genuineInstructionConflict
? [
`Multiple root agent instruction sources require reconciliation: ${rootInstructions.join(", ")}`,
`Multiple root agent instruction sources require reconciliation: ${rootInstructions.join(", ")}. Choose one canonical entrypoint and make the others forward to it; Noxroot will not merge differing instructions automatically.`,
]
: []),
...coordinators.map(
Expand Down
51 changes: 51 additions & 0 deletions src/detection/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,40 @@ function slug(value: string): string {
.replace(/^-|-$/g, "");
}

function workspaceMember(contents: ContentMap, directory: string): boolean {
try {
const manifest = JSON.parse(contents["package.json"] ?? "{}") as { workspaces?: unknown };
const pnpm = parseYaml(contents["pnpm-workspace.yaml"] ?? "{}") as {
packages?: unknown;
} | null;
const patterns = pnpm?.packages ?? manifest.workspaces;
if (
!Array.isArray(patterns) ||
!patterns.every((value): value is string => typeof value === "string")
)
return false;
if (!patterns.every((value) => /^!?[\w./*-]+$/.test(value))) return false;
const match = (value: string): boolean => {
// Deliberately support only ordinary workspace globs; do not guess brace/extglob semantics.
if (!/^[\w./*-]+$/.test(value)) return false;
const expression = value
.replace(/[.+]/g, "\\$&")
.replaceAll("**/", "\0")
.replaceAll("**", "\x01")
.replaceAll("*", "[^/]*")
.replaceAll("\0", "(?:.*/)?")
.replaceAll("\x01", ".*");
return new RegExp(`^${expression}$`).test(directory);
};
return (
patterns.some((value) => !value.startsWith("!") && match(value)) &&
!patterns.some((value) => value.startsWith("!") && match(value.slice(1)))
);
} catch {
return false;
}
}

function packageManagerEvidence(
files: string[],
contents: ContentMap,
Expand Down Expand Up @@ -271,6 +305,23 @@ function packageManagerEvidence(
detail: "Multiple package-manager lockfiles are present.",
};
}
if (directory !== ".") {
if (workspaceMember(contents, directory)) {
const rootManifest = JSON.parse(contents["package.json"] ?? "{}") as PackageManifest;
const workspaceManager = packageManagerEvidence(files, contents, rootManifest);
if (workspaceManager.name && workspaceManager.status === "confirmed")
return {
...workspaceManager,
detail: `Workspace member ${directory} uses ${workspaceManager.name} from the repository root.`,
};
}
return {
status: "unknown",
sources: [manifestPath],
detail:
"No local package-manager evidence or confirmed workspace membership; repository-wide CI commands are not evidence for this package.",
};
}
const ciSources = Object.entries(contents).filter(([file]) =>
file.startsWith(".github/workflows/"),
);
Expand Down
Loading