@plebeiantech/lat.md 0.12.3-fork.1

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.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/dist/src/cli/check-frontmatter.d.ts +12 -0
  4. package/dist/src/cli/check-frontmatter.js +44 -0
  5. package/dist/src/cli/check-mode.d.ts +6 -0
  6. package/dist/src/cli/check-mode.js +303 -0
  7. package/dist/src/cli/check-status.d.ts +74 -0
  8. package/dist/src/cli/check-status.js +217 -0
  9. package/dist/src/cli/check.d.ts +45 -0
  10. package/dist/src/cli/check.js +777 -0
  11. package/dist/src/cli/checklist-menu.d.ts +12 -0
  12. package/dist/src/cli/checklist-menu.js +111 -0
  13. package/dist/src/cli/comment-guard.d.ts +16 -0
  14. package/dist/src/cli/comment-guard.js +111 -0
  15. package/dist/src/cli/comment-reminder.d.ts +62 -0
  16. package/dist/src/cli/comment-reminder.js +210 -0
  17. package/dist/src/cli/context.d.ts +11 -0
  18. package/dist/src/cli/context.js +58 -0
  19. package/dist/src/cli/expand.d.ts +8 -0
  20. package/dist/src/cli/expand.js +94 -0
  21. package/dist/src/cli/gen-index.d.ts +45 -0
  22. package/dist/src/cli/gen-index.js +235 -0
  23. package/dist/src/cli/gen.d.ts +6 -0
  24. package/dist/src/cli/gen.js +42 -0
  25. package/dist/src/cli/graph.d.ts +7 -0
  26. package/dist/src/cli/graph.js +47 -0
  27. package/dist/src/cli/hook.d.ts +1 -0
  28. package/dist/src/cli/hook.js +432 -0
  29. package/dist/src/cli/index.d.ts +2 -0
  30. package/dist/src/cli/index.js +307 -0
  31. package/dist/src/cli/init.d.ts +9 -0
  32. package/dist/src/cli/init.js +1031 -0
  33. package/dist/src/cli/link-scheme.d.ts +54 -0
  34. package/dist/src/cli/link-scheme.js +101 -0
  35. package/dist/src/cli/locate.d.ts +2 -0
  36. package/dist/src/cli/locate.js +17 -0
  37. package/dist/src/cli/refs.d.ts +22 -0
  38. package/dist/src/cli/refs.js +229 -0
  39. package/dist/src/cli/reindex.d.ts +13 -0
  40. package/dist/src/cli/reindex.js +136 -0
  41. package/dist/src/cli/search.d.ts +36 -0
  42. package/dist/src/cli/search.js +217 -0
  43. package/dist/src/cli/section.d.ts +43 -0
  44. package/dist/src/cli/section.js +252 -0
  45. package/dist/src/cli/select-menu.d.ts +13 -0
  46. package/dist/src/cli/select-menu.js +100 -0
  47. package/dist/src/cli/templates.d.ts +1 -0
  48. package/dist/src/cli/templates.js +15 -0
  49. package/dist/src/cli/ui.d.ts +11 -0
  50. package/dist/src/cli/ui.js +34 -0
  51. package/dist/src/code-refs.d.ts +17 -0
  52. package/dist/src/code-refs.js +277 -0
  53. package/dist/src/config.d.ts +29 -0
  54. package/dist/src/config.js +83 -0
  55. package/dist/src/context.d.ts +23 -0
  56. package/dist/src/context.js +11 -0
  57. package/dist/src/extensions/wiki-link/from-markdown.d.ts +5 -0
  58. package/dist/src/extensions/wiki-link/from-markdown.js +34 -0
  59. package/dist/src/extensions/wiki-link/index.d.ts +4 -0
  60. package/dist/src/extensions/wiki-link/index.js +3 -0
  61. package/dist/src/extensions/wiki-link/syntax.d.ts +14 -0
  62. package/dist/src/extensions/wiki-link/syntax.js +121 -0
  63. package/dist/src/extensions/wiki-link/to-markdown.d.ts +5 -0
  64. package/dist/src/extensions/wiki-link/to-markdown.js +28 -0
  65. package/dist/src/extensions/wiki-link/types.d.ts +34 -0
  66. package/dist/src/extensions/wiki-link/types.js +1 -0
  67. package/dist/src/format.d.ts +8 -0
  68. package/dist/src/format.js +43 -0
  69. package/dist/src/graph-export.d.ts +60 -0
  70. package/dist/src/graph-export.js +309 -0
  71. package/dist/src/init-version.d.ts +10 -0
  72. package/dist/src/init-version.js +49 -0
  73. package/dist/src/knowledge/bd.d.ts +2 -0
  74. package/dist/src/knowledge/bd.js +107 -0
  75. package/dist/src/knowledge/claude-memory.d.ts +13 -0
  76. package/dist/src/knowledge/claude-memory.js +204 -0
  77. package/dist/src/knowledge/cq.d.ts +2 -0
  78. package/dist/src/knowledge/cq.js +74 -0
  79. package/dist/src/knowledge/index.d.ts +49 -0
  80. package/dist/src/knowledge/index.js +271 -0
  81. package/dist/src/knowledge/ranking.d.ts +17 -0
  82. package/dist/src/knowledge/ranking.js +62 -0
  83. package/dist/src/knowledge/session.d.ts +46 -0
  84. package/dist/src/knowledge/session.js +203 -0
  85. package/dist/src/knowledge/types.d.ts +47 -0
  86. package/dist/src/knowledge/types.js +10 -0
  87. package/dist/src/lattice.d.ts +108 -0
  88. package/dist/src/lattice.js +816 -0
  89. package/dist/src/mcp/server.d.ts +1 -0
  90. package/dist/src/mcp/server.js +58 -0
  91. package/dist/src/parser.d.ts +3 -0
  92. package/dist/src/parser.js +18 -0
  93. package/dist/src/search/db.d.ts +16 -0
  94. package/dist/src/search/db.js +54 -0
  95. package/dist/src/search/embedder.d.ts +25 -0
  96. package/dist/src/search/embedder.js +58 -0
  97. package/dist/src/search/index.d.ts +9 -0
  98. package/dist/src/search/index.js +66 -0
  99. package/dist/src/search/search.d.ts +9 -0
  100. package/dist/src/search/search.js +16 -0
  101. package/dist/src/source-parser.d.ts +27 -0
  102. package/dist/src/source-parser.js +916 -0
  103. package/dist/src/untrusted.d.ts +24 -0
  104. package/dist/src/untrusted.js +97 -0
  105. package/dist/src/version.d.ts +7 -0
  106. package/dist/src/version.js +39 -0
  107. package/dist/src/view/client/assets/index-B0n0u03y.css +1 -0
  108. package/dist/src/view/client/assets/index-xm2_px4q.js +23 -0
  109. package/dist/src/view/client/index.html +14 -0
  110. package/dist/src/view/diagnostics.d.ts +5 -0
  111. package/dist/src/view/diagnostics.js +171 -0
  112. package/dist/src/view/git-diff.d.ts +3 -0
  113. package/dist/src/view/git-diff.js +255 -0
  114. package/dist/src/view/git.d.ts +22 -0
  115. package/dist/src/view/git.js +193 -0
  116. package/dist/src/view/highlight.d.ts +2 -0
  117. package/dist/src/view/highlight.js +3767 -0
  118. package/dist/src/view/markdown.d.ts +22 -0
  119. package/dist/src/view/markdown.js +322 -0
  120. package/dist/src/view/protocol.d.ts +89 -0
  121. package/dist/src/view/protocol.js +1 -0
  122. package/dist/src/view/references.d.ts +62 -0
  123. package/dist/src/view/references.js +345 -0
  124. package/dist/src/view/repository.d.ts +12 -0
  125. package/dist/src/view/repository.js +167 -0
  126. package/dist/src/view/search.d.ts +14 -0
  127. package/dist/src/view/search.js +64 -0
  128. package/dist/src/view/server.d.ts +20 -0
  129. package/dist/src/view/server.js +233 -0
  130. package/dist/src/view/store.d.ts +57 -0
  131. package/dist/src/view/store.js +404 -0
  132. package/dist/src/walk.d.ts +18 -0
  133. package/dist/src/walk.js +27 -0
  134. package/package.json +85 -0
  135. package/templates/AGENTS.md +118 -0
  136. package/templates/README +1 -0
  137. package/templates/cursor-rules.md +77 -0
  138. package/templates/init/lat.md +1 -0
  139. package/templates/logo-dark.svg +182 -0
  140. package/templates/logo-light.svg +182 -0
  141. package/templates/logo.txt +7 -0
  142. package/templates/opencode-plugin.ts +180 -0
  143. package/templates/pi-extension.ts +364 -0
  144. package/templates/skill/SKILL.md +189 -0
@@ -0,0 +1,12 @@
1
+ export interface ChecklistOption {
2
+ label: string;
3
+ value: string;
4
+ }
5
+ /**
6
+ * Display an interactive multi-select checklist with arrow-key navigation.
7
+ * Returns an array of checked values.
8
+ *
9
+ * Keys: Up/Down (j/k) to move, Space to toggle, Enter to confirm, Ctrl+C to exit.
10
+ * Non-TTY fallback: returns [].
11
+ */
12
+ export declare function checklistMenu(options: ChecklistOption[], prompt?: string): Promise<string[]>;
@@ -0,0 +1,111 @@
1
+ import { styleText } from 'node:util';
2
+ /**
3
+ * Display an interactive multi-select checklist with arrow-key navigation.
4
+ * Returns an array of checked values.
5
+ *
6
+ * Keys: Up/Down (j/k) to move, Space to toggle, Enter to confirm, Ctrl+C to exit.
7
+ * Non-TTY fallback: returns [].
8
+ */
9
+ export async function checklistMenu(options, prompt) {
10
+ if (options.length === 0)
11
+ return [];
12
+ if (!process.stdin.isTTY)
13
+ return [];
14
+ return new Promise((resolve) => {
15
+ let cursor = 0;
16
+ const checked = new Set();
17
+ const stdin = process.stdin;
18
+ const wasRaw = stdin.isRaw;
19
+ stdin.setRawMode(true);
20
+ stdin.resume();
21
+ stdin.setEncoding('utf-8');
22
+ function render() {
23
+ process.stdout.write('\x1B[?25l'); // hide cursor
24
+ const lines = [];
25
+ if (prompt) {
26
+ lines.push(styleText('bold', prompt));
27
+ }
28
+ for (let i = 0; i < options.length; i++) {
29
+ const opt = options[i];
30
+ const selected = i === cursor;
31
+ const box = checked.has(i) ? '[x]' : '[ ]';
32
+ if (selected) {
33
+ lines.push(` ${box} ${styleText(['bgCyan', 'black', 'bold'], ` ${opt.label} `)}`);
34
+ }
35
+ else {
36
+ lines.push(` ${box} ${styleText('dim', opt.label)}`);
37
+ }
38
+ }
39
+ lines.push('');
40
+ lines.push(styleText('dim', ' space: toggle enter: confirm'));
41
+ process.stdout.write(lines.join('\n') + '\n');
42
+ }
43
+ function clearRender() {
44
+ const totalLines = options.length + (prompt ? 1 : 0) + 2; // +2 for blank line + hint
45
+ for (let i = 0; i < totalLines; i++) {
46
+ process.stdout.write('\x1B[A\x1B[2K');
47
+ }
48
+ }
49
+ function cleanup() {
50
+ stdin.setRawMode(wasRaw ?? false);
51
+ stdin.pause();
52
+ process.stdout.write('\x1B[?25h'); // show cursor
53
+ stdin.removeListener('data', onData);
54
+ }
55
+ function onData(data) {
56
+ const key = data.toString();
57
+ // Ctrl+C
58
+ if (key === '\x03') {
59
+ clearRender();
60
+ cleanup();
61
+ console.log('');
62
+ process.exit(130);
63
+ }
64
+ // Enter — confirm
65
+ if (key === '\r' || key === '\n') {
66
+ clearRender();
67
+ cleanup();
68
+ const result = [...checked].sort().map((i) => options[i].value);
69
+ // Print summary
70
+ if (prompt) {
71
+ const labels = [...checked]
72
+ .sort()
73
+ .map((i) => options[i].label)
74
+ .join(', ');
75
+ console.log(styleText('bold', prompt) +
76
+ ' ' +
77
+ (labels ? styleText('green', labels) : styleText('dim', 'None')));
78
+ }
79
+ resolve(result);
80
+ return;
81
+ }
82
+ // Space — toggle
83
+ if (key === ' ') {
84
+ clearRender();
85
+ if (checked.has(cursor)) {
86
+ checked.delete(cursor);
87
+ }
88
+ else {
89
+ checked.add(cursor);
90
+ }
91
+ render();
92
+ return;
93
+ }
94
+ // Arrow keys
95
+ if (key === '\x1B[A' || key === 'k') {
96
+ // Up
97
+ clearRender();
98
+ cursor = (cursor - 1 + options.length) % options.length;
99
+ render();
100
+ }
101
+ else if (key === '\x1B[B' || key === 'j') {
102
+ // Down
103
+ clearRender();
104
+ cursor = (cursor + 1) % options.length;
105
+ render();
106
+ }
107
+ }
108
+ stdin.on('data', onData);
109
+ render();
110
+ });
111
+ }
@@ -0,0 +1,16 @@
1
+ import { type ToolInput } from './comment-reminder.js';
2
+ export type PreToolUseInput = {
3
+ hook_event_name?: string;
4
+ session_id?: string;
5
+ cwd?: string;
6
+ tool_name?: string;
7
+ tool_input?: ToolInput;
8
+ };
9
+ /**
10
+ * PreToolUse entry point. Lives here rather than in `hook.ts` so the upstream
11
+ * file carries only an import and a `case` — see the fork's upstream-divergence
12
+ * constraint. `readStdin` is injected for the same reason: it is `hook.ts`'s
13
+ * private helper and exporting it would widen that file's diff.
14
+ */
15
+ export declare function handlePreToolUse(readStdin: () => Promise<string>): Promise<void>;
16
+ export declare function computeCommentBlock(input: PreToolUseInput): string | null;
@@ -0,0 +1,111 @@
1
+ import { candidateCommentLines, extractWrittenText, hasLatTree, matchFamily, resolveProjectRoot, } 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
+ */
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])/;
26
+ function buildReason(opts) {
27
+ const where = opts.hasTree
28
+ ? ' 1. Put the reasoning in a lat.md/ section. Verbatim is fine — it does\n' +
29
+ ' not need rewriting. Use `lat search` / `lat locate` to find where it\n' +
30
+ ' belongs; create a new section only if nothing existing fits.'
31
+ : ' 1. This project has no lat.md/ tree yet. Run `lat init` to create one\n' +
32
+ ' (or create lat.md/ by hand), then put the reasoning in a section there.';
33
+ return (`Blocked by the lat.md comment convention: this edit writes ${opts.count} ` +
34
+ `comment lines into ${opts.base}.\n\n` +
35
+ 'A code comment states a bare fact only — a unit, a bound, what null means, ' +
36
+ 'an issue id, or an `@lat:` pointer. A comment that explains WHY (a reason, ' +
37
+ 'a trade-off, a rejected alternative, a constraint, or the rationale behind ' +
38
+ 'an invariant) is documentation, and documentation does not live in source.\n\n' +
39
+ 'Do this instead:\n' +
40
+ `${where}\n` +
41
+ ' 2. Leave a pointer in the code where the prose was:\n' +
42
+ ` ${opts.marker} @lat: [[section-id]]\n` +
43
+ ' 3. Re-apply the edit with the prose replaced by that one pointer line.\n\n' +
44
+ 'If a comment only narrates what the code does, delete it rather than ' +
45
+ 'moving it — a reader could reconstruct it from the code.\n\n' +
46
+ `To keep a specific line anyway, put \`lat:ignore\` on it. That is an ` +
47
+ 'explicit, reviewable exception, not a way to silence the gate.');
48
+ }
49
+ /**
50
+ * Returns the denial reason for a single Edit/Write/MultiEdit call, or `null`
51
+ * to let the edit through. Pure and synchronous: every fallible bit (fs, git)
52
+ * is wrapped internally and degrades to "allow", never to a thrown error. A
53
+ * gate that crashes must fail open — refusing every edit because `git` is
54
+ * missing would be far worse than missing a comment.
55
+ */
56
+ function outputDeny(reason) {
57
+ process.stdout.write(JSON.stringify({
58
+ hookSpecificOutput: {
59
+ hookEventName: 'PreToolUse',
60
+ permissionDecision: 'deny',
61
+ permissionDecisionReason: reason,
62
+ },
63
+ }));
64
+ }
65
+ /**
66
+ * PreToolUse entry point. Lives here rather than in `hook.ts` so the upstream
67
+ * file carries only an import and a `case` — see the fork's upstream-divergence
68
+ * constraint. `readStdin` is injected for the same reason: it is `hook.ts`'s
69
+ * private helper and exporting it would widen that file's diff.
70
+ */
71
+ export async function handlePreToolUse(readStdin) {
72
+ try {
73
+ const raw = await readStdin();
74
+ const input = JSON.parse(raw);
75
+ const toolName = input.tool_name ?? '';
76
+ if (!/^(Edit|Write|MultiEdit)$/.test(toolName))
77
+ return;
78
+ const reason = computeCommentBlock(input);
79
+ if (reason)
80
+ outputDeny(reason);
81
+ }
82
+ catch {
83
+ // Fail open: never block a tool call over a malformed payload or fs error.
84
+ }
85
+ }
86
+ export function computeCommentBlock(input) {
87
+ const toolInput = input.tool_input ?? {};
88
+ const filePath = toolInput.file_path;
89
+ if (!filePath)
90
+ return null;
91
+ // `FAMILIES` is the single place that decides which files are eligible, and
92
+ // no family matches `.md` — markdown, where prose belongs, is never gated.
93
+ const family = matchFamily(filePath);
94
+ if (!family)
95
+ return null;
96
+ const written = extractWrittenText(toolInput);
97
+ if (!written)
98
+ return null;
99
+ const candidates = candidateCommentLines(written, family).filter((line) => !LAT_IGNORE_RE.test(line));
100
+ if (candidates.length < BLOCK_THRESHOLD)
101
+ return null;
102
+ const projectRoot = resolveProjectRoot(filePath, input.cwd);
103
+ if (!projectRoot)
104
+ return null;
105
+ return buildReason({
106
+ base: filePath.split('/').pop() ?? filePath,
107
+ count: candidates.length,
108
+ marker: family.marker,
109
+ hasTree: hasLatTree(projectRoot),
110
+ });
111
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Ports `hooks/comment-docref-reminder.sh` from the Diátaxis plugin into a
3
+ * lat.md hook event. The read side of lat.md surfaces knowledge behind a
4
+ * `// @lat:` pointer that already exists; nothing acts on WRITING one, so an
5
+ * agent that writes a nine-line rationale comment gets no signal at all — the
6
+ * write succeeds and looks correct. This is that missing half.
7
+ *
8
+ * Deliberately a reminder, not a gate: it always resolves without throwing,
9
+ * and the caller (`hookCmd`) always exits 0. Judging whether a comment
10
+ * explains WHY is not mechanically decidable, so the decision stays with the
11
+ * model — this only guarantees the question gets asked, once per file per
12
+ * session.
13
+ */
14
+ export type CommentFamily = {
15
+ /** Matches the file's basename (Rakefile, Dockerfile, etc. carry the
16
+ * language in the name, not an extension, so this matches on basename). */
17
+ basenameRe: RegExp;
18
+ /** A line that opens or continues a comment in this family. */
19
+ commentRe: RegExp;
20
+ /** Machine directives (shebangs, magic comments, linter/type pragmas) that
21
+ * are not prose and can never move into a doc. */
22
+ pragmaRe: RegExp;
23
+ /** The `@lat:` marker syntax for this family, e.g. `#` or `//`. */
24
+ marker: string;
25
+ };
26
+ export declare function matchFamily(filePath: string): CommentFamily | null;
27
+ export type ToolInput = {
28
+ file_path?: string;
29
+ content?: string;
30
+ new_string?: string;
31
+ edits?: {
32
+ new_string?: string;
33
+ }[];
34
+ };
35
+ /** Only the NEW text — reading the file would fire on every pre-existing
36
+ * comment in it, nagging about prose the agent did not write. */
37
+ export declare function extractWrittenText(toolInput: ToolInput): string;
38
+ /** Comment lines worth reminding about. Dropped before counting:
39
+ * - a line that already carries a `@lat:` pointer (the compliant state)
40
+ * - machine directives: shebangs, magic comments, linter/type pragmas
41
+ * - decoration with no alphanumeric character (`# ----`, `//////`, a bare
42
+ * `*​/` closing a block) */
43
+ export declare function candidateCommentLines(written: string, family: CommentFamily): string[];
44
+ /** Resolvable project root, required so a scratch file in /tmp is not treated
45
+ * as a project. `cwd` is preferred (matches CLAUDE_PROJECT_DIR / payload cwd
46
+ * conventions); falls back to `git rev-parse --show-toplevel`. */
47
+ export declare function resolveProjectRoot(filePath: string, payloadCwd?: string): string | null;
48
+ export declare function hasLatTree(projectRoot: string): boolean;
49
+ export type PostToolUseInput = {
50
+ hook_event_name?: string;
51
+ session_id?: string;
52
+ cwd?: string;
53
+ tool_name?: string;
54
+ tool_input?: ToolInput;
55
+ };
56
+ /**
57
+ * Computes the reminder context for a single Edit/Write call, or `null` if
58
+ * nothing warrants one. Pure and synchronous so it's trivial to unit test —
59
+ * all the fallible bits (fs, git, marker dedup) are wrapped internally and
60
+ * degrade to "say nothing" or "say it again", never to a thrown error.
61
+ */
62
+ export declare function computeCommentReminder(input: PostToolUseInput): string | null;
@@ -0,0 +1,210 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, mkdirSync, writeFileSync, statSync, lstatSync, } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { basename, dirname, join } from 'node:path';
6
+ const FAMILIES = [
7
+ // Ruby, Python, shell, Rake, Dockerfile, Make, Terraform, YAML, TOML
8
+ {
9
+ basenameRe: /^(.*\.(rb|rake|py|sh|bash|zsh|dockerfile|mk|tf|tfvars|yml|yaml|toml)|Rakefile|Dockerfile(\..*)?|Makefile|makefile|GNUmakefile)$/,
10
+ commentRe: /^\s*#/,
11
+ pragmaRe: /^\s*#\s*(frozen_string_literal|encoding|coding[:=]|typed|rubocop|standard|:nodoc:|-\*-|type:|noqa|pylint|pyright|pragma:|fmt:|mypy:|ruff:|isort:|shellcheck|syntax=|escape=|tflint-ignore|yamllint|checkov:|terraform-docs)/,
12
+ marker: '#',
13
+ },
14
+ // TS/JS, Rust, Java, Go, C/C++, Kotlin, Swift, PHP, Scala. The `{/*` arm is
15
+ // JSX in practice but costs nothing elsewhere. Rust `///`/`//!` and Java
16
+ // `/**` are deliberately in scope — a doc comment explaining why is still
17
+ // prose that belongs in a doc with a pointer behind it.
18
+ //
19
+ // A bare `*` only opens/continues a comment when followed by whitespace, a
20
+ // slash, or end of line — otherwise `*count += 1;` (a pointer dereference)
21
+ // reads as a comment.
22
+ {
23
+ basenameRe: /\.(ts|tsx|js|jsx|mjs|cjs|mts|cts|rs|java|go|kt|kts|swift|php|scala|sc|c|h|cc|cpp|cxx|hpp|hh|hxx|m|mm)$/,
24
+ commentRe: /^\s*(\/\/|\/\*|\{\/\*|\*([\s/]|$))/,
25
+ pragmaRe: /^\s*(\/\/|\/\*)\s*(eslint|prettier-ignore|biome-ignore|istanbul|c8 |v8 |@ts-|<reference|CHECKSTYLE|NOPMD|\$NON-NLS|go:|\+build|nolint|clang-format|NOLINT|IWYU|swiftlint:|ktlint-|phpcs:|psalm-|@codingStandardsIgnore)/,
26
+ marker: '//',
27
+ },
28
+ // SQL. `#` is there for the MySQL dialect, which accepts it alongside `--`.
29
+ {
30
+ basenameRe: /\.sql$/,
31
+ commentRe: /^\s*(--|\/\*|#|\*([\s/]|$))/,
32
+ pragmaRe: /^\s*(--|#)\s*(noqa|sqlfluff|sqlformat)/,
33
+ marker: '--',
34
+ },
35
+ ];
36
+ export function matchFamily(filePath) {
37
+ const base = basename(filePath);
38
+ for (const family of FAMILIES) {
39
+ if (family.basenameRe.test(base))
40
+ return family;
41
+ }
42
+ return null;
43
+ }
44
+ /** Only the NEW text — reading the file would fire on every pre-existing
45
+ * comment in it, nagging about prose the agent did not write. */
46
+ export function extractWrittenText(toolInput) {
47
+ const parts = [];
48
+ if (typeof toolInput.content === 'string')
49
+ parts.push(toolInput.content);
50
+ if (typeof toolInput.new_string === 'string')
51
+ parts.push(toolInput.new_string);
52
+ if (Array.isArray(toolInput.edits)) {
53
+ for (const edit of toolInput.edits) {
54
+ if (typeof edit?.new_string === 'string')
55
+ parts.push(edit.new_string);
56
+ }
57
+ }
58
+ return parts.join('\n');
59
+ }
60
+ /** Comment lines worth reminding about. Dropped before counting:
61
+ * - a line that already carries a `@lat:` pointer (the compliant state)
62
+ * - machine directives: shebangs, magic comments, linter/type pragmas
63
+ * - decoration with no alphanumeric character (`# ----`, `//////`, a bare
64
+ * `*​/` closing a block) */
65
+ export function candidateCommentLines(written, family) {
66
+ return written
67
+ .split('\n')
68
+ .filter((line) => family.commentRe.test(line))
69
+ .filter((line) => !line.includes('@lat:'))
70
+ .filter((line) => !/^\s*#!/.test(line))
71
+ .filter((line) => !family.pragmaRe.test(line))
72
+ .filter((line) => /[a-zA-Z0-9]/.test(line));
73
+ }
74
+ /** Resolvable project root, required so a scratch file in /tmp is not treated
75
+ * as a project. `cwd` is preferred (matches CLAUDE_PROJECT_DIR / payload cwd
76
+ * conventions); falls back to `git rev-parse --show-toplevel`. */
77
+ export function resolveProjectRoot(filePath, payloadCwd) {
78
+ if (payloadCwd)
79
+ return payloadCwd;
80
+ try {
81
+ const dir = dirname(filePath);
82
+ const out = execSync('git rev-parse --show-toplevel', {
83
+ cwd: dir,
84
+ encoding: 'utf-8',
85
+ stdio: ['ignore', 'pipe', 'ignore'],
86
+ timeout: 2000,
87
+ }).trim();
88
+ return out || null;
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ export function hasLatTree(projectRoot) {
95
+ try {
96
+ return statSync(join(projectRoot, 'lat.md')).isDirectory();
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
102
+ /** Once per file per session. A private per-user temp dir; if a marker
103
+ * already exists (or can't be checked), the caller treats it as "already
104
+ * reminded" only when the file actually exists, never on error — a dedup
105
+ * failure should fire the reminder again, not suppress it forever. */
106
+ function alreadyReminded(sessionId, filePath) {
107
+ const noop = { seen: false, markAsSeen: () => { } };
108
+ try {
109
+ const dir = join(tmpdir(), `lat-comment-reminder-${process.getuid?.() ?? 0}`);
110
+ if (!existsSync(dir)) {
111
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
112
+ }
113
+ else {
114
+ // Refuse a symlinked or world-writable directory — same reasoning as
115
+ // lib.sh's private_dir: dedup state must not be steerable by another
116
+ // user on a shared machine.
117
+ const st = lstatSync(dir);
118
+ if (st.isSymbolicLink() || (st.mode & 0o022) !== 0)
119
+ return noop;
120
+ }
121
+ const hash = createHash('sha256')
122
+ .update(`${sessionId}:${filePath}`)
123
+ .digest('hex');
124
+ const marker = join(dir, hash);
125
+ if (existsSync(marker))
126
+ return { seen: true, markAsSeen: () => { } };
127
+ return {
128
+ seen: false,
129
+ markAsSeen: () => {
130
+ try {
131
+ writeFileSync(marker, '');
132
+ }
133
+ catch {
134
+ // best-effort — a missed marker just means the reminder repeats
135
+ }
136
+ },
137
+ };
138
+ }
139
+ catch {
140
+ return noop;
141
+ }
142
+ }
143
+ function buildMessage(opts) {
144
+ const where = opts.hasTree
145
+ ? ' 1. Move it — verbatim is fine, it does not need rewriting — into a\n' +
146
+ ' lat.md/ section describing this behavior, decision, or design\n' +
147
+ ' intent. Use `lat search`/`lat locate` to find where it belongs;\n' +
148
+ ' create a new section only if nothing existing fits.'
149
+ : ' 1. This project has no lat.md/ tree yet. Run `lat init` to create one\n' +
150
+ ' (or create lat.md/ by hand), then put the prose in a section there.';
151
+ return (`lat.md convention: you just wrote ${opts.count} comment line(s) in ${opts.base}.\n\n` +
152
+ 'A code comment states a bare fact only — a unit, a bound, what null means, ' +
153
+ 'an issue id, or an `@lat:` pointer. If a comment explains WHY (a reason, a ' +
154
+ "trade-off, a rejected alternative, a constraint, or an invariant's " +
155
+ 'rationale) then it is documentation, not a comment.\n\n' +
156
+ 'If any of what you just wrote explains why:\n' +
157
+ `${where}\n` +
158
+ ' 2. Leave a pointer behind in the code:\n' +
159
+ ` ${opts.marker} @lat: [[section-id]]\n\n` +
160
+ 'Never narrate what the code does: if a reader could write the comment ' +
161
+ 'from the code alone, delete it.');
162
+ }
163
+ /**
164
+ * Computes the reminder context for a single Edit/Write call, or `null` if
165
+ * nothing warrants one. Pure and synchronous so it's trivial to unit test —
166
+ * all the fallible bits (fs, git, marker dedup) are wrapped internally and
167
+ * degrade to "say nothing" or "say it again", never to a thrown error.
168
+ */
169
+ export function computeCommentReminder(input) {
170
+ const toolInput = input.tool_input ?? {};
171
+ const filePath = toolInput.file_path;
172
+ if (!filePath)
173
+ return null;
174
+ if (/\.md$/i.test(filePath))
175
+ return null;
176
+ const family = matchFamily(filePath);
177
+ if (!family)
178
+ return null;
179
+ const written = extractWrittenText(toolInput);
180
+ if (!written)
181
+ return null;
182
+ // A single-line comment ("// increment the counter") almost always states
183
+ // a bare fact, not a rationale — the acceptance case for this hook. Only a
184
+ // multi-line block is treated as prose worth a reminder; the model still
185
+ // makes the real judgment call, this just filters the overwhelmingly
186
+ // common one-liner before it ever reaches that question.
187
+ const candidates = candidateCommentLines(written, family);
188
+ if (candidates.length < 2)
189
+ return null;
190
+ const sessionId = input.session_id;
191
+ let dedup = null;
192
+ if (sessionId) {
193
+ dedup = alreadyReminded(sessionId, filePath);
194
+ if (dedup.seen)
195
+ return null;
196
+ }
197
+ const projectRoot = resolveProjectRoot(filePath, input.cwd);
198
+ if (!projectRoot)
199
+ return null;
200
+ const hasTree = hasLatTree(projectRoot);
201
+ const base = basename(filePath);
202
+ const message = buildMessage({
203
+ base,
204
+ count: candidates.length,
205
+ marker: family.marker,
206
+ hasTree,
207
+ });
208
+ dedup?.markAsSeen();
209
+ return message;
210
+ }
@@ -0,0 +1,11 @@
1
+ import type { CmdContext, Styler } from '../context.js';
2
+ export type { CmdContext };
3
+ export declare function makeStyler(): Styler;
4
+ export declare function resolveContext(opts: {
5
+ dir?: string;
6
+ color?: boolean;
7
+ }): CmdContext;
8
+ export declare function resolveCheckContext(opts: {
9
+ dir?: string;
10
+ color?: boolean;
11
+ }, target?: string): CmdContext;
@@ -0,0 +1,58 @@
1
+ import { statSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { styleText } from 'node:util';
4
+ import { findLatticeDir } from '../lattice.js';
5
+ export function makeStyler() {
6
+ return {
7
+ bold: (s) => styleText('bold', s),
8
+ dim: (s) => styleText('dim', s),
9
+ red: (s) => styleText('red', s),
10
+ cyan: (s) => styleText('cyan', s),
11
+ white: (s) => styleText('white', s),
12
+ green: (s) => styleText('green', s),
13
+ yellow: (s) => styleText('yellow', s),
14
+ boldWhite: (s) => styleText(['bold', 'white'], s),
15
+ };
16
+ }
17
+ export function resolveContext(opts) {
18
+ const color = opts.color !== false;
19
+ if (!color) {
20
+ process.env.NO_COLOR = '1';
21
+ }
22
+ const latDir = findLatticeDir(opts.dir) ?? '';
23
+ if (!latDir) {
24
+ console.error(styleText('red', 'No lat.md directory found'));
25
+ console.error(styleText('dim', 'Run `lat init` to create one.'));
26
+ process.exit(1);
27
+ }
28
+ const projectRoot = dirname(latDir);
29
+ return { latDir, projectRoot, styler: makeStyler(), mode: 'cli' };
30
+ }
31
+ export function resolveCheckContext(opts, target) {
32
+ if (target === undefined)
33
+ return resolveContext(opts);
34
+ if (opts.color === false) {
35
+ process.env.NO_COLOR = '1';
36
+ }
37
+ const projectRoot = resolve(opts.dir ?? process.cwd());
38
+ const latDir = resolve(projectRoot, target);
39
+ try {
40
+ if (!statSync(latDir).isDirectory()) {
41
+ console.error(styleText('red', `Check target is not a directory: ${target}`));
42
+ process.exit(1);
43
+ }
44
+ }
45
+ catch (error) {
46
+ if (error.code !== 'ENOENT')
47
+ throw error;
48
+ console.error(styleText('red', `Check directory not found: ${target}`));
49
+ process.exit(1);
50
+ }
51
+ return {
52
+ latDir,
53
+ projectRoot,
54
+ styler: makeStyler(),
55
+ mode: 'cli',
56
+ headless: true,
57
+ };
58
+ }
@@ -0,0 +1,8 @@
1
+ import { type Section } from '../lattice.js';
2
+ import type { CmdContext, CmdResult } from '../context.js';
3
+ /**
4
+ * Resolve [[refs]] in text and return the expanded output.
5
+ * Returns null if there are no wiki links, or if resolution fails.
6
+ */
7
+ export declare function expandPrompt(ctx: CmdContext, text: string, preloadedSections?: Section[]): Promise<string | null>;
8
+ export declare function expandCommand(ctx: CmdContext, text: string): Promise<CmdResult>;