@plebeiantech/lat.md 0.12.3-fork.5 → 0.12.3-fork.7
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/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 +6 -0
- package/dist/src/cli/check-mode.js +44 -2
- package/dist/src/cli/check.js +5 -1
- 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 +44 -0
- package/dist/src/cli/fork-scaffold.js +300 -0
- package/dist/src/cli/init.js +5 -0
- package/dist/src/init-version.d.ts +1 -1
- package/dist/src/init-version.js +1 -1
- package/dist/src/lattice.d.ts +1 -1
- package/dist/src/lattice.js +1 -0
- package/package.json +1 -1
- package/templates/AGENTS.md +0 -13
- package/templates/fork/conventions.md +95 -0
- package/templates/skill/SKILL.md +1 -14
|
@@ -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,10 @@ 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
|
+
/**
|
|
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;
|
|
6
12
|
export declare function checkMode(latticeDir: string, projectRoot?: string): 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';
|
|
@@ -181,9 +181,38 @@ function findImperativeHits(tree, content) {
|
|
|
181
181
|
}
|
|
182
182
|
return hits;
|
|
183
183
|
}
|
|
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
|
+
* Whether the root index turns the mode requirement on.
|
|
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
|
+
async function requireModeEnabled(latticeDir) {
|
|
203
|
+
const indexPath = join(latticeDir, indexNameFor(basename(latticeDir)));
|
|
204
|
+
try {
|
|
205
|
+
const content = await readFile(indexPath, 'utf-8');
|
|
206
|
+
return parseFrontmatter(content).raw['require-mode'] === true;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
184
212
|
export async function checkMode(latticeDir, projectRoot = dirname(latticeDir)) {
|
|
185
213
|
const files = await listLatticeFiles(latticeDir);
|
|
186
214
|
const errors = [];
|
|
215
|
+
const requireMode = await requireModeEnabled(latticeDir);
|
|
187
216
|
for (const file of files) {
|
|
188
217
|
const content = await readFile(file, 'utf-8');
|
|
189
218
|
const relPath = relative(process.cwd(), file);
|
|
@@ -238,8 +267,21 @@ export async function checkMode(latticeDir, projectRoot = dirname(latticeDir)) {
|
|
|
238
267
|
continue;
|
|
239
268
|
}
|
|
240
269
|
const mode = (declared ?? dirMode);
|
|
241
|
-
if (mode === undefined)
|
|
270
|
+
if (mode === undefined) {
|
|
271
|
+
const segments = relFromLattice.split('/');
|
|
272
|
+
const fileName = segments.pop();
|
|
273
|
+
const holder = segments.length === 0 ? basename(latticeDir) : segments.at(-1);
|
|
274
|
+
const isIndex = fileName === indexNameFor(holder);
|
|
275
|
+
if (requireMode && !isIndex) {
|
|
276
|
+
errors.push({
|
|
277
|
+
file: relPath,
|
|
278
|
+
line: 1,
|
|
279
|
+
target,
|
|
280
|
+
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.)',
|
|
281
|
+
});
|
|
282
|
+
}
|
|
242
283
|
continue;
|
|
284
|
+
}
|
|
243
285
|
const tree = parse(content);
|
|
244
286
|
if (mode === 'how-to') {
|
|
245
287
|
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 };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare function readForkConventions(): string;
|
|
2
|
+
/**
|
|
3
|
+
* Replace the fork block in `content`, or append one when absent.
|
|
4
|
+
*
|
|
5
|
+
* Returns null when the block is already byte-identical, so the caller can
|
|
6
|
+
* stay quiet rather than announce a write that did not happen.
|
|
7
|
+
*/
|
|
8
|
+
export declare function spliceForkBlock(content: string, body: string): string | null;
|
|
9
|
+
/** Read the body currently fenced by the fork markers, or null if absent. */
|
|
10
|
+
export declare function extractForkBlock(content: string): string | null;
|
|
11
|
+
/**
|
|
12
|
+
* Write the fork's conventions into every instruction file and skill directory
|
|
13
|
+
* the selected agents produced.
|
|
14
|
+
*
|
|
15
|
+
* `fileHashes` is upstream's record, threaded through so `writeInitMeta`
|
|
16
|
+
* persists these entries alongside its own; a stored hash is what lets a later
|
|
17
|
+
* run tell "the user edited our block" from "the template moved on".
|
|
18
|
+
*/
|
|
19
|
+
export declare function writeForkInstructions(root: string, latDir: string, fileHashes: Record<string, string>, ask: (message: string) => Promise<boolean>): Promise<void>;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { styleText } from 'node:util';
|
|
4
|
+
import { contentHash, readFileHash } from '../init-version.js';
|
|
5
|
+
import { findTemplatesDir } from './templates.js';
|
|
6
|
+
/**
|
|
7
|
+
* The fork's own instruction channel into a consumer project.
|
|
8
|
+
*
|
|
9
|
+
* Upstream teaches a consumer's agent through `templates/AGENTS.md` and
|
|
10
|
+
* `templates/skill/SKILL.md`. Both are upstream files, and both are the ones
|
|
11
|
+
* upstream is most likely to rewrite, so every sentence this fork adds to them
|
|
12
|
+
* is a merge conflict bought on credit. The fork's Diátaxis block cost 13
|
|
13
|
+
* lines in one and 14 in the other before this module existed.
|
|
14
|
+
*
|
|
15
|
+
* Everything here writes into fork-owned paths instead. The entire upstream
|
|
16
|
+
* footprint is one import and one call in `initCmd`.
|
|
17
|
+
*
|
|
18
|
+
* Two shapes, because the destinations behave differently:
|
|
19
|
+
*
|
|
20
|
+
* - Always-loaded instruction files (`CLAUDE.md`, `AGENTS.md`,
|
|
21
|
+
* `.github/copilot-instructions.md`) already carry upstream's
|
|
22
|
+
* `%% lat:begin %%` block, so this appends a *second* block under its own
|
|
23
|
+
* `%% lat-fork:begin %%` markers. Upstream's `appendTemplateSection` slices
|
|
24
|
+
* between its own markers and leaves anything after them intact, so the two
|
|
25
|
+
* blocks do not fight on re-run.
|
|
26
|
+
* - Skill directories get a separate `lat-md-conventions/SKILL.md`. Upstream
|
|
27
|
+
* writes its `lat-md` skill with a whole-file hash comparison, so appending
|
|
28
|
+
* to that file would make every later `lat init` report it as
|
|
29
|
+
* user-modified and prompt.
|
|
30
|
+
*/
|
|
31
|
+
const FORK_BEGIN = '%% lat-fork:begin %%';
|
|
32
|
+
const FORK_END = '%% lat-fork:end %%';
|
|
33
|
+
/**
|
|
34
|
+
* Hash-map key for a fork block inside a file upstream also owns. Suffixed so
|
|
35
|
+
* it can never collide with upstream's entry for the same path — both live in
|
|
36
|
+
* one flat `file_hashes` record.
|
|
37
|
+
*/
|
|
38
|
+
function forkHashKey(relPath) {
|
|
39
|
+
return `${relPath}#lat-fork`;
|
|
40
|
+
}
|
|
41
|
+
/** Instruction files that use upstream's marker-append shape. */
|
|
42
|
+
const BLOCK_TARGETS = [
|
|
43
|
+
'CLAUDE.md',
|
|
44
|
+
'AGENTS.md',
|
|
45
|
+
'.github/copilot-instructions.md',
|
|
46
|
+
];
|
|
47
|
+
/**
|
|
48
|
+
* Skill roots every agent integration installs into. Written to only when the
|
|
49
|
+
* directory already exists, which is what keeps this list from creating skill
|
|
50
|
+
* directories for agents the user did not select.
|
|
51
|
+
*/
|
|
52
|
+
const SKILL_ROOTS = [
|
|
53
|
+
'.claude/skills',
|
|
54
|
+
'.pi/skills',
|
|
55
|
+
'.agents/skills',
|
|
56
|
+
'.codex/skills',
|
|
57
|
+
];
|
|
58
|
+
const SKILL_NAME = 'lat-md-conventions';
|
|
59
|
+
const SKILL_FRONTMATTER = `---
|
|
60
|
+
name: ${SKILL_NAME}
|
|
61
|
+
description: >-
|
|
62
|
+
Rules lat check enforces in this project: Diátaxis mode placement for every
|
|
63
|
+
lat.md/ document, mandatory @lat: code refs starting at the application
|
|
64
|
+
entrypoint, and test specs wired to their tests. Load alongside lat-md
|
|
65
|
+
whenever creating or editing files under lat.md/.
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
`;
|
|
69
|
+
export function readForkConventions() {
|
|
70
|
+
return readFileSync(join(findTemplatesDir(), 'fork', 'conventions.md'), 'utf-8');
|
|
71
|
+
}
|
|
72
|
+
function wrapBlock(body) {
|
|
73
|
+
return `${FORK_BEGIN}\n${body}${body.endsWith('\n') ? '' : '\n'}${FORK_END}\n`;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Replace the fork block in `content`, or append one when absent.
|
|
77
|
+
*
|
|
78
|
+
* Returns null when the block is already byte-identical, so the caller can
|
|
79
|
+
* stay quiet rather than announce a write that did not happen.
|
|
80
|
+
*/
|
|
81
|
+
export function spliceForkBlock(content, body) {
|
|
82
|
+
const wrapped = wrapBlock(body);
|
|
83
|
+
const begin = content.indexOf(FORK_BEGIN);
|
|
84
|
+
const end = content.indexOf(FORK_END);
|
|
85
|
+
if (begin === -1 || end === -1 || end <= begin) {
|
|
86
|
+
let base = content;
|
|
87
|
+
if (base.length > 0 && !base.endsWith('\n'))
|
|
88
|
+
base += '\n';
|
|
89
|
+
return base + (base.length > 0 ? '\n' : '') + wrapped;
|
|
90
|
+
}
|
|
91
|
+
const endWithMarker = end + FORK_END.length;
|
|
92
|
+
const endWithNl = content[endWithMarker] === '\n' ? endWithMarker + 1 : endWithMarker;
|
|
93
|
+
const existing = content.slice(begin, endWithNl);
|
|
94
|
+
if (existing === wrapped)
|
|
95
|
+
return null;
|
|
96
|
+
return content.slice(0, begin) + wrapped + content.slice(endWithNl);
|
|
97
|
+
}
|
|
98
|
+
/** Read the body currently fenced by the fork markers, or null if absent. */
|
|
99
|
+
export function extractForkBlock(content) {
|
|
100
|
+
const begin = content.indexOf(FORK_BEGIN);
|
|
101
|
+
const end = content.indexOf(FORK_END);
|
|
102
|
+
if (begin === -1 || end === -1 || end <= begin)
|
|
103
|
+
return null;
|
|
104
|
+
return content.slice(begin + FORK_BEGIN.length + 1, end);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Write the fork's conventions into every instruction file and skill directory
|
|
108
|
+
* the selected agents produced.
|
|
109
|
+
*
|
|
110
|
+
* `fileHashes` is upstream's record, threaded through so `writeInitMeta`
|
|
111
|
+
* persists these entries alongside its own; a stored hash is what lets a later
|
|
112
|
+
* run tell "the user edited our block" from "the template moved on".
|
|
113
|
+
*/
|
|
114
|
+
export async function writeForkInstructions(root, latDir, fileHashes, ask) {
|
|
115
|
+
const body = readForkConventions();
|
|
116
|
+
const bodyHash = contentHash(body);
|
|
117
|
+
const written = [];
|
|
118
|
+
for (const relPath of BLOCK_TARGETS) {
|
|
119
|
+
const absPath = join(root, relPath);
|
|
120
|
+
if (!existsSync(absPath))
|
|
121
|
+
continue;
|
|
122
|
+
const current = readFileSync(absPath, 'utf-8');
|
|
123
|
+
const existingBlock = extractForkBlock(current);
|
|
124
|
+
if (existingBlock !== null) {
|
|
125
|
+
const existingHash = contentHash(existingBlock);
|
|
126
|
+
if (existingHash === bodyHash) {
|
|
127
|
+
fileHashes[forkHashKey(relPath)] = bodyHash;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const storedHash = readFileHash(latDir, forkHashKey(relPath));
|
|
131
|
+
if (storedHash && existingHash !== storedHash) {
|
|
132
|
+
console.log(styleText('yellow', ` ${relPath}`) +
|
|
133
|
+
' lat-fork section has been modified.');
|
|
134
|
+
if (!(await ask(' Replace lat-fork section with latest template?'))) {
|
|
135
|
+
console.log(styleText('dim', ' Kept existing section.'));
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const updated = spliceForkBlock(current, body);
|
|
141
|
+
if (updated === null) {
|
|
142
|
+
fileHashes[forkHashKey(relPath)] = bodyHash;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
writeFileSync(absPath, updated);
|
|
146
|
+
fileHashes[forkHashKey(relPath)] = bodyHash;
|
|
147
|
+
written.push(relPath);
|
|
148
|
+
}
|
|
149
|
+
const skillBody = SKILL_FRONTMATTER + body;
|
|
150
|
+
const skillHash = contentHash(skillBody);
|
|
151
|
+
for (const skillRoot of SKILL_ROOTS) {
|
|
152
|
+
const rootAbs = join(root, skillRoot);
|
|
153
|
+
if (!existsSync(rootAbs))
|
|
154
|
+
continue;
|
|
155
|
+
const relPath = `${skillRoot}/${SKILL_NAME}/SKILL.md`;
|
|
156
|
+
const absPath = join(root, relPath);
|
|
157
|
+
if (existsSync(absPath)) {
|
|
158
|
+
const current = readFileSync(absPath, 'utf-8');
|
|
159
|
+
if (contentHash(current) === skillHash) {
|
|
160
|
+
fileHashes[relPath] = skillHash;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const storedHash = readFileHash(latDir, relPath);
|
|
164
|
+
if (storedHash && contentHash(current) !== storedHash) {
|
|
165
|
+
console.log(styleText('yellow', ` ${relPath}`) + ' has been modified.');
|
|
166
|
+
if (!(await ask(' Overwrite with latest lat template?'))) {
|
|
167
|
+
console.log(styleText('dim', ' Kept existing file.'));
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
mkdirSync(join(absPath, '..'), { recursive: true });
|
|
173
|
+
writeFileSync(absPath, skillBody);
|
|
174
|
+
fileHashes[relPath] = skillHash;
|
|
175
|
+
written.push(relPath);
|
|
176
|
+
}
|
|
177
|
+
if (written.length > 0) {
|
|
178
|
+
console.log('');
|
|
179
|
+
console.log(styleText('green', ' lat.md conventions') +
|
|
180
|
+
' written to ' +
|
|
181
|
+
written.join(', '));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn the gate on in a root index, whatever frontmatter it already has.
|
|
3
|
+
*
|
|
4
|
+
* Bailing on any existing frontmatter — the first version of this — produced a
|
|
5
|
+
* silent no-op: a root index carrying `lat: tags: [...]` could never opt in,
|
|
6
|
+
* and `lat init` re-offered on every run because the flag it had just written
|
|
7
|
+
* was not there. Merging is what makes the offer terminate.
|
|
8
|
+
*
|
|
9
|
+
* Edited line by line rather than parsed and re-emitted, because re-emitting
|
|
10
|
+
* reformats the whole block and discards its comments. The one shape that
|
|
11
|
+
* cannot be edited safely — `lat:` carrying a flow mapping on its own line —
|
|
12
|
+
* is returned unchanged, and the caller falls back to printing the edit.
|
|
13
|
+
*/
|
|
14
|
+
export declare function stampRequireMode(content: string): string;
|
|
15
|
+
/** Whether the gate is on, read straight from a root index's frontmatter. */
|
|
16
|
+
export declare function requireModeSet(content: string): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Append the four mode directories to the root index.
|
|
19
|
+
*
|
|
20
|
+
* Without this a fresh `lat init` leaves `lat check index` reporting four
|
|
21
|
+
* missing entries, which is a poor first impression of a tool whose whole
|
|
22
|
+
* pitch is that the check passes.
|
|
23
|
+
*/
|
|
24
|
+
export declare function listModeDirs(content: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Scaffold the four mode directories and turn the gate on. Called only on the
|
|
27
|
+
* run that creates `lat.md/`, so an existing tree is never restructured
|
|
28
|
+
* underneath its owner.
|
|
29
|
+
*/
|
|
30
|
+
export declare function writeForkScaffold(latDir: string): void;
|
|
31
|
+
/**
|
|
32
|
+
* Offer the gate to a tree `lat init` did not create.
|
|
33
|
+
*
|
|
34
|
+
* `writeForkScaffold` runs only on the branch that creates `lat.md/`, which
|
|
35
|
+
* left the flag unreachable for every project that already had one — that is,
|
|
36
|
+
* for every project that needs it. Restructuring someone's tree without asking
|
|
37
|
+
* is still wrong, so this asks.
|
|
38
|
+
*
|
|
39
|
+
* Silent when the flag is already set, when a previous run was told no, and
|
|
40
|
+
* when there is no TTY to ask — the non-interactive path prints the count and
|
|
41
|
+
* the manual edit instead, and records nothing, so a later interactive run
|
|
42
|
+
* still offers.
|
|
43
|
+
*/
|
|
44
|
+
export declare function offerRequireMode(latDir: string, interactive: boolean, ask: (message: string) => Promise<boolean>): Promise<void>;
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { listLatticeFiles, parseFrontmatter } from '../lattice.js';
|
|
3
|
+
import { basename, join, relative, sep } from 'node:path';
|
|
4
|
+
import { styleText } from 'node:util';
|
|
5
|
+
import { DIATAXIS_MODES, MODE_DIRS, indexNameFor } from './check-mode.js';
|
|
6
|
+
/**
|
|
7
|
+
* Give a freshly created `lat.md/` the Diátaxis shape, and turn the gate on.
|
|
8
|
+
*
|
|
9
|
+
* Upstream's `templates/init/` holds two files and is copied wholesale, so a
|
|
10
|
+
* new tree is one flat root index. An agent asked to document a codebase then
|
|
11
|
+
* writes flat files beside it — which is not merely untidy: `checkMode` binds
|
|
12
|
+
* only inside the four mode directories, so flat placement puts every document
|
|
13
|
+
* out of reach of every shape rule. A bella-derms session did exactly that and
|
|
14
|
+
* confirmed afterwards that its largest document produced 64 errors the moment
|
|
15
|
+
* it was moved into `reference/`.
|
|
16
|
+
*
|
|
17
|
+
* Structure is the cheaper half of the fix. Four directories that already
|
|
18
|
+
* exist, each with a passing index, make placement the path of least
|
|
19
|
+
* resistance; `require-mode: true` in the root index makes flat placement an
|
|
20
|
+
* error rather than an option.
|
|
21
|
+
*
|
|
22
|
+
* Written from here rather than added to `templates/init/` because the flag
|
|
23
|
+
* has to land inside `lat.md/lat.md`, which upstream's template owns.
|
|
24
|
+
*/
|
|
25
|
+
/** One-line summary and body for each mode's index, keyed by directory. */
|
|
26
|
+
const MODE_INDEX = {
|
|
27
|
+
tutorials: {
|
|
28
|
+
title: 'Tutorials',
|
|
29
|
+
lead: 'Guided first passes through this system, each ending somewhere the reader can see.',
|
|
30
|
+
},
|
|
31
|
+
'how-to': {
|
|
32
|
+
title: 'How-to',
|
|
33
|
+
lead: 'Recipes for tasks that recur, written for someone who already knows what they want.',
|
|
34
|
+
},
|
|
35
|
+
reference: {
|
|
36
|
+
title: 'Reference',
|
|
37
|
+
lead: 'Lookup facts about this system: fields, states, limits, names and defaults.',
|
|
38
|
+
},
|
|
39
|
+
explanation: {
|
|
40
|
+
title: 'Explanation',
|
|
41
|
+
lead: 'Why this system is shaped the way it is, and what the alternatives cost.',
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* A mode index has to satisfy its own mode. A tutorial index needs ordered
|
|
46
|
+
* steps and a stated outcome, a how-to index needs ordered steps, and a
|
|
47
|
+
* reference index must not carry a second paragraph — so each stub is written
|
|
48
|
+
* to the rule it will be checked against rather than to one house style.
|
|
49
|
+
*/
|
|
50
|
+
function indexBody(dir) {
|
|
51
|
+
const { title, lead } = MODE_INDEX[dir];
|
|
52
|
+
const header = `# ${title}\n\n${lead}\n`;
|
|
53
|
+
if (dir === 'tutorials') {
|
|
54
|
+
return (header +
|
|
55
|
+
'\nBy the end of any document here the reader will have run something and seen it work.\n' +
|
|
56
|
+
'\n1. Pick the tutorial closest to what you are trying to learn.\n' +
|
|
57
|
+
'2. Follow it top to bottom without skipping.\n' +
|
|
58
|
+
'3. Come back to `reference/` for the details it left out.\n');
|
|
59
|
+
}
|
|
60
|
+
if (dir === 'how-to') {
|
|
61
|
+
return (header +
|
|
62
|
+
'\n1. Find the recipe for the task in front of you.\n' +
|
|
63
|
+
'2. Follow its steps.\n' +
|
|
64
|
+
'3. If no recipe fits, write one here once the task is done.\n');
|
|
65
|
+
}
|
|
66
|
+
if (dir === 'reference') {
|
|
67
|
+
return header;
|
|
68
|
+
}
|
|
69
|
+
return (header +
|
|
70
|
+
'\nEach document here answers a "why", not a "how". The steps that follow ' +
|
|
71
|
+
'from a decision belong in `how-to/`, and the facts it fixes belong in ' +
|
|
72
|
+
'`reference/`.\n');
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Turn the gate on in a root index, whatever frontmatter it already has.
|
|
76
|
+
*
|
|
77
|
+
* Bailing on any existing frontmatter — the first version of this — produced a
|
|
78
|
+
* silent no-op: a root index carrying `lat: tags: [...]` could never opt in,
|
|
79
|
+
* and `lat init` re-offered on every run because the flag it had just written
|
|
80
|
+
* was not there. Merging is what makes the offer terminate.
|
|
81
|
+
*
|
|
82
|
+
* Edited line by line rather than parsed and re-emitted, because re-emitting
|
|
83
|
+
* reformats the whole block and discards its comments. The one shape that
|
|
84
|
+
* cannot be edited safely — `lat:` carrying a flow mapping on its own line —
|
|
85
|
+
* is returned unchanged, and the caller falls back to printing the edit.
|
|
86
|
+
*/
|
|
87
|
+
export function stampRequireMode(content) {
|
|
88
|
+
const fence = content.match(/^---\n([\s\S]*?)\n---[ \t]*(\r?\n|$)/);
|
|
89
|
+
if (!fence) {
|
|
90
|
+
return `---\nlat:\n require-mode: true\n---\n\n${content.replace(/^\n+/, '')}`;
|
|
91
|
+
}
|
|
92
|
+
const body = fence[1];
|
|
93
|
+
if (/^\s*require-mode\s*:/m.test(body))
|
|
94
|
+
return content;
|
|
95
|
+
const lines = body.split('\n');
|
|
96
|
+
const latAt = lines.findIndex((line) => /^lat\s*:/.test(line));
|
|
97
|
+
let merged;
|
|
98
|
+
if (latAt === -1) {
|
|
99
|
+
merged = [...lines, 'lat:', ' require-mode: true'];
|
|
100
|
+
}
|
|
101
|
+
else if (lines[latAt].replace(/^lat\s*:/, '').trim() !== '') {
|
|
102
|
+
// `lat: {mode: reference}` or `lat: &anchor` — a line-based insert would
|
|
103
|
+
// corrupt it, so say nothing and let the caller print the edit.
|
|
104
|
+
return content;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
// Siblings must share indentation, so take it from the existing first
|
|
108
|
+
// child rather than assuming two spaces.
|
|
109
|
+
const child = lines
|
|
110
|
+
.slice(latAt + 1)
|
|
111
|
+
.find((line) => line.trim() !== '' && /^\s/.test(line));
|
|
112
|
+
const indent = child ? (child.match(/^\s+/)?.[0] ?? ' ') : ' ';
|
|
113
|
+
merged = [
|
|
114
|
+
...lines.slice(0, latAt + 1),
|
|
115
|
+
`${indent}require-mode: true`,
|
|
116
|
+
...lines.slice(latAt + 1),
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
const end = fence[0].length;
|
|
120
|
+
return `---\n${merged.join('\n')}\n---${fence[2] || '\n'}${content.slice(end)}`;
|
|
121
|
+
}
|
|
122
|
+
/** Whether the gate is on, read straight from a root index's frontmatter. */
|
|
123
|
+
export function requireModeSet(content) {
|
|
124
|
+
return parseFrontmatter(content).raw['require-mode'] !== undefined;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Append the four mode directories to the root index.
|
|
128
|
+
*
|
|
129
|
+
* Without this a fresh `lat init` leaves `lat check index` reporting four
|
|
130
|
+
* missing entries, which is a poor first impression of a tool whose whole
|
|
131
|
+
* pitch is that the check passes.
|
|
132
|
+
*/
|
|
133
|
+
export function listModeDirs(content) {
|
|
134
|
+
const entries = DIATAXIS_MODES.map((mode) => {
|
|
135
|
+
const dir = MODE_DIRS[mode];
|
|
136
|
+
const { title, lead } = MODE_INDEX[dir];
|
|
137
|
+
return `- [${title}](${dir}/${dir}.md) — ${lead}`;
|
|
138
|
+
}).join('\n');
|
|
139
|
+
if (content.includes(`(${MODE_DIRS.reference}/`))
|
|
140
|
+
return content;
|
|
141
|
+
let base = content;
|
|
142
|
+
if (!base.endsWith('\n'))
|
|
143
|
+
base += '\n';
|
|
144
|
+
return `${base}\nEvery document belongs in exactly one of these.\n\n${entries}\n`;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Scaffold the four mode directories and turn the gate on. Called only on the
|
|
148
|
+
* run that creates `lat.md/`, so an existing tree is never restructured
|
|
149
|
+
* underneath its owner.
|
|
150
|
+
*/
|
|
151
|
+
export function writeForkScaffold(latDir) {
|
|
152
|
+
const created = [];
|
|
153
|
+
for (const mode of DIATAXIS_MODES) {
|
|
154
|
+
const dir = MODE_DIRS[mode];
|
|
155
|
+
const dirPath = join(latDir, dir);
|
|
156
|
+
const indexPath = join(dirPath, `${dir}.md`);
|
|
157
|
+
if (existsSync(indexPath))
|
|
158
|
+
continue;
|
|
159
|
+
mkdirSync(dirPath, { recursive: true });
|
|
160
|
+
writeFileSync(indexPath, indexBody(dir));
|
|
161
|
+
created.push(`${dir}/`);
|
|
162
|
+
}
|
|
163
|
+
const rootIndex = join(latDir, indexNameFor(basename(latDir)));
|
|
164
|
+
if (existsSync(rootIndex)) {
|
|
165
|
+
const current = readFileSync(rootIndex, 'utf-8');
|
|
166
|
+
const stamped = listModeDirs(stampRequireMode(current));
|
|
167
|
+
if (stamped !== current)
|
|
168
|
+
writeFileSync(rootIndex, stamped);
|
|
169
|
+
}
|
|
170
|
+
if (created.length > 0) {
|
|
171
|
+
console.log(styleText('green', 'Scaffolded Diátaxis modes') +
|
|
172
|
+
' — ' +
|
|
173
|
+
created.join(' ') +
|
|
174
|
+
styleText('dim', ' (every document belongs in one of these)'));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// ── Adopting the gate in a tree that already exists ──────────────────
|
|
178
|
+
/**
|
|
179
|
+
* Where a declined offer is remembered.
|
|
180
|
+
*
|
|
181
|
+
* Fork-owned rather than a field on upstream's `lat_init.json`, so recording
|
|
182
|
+
* it costs no edit to `src/init-version.ts`. `lat.md/.cache/` is already in
|
|
183
|
+
* the scaffolded `.gitignore`, so the marker stays out of the repository.
|
|
184
|
+
*/
|
|
185
|
+
function declinePath(latDir) {
|
|
186
|
+
return join(latDir, '.cache', 'lat_fork.json');
|
|
187
|
+
}
|
|
188
|
+
function declined(latDir) {
|
|
189
|
+
try {
|
|
190
|
+
const raw = JSON.parse(readFileSync(declinePath(latDir), 'utf-8'));
|
|
191
|
+
return (typeof raw === 'object' &&
|
|
192
|
+
raw !== null &&
|
|
193
|
+
raw['require_mode_declined'] === true);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function recordDecline(latDir) {
|
|
200
|
+
mkdirSync(join(latDir, '.cache'), { recursive: true });
|
|
201
|
+
writeFileSync(declinePath(latDir), JSON.stringify({ require_mode_declined: true }, null, 2) + '\n');
|
|
202
|
+
}
|
|
203
|
+
/** Documents the gate would reject today: not an index, no mode either way. */
|
|
204
|
+
async function unmodedDocuments(latDir) {
|
|
205
|
+
const modeDirs = new Set(DIATAXIS_MODES.map((mode) => MODE_DIRS[mode]));
|
|
206
|
+
const files = await listLatticeFiles(latDir);
|
|
207
|
+
const offenders = [];
|
|
208
|
+
for (const file of files) {
|
|
209
|
+
const rel = relative(latDir, file).split(sep).join('/');
|
|
210
|
+
const segments = rel.split('/');
|
|
211
|
+
const fileName = segments.pop();
|
|
212
|
+
const holder = segments.length === 0 ? basename(latDir) : segments.at(-1);
|
|
213
|
+
if (fileName === indexNameFor(holder))
|
|
214
|
+
continue;
|
|
215
|
+
if (segments.length > 0 && modeDirs.has(segments[0]))
|
|
216
|
+
continue;
|
|
217
|
+
const declaredMode = parseFrontmatter(readFileSync(file, 'utf-8')).raw['mode'];
|
|
218
|
+
if (typeof declaredMode === 'string')
|
|
219
|
+
continue;
|
|
220
|
+
offenders.push(rel);
|
|
221
|
+
}
|
|
222
|
+
return offenders;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Offer the gate to a tree `lat init` did not create.
|
|
226
|
+
*
|
|
227
|
+
* `writeForkScaffold` runs only on the branch that creates `lat.md/`, which
|
|
228
|
+
* left the flag unreachable for every project that already had one — that is,
|
|
229
|
+
* for every project that needs it. Restructuring someone's tree without asking
|
|
230
|
+
* is still wrong, so this asks.
|
|
231
|
+
*
|
|
232
|
+
* Silent when the flag is already set, when a previous run was told no, and
|
|
233
|
+
* when there is no TTY to ask — the non-interactive path prints the count and
|
|
234
|
+
* the manual edit instead, and records nothing, so a later interactive run
|
|
235
|
+
* still offers.
|
|
236
|
+
*/
|
|
237
|
+
export async function offerRequireMode(latDir, interactive, ask) {
|
|
238
|
+
const rootIndex = join(latDir, indexNameFor(basename(latDir)));
|
|
239
|
+
if (!existsSync(rootIndex))
|
|
240
|
+
return;
|
|
241
|
+
if (requireModeSet(readFileSync(rootIndex, 'utf-8')))
|
|
242
|
+
return;
|
|
243
|
+
if (declined(latDir))
|
|
244
|
+
return;
|
|
245
|
+
const offenders = await unmodedDocuments(latDir);
|
|
246
|
+
console.log('');
|
|
247
|
+
console.log(styleText('bold', 'Diátaxis modes'));
|
|
248
|
+
console.log(' ' +
|
|
249
|
+
styleText('dim', 'Every document belongs in one of four modes. Without') +
|
|
250
|
+
' require-mode ' +
|
|
251
|
+
styleText('dim', 'a document'));
|
|
252
|
+
console.log(' ' +
|
|
253
|
+
styleText('dim', 'placed outside a mode directory is checked against no shape rule at all.'));
|
|
254
|
+
if (offenders.length > 0) {
|
|
255
|
+
console.log('');
|
|
256
|
+
console.log(` ${styleText('yellow', String(offenders.length))} document(s) would need a mode:`);
|
|
257
|
+
for (const name of offenders.slice(0, 5)) {
|
|
258
|
+
console.log(' ' + styleText('dim', name));
|
|
259
|
+
}
|
|
260
|
+
if (offenders.length > 5) {
|
|
261
|
+
console.log(' ' + styleText('dim', `... and ${offenders.length - 5} more`));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (!interactive) {
|
|
265
|
+
console.log('');
|
|
266
|
+
console.log(' ' +
|
|
267
|
+
styleText('dim', 'To turn it on, add this to the top of') +
|
|
268
|
+
` ${basename(latDir)}/${indexNameFor(basename(latDir))}:`);
|
|
269
|
+
console.log('');
|
|
270
|
+
for (const line of ['---', 'lat:', ' require-mode: true', '---']) {
|
|
271
|
+
console.log(' ' + styleText('cyan', line));
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
console.log('');
|
|
276
|
+
if (!(await ask(' Turn require-mode on for this project?'))) {
|
|
277
|
+
recordDecline(latDir);
|
|
278
|
+
console.log(' ' +
|
|
279
|
+
styleText('dim', 'Skipped. Add') +
|
|
280
|
+
' require-mode: true ' +
|
|
281
|
+
styleText('dim', 'to the root index whenever you want it.'));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
writeForkScaffold(latDir);
|
|
285
|
+
// The one frontmatter shape `stampRequireMode` refuses to edit. Saying so
|
|
286
|
+
// beats re-offering forever on a flag that never lands.
|
|
287
|
+
if (!requireModeSet(readFileSync(rootIndex, 'utf-8'))) {
|
|
288
|
+
console.log(' ' +
|
|
289
|
+
styleText('yellow', 'Could not edit the frontmatter safely.') +
|
|
290
|
+
styleText('dim', ' Add this under its `lat:` mapping by hand:'));
|
|
291
|
+
console.log(' ' + styleText('cyan', 'require-mode: true'));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (offenders.length > 0) {
|
|
295
|
+
console.log(' ' +
|
|
296
|
+
styleText('dim', 'Run') +
|
|
297
|
+
' lat check ' +
|
|
298
|
+
styleText('dim', 'to see which documents need placing.'));
|
|
299
|
+
}
|
|
300
|
+
}
|
package/dist/src/cli/init.js
CHANGED
|
@@ -15,6 +15,8 @@ import { INIT_VERSION, writeInitMeta, readInitVersion, readFileHash, contentHash
|
|
|
15
15
|
import { getLocalVersion, fetchLatestVersion } from '../version.js';
|
|
16
16
|
import { selectMenu } from './select-menu.js';
|
|
17
17
|
import { checklistMenu } from './checklist-menu.js';
|
|
18
|
+
import { writeForkInstructions } from './fork-instructions.js';
|
|
19
|
+
import { offerRequireMode, writeForkScaffold } from './fork-scaffold.js';
|
|
18
20
|
async function confirm(rl, message) {
|
|
19
21
|
while (true) {
|
|
20
22
|
let answer;
|
|
@@ -900,6 +902,7 @@ export async function initCmd(targetDir) {
|
|
|
900
902
|
mkdirSync(latDir, { recursive: true });
|
|
901
903
|
cpSync(templateDir, latDir, { recursive: true });
|
|
902
904
|
console.log(styleText('green', 'Created lat.md/'));
|
|
905
|
+
writeForkScaffold(latDir);
|
|
903
906
|
}
|
|
904
907
|
// Step 2: Configure fresh/outdated setups, ask interactive users about an
|
|
905
908
|
// available key, and offer to rebuild an index whose backend differs. This
|
|
@@ -954,6 +957,7 @@ export async function initCmd(targetDir) {
|
|
|
954
957
|
output: process.stdout,
|
|
955
958
|
});
|
|
956
959
|
}
|
|
960
|
+
await offerRequireMode(latDir, interactive, ask);
|
|
957
961
|
if (!anySelected) {
|
|
958
962
|
// Embedding setup is complete even when the user does not configure an
|
|
959
963
|
// agent. Stamp the version so future non-interactive runs do not reapply
|
|
@@ -1004,6 +1008,7 @@ export async function initCmd(targetDir) {
|
|
|
1004
1008
|
console.log(styleText('bold', 'Setting up Codex...'));
|
|
1005
1009
|
await setupCodex(root, latDir, fileHashes, ask, commandStyle);
|
|
1006
1010
|
}
|
|
1011
|
+
await writeForkInstructions(root, latDir, fileHashes, ask);
|
|
1007
1012
|
// Record init version and file hashes so `lat check` can detect stale setups
|
|
1008
1013
|
writeInitMeta(latDir, fileHashes);
|
|
1009
1014
|
console.log('');
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* requires users to re-run it (e.g. new hooks, generated files, MCP config, or
|
|
4
4
|
* a configuration migration).
|
|
5
5
|
*/
|
|
6
|
-
export declare const INIT_VERSION =
|
|
6
|
+
export declare const INIT_VERSION = 7;
|
|
7
7
|
export declare function readInitVersion(latDir: string): number | null;
|
|
8
8
|
export declare function readFileHash(latDir: string, relPath: string): string | null;
|
|
9
9
|
export declare function contentHash(content: string): string;
|
package/dist/src/init-version.js
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from 'node:path';
|
|
|
6
6
|
* requires users to re-run it (e.g. new hooks, generated files, MCP config, or
|
|
7
7
|
* a configuration migration).
|
|
8
8
|
*/
|
|
9
|
-
export const INIT_VERSION =
|
|
9
|
+
export const INIT_VERSION = 7;
|
|
10
10
|
function cachePath(latDir) {
|
|
11
11
|
return join(latDir, '.cache', 'lat_init.json');
|
|
12
12
|
}
|
package/dist/src/lattice.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export type MdLink = {
|
|
|
38
38
|
* edit — the trap this list guards against is silent, so it must not depend on
|
|
39
39
|
* whoever adds the next field remembering to register it twice.
|
|
40
40
|
*/
|
|
41
|
-
export declare const LAT_FIELDS: readonly ["require-code-mention", "mode", "status", "reviewed-hash", "tags"];
|
|
41
|
+
export declare const LAT_FIELDS: readonly ["require-code-mention", "mode", "require-mode", "status", "reviewed-hash", "tags"];
|
|
42
42
|
/**
|
|
43
43
|
* Something wrong with the frontmatter itself, carried out of the parser for a
|
|
44
44
|
* caller to report. Both kinds fail OPEN if they stay quiet: a misplaced or
|
package/dist/src/lattice.js
CHANGED
package/package.json
CHANGED
package/templates/AGENTS.md
CHANGED
|
@@ -105,16 +105,3 @@ Details about this child topic.
|
|
|
105
105
|
```
|
|
106
106
|
|
|
107
107
|
The second example is invalid because `Bad Section` has no leading paragraph. `lat check` validates this rule and reports errors for missing or overly long leading paragraphs.
|
|
108
|
-
|
|
109
|
-
# Diátaxis modes
|
|
110
|
-
|
|
111
|
-
Documents under `lat.md/tutorials/`, `lat.md/how-to/`, `lat.md/reference/`, and `lat.md/explanation/` are checked against their Diátaxis mode. Declare it explicitly with nested frontmatter, or rely on the directory:
|
|
112
|
-
|
|
113
|
-
```markdown
|
|
114
|
-
---
|
|
115
|
-
lat:
|
|
116
|
-
mode: reference
|
|
117
|
-
---
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
If a document is in one of the four mode directories, the declared mode must match that directory. Each mode enforces one shape rule: tutorials need an ordered list of steps and a stated outcome; how-to guides need an ordered list of steps; reference docs must not contain narrative prose (no second paragraph under a heading); explanations must not give imperative commands. Run `lat check mode` to validate.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# lat.md conventions this project enforces
|
|
2
|
+
|
|
3
|
+
`lat check` fails on every rule below. They are not style preferences. This
|
|
4
|
+
block is generated and a later `lat init` replaces it — record project guidance
|
|
5
|
+
in `lat.md/`, never here.
|
|
6
|
+
|
|
7
|
+
## Every document goes in a Diátaxis mode directory
|
|
8
|
+
|
|
9
|
+
A document belongs in exactly one of four subdirectories, and `lat check mode`
|
|
10
|
+
enforces one shape rule for each:
|
|
11
|
+
|
|
12
|
+
| Directory | Holds | Shape rule |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| `lat.md/tutorials/` | a guided first pass for someone new | an ordered list of steps, and a stated outcome |
|
|
15
|
+
| `lat.md/how-to/` | a recipe for a task that recurs | an ordered list of steps |
|
|
16
|
+
| `lat.md/reference/` | lookup facts: fields, states, limits, names | no narrative prose — no second paragraph under a heading |
|
|
17
|
+
| `lat.md/explanation/` | why the design is what it is | no imperative commands |
|
|
18
|
+
|
|
19
|
+
Do not write documents at the top of `lat.md/`. The root index is the only file
|
|
20
|
+
that belongs there. When it carries `require-mode: true`, a document with no
|
|
21
|
+
declared mode and no mode directory is a `lat check` error.
|
|
22
|
+
|
|
23
|
+
**A document that fails its mode is usually two documents.** A file that lists
|
|
24
|
+
the states *and* argues for the design is mixed: the lookup half goes in
|
|
25
|
+
`reference/`, the reasoning half in `explanation/`, and each links the other.
|
|
26
|
+
Filing it under one mode and softening the prose until the check passes is the
|
|
27
|
+
wrong repair. So is leaving it at the top level where no check can see it.
|
|
28
|
+
|
|
29
|
+
Classify before writing, not after. If a `diataxis` skill is available, use it.
|
|
30
|
+
|
|
31
|
+
## Every project has at least one `@lat:` code ref
|
|
32
|
+
|
|
33
|
+
`lat check` fails a tree that has documents and no `@lat:` ref anywhere in the
|
|
34
|
+
codebase. A graph nothing points at cannot be reached from the code, which is
|
|
35
|
+
the only reason it exists.
|
|
36
|
+
|
|
37
|
+
Start at the application entrypoint — `config/application.rb`, `src/index.ts`,
|
|
38
|
+
`main.go`, `manage.py`, whatever a new reader opens first — and point it at the
|
|
39
|
+
root index:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# @lat: [[lat#Acme]]
|
|
43
|
+
module Acme
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then one ref per section that source code actually implements, placed at that
|
|
47
|
+
code. Never in a block at the top of a file.
|
|
48
|
+
|
|
49
|
+
### An `@lat:` pointer is exempt from this project's comment conventions
|
|
50
|
+
|
|
51
|
+
A repository that minimises, budgets, or forbids comments is right about prose
|
|
52
|
+
comments and does not reach this one. An `@lat:` line is a machine directive,
|
|
53
|
+
in the same class as `# frozen_string_literal:` or `// eslint-disable`. It
|
|
54
|
+
carries no rationale, asserts nothing a reader could dispute, and cannot rot
|
|
55
|
+
quietly — `lat check` fails the moment its target moves or disappears.
|
|
56
|
+
|
|
57
|
+
Add the ref. Then, if the reasoning that would have been a comment is worth
|
|
58
|
+
keeping, put it in a `lat.md/explanation/` section and let the pointer reach it.
|
|
59
|
+
|
|
60
|
+
### The marker is a comment syntax, not a language allowlist
|
|
61
|
+
|
|
62
|
+
`//` for C-family languages; `#` for Ruby, Python, shell, Elixir, YAML, Make,
|
|
63
|
+
and everything else that comments with `#`. The scan is textual and walks every
|
|
64
|
+
non-markdown file in the project, so every language works.
|
|
65
|
+
|
|
66
|
+
Any list of example languages you find is a list of examples. Never conclude a
|
|
67
|
+
language is unsupported because it is absent from one. `lat check` prints what
|
|
68
|
+
it scanned:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
Scanned 41 .md, 622 .rb, 118 .tsx in 1.2s
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Read that line rather than guessing, and if you are still unsure, add one ref
|
|
75
|
+
and run the check. It costs one command.
|
|
76
|
+
|
|
77
|
+
(The tree-sitter grammars limit only `[[src/file.ts#symbol]]` wiki links, which
|
|
78
|
+
resolve a symbol inside source. `@lat:` refs point the other way and have no
|
|
79
|
+
such limit.)
|
|
80
|
+
|
|
81
|
+
## Test specs are wired to their tests
|
|
82
|
+
|
|
83
|
+
A document describing tests declares it in frontmatter:
|
|
84
|
+
|
|
85
|
+
```markdown
|
|
86
|
+
---
|
|
87
|
+
lat:
|
|
88
|
+
require-code-mention: true
|
|
89
|
+
---
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Every leaf section then needs exactly one `@lat:` comment at the test that
|
|
93
|
+
covers it, and every section needs at least one sentence saying what it
|
|
94
|
+
verifies and why. `lat check` reports any spec section no test claims, and any
|
|
95
|
+
ref pointing at no section.
|
package/templates/skill/SKILL.md
CHANGED
|
@@ -164,20 +164,7 @@ lat:
|
|
|
164
164
|
---
|
|
165
165
|
```
|
|
166
166
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
## Diátaxis modes
|
|
170
|
-
|
|
171
|
-
Documents in `tutorials/`, `how-to/`, `reference/`, or `explanation/` under `lat.md/` are checked against their Diátaxis mode. Declare it with nested frontmatter:
|
|
172
|
-
|
|
173
|
-
```yaml
|
|
174
|
-
---
|
|
175
|
-
lat:
|
|
176
|
-
mode: reference
|
|
177
|
-
---
|
|
178
|
-
```
|
|
179
|
-
|
|
180
|
-
If the document is in one of the four mode directories, the declared mode must match that directory. Each mode enforces one shape rule: tutorials need an ordered list of steps and a stated outcome; how-to guides need an ordered list of steps; reference docs must not contain narrative prose (no second paragraph under a heading); explanations must not give imperative commands. `lat check mode` validates this.
|
|
167
|
+
Currently the only supported field is `require-code-mention` for test spec enforcement.
|
|
181
168
|
|
|
182
169
|
## Validation
|
|
183
170
|
|