Skip to content
Closed
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
2 changes: 1 addition & 1 deletion lat.md/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ Conditionally blocks the agent from stopping — only when something is actually
1. **No `lat.md/` dir** — exit silently.
2. **Run `lat check`** — always, on both first and second pass.
3. **Second pass** (`stop_hook_active` true) — if check still fails, print warning to stderr (no block, loop stops). If check passes, exit silently.
4. **First pass** — run `git diff HEAD --numstat`. Count `codeLines` (files matching [[src/source-parser.ts#SOURCE_EXTENSIONS]]) and `latMdLines`. Skip ratio check if `codeLines < 5` or `latMdLines >= 50` (enough doc work was clearly done). Otherwise round `latMdLines` up to 1 (if nonzero) and flag `needsSync` when `latMdLines < codeLines * 5%`.
4. **First pass** — measure churn via [[src/cli/hook.ts#analyzeDiff]]: `git diff HEAD --numstat` for tracked changes **plus** `git ls-files --others --exclude-standard` for untracked files (each untracked file's line count is added). Counting untracked files is essential — a freshly scaffolded, never-committed `lat.md/` is invisible to `git diff HEAD`, so without it the reminder fires every turn until `lat.md/` is committed. Count `codeLines` (files matching [[src/source-parser.ts#SOURCE_EXTENSIONS]]) and `latMdLines`. Skip ratio check if `codeLines < 5` or `latMdLines >= 50` (enough doc work was clearly done). Otherwise round `latMdLines` up to 1 (if nonzero) and flag `needsSync` when `latMdLines < codeLines * 5%`.
5. **Decision** — both pass: exit silently, clean output. Check failed + needs sync: block ("update `lat.md/`, then run `lat check` until it passes"). Check failed only: block ("run `lat check` until it passes"). Needs sync only: block with explicit context ("not updated" when 0 lat.md lines, "may not be fully in sync (N lines)" when some changes exist but below ratio).

### cursor stop
Expand Down
10 changes: 8 additions & 2 deletions lat.md/tests/hook.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ lat:
---
# Hook

Functional tests for the Stop hook. Runs `lat hook claude Stop` as a subprocess against test case fixtures, with a fake `git` script injected via PATH to control `git diff HEAD --numstat` output.
Functional tests for the Stop hook. Most run `lat hook claude Stop` as a subprocess with a fake `git` on PATH serving both `git diff --numstat` and `git ls-files` output; one exercises [[src/cli/hook.ts#analyzeDiff]] directly.

Tests in `tests/hook.test.ts`.
The fake `git` dispatches on the subcommand, so a single helper controls both the tracked diff and the untracked-file list. Tests in `tests/hook.test.ts`.

## Exits silently when check passes and no diff

Expand Down Expand Up @@ -47,3 +47,9 @@ Files that don't match `SOURCE_EXTENSIONS` (e.g. `.md`) are not counted toward c
## Cursor stop hook returns follow-up work instead of a Claude block

When Cursor needs more work at stop time, the hook returns a `followup_message` payload instead of Claude's `decision: "block"` shape so the agent keeps going in Cursor's native hook format.

## Counts untracked lat.md/ and source files

[[src/cli/hook.ts#analyzeDiff]] counts untracked files (via `git ls-files`) alongside tracked changes, so a freshly scaffolded, never-committed `lat.md/` registers as updated.

Untracked `lat.md/` files count as lat.md lines and untracked source as code lines. Without this, an uncommitted `lat.md/` reads as zero churn and the sync reminder fires every turn (issue #61).
80 changes: 60 additions & 20 deletions src/cli/hook.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { execSync } from 'node:child_process';
import { dirname, extname } from 'node:path';
import { readFileSync } from 'node:fs';
import { dirname, extname, join } from 'node:path';
import { findLatticeDir } from '../lattice.js';
import { plainStyler, type CmdContext } from '../context.js';
import { expandPrompt } from './expand.js';
Expand Down Expand Up @@ -165,37 +166,76 @@ const LATMD_RATIO = 0.05;
/** If lat.md/ changes exceed this many lines, skip the ratio check entirely. */
const LATMD_UPPER_THRESHOLD = 50;

/** Run `git diff --numstat` and return { codeLines, latMdLines }. */
function analyzeDiff(projectRoot: string): {
codeLines: number;
latMdLines: number;
} {
let output: string;
/**
* Count lines in a file the way `git diff --numstat` reports a brand-new file:
* every line is "added". Returns 0 if the file can't be read (binary, gone).
*/
function countFileLines(projectRoot: string, file: string): number {
try {
output = execSync('git diff HEAD --numstat', {
cwd: projectRoot,
encoding: 'utf-8',
});
const text = readFileSync(join(projectRoot, file), 'utf-8');
if (text.length === 0) return 0;
return text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
} catch {
return { codeLines: 0, latMdLines: 0 };
return 0;
}
}

/**
* Measure code vs `lat.md/` churn since HEAD, in lines. Combines tracked
* changes (`git diff HEAD --numstat`) with untracked files
* (`git ls-files --others --exclude-standard`). Counting untracked files is
* what makes a freshly scaffolded, never-committed `lat.md/` register as
* updated — otherwise its edits are invisible to `git diff HEAD` and the sync
* reminder fires on every turn until `lat.md/` is committed (issue #61).
*/
export function analyzeDiff(projectRoot: string): {
codeLines: number;
latMdLines: number;
} {
let codeLines = 0;
let latMdLines = 0;

// Each line: "added\tremoved\tfile" (e.g. "42\t11\tsrc/cli/hook.ts")
for (const line of output.split('\n')) {
const parts = line.split('\t');
if (parts.length < 3) continue;
const added = parseInt(parts[0], 10) || 0;
const removed = parseInt(parts[1], 10) || 0;
const file = parts[2];
const changed = added + removed;
// git always emits forward-slash paths, so the `lat.md/` prefix test holds on
// every platform.
const tally = (file: string, changed: number): void => {
if (file.startsWith('lat.md/')) {
latMdLines += changed;
} else if (SOURCE_EXTENSIONS.has(extname(file))) {
codeLines += changed;
}
};

// Tracked changes vs HEAD. Throws when there is no HEAD yet (a repo with no
// commits) or no repo at all; the untracked scan below still runs.
try {
const output = execSync('git diff HEAD --numstat', {
cwd: projectRoot,
encoding: 'utf-8',
});
// Each line: "added\tremoved\tfile" (e.g. "42\t11\tsrc/cli/hook.ts")
for (const line of output.split('\n')) {
const parts = line.split('\t');
if (parts.length < 3) continue;
const added = parseInt(parts[0], 10) || 0;
const removed = parseInt(parts[1], 10) || 0;
tally(parts[2], added + removed);
}
} catch {
// Not a git repo, or no HEAD — fall through to the untracked scan.
}

// Untracked files (respecting .gitignore, so `lat.md/.cache/` is excluded).
try {
const output = execSync('git ls-files --others --exclude-standard', {
cwd: projectRoot,
encoding: 'utf-8',
});
for (const file of output.split('\n')) {
if (!file) continue;
tally(file, countFileLines(projectRoot, file));
}
} catch {
// Not a git repo — nothing to add.
}

return { codeLines, latMdLines };
Expand Down
73 changes: 61 additions & 12 deletions tests/hook.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { describe, it, expect } from 'vitest';
import { spawnSync } from 'node:child_process';
import { join, delimiter } from 'node:path';
import { mkdtempSync, writeFileSync, chmodSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { rmDirBestEffort } from './util.js';
import { analyzeDiff } from '../src/cli/hook.js';

const casesDir = join(import.meta.dirname, 'cases');
const cliPath = join(
Expand All @@ -21,25 +22,38 @@ function numstat(files: [number, number, string][]): string {
}

/**
* Create a temp dir with a fake `git` that prints the given numstat regardless
* of args. Cross-platform: the payload is stored in a data file (preserving the
* tab separators), and both a POSIX `git` shell script and a Windows `git.cmd`
* batch shim emit it — so the hook's `git diff --numstat` is intercepted on
* every OS. Callers prepend this dir to PATH.
* Create a temp dir with a fake `git` that dispatches on the subcommand:
* `git diff …` prints the given numstat, `git ls-files …` prints the untracked
* list. Cross-platform: payloads live in data files (preserving tab separators),
* and both a POSIX `git` shell script and a Windows `git.cmd` batch shim serve
* them — so `git diff --numstat` and `git ls-files` are intercepted on every OS.
* Callers prepend this dir to PATH.
*/
function makeFakeGitDir(output: string): string {
function makeFakeGitDir(diffOutput: string, lsFilesOutput = ''): string {
const dir = mkdtempSync(join(tmpdir(), 'lat-hook-'));
const dataFile = join(dir, 'numstat.txt');
writeFileSync(dataFile, output);
writeFileSync(join(dir, 'diff.txt'), diffOutput);
writeFileSync(join(dir, 'lsfiles.txt'), lsFilesOutput);

// POSIX: `git` shell script.
// POSIX: dispatch on the git subcommand ($1).
const shScript = join(dir, 'git');
writeFileSync(shScript, '#!/bin/sh\ncat "$(dirname "$0")/numstat.txt"\n');
writeFileSync(
shScript,
'#!/bin/sh\n' +
'case "$1" in\n' +
' diff) cat "$(dirname "$0")/diff.txt" ;;\n' +
' ls-files) cat "$(dirname "$0")/lsfiles.txt" ;;\n' +
'esac\n',
);
chmodSync(shScript, 0o755);

// Windows: `git.cmd` batch shim (resolved via PATHEXT). `type` preserves tabs.
const cmdScript = join(dir, 'git.cmd');
writeFileSync(cmdScript, '@type "%~dp0numstat.txt"\r\n');
writeFileSync(
cmdScript,
'@echo off\r\n' +
'if "%1"=="diff" type "%~dp0diff.txt"\r\n' +
'if "%1"=="ls-files" type "%~dp0lsfiles.txt"\r\n',
);

return dir;
}
Expand Down Expand Up @@ -220,3 +234,38 @@ describe('hook stop', () => {
}
});
});

describe('analyzeDiff', () => {
// @lat: [[tests/hook#Counts untracked lat.md/ and source files]]
it('counts untracked lat.md/ and source files, not just tracked changes', () => {
const proj = mkdtempSync(join(tmpdir(), 'lat-untracked-'));
try {
// A freshly scaffolded, never-committed lat.md/ (60 lines) plus an
// untracked source file (20 lines) — the issue #61 scenario.
mkdirSync(join(proj, 'lat.md'), { recursive: true });
mkdirSync(join(proj, 'src'), { recursive: true });
writeFileSync(join(proj, 'lat.md', 'feature.md'), 'x\n'.repeat(60));
writeFileSync(join(proj, 'src', 'brand-new.ts'), 'y\n'.repeat(20));

// Fake git: 110 tracked code lines + both untracked files listed.
const fakeBinDir = makeFakeGitDir(
numstat([[80, 30, 'src/refactor.ts']]),
'lat.md/feature.md\nsrc/brand-new.ts\n',
);
const savedPath = process.env.PATH;
process.env.PATH = fakeBinDir + delimiter + (savedPath ?? '');
try {
const { codeLines, latMdLines } = analyzeDiff(proj);
// Untracked lat.md/ is counted (previously read as 0 → perpetual nag).
expect(latMdLines).toBe(60);
// Tracked (110) + untracked source (20).
expect(codeLines).toBe(130);
} finally {
process.env.PATH = savedPath;
rmDirBestEffort(fakeBinDir);
}
} finally {
rmDirBestEffort(proj);
}
});
});
Loading