@plebeiantech/lat.md 0.12.3-fork.1 → 0.12.3-fork.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -3
- package/dist/src/cli/check-coverage.d.ts +2 -0
- package/dist/src/cli/check-coverage.js +71 -0
- package/dist/src/cli/check-mode.d.ts +16 -1
- package/dist/src/cli/check-mode.js +65 -4
- package/dist/src/cli/check.js +5 -1
- package/dist/src/cli/comment-guard.d.ts +14 -0
- package/dist/src/cli/comment-guard.js +10 -33
- package/dist/src/cli/comment-reminder.d.ts +65 -6
- package/dist/src/cli/comment-reminder.js +164 -28
- package/dist/src/cli/fork-instructions.d.ts +19 -0
- package/dist/src/cli/fork-instructions.js +183 -0
- package/dist/src/cli/fork-scaffold.d.ts +106 -0
- package/dist/src/cli/fork-scaffold.js +490 -0
- package/dist/src/cli/index.js +29 -4
- package/dist/src/cli/init.js +5 -0
- package/dist/src/cli/search.js +6 -4
- package/dist/src/cli/ui-build.d.ts +4 -0
- package/dist/src/cli/ui-build.js +13 -0
- package/dist/src/cli/ui.js +18 -2
- package/dist/src/code-refs.js +25 -4
- package/dist/src/fork/upstream-drift-cli.d.ts +1 -0
- package/dist/src/fork/upstream-drift-cli.js +2 -0
- package/dist/src/fork/upstream-drift.d.ts +26 -0
- package/dist/src/fork/upstream-drift.js +185 -0
- package/dist/src/fork/upstream-guard-cli.d.ts +1 -0
- package/dist/src/fork/upstream-guard-cli.js +2 -0
- package/dist/src/fork/upstream-guard.d.ts +72 -0
- package/dist/src/fork/upstream-guard.js +445 -0
- package/dist/src/graph-export.d.ts +2 -0
- package/dist/src/graph-export.js +31 -12
- package/dist/src/init-version.d.ts +1 -1
- package/dist/src/init-version.js +1 -1
- package/dist/src/knowledge/claude-memory.d.ts +1 -0
- package/dist/src/knowledge/claude-memory.js +33 -4
- package/dist/src/lattice.d.ts +2 -1
- package/dist/src/lattice.js +1 -0
- package/dist/src/search/index.js +23 -6
- package/dist/src/search/search.d.ts +1 -0
- package/dist/src/search/search.js +15 -9
- package/dist/src/untrusted.d.ts +0 -6
- package/dist/src/untrusted.js +7 -11
- package/dist/src/view/client/assets/index-C3iaZyYT.css +1 -0
- package/dist/src/view/client/assets/index-KWi67IMz.js +334 -0
- package/dist/src/view/client/assets/logo-CNF8zs47.svg +182 -0
- package/dist/src/view/client/index.html +2 -2
- package/dist/src/view/git-diff.js +17 -0
- package/dist/src/view/graph.d.ts +6 -0
- package/dist/src/view/graph.js +176 -0
- package/dist/src/view/markdown.d.ts +5 -1
- package/dist/src/view/markdown.js +112 -25
- package/dist/src/view/protocol.d.ts +43 -0
- package/dist/src/view/protocol.js +1 -1
- package/dist/src/view/references.d.ts +2 -0
- package/dist/src/view/references.js +30 -8
- package/dist/src/view/repository.d.ts +5 -2
- package/dist/src/view/repository.js +13 -12
- package/dist/src/view/search.js +1 -0
- package/dist/src/view/server.d.ts +3 -0
- package/dist/src/view/server.js +42 -9
- package/dist/src/view/source-target.d.ts +8 -0
- package/dist/src/view/source-target.js +22 -0
- package/dist/src/view/static-build.d.ts +16 -0
- package/dist/src/view/static-build.js +408 -0
- package/dist/src/view/static-protocol.d.ts +23 -0
- package/dist/src/view/static-protocol.js +10 -0
- package/dist/src/view/store.d.ts +9 -1
- package/dist/src/view/store.js +82 -15
- package/dist/src/view/table-of-contents.d.ts +9 -0
- package/dist/src/view/table-of-contents.js +103 -0
- package/package.json +12 -10
- package/templates/AGENTS.md +2 -13
- package/templates/fork/conventions.md +147 -0
- package/templates/skill/SKILL.md +3 -14
- package/dist/src/view/client/assets/index-B0n0u03y.css +0 -1
- package/dist/src/view/client/assets/index-xm2_px4q.js +0 -23
package/README.md
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<a href="https://github.com/
|
|
7
|
-
<a href="https://www.npmjs.com/package/lat.md"><img src="https://img.shields.io/npm/v/lat.md" alt="npm"></a>
|
|
6
|
+
<a href="https://github.com/PlebeianTech/lat.md/actions/workflows/ci.yml"><img src="https://github.com/PlebeianTech/lat.md/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
7
|
+
<a href="https://www.npmjs.com/package/@plebeiantech/lat.md"><img src="https://img.shields.io/npm/v/@plebeiantech/lat.md" alt="npm"></a>
|
|
8
8
|
</p>
|
|
9
9
|
|
|
10
|
+
# Fork of https://github.com/vercel-labs/lat.md
|
|
11
|
+
|
|
10
12
|
<p align="center">A knowledge graph for your codebase, written in markdown.</p>
|
|
11
13
|
|
|
12
14
|
## The problem
|
|
@@ -35,12 +37,46 @@ The `lat` CLI gives agents and humans a system to navigate and maintain the grap
|
|
|
35
37
|
|
|
36
38
|
## Install
|
|
37
39
|
|
|
40
|
+
This is a fork. It publishes as [`@plebeiantech/lat.md`](https://www.npmjs.com/package/@plebeiantech/lat.md) and never under upstream's `lat.md` name, so the two can coexist.
|
|
41
|
+
|
|
42
|
+
With [mise](https://mise.jdx.dev/):
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
mise settings add minimum_release_age_excludes npm:@plebeiantech/lat.md
|
|
46
|
+
mise use -g npm:@plebeiantech/lat.md@latest
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The first line is needed once per machine. mise quarantines any npm version published in the last day or so, and a fresh release is invisible until that window passes — the exclude waives it for this package alone and leaves the guard in force for every other tool.
|
|
50
|
+
|
|
51
|
+
With npm:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npm install -g @plebeiantech/lat.md
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or straight from a GitHub Release, which needs no registry and no authentication:
|
|
58
|
+
|
|
38
59
|
```bash
|
|
39
|
-
npm
|
|
60
|
+
npm i -g --prefix ~/.local/lat https://github.com/PlebeianTech/lat.md/releases/latest/download/lat.md-latest.tgz
|
|
40
61
|
```
|
|
41
62
|
|
|
63
|
+
That last one wants `~/.local/lat/bin` on your `PATH`. A fixed prefix keeps the install independent of whichever Node version is active.
|
|
64
|
+
|
|
65
|
+
Pick one route and stay on it. A Release-route install left on `PATH` shadows a mise-managed one — `mise upgrade` then reports a new version while the shell keeps running the old binary.
|
|
66
|
+
|
|
42
67
|
Then run `lat init` in the repo you want to use lat in.
|
|
43
68
|
|
|
69
|
+
### As a Claude Code plugin
|
|
70
|
+
|
|
71
|
+
The hooks are also packaged as a plugin, installable straight from this repository:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
/plugin marketplace add PlebeianTech/lat.md
|
|
75
|
+
/plugin install lat-md@lat-md
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The plugin ships hooks only and finds `lat` on your `PATH`, accepting a build only if its version carries a `-fork` suffix.
|
|
79
|
+
|
|
44
80
|
## How it works
|
|
45
81
|
|
|
46
82
|
Run `lat init` to scaffold a `lat.md/` directory, then write markdown files describing your architecture, business logic, test specs — whatever matters. Link between sections using `[[file#Section#Subsection]]` syntax. Link to source code symbols with `[[src/auth.ts#validateToken]]`. Annotate source code with `// @lat: [[section-id]]` (or `# @lat: [[section-id]]` in Python) comments to tie implementation back to concepts.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { relative } from 'node:path';
|
|
3
|
+
import { scanCodeRefs } from '../code-refs.js';
|
|
4
|
+
import { listLatticeFiles } from '../lattice.js';
|
|
5
|
+
import { indexNameFor } from './check-mode.js';
|
|
6
|
+
import { toPosix } from '../walk.js';
|
|
7
|
+
import { basename, dirname } from 'node:path';
|
|
8
|
+
/**
|
|
9
|
+
* Fail a `lat.md/` tree that has documents and no `@lat:` ref pointing into it
|
|
10
|
+
* from anywhere in the codebase.
|
|
11
|
+
*
|
|
12
|
+
* `require-code-mention` was the only thing asking for refs, and it is opt-in
|
|
13
|
+
* frontmatter: an agent setting up a tree can simply not write it, and every
|
|
14
|
+
* check still passes. One did. Its reasons were both ordinary — it read a list
|
|
15
|
+
* of example comment markers as an exclusive language list and concluded its
|
|
16
|
+
* language was unsupported, and it was optimising for a green check rather
|
|
17
|
+
* than a correct one, so the option that could not fail was the one it took.
|
|
18
|
+
*
|
|
19
|
+
* A graph nothing points at cannot be reached from the code, which is the only
|
|
20
|
+
* reason it exists. So the floor is one ref, and the message says where to put
|
|
21
|
+
* the first one.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately a floor and not a coverage ratio. Half this repository's own
|
|
24
|
+
* documents have no incoming ref and should not have one — a per-document rule
|
|
25
|
+
* would have to be either wrong here or watered down everywhere.
|
|
26
|
+
*/
|
|
27
|
+
/** Nothing to anchor: a tree of pure navigation, or a project with no code. */
|
|
28
|
+
function isIndex(latticeDir, file) {
|
|
29
|
+
const holder = basename(dirname(file));
|
|
30
|
+
return (basename(file) ===
|
|
31
|
+
indexNameFor(holder === '' ? basename(latticeDir) : holder));
|
|
32
|
+
}
|
|
33
|
+
export async function checkCoverage(latticeDir, projectRoot = dirname(latticeDir)) {
|
|
34
|
+
const files = await listLatticeFiles(latticeDir);
|
|
35
|
+
const documents = files.filter((f) => !isIndex(latticeDir, f));
|
|
36
|
+
if (documents.length === 0)
|
|
37
|
+
return [];
|
|
38
|
+
const scan = await scanCodeRefs(projectRoot);
|
|
39
|
+
if (scan.files.length === 0)
|
|
40
|
+
return [];
|
|
41
|
+
if (scan.refs.length > 0)
|
|
42
|
+
return [];
|
|
43
|
+
const rootIndexPath = `${latticeDir}/${indexNameFor(basename(latticeDir))}`;
|
|
44
|
+
const rootIndexRel = toPosix(relative(process.cwd(), rootIndexPath));
|
|
45
|
+
// Quote the ref the reader should actually paste. A generic placeholder gets
|
|
46
|
+
// pasted verbatim and then fails `lat check code-refs`, which turns one
|
|
47
|
+
// error into two.
|
|
48
|
+
const stem = basename(latticeDir).replace(/\.md$/, '');
|
|
49
|
+
let anchor = `${stem}#Your Project`;
|
|
50
|
+
try {
|
|
51
|
+
const heading = (await readFile(rootIndexPath, 'utf-8')).match(/^#\s+(.+)$/m);
|
|
52
|
+
if (heading)
|
|
53
|
+
anchor = `${stem}#${heading[1].trim()}`;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// No root index, or unreadable — the placeholder still names the shape.
|
|
57
|
+
}
|
|
58
|
+
return [
|
|
59
|
+
{
|
|
60
|
+
file: rootIndexRel,
|
|
61
|
+
line: 1,
|
|
62
|
+
target: toPosix(relative(projectRoot, latticeDir)),
|
|
63
|
+
message: `${documents.length} document(s) here and no \`@lat:\` code ref anywhere in the project — nothing in the codebase can reach this graph.\n` +
|
|
64
|
+
' Start at the application entrypoint (config/application.rb, src/index.ts, main.go, manage.py) and point it at the root index:\n' +
|
|
65
|
+
` # @lat: [[${anchor}]]\n` +
|
|
66
|
+
' Then add one ref per section that source code implements, at that code.\n' +
|
|
67
|
+
' The marker is a comment syntax, not a language allowlist: `//` for C-family, `#` for Ruby, Python, shell, Elixir and the rest. The scan is textual and reads every non-markdown file.\n' +
|
|
68
|
+
' An `@lat:` line is a machine directive, like a magic comment or a linter pragma. A project convention that minimises comments does not reach it.',
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
}
|
|
@@ -3,4 +3,19 @@ export declare const DIATAXIS_MODES: readonly ["tutorial", "how-to", "reference"
|
|
|
3
3
|
export type DiataxisMode = (typeof DIATAXIS_MODES)[number];
|
|
4
4
|
/** Directory below lat.md/ that holds each mode. */
|
|
5
5
|
export declare const MODE_DIRS: Record<DiataxisMode, string>;
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* The index file a directory is required to carry, e.g. `lat.md` for the root
|
|
8
|
+
* and `reference.md` for `reference/`. An index is navigation rather than
|
|
9
|
+
* content, so the `require-mode` gate below never asks one to declare a mode.
|
|
10
|
+
*/
|
|
11
|
+
export declare function indexNameFor(dirName: string): string;
|
|
12
|
+
export type CheckModeOptions = {
|
|
13
|
+
/**
|
|
14
|
+
* Enforce as if the root index said this, whatever it actually says. Used to
|
|
15
|
+
* price adoption — running once each way and taking the difference is what
|
|
16
|
+
* the offer in `lat init` reports, and it keeps that number defined by this
|
|
17
|
+
* checker rather than by a second copy of its rules.
|
|
18
|
+
*/
|
|
19
|
+
requireMode?: boolean;
|
|
20
|
+
};
|
|
21
|
+
export declare function checkMode(latticeDir: string, projectRoot?: string, options?: CheckModeOptions): Promise<CheckError[]>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { dirname, relative } from 'node:path';
|
|
2
|
+
import { basename, dirname, join, relative } from 'node:path';
|
|
3
3
|
import { listLatticeFiles, parseFrontmatter } from '../lattice.js';
|
|
4
4
|
import { parse } from '../parser.js';
|
|
5
5
|
import { quoteUntrusted } from '../untrusted.js';
|
|
@@ -18,7 +18,11 @@ export const MODE_DIRS = {
|
|
|
18
18
|
explanation: 'explanation',
|
|
19
19
|
};
|
|
20
20
|
/** Reverse lookup: first path segment -> mode. Built once, not per file. */
|
|
21
|
-
|
|
21
|
+
// Null-prototype: the key is a directory name read off disk.
|
|
22
|
+
const DIR_TO_MODE = Object.create(null);
|
|
23
|
+
for (const mode of DIATAXIS_MODES) {
|
|
24
|
+
DIR_TO_MODE[MODE_DIRS[mode]] = mode;
|
|
25
|
+
}
|
|
22
26
|
const OUTCOME_RE = /\b(outcome|by the end|you will (build|learn|have|create)|what you (will )?(build|learn|have|create))\b/i;
|
|
23
27
|
/**
|
|
24
28
|
* Imperative verbs that flag a command inside an explanation document.
|
|
@@ -177,9 +181,53 @@ function findImperativeHits(tree, content) {
|
|
|
177
181
|
}
|
|
178
182
|
return hits;
|
|
179
183
|
}
|
|
180
|
-
|
|
184
|
+
/**
|
|
185
|
+
* The index file a directory is required to carry, e.g. `lat.md` for the root
|
|
186
|
+
* and `reference.md` for `reference/`. An index is navigation rather than
|
|
187
|
+
* content, so the `require-mode` gate below never asks one to declare a mode.
|
|
188
|
+
*/
|
|
189
|
+
export function indexNameFor(dirName) {
|
|
190
|
+
return dirName.endsWith('.md') ? dirName : dirName + '.md';
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* What the root index says about the mode requirement.
|
|
194
|
+
*
|
|
195
|
+
* Opt-in, and deliberately read from the tree rather than from a flag or an
|
|
196
|
+
* environment variable: the rule belongs to a documentation set, not to a
|
|
197
|
+
* machine or a CI job, and a tree that predates the rule has to keep passing.
|
|
198
|
+
* `lat init` stamps the flag into the root index it scaffolds, so a project
|
|
199
|
+
* set up after this exists is gated from its first commit while an older one
|
|
200
|
+
* is untouched until someone adds the line.
|
|
201
|
+
*
|
|
202
|
+
* The raw value is returned rather than a boolean so the caller can tell an
|
|
203
|
+
* absent flag from one that was set to something this never reads. `yes` and
|
|
204
|
+
* `1` are strings to a YAML 1.2 parser, and enforcing nothing on them while
|
|
205
|
+
* saying nothing about them is how a project ends up believing the gate is on.
|
|
206
|
+
*/
|
|
207
|
+
async function readRequireModeFlag(latticeDir) {
|
|
208
|
+
const indexPath = join(latticeDir, indexNameFor(basename(latticeDir)));
|
|
209
|
+
try {
|
|
210
|
+
const content = await readFile(indexPath, 'utf-8');
|
|
211
|
+
return parseFrontmatter(content).raw['require-mode'];
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
export async function checkMode(latticeDir, projectRoot = dirname(latticeDir), options = {}) {
|
|
181
218
|
const files = await listLatticeFiles(latticeDir);
|
|
182
219
|
const errors = [];
|
|
220
|
+
const flag = await readRequireModeFlag(latticeDir);
|
|
221
|
+
const requireMode = options.requireMode ?? flag === true;
|
|
222
|
+
if (flag !== undefined && typeof flag !== 'boolean') {
|
|
223
|
+
const indexPath = join(latticeDir, indexNameFor(basename(latticeDir)));
|
|
224
|
+
errors.push({
|
|
225
|
+
file: relative(process.cwd(), indexPath),
|
|
226
|
+
line: 1,
|
|
227
|
+
target: toPosix(relative(projectRoot, indexPath)).replace(/\.md$/, ''),
|
|
228
|
+
message: `require-mode is ${JSON.stringify(flag)} — it must be true or false.\n Anything else is enforced as off, so the gate this line looks like it turns on is not running.`,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
183
231
|
for (const file of files) {
|
|
184
232
|
const content = await readFile(file, 'utf-8');
|
|
185
233
|
const relPath = relative(process.cwd(), file);
|
|
@@ -234,8 +282,21 @@ export async function checkMode(latticeDir, projectRoot = dirname(latticeDir)) {
|
|
|
234
282
|
continue;
|
|
235
283
|
}
|
|
236
284
|
const mode = (declared ?? dirMode);
|
|
237
|
-
if (mode === undefined)
|
|
285
|
+
if (mode === undefined) {
|
|
286
|
+
const segments = relFromLattice.split('/');
|
|
287
|
+
const fileName = segments.pop();
|
|
288
|
+
const holder = segments.length === 0 ? basename(latticeDir) : segments.at(-1);
|
|
289
|
+
const isIndex = fileName === indexNameFor(holder);
|
|
290
|
+
if (requireMode && !isIndex) {
|
|
291
|
+
errors.push({
|
|
292
|
+
file: relPath,
|
|
293
|
+
line: 1,
|
|
294
|
+
target,
|
|
295
|
+
message: 'document declares no Diátaxis mode and is not in a mode directory — move it under lat.md/tutorials/, lat.md/how-to/, lat.md/reference/ or lat.md/explanation/, or declare one under its `lat:` frontmatter.\n A document that would fail its mode is usually two documents: the lookup half belongs in reference/, the reasoning half in explanation/.\n (The root index sets require-mode: true. Remove that line to turn this off.)',
|
|
296
|
+
});
|
|
297
|
+
}
|
|
238
298
|
continue;
|
|
299
|
+
}
|
|
239
300
|
const tree = parse(content);
|
|
240
301
|
if (mode === 'how-to') {
|
|
241
302
|
if (!orderedListPresent(tree)) {
|
package/dist/src/cli/check.js
CHANGED
|
@@ -6,6 +6,7 @@ import { scanCodeRefs } from '../code-refs.js';
|
|
|
6
6
|
import { checkMode } from './check-mode.js';
|
|
7
7
|
import { checkStatus } from './check-status.js';
|
|
8
8
|
import { checkFrontmatter } from './check-frontmatter.js';
|
|
9
|
+
import { checkCoverage } from './check-coverage.js';
|
|
9
10
|
import { indexEntryNameFromDest } from './link-scheme.js';
|
|
10
11
|
import { fencedLineMask } from './gen-index.js';
|
|
11
12
|
import { SOURCE_EXTENSIONS, clearSymbolCache } from '../source-parser.js';
|
|
@@ -621,6 +622,7 @@ export async function checkAllCommand(ctx, opts = {}) {
|
|
|
621
622
|
const modeErrors = await checkMode(ctx.latDir, ctx.projectRoot);
|
|
622
623
|
const statusErrors = await checkStatus(ctx.latDir, ctx.projectRoot);
|
|
623
624
|
const fmErrors = await checkFrontmatter(ctx.latDir, ctx.projectRoot);
|
|
625
|
+
const coverageErrors = await checkCoverage(ctx.latDir, ctx.projectRoot);
|
|
624
626
|
const elapsed = Date.now() - startTime;
|
|
625
627
|
const allErrors = [...md.errors, ...linkErrors, ...code.errors];
|
|
626
628
|
const allFiles = { ...md.files };
|
|
@@ -658,12 +660,14 @@ export async function checkAllCommand(ctx, opts = {}) {
|
|
|
658
660
|
lines.push(...formatCheckErrors(modeErrors, s));
|
|
659
661
|
lines.push(...formatCheckErrors(statusErrors, s));
|
|
660
662
|
lines.push(...formatCheckErrors(fmErrors, s));
|
|
663
|
+
lines.push(...formatCheckErrors(coverageErrors, s));
|
|
661
664
|
const totalErrors = allErrors.length +
|
|
662
665
|
indexErrors.length +
|
|
663
666
|
sectionErrors.length +
|
|
664
667
|
modeErrors.length +
|
|
665
668
|
statusErrors.length +
|
|
666
|
-
fmErrors.length
|
|
669
|
+
fmErrors.length +
|
|
670
|
+
coverageErrors.length;
|
|
667
671
|
if (totalErrors > 0) {
|
|
668
672
|
lines.push(formatErrorCount(totalErrors, s));
|
|
669
673
|
return { output: lines.join('\n'), isError: true };
|
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import { type ToolInput } from './comment-reminder.js';
|
|
2
|
+
/**
|
|
3
|
+
* The blocking half of the comment convention. `computeCommentReminder` runs
|
|
4
|
+
* at PostToolUse and only advises: by then the prose is already on disk, and
|
|
5
|
+
* it is deduped to once per file per session, so the second rationale block
|
|
6
|
+
* in a file draws no signal at all.
|
|
7
|
+
*
|
|
8
|
+
* This runs at PreToolUse instead, where `tool_input` is visible before the
|
|
9
|
+
* edit is applied and the hook can answer `deny`. A multi-line comment is
|
|
10
|
+
* refused outright and the agent is told to put the reasoning in `lat.md/`
|
|
11
|
+
* and leave a `@lat:` pointer behind.
|
|
12
|
+
*
|
|
13
|
+
* There is deliberately no per-session dedup here. A gate that fires once is
|
|
14
|
+
* not a gate.
|
|
15
|
+
*/
|
|
2
16
|
export type PreToolUseInput = {
|
|
3
17
|
hook_event_name?: string;
|
|
4
18
|
session_id?: string;
|
|
@@ -1,28 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
/**
|
|
3
|
-
* The blocking half of the comment convention. `computeCommentReminder` runs
|
|
4
|
-
* at PostToolUse and only advises: by then the prose is already on disk, and
|
|
5
|
-
* it is deduped to once per file per session, so the second rationale block
|
|
6
|
-
* in a file draws no signal at all.
|
|
7
|
-
*
|
|
8
|
-
* This runs at PreToolUse instead, where `tool_input` is visible before the
|
|
9
|
-
* edit is applied and the hook can answer `deny`. A multi-line comment is
|
|
10
|
-
* refused outright and the agent is told to put the reasoning in `lat.md/`
|
|
11
|
-
* and leave a `@lat:` pointer behind.
|
|
12
|
-
*
|
|
13
|
-
* There is deliberately no per-session dedup here. A gate that fires once is
|
|
14
|
-
* not a gate.
|
|
15
|
-
*/
|
|
16
|
-
/** Minimum candidate comment lines in one write before the edit is refused.
|
|
17
|
-
* A single line ("// bytes, not chars") is the bare-fact case the convention
|
|
18
|
-
* explicitly allows; two or more is a block of prose. */
|
|
19
|
-
const BLOCK_THRESHOLD = 2;
|
|
20
|
-
/** Explicit, reviewable opt-out. Spelled the same as the `lat:ignore` token
|
|
21
|
-
* [[src/code-refs.ts]] already honours, so a project has one escape hatch to
|
|
22
|
-
* learn rather than two. It suppresses only the line it appears on, which
|
|
23
|
-
* keeps a deliberate exception visible in the diff instead of silently
|
|
24
|
-
* disarming the gate for a whole file. */
|
|
25
|
-
const LAT_IGNORE_RE = /(?<![\w])lat:ignore(?![-\w])/;
|
|
1
|
+
import { hasLatTree, judgeWrittenComments, matchFamily, resolveProjectRoot, } from './comment-reminder.js';
|
|
26
2
|
function buildReason(opts) {
|
|
27
3
|
const where = opts.hasTree
|
|
28
4
|
? ' 1. Put the reasoning in a lat.md/ section. Verbatim is fine — it does\n' +
|
|
@@ -43,8 +19,12 @@ function buildReason(opts) {
|
|
|
43
19
|
' 3. Re-apply the edit with the prose replaced by that one pointer line.\n\n' +
|
|
44
20
|
'If a comment only narrates what the code does, delete it rather than ' +
|
|
45
21
|
'moving it — a reader could reconstruct it from the code.\n\n' +
|
|
46
|
-
|
|
47
|
-
'
|
|
22
|
+
'Two exits, if neither applies:\n' +
|
|
23
|
+
' - Put `lat:ignore` on a specific line to keep it. An explicit, ' +
|
|
24
|
+
'reviewable exception, not a way to silence the gate.\n' +
|
|
25
|
+
' - Use `Edit` rather than a whole-file `Write`. Re-emitting a comment ' +
|
|
26
|
+
'the file already holds is free in either shape, but an `Edit` puts far ' +
|
|
27
|
+
'less text in front of the count.');
|
|
48
28
|
}
|
|
49
29
|
/**
|
|
50
30
|
* Returns the denial reason for a single Edit/Write/MultiEdit call, or `null`
|
|
@@ -93,18 +73,15 @@ export function computeCommentBlock(input) {
|
|
|
93
73
|
const family = matchFamily(filePath);
|
|
94
74
|
if (!family)
|
|
95
75
|
return null;
|
|
96
|
-
const
|
|
97
|
-
if (!
|
|
98
|
-
return null;
|
|
99
|
-
const candidates = candidateCommentLines(written, family).filter((line) => !LAT_IGNORE_RE.test(line));
|
|
100
|
-
if (candidates.length < BLOCK_THRESHOLD)
|
|
76
|
+
const verdict = judgeWrittenComments(toolInput, family, 'before-write');
|
|
77
|
+
if (!verdict.flagged)
|
|
101
78
|
return null;
|
|
102
79
|
const projectRoot = resolveProjectRoot(filePath, input.cwd);
|
|
103
80
|
if (!projectRoot)
|
|
104
81
|
return null;
|
|
105
82
|
return buildReason({
|
|
106
83
|
base: filePath.split('/').pop() ?? filePath,
|
|
107
|
-
count:
|
|
84
|
+
count: verdict.count,
|
|
108
85
|
marker: family.marker,
|
|
109
86
|
hasTree: hasLatTree(projectRoot),
|
|
110
87
|
});
|
|
@@ -27,20 +27,79 @@ export declare function matchFamily(filePath: string): CommentFamily | null;
|
|
|
27
27
|
export type ToolInput = {
|
|
28
28
|
file_path?: string;
|
|
29
29
|
content?: string;
|
|
30
|
+
old_string?: string;
|
|
30
31
|
new_string?: string;
|
|
31
32
|
edits?: {
|
|
33
|
+
old_string?: string;
|
|
32
34
|
new_string?: string;
|
|
33
35
|
}[];
|
|
34
36
|
};
|
|
35
|
-
/**
|
|
36
|
-
*
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Which side of the tool call the caller is on.
|
|
39
|
+
*
|
|
40
|
+
* `'before-write'` — PreToolUse. The file on disk is still the pre-edit state,
|
|
41
|
+
* so it is usable as a baseline for a whole-file `Write`, which carries no
|
|
42
|
+
* other record of what it replaced.
|
|
43
|
+
*
|
|
44
|
+
* `'after-write'` — PostToolUse. The write has landed, so the file contains
|
|
45
|
+
* every line of `content` by construction. Diffing against it there cancels
|
|
46
|
+
* the whole payload and the caller sees nothing at all.
|
|
47
|
+
*
|
|
48
|
+
* An `Edit` carries its own `old_string` and needs neither reading: its
|
|
49
|
+
* baseline is phase-independent, which is the shape to prefer.
|
|
50
|
+
*
|
|
51
|
+
* The parameter is required so a third call site has to answer the question
|
|
52
|
+
* rather than inherit whichever default happened to suit the first two.
|
|
53
|
+
*/
|
|
54
|
+
export type WritePhase = 'before-write' | 'after-write';
|
|
55
|
+
/** Explicit, reviewable per-line opt-out, spelled the same as the token
|
|
56
|
+
* [[src/code-refs.ts]] already honours so a project has one escape hatch to
|
|
57
|
+
* learn rather than two. It lives here, not in the guard, because the
|
|
58
|
+
* advisory and blocking halves have to agree on it: an exemption that the
|
|
59
|
+
* gate honours but the reminder does not just relocates the nag.
|
|
60
|
+
* It suppresses only the line it appears on, keeping a deliberate exception
|
|
61
|
+
* visible in the diff instead of silently disarming the convention for a
|
|
62
|
+
* whole file. */
|
|
63
|
+
export declare const LAT_IGNORE_RE: RegExp;
|
|
64
|
+
/** Whether one line is a comment worth counting. Not counted:
|
|
39
65
|
* - a line that already carries a `@lat:` pointer (the compliant state)
|
|
66
|
+
* - a line carrying the explicit opt-out token
|
|
40
67
|
* - machine directives: shebangs, magic comments, linter/type pragmas
|
|
41
68
|
* - decoration with no alphanumeric character (`# ----`, `//////`, a bare
|
|
42
|
-
* `*/` closing a block)
|
|
43
|
-
|
|
69
|
+
* `*/` closing a block)
|
|
70
|
+
*
|
|
71
|
+
* Both halves of the convention call this and nothing else, so the gate and
|
|
72
|
+
* the reminder cannot drift apart on what counts as prose. */
|
|
73
|
+
export declare function isCandidateCommentLine(line: string, family: CommentFamily): boolean;
|
|
74
|
+
export type CommentVerdict = {
|
|
75
|
+
/** Candidate comment lines this call is answerable for. Names the number
|
|
76
|
+
* printed in the denial or the reminder. */
|
|
77
|
+
count: number;
|
|
78
|
+
/** Whether the call warrants a denial from the gate or a reminder from its
|
|
79
|
+
* advisory half. */
|
|
80
|
+
flagged: boolean;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* What one Edit/Write/MultiEdit call did to the comments in a file.
|
|
84
|
+
*
|
|
85
|
+
* Two rules, because neither alone survives contact with an agent:
|
|
86
|
+
*
|
|
87
|
+
* A run of `PROSE_THRESHOLD` or more *adjacent* comment lines is flagged as
|
|
88
|
+
* soon as any one of them is new. Counting only the new lines lets a block be
|
|
89
|
+
* grown one line per `Edit` and never reach the threshold — and the denial
|
|
90
|
+
* text itself recommends `Edit` over `Write`, so the technique is advertised.
|
|
91
|
+
* What matters is the block the edit leaves behind, not the increment.
|
|
92
|
+
*
|
|
93
|
+
* Isolated new comment lines are flagged once `PROSE_THRESHOLD` of them
|
|
94
|
+
* accumulate anywhere in the call. Two one-line comments in different places
|
|
95
|
+
* are still two lines of prose; without this a scattered write walks past the
|
|
96
|
+
* adjacency rule.
|
|
97
|
+
*
|
|
98
|
+
* Neither rule counts a line that was already there, so re-emitting a
|
|
99
|
+
* comment — the whole of a `Write`'s payload, or the lines bracketing an
|
|
100
|
+
* `Edit` — stays free.
|
|
101
|
+
*/
|
|
102
|
+
export declare function judgeWrittenComments(toolInput: ToolInput, family: CommentFamily, phase: WritePhase): CommentVerdict;
|
|
44
103
|
/** Resolvable project root, required so a scratch file in /tmp is not treated
|
|
45
104
|
* as a project. `cwd` is preferred (matches CLAUDE_PROJECT_DIR / payload cwd
|
|
46
105
|
* conventions); falls back to `git rev-parse --show-toplevel`. */
|