diff --git a/AGENTS.md b/AGENTS.md
index 4bb4dacba..3b77dcb13 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -18,8 +18,8 @@ task procedures only when needed:
| Writing issues or PRDs, and triage labels | `docs/agents/issue-tracker.md`, `docs/agents/triage-labels.md` |
| Web backend setup or diagnostics | `docs/agents/web-backend.md` |
-Versioned CLI help is the source of truth for command behavior. Start workflow planning with
-`agent-device help workflow`, then use the relevant topic help.
+Versioned CLI help is the source of truth for command behavior. Start with `agent-device help
+workflow`, then the relevant topic help.
## Incident-derived principles
@@ -91,15 +91,16 @@ cross-language rules change through golden tables under `contracts/fixtures/`.
- Implementation files target at most 300 lines. Extract before adding behavior past 500 lines;
files past 1,000 lines are architecture debt unless generated or fixture data.
- Tests mirror source topology one-to-one. Split a source module and its test together; do not add to
- the legacy `interaction.test.ts` or platform `index.test.ts` aggregations.
+ the legacy `interaction.test.ts` or platform `index.test.ts` aggregations. Pure moves carry their
+ tests unchanged; rename-only hunks owe no new coverage.
- Shared fixtures are named exports in a sibling fixture module, not repeated inline literals.
- `src/daemon/handlers/session.ts` is already over budget; extract the relevant platform-specific
concept before adding behavior.
## Toolchain and worktree traps
-- Use `pnpm`; never add `package-lock.json`. OXC owns lint and format. Run `pnpm format` for the
- repository, not a path-scoped formatter invocation.
+- Use `pnpm`; never add `package-lock.json`. OXC owns lint and format. Run `pnpm format`
+ repository-wide, not path-scoped.
- A fresh worktree requires `pnpm install --frozen-lockfile && pnpm build`. Until then package and
optional-peer resolution may point at another checkout and produce false failures.
- Source-checkout daemon state is worktree-scoped, but devices are not. Use `pnpm daemon:state-dir`
@@ -155,5 +156,5 @@ contents. Keep a sentence in this file only when no gate, lint rule, versioned h
decision-site comment can own it. `CONTEXT.md` is glossary-only: no implementation paths,
architecture decisions, migration state, or workflows.
-Behavior changes update their owning help/metadata and user docs when relevant. In the final summary,
+Behavior changes update their owning help/metadata and user docs. In the final summary,
state whether docs or skills changed and why.
diff --git a/scripts/coverage-changed/model.test.ts b/scripts/coverage-changed/model.test.ts
index 217372333..058e81d8e 100644
--- a/scripts/coverage-changed/model.test.ts
+++ b/scripts/coverage-changed/model.test.ts
@@ -239,3 +239,74 @@ test('changed line suppressed by an ignore directive is tallied, not gated', ()
test('threshold constant is the single source of truth', () => {
assert.equal(CHANGED_LINE_COVERAGE_THRESHOLD, 70);
});
+
+// Shape of `git diff --unified=0 --find-renames=90%` for a move PR: a pure
+// move carries no hunks, an edited move carries only the hunks that differ from
+// its source, and an ordinary edit is unaffected.
+const MOVE_PR_DIFF = [
+ 'diff --git a/src/mod.ts b/src/mod.ts',
+ 'index 57ee600..9fe64ef 100644',
+ '--- a/src/mod.ts',
+ '+++ b/src/mod.ts',
+ '@@ -2 +2,2 @@ export const m1 = 1;',
+ '-export const m2 = 2;',
+ '+export const m2 = 22;',
+ '+export const m3 = 3;',
+ 'diff --git a/src/edited.ts b/src/moved-edited.ts',
+ 'similarity index 90%',
+ 'rename from src/edited.ts',
+ 'rename to src/moved-edited.ts',
+ 'index a3d7378..fe85380 100644',
+ '--- a/src/edited.ts',
+ '+++ b/src/moved-edited.ts',
+ '@@ -3 +3 @@ export const b2 = 2;',
+ '-export const b3 = 3;',
+ '+export const b3 = 33;',
+ '@@ -7 +7 @@ export const b6 = 6;',
+ '-export const b7 = 7;',
+ '+export const b7 = 77;',
+ '@@ -9 +9 @@ export const b8 = 8;',
+ '-export const b9 = 9;',
+ '+export const b9 = 99;',
+ 'diff --git a/src/pure.ts b/src/moved-pure.ts',
+ 'similarity index 100%',
+ 'rename from src/pure.ts',
+ 'rename to src/moved-pure.ts',
+].join('\n');
+
+function uncoveredRecord(file: string, lineCount: number): string {
+ const da = Array.from({ length: lineCount }, (_, i) => `DA:${i + 1},0`);
+ return [`SF:${file}`, ...da, 'end_of_record'].join('\n');
+}
+
+test('rename-only hunks owe no changed-line coverage; edited moves owe their edits', () => {
+ const diffs = parseUnifiedDiff(MOVE_PR_DIFF);
+ assert.deepEqual(
+ diffs.map((d) => [d.path, d.added]),
+ [
+ ['src/mod.ts', [2, 3]],
+ ['src/moved-edited.ts', [3, 7, 9]],
+ ['src/moved-pure.ts', []],
+ ],
+ );
+ const result = computeChangedCoverage({
+ diffs,
+ coverage: parseLcov(
+ [
+ uncoveredRecord('src/mod.ts', 3),
+ uncoveredRecord('src/moved-edited.ts', 30),
+ uncoveredRecord('src/moved-pure.ts', 30),
+ ].join('\n'),
+ ),
+ fileLines: () => null,
+ });
+ // 2 ordinary edits + 3 edited move lines; the 30-line pure move owes nothing.
+ assert.equal(result.totalLines, 5);
+ assert.deepEqual(
+ result.offenders.map((f) => [f.path, f.uncoveredLines]),
+ [
+ ['src/mod.ts', [2, 3]],
+ ['src/moved-edited.ts', [3, 7, 9]],
+ ],
+ );
+});
diff --git a/scripts/coverage-changed/run.test.ts b/scripts/coverage-changed/run.test.ts
index b16972d4b..4c2af3bb0 100644
--- a/scripts/coverage-changed/run.test.ts
+++ b/scripts/coverage-changed/run.test.ts
@@ -147,3 +147,14 @@ test('errors when the lcov report is missing rather than silently passing', () =
assert.equal(code, 1);
assert.match(out, /no lcov report/);
});
+
+test('a pure move owes nothing regardless of the host diff.renames setting', () => {
+ git('config', 'diff.renames', 'false');
+ git('mv', 'src/base.ts', 'src/moved.ts');
+ git('commit', '-q', '-m', 'move');
+ writeLcov('SF:src/moved.ts\nDA:1,0\nend_of_record\n');
+ const { code, out } = capture(() => run(['--base', 'main'], repo));
+ assert.equal(code, 0);
+ assert.match(out, /Changed-line coverage gate: PASS/);
+ assert.match(out, /0\/0 \(n\/a\)/);
+});
diff --git a/scripts/coverage-changed/run.ts b/scripts/coverage-changed/run.ts
index 8d7644b07..6a4bfd965 100644
--- a/scripts/coverage-changed/run.ts
+++ b/scripts/coverage-changed/run.ts
@@ -3,7 +3,9 @@
//
// Reuses the lcov report that `pnpm test:coverage` already produced (never runs
// coverage a second time), joins it with `git diff --unified=0 ...HEAD`,
-// and fails when changed-line coverage is below the threshold. A PR carrying
+// and fails when changed-line coverage is below the threshold. Renames are
+// detected at RENAME_SIMILARITY regardless of the host's `diff.renames`, so a
+// moved file owes only the hunks that differ from its source. A PR carrying
// the `coverage-waiver` label skips the failure but still prints every number.
// The same markdown report goes to stdout and to the GitHub job summary; it
// includes changed-branch coverage and the count of changed executable lines
@@ -23,6 +25,7 @@ import {
const USAGE = 'Usage: pnpm check:coverage-changed [--base []\n';
const LCOV_PATH = 'coverage/lcov.info';
+const RENAME_SIMILARITY = '90%';
const GIT_DIFF_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
function fmtPct(pct: number | null): string {
@@ -72,10 +75,11 @@ export function run(argv: readonly string[], cwd?: string): number {
return 1;
}
- const diff = runCmdSync('git', ['diff', '--unified=0', '--no-color', `${base}...HEAD`], {
- cwd: root,
- maxBuffer: GIT_DIFF_MAX_BUFFER_BYTES,
- }).stdout;
+ const diff = runCmdSync(
+ 'git',
+ ['diff', '--unified=0', '--no-color', `--find-renames=${RENAME_SIMILARITY}`, `${base}...HEAD`],
+ { cwd: root, maxBuffer: GIT_DIFF_MAX_BUFFER_BYTES },
+ ).stdout;
const result = computeChangedCoverage({
diffs: parseUnifiedDiff(diff),
coverage: parseLcov(fs.readFileSync(lcovPath, 'utf8'), root),
]