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
30 changes: 29 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
CodeTruss CLI follows semantic versioning. Release artifacts and their SHA-256
checksums are published at <https://codetruss.com/downloads/codetruss-cli-latest.json>.

The current public release is [v0.2.37 on GitHub](https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v0.2.37),
The current public release is [v0.2.38 on GitHub](https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v0.2.38),
distributed from <https://codetruss.com/downloads/codetruss-cli-latest.json>.
The npm `latest` tag is still
[`@codetruss/cli@0.2.24`](https://www.npmjs.com/package/@codetruss/cli/v/0.2.24):
Expand All @@ -16,6 +16,34 @@ were superseded before distribution.

No unreleased changes.

## 0.2.38 — 2026-08-07

- **A lone changed file no longer seats its own directory as inferred scope
while the turn is inferring scope elsewhere.** With no allow globs configured,
the working-set rule let a single file establish its parent directory as a
root. On a turn touching several directories with one file each, every
directory it touched vouched for itself, so scope drift — the detection that
runs on an unconfigured first run — could not fire at all. The scope was being
read off the very change it was meant to judge. Measured on the 0.2.36
teardown fixture: `codetruss review --task "Add free-text search to the task
list endpoint"` with no `.codetruss.yml` and no `--allow` called the unrelated
`src/lib/billing.ts` in scope and reported no drift, while the same fixture
under `--allow 'src/routes/**'` named it correctly.
- The single-file allowance stays, narrowed to what it was introduced as in
0.2.32: the whole turn's fallback, not a grant each directory can claim for
itself. It now applies only when admitting it would be the turn's **only**
inferred root — no approved allow root, no root the task already named, and
one candidate directory. A lone file beside any other inferred scope is drift
again. A cluster of two files still stands on its own evidence, and a
directory the task names by feature is still reached without it.
- Verdicts change only for that shape: an unconfigured turn whose changed files
spread across several directories with too few files to cohere. Turns with
configured allow globs are untouched, and so is the archetypal first-run case
the allowance exists for — one file, or one directory, with nothing else
inferred. Receipts disclose `inferred` classifications exactly as before: the
classification, its bases, the receipt schema, and every signed Markdown
rendering are unchanged, so receipts already on disk verify byte for byte.

## 0.2.37 — 2026-08-07

- **A process tree whose leader already exited is never force-killed on
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ To pin an exact version, install the immutable archive directly:

```bash
npm install --global --ignore-scripts --no-audit --no-fund \
https://codetruss.com/downloads/codetruss-cli-0.2.37.tgz
https://codetruss.com/downloads/codetruss-cli-0.2.38.tgz
```

The `@codetruss/cli` package on the npm registry is published as a separate,
Expand Down Expand Up @@ -313,8 +313,8 @@ clean global install.
Verify a downloaded release yourself:

```bash
gh attestation verify codetruss-cli-0.2.37.tgz --repo DeliriumPulse/codetruss-cli
shasum -a 256 -c codetruss-cli-0.2.37.tgz.sha256
gh attestation verify codetruss-cli-0.2.38.tgz --repo DeliriumPulse/codetruss-cli
shasum -a 256 -c codetruss-cli-0.2.38.tgz.sha256
```

Maintainers should follow [docs/RELEASE.md](docs/RELEASE.md). Tag-driven GitHub
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@ checksums are published at <https://codetruss.com/downloads/codetruss-cli-latest

## Unreleased

## 0.2.38 — 2026-08-07

- **A lone changed file no longer seats its own directory as inferred scope
while the turn is inferring scope elsewhere.** With no allow globs configured,
the working-set rule let a single file establish its parent directory as a
root. On a turn touching several directories with one file each, every
directory it touched vouched for itself, so scope drift — the detection that
runs on an unconfigured first run — could not fire at all. The scope was being
read off the very change it was meant to judge. Measured on the 0.2.36
teardown fixture: `codetruss review --task "Add free-text search to the task
list endpoint"` with no `.codetruss.yml` and no `--allow` called the unrelated
`src/lib/billing.ts` in scope and reported no drift, while the same fixture
under `--allow 'src/routes/**'` named it correctly.
- The single-file allowance stays, narrowed to what it was introduced as in
0.2.32: the whole turn's fallback, not a grant each directory can claim for
itself. It now applies only when admitting it would be the turn's **only**
inferred root — no approved allow root, no root the task already named, and
one candidate directory. A lone file beside any other inferred scope is drift
again. A cluster of two files still stands on its own evidence, and a
directory the task names by feature is still reached without it.
- Verdicts change only for that shape: an unconfigured turn whose changed files
spread across several directories with too few files to cohere. Turns with
configured allow globs are untouched, and so is the archetypal first-run case
the allowance exists for — one file, or one directory, with nothing else
inferred. Receipts disclose `inferred` classifications exactly as before: the
classification, its bases, the receipt schema, and every signed Markdown
rendering are unchanged, so receipts already on disk verify byte for byte.

## 0.2.37 — 2026-08-07

- **A process tree whose leader already exited is never force-killed on
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codetruss/cli",
"version": "0.2.37",
"version": "0.2.38",
"description": "Local-first scope, quality, and verification receipts for coding agents",
"license": "SEE LICENSE IN LICENSE",
"type": "module",
Expand Down
23 changes: 18 additions & 5 deletions packages/cli/src/scope-inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,15 @@ function taskPathTokens(task: string): string[] {
* feature name, that the turn actually changed files under.
* 2. `working-set` — changed files cluster under one shared parent directory.
* The parent is the file's immediate directory, never a climb toward the
* repository root, and never the repository root itself. With approved allow
* roots present a cluster needs two files; with none configured a single
* file establishes its directory, because there is no approved scope for it
* to drift from and the receipt says the scope was inferred entirely.
* repository root, and never the repository root itself. A cluster needs two
* files, with one exception: a turn that inferred nothing else at all — no
* approved allow root, no root the task named, and one candidate directory —
* may seat that directory on a single file, because there is genuinely no
* approved scope for it to drift from and the receipt says so. The exception
* is capped at that one root on purpose. Granted per directory instead, it
* lets every directory a turn touched vouch for itself, and scope drift
* stops being detectable on exactly the unconfigured first run where the
* detection is supposed to earn its keep.
* 3. `sibling-test` — a test file whose name mirrors a source file already in
* scope. Only that exact file is admitted, never its directory.
*/
Expand Down Expand Up @@ -204,14 +209,22 @@ export function inferTurnScope(input: TurnScopeInput): InferredScopeRoot[] {
}
}

const cohesion = explicitPrefixes.length === 0 ? 1 : 2
const groups = new Map<string, string[]>()
for (const path of paths) {
if (covered(path)) continue
const parent = parentDirectory(path)
if (!parent) continue
groups.set(parent, [...(groups.get(parent) ?? []), path])
}
// The single-file allowance is the whole turn's fallback, not a grant each
// directory can claim for itself. It holds only when admitting it is the
// entirety of what this turn inferred: nothing approved to drift from, no
// root the task already named, and one candidate directory. Let a lone file
// establish a root while other roots exist and the turn defines its own
// scope — every directory it touched legitimizes itself, drift becomes
// unreachable, and the receipt reasons in a circle.
const soleFallback = explicitPrefixes.length === 0 && roots.length === 0 && groups.size === 1
const cohesion = soleFallback ? 1 : 2
const ordered = [...groups].sort(([leftRoot, left], [rightRoot, right]) => (
right.length - left.length || leftRoot.localeCompare(rightRoot)
))
Expand Down
87 changes: 87 additions & 0 deletions packages/cli/test/scope-inference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,93 @@ describe('turn-scoped scope inference', () => {
expect(roots.map((root) => root.root)).toEqual(['server/handlers'])
})

// The 0.2.36 teardown ran the unconfigured first-run demo — no .codetruss.yml,
// no --allow — and the file the task never mentioned came back `inferred`, so
// the one detection nobody else ships reported nothing on the one path every
// stranger walks. Each file had seated its own directory as a working set.
it('does not let a lone file vouch for its own directory while the turn infers elsewhere', () => {
const files = turn(['src/lib/billing.ts', 'src/routes/tasks.ts'], [])

const { files: classified, roots } = resolve('Add free-text search to the task list endpoint', files, [])

// Two lone files in two directories, no configured scope, and a task naming
// no path: nothing here says which directory the turn was about. Blessing
// both is the circular answer, and picking one would be a guess.
expect(scope(classified)).toEqual({
'src/lib/billing.ts': 'unexpected',
'src/routes/tasks.ts': 'unexpected',
})
expect(roots).toEqual([])
})

it('still seats a single file when that root is the whole of what the turn inferred', () => {
const files = turn(['src/routes/tasks.ts'], [])

const { files: classified, roots } = resolve('Add free-text search to the task list endpoint', files, [])

expect(scope(classified)).toEqual({ 'src/routes/tasks.ts': 'inferred' })
expect(roots).toEqual([{ root: 'src/routes', basis: 'working-set', evidence: ['src/routes/tasks.ts'] }])
})

it('refuses to widen a scope the task already declared with a lone file elsewhere', () => {
const files = turn(['src/auth/reset.ts', 'src/mailer/send.ts'], [])

// The task named src/auth and the turn changed a file under it, so the task
// has said what this turn is about. The stray mailer file is the drift.
const { files: classified, roots } = resolve('Add password reset under src/auth', files, [])

expect(scope(classified)).toEqual({
'src/auth/reset.ts': 'inferred',
'src/mailer/send.ts': 'unexpected',
})
expect(roots).toEqual([{ root: 'src/auth', basis: 'task-reference', evidence: ['src/auth'] }])
})

it('still admits a genuine second cluster beside a root the task named', () => {
const files = turn([
'src/auth/reset.ts',
'src/mailer/send.ts',
'src/mailer/templates.ts',
], [])

// A new sibling subsystem the turn actually built out clears cohesion 2 on
// its own evidence; only the lone file could not.
const { files: classified, roots } = resolve('Add password reset under src/auth', files, [])

expect(scope(classified)).toEqual({
'src/auth/reset.ts': 'inferred',
'src/mailer/send.ts': 'inferred',
'src/mailer/templates.ts': 'inferred',
})
expect(roots.map((root) => root.root)).toEqual(['src/auth', 'src/mailer'])
})

it('admits a new sibling directory the task named by feature, however few files it has', () => {
const files = turn(['src/auth/reset.ts', 'src/password-reset/token.ts'], [])

// Creating a directory named for the feature is the task naming it: the
// feature-name rule reaches it without any help from cohesion.
const { files: classified } = resolve('Add password reset under src/auth', files, [])

expect(scope(classified)).toEqual({
'src/auth/reset.ts': 'inferred',
'src/password-reset/token.ts': 'inferred',
})
})

it('will not seat a rename destination on a single file once the task named a root', () => {
const files = turn(['src/auth/reset.ts', ['src/mailer/send.ts', 'src/auth/send.ts']], [])

// Moving a file out of the declared root and into a directory of its own is
// exactly the drift a single-file working set used to launder.
const { files: classified } = resolve('Add password reset under src/auth', files, [])

expect(scope(classified)).toEqual({
'src/auth/reset.ts': 'inferred',
'src/mailer/send.ts': 'unexpected',
})
})

it('admits a test file only when it mirrors a source file already in scope', () => {
const allow = ['src/**']
const files = turn(['src/auth/reset.ts', 'tests/auth/reset.test.ts', 'tests/billing/refund.test.ts'], allow)
Expand Down
Loading