@vicentelyrio/netherite 1.0.0 → 1.1.0
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 +4 -0
- package/dist/bin.mjs +388 -12
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -30,6 +30,10 @@ npx @vicentelyrio/netherite # the app, at http://127.0.0.1:4747
|
|
|
30
30
|
|
|
31
31
|
To type less, install it once with `npm install --global @vicentelyrio/netherite`; the command is then just `netherite`.
|
|
32
32
|
|
|
33
|
+
Already have docs or decision records? `npx @vicentelyrio/netherite scan` drafts a list of them and
|
|
34
|
+
`import` writes it. Or ask your agent to migrate them (the `netherite-migrate` skill, installed by
|
|
35
|
+
`init`). See [Migrate](https://vicentelyrio.github.io/netherite/migrate).
|
|
36
|
+
|
|
33
37
|
`init` is safe to run again. `npx @vicentelyrio/netherite serve --no-open` starts the same thing without opening
|
|
34
38
|
a browser. The server listens on `127.0.0.1` only.
|
|
35
39
|
|
package/dist/bin.mjs
CHANGED
|
@@ -6079,6 +6079,282 @@ const raised = (repo, input = {}) => {
|
|
|
6079
6079
|
}
|
|
6080
6080
|
return out.sort((x, y) => (y.date ?? "").localeCompare(x.date ?? "") || x.key.localeCompare(y.key));
|
|
6081
6081
|
};
|
|
6082
|
+
const labels = {
|
|
6083
|
+
"already-imported": "already imported",
|
|
6084
|
+
"nested-agent-file": "per-folder agent files (agents read them in place; they would fill the context budget)",
|
|
6085
|
+
stub: "stubs that only point at another file",
|
|
6086
|
+
"managed-by-a-tool": "folders another tool manages and rewrites",
|
|
6087
|
+
"not-documentation": "changelogs, licences and similar",
|
|
6088
|
+
template: "templates",
|
|
6089
|
+
"test-data": "test fixtures, golden files and snapshots",
|
|
6090
|
+
"decision-index": "the index of a decision record folder (the records are the content)",
|
|
6091
|
+
"maybe-a-secret": "files that look like they hold a secret (not read into the batch)",
|
|
6092
|
+
"too-large": "files over 1 MB"
|
|
6093
|
+
};
|
|
6094
|
+
/** Folders that are never walked into. */
|
|
6095
|
+
const pruned = /* @__PURE__ */ new Set([
|
|
6096
|
+
"node_modules",
|
|
6097
|
+
".git",
|
|
6098
|
+
"dist",
|
|
6099
|
+
"build",
|
|
6100
|
+
"out",
|
|
6101
|
+
"coverage",
|
|
6102
|
+
".next",
|
|
6103
|
+
".nuxt",
|
|
6104
|
+
".turbo",
|
|
6105
|
+
".cache",
|
|
6106
|
+
".parcel-cache",
|
|
6107
|
+
"target",
|
|
6108
|
+
"vendor",
|
|
6109
|
+
"__pycache__",
|
|
6110
|
+
".venv",
|
|
6111
|
+
"venv",
|
|
6112
|
+
".tox",
|
|
6113
|
+
".gradle",
|
|
6114
|
+
".idea",
|
|
6115
|
+
".vscode",
|
|
6116
|
+
".yarn",
|
|
6117
|
+
".pnpm-store",
|
|
6118
|
+
".terraform"
|
|
6119
|
+
]);
|
|
6120
|
+
/** Folders that another tool owns and keeps rewriting. */
|
|
6121
|
+
const managedFolders = /* @__PURE__ */ new Set([
|
|
6122
|
+
"openspec",
|
|
6123
|
+
".changeset",
|
|
6124
|
+
".changes",
|
|
6125
|
+
"changelog.d",
|
|
6126
|
+
".vitepress"
|
|
6127
|
+
]);
|
|
6128
|
+
/** Hidden folders that still hold things worth importing. */
|
|
6129
|
+
const hiddenKept = /* @__PURE__ */ new Set([
|
|
6130
|
+
".ai",
|
|
6131
|
+
".claude",
|
|
6132
|
+
".cursor",
|
|
6133
|
+
".github",
|
|
6134
|
+
".agents"
|
|
6135
|
+
]);
|
|
6136
|
+
const markdown = /\.(md|mdx|mdc|markdown)$/i;
|
|
6137
|
+
const agentFile = /^(AGENTS|CLAUDE|GEMINI)\.md$/;
|
|
6138
|
+
const notDocs = /^(changelog|changes|history|license|licence|copying|notice|authors|contributors|code_of_conduct)(\.[a-z]+)?$/i;
|
|
6139
|
+
const templateName = /(^|[-_.])(template|tpl)([-_.]|$)|^_template|issue_template|pull_request_template/i;
|
|
6140
|
+
const secretPattern = /(npm_[A-Za-z0-9]{36}|gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----|sk-[A-Za-z0-9]{32,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{35})/;
|
|
6141
|
+
const adrFolder = /(^|\/)(adrs?|decisions?|architecture-decisions?|rfcs?|arch)(\/|$)/i;
|
|
6142
|
+
const runbookWords = /(^|[/_.-])(runbooks?|playbooks?|on-?call|oncall|incident)([/_.-]|$)/i;
|
|
6143
|
+
const maxBytes = 1048576;
|
|
6144
|
+
const examplesPerGroup = 4;
|
|
6145
|
+
const pad = (n, width) => String(n).padStart(width, "0");
|
|
6146
|
+
/** An ADR's number from its file name: `0062-x.md`, `adr-7-x.md`, `12_x.md`. */
|
|
6147
|
+
const adrNumber = (name) => {
|
|
6148
|
+
const m = name.match(/^(?:adr[-_ ]*)?(\d{1,5})(?:[-_. ]|$)/i);
|
|
6149
|
+
return m === null ? void 0 : Number(m[1]);
|
|
6150
|
+
};
|
|
6151
|
+
/** The status a decision record states: `Status: x`, `- **Status:** x`, or a `## Status` section. */
|
|
6152
|
+
const adrStatus = (text) => {
|
|
6153
|
+
const line = text.match(/^\s*[-*]?\s*\**status\**:\**\s*(.+)$/im)?.[1];
|
|
6154
|
+
const section = text.match(/^#{1,4}\s*status\s*\r?\n+\s*([^\r\n#].*)$/im)?.[1];
|
|
6155
|
+
const raw = (line ?? section)?.replace(/[*_`[\]]/g, "").trim().toLowerCase();
|
|
6156
|
+
if (raw === void 0 || raw === "") return void 0;
|
|
6157
|
+
if (raw.startsWith("superseded")) return "superseded";
|
|
6158
|
+
if (raw.startsWith("deprecated") || raw.startsWith("retired") || raw.startsWith("rejected")) return "deprecated";
|
|
6159
|
+
if (raw.startsWith("accepted") || raw.startsWith("approved") || raw.startsWith("adopted")) return "accepted";
|
|
6160
|
+
if (raw.startsWith("proposed") || raw.startsWith("draft")) return "proposed";
|
|
6161
|
+
};
|
|
6162
|
+
/** The date a decision record states, `YYYY-MM-DD`. */
|
|
6163
|
+
const adrDate = (text) => text.match(/^\s*[-*]?\s*\**date\**:\**\s*(\d{4}-\d{2}-\d{2})/im)?.[1] ?? text.match(/^date:\s*["']?(\d{4}-\d{2}-\d{2})/im)?.[1];
|
|
6164
|
+
/** Matchers for the root `.gitignore`: plain names and globs, not negations. */
|
|
6165
|
+
const gitignored = (text) => {
|
|
6166
|
+
const patterns = (text ?? "").split(/\r?\n/).map((l) => l.trim()).filter((l) => l !== "" && !l.startsWith("#") && !l.startsWith("!")).flatMap((raw) => {
|
|
6167
|
+
const anchored = raw.startsWith("/") || raw.replace(/\/$/, "").includes("/");
|
|
6168
|
+
const body = raw.replace(/^\//, "").replace(/\/$/, "");
|
|
6169
|
+
return anchored ? [body, `${body}/**`] : [
|
|
6170
|
+
`**/${body}`,
|
|
6171
|
+
`**/${body}/**`,
|
|
6172
|
+
body,
|
|
6173
|
+
`${body}/**`
|
|
6174
|
+
];
|
|
6175
|
+
});
|
|
6176
|
+
if (patterns.length === 0) return (_path) => false;
|
|
6177
|
+
const match = picomatch(patterns, { dot: true });
|
|
6178
|
+
return (path) => match(path);
|
|
6179
|
+
};
|
|
6180
|
+
const isStub = (text) => {
|
|
6181
|
+
const body = text.replace(/\s+/g, " ").trim();
|
|
6182
|
+
return body.length < 80 && /^@\S+\.md$/.test(body);
|
|
6183
|
+
};
|
|
6184
|
+
/**
|
|
6185
|
+
* Walks the repository from the workspace root and sorts every Markdown file and agent file into a
|
|
6186
|
+
* draft batch or a group of files left out.
|
|
6187
|
+
*/
|
|
6188
|
+
const surveyRepository = (workspace, index, options = {}) => Effect.gen(function* () {
|
|
6189
|
+
const fs = yield* FileSystem.FileSystem;
|
|
6190
|
+
const path = yield* Path.Path;
|
|
6191
|
+
const root = workspace.root;
|
|
6192
|
+
const contentFolder = path.relative(root, workspace.contentRoot).split(path.sep).join("/");
|
|
6193
|
+
const ignoreFile = yield* fs.readFileString(path.join(root, ".gitignore")).pipe(Effect.orElseSucceed(() => void 0));
|
|
6194
|
+
const isIgnored = gitignored(ignoreFile);
|
|
6195
|
+
const imported = new Set(index.items().flatMap((item) => {
|
|
6196
|
+
const data = item.parsed.frontmatter?.data;
|
|
6197
|
+
const source = typeof data === "object" && data !== null ? data.source : void 0;
|
|
6198
|
+
return typeof source === "string" ? [normalizeSource(source)] : [];
|
|
6199
|
+
}));
|
|
6200
|
+
const skipped = /* @__PURE__ */ new Map();
|
|
6201
|
+
const skip = (reason, file) => {
|
|
6202
|
+
const list = skipped.get(reason) ?? [];
|
|
6203
|
+
list.push(file);
|
|
6204
|
+
skipped.set(reason, list);
|
|
6205
|
+
};
|
|
6206
|
+
const files = [];
|
|
6207
|
+
let scanned = 0;
|
|
6208
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6209
|
+
const walk = (dir) => Effect.gen(function* () {
|
|
6210
|
+
const real = yield* fs.realPath(path.join(root, dir)).pipe(Effect.orElseSucceed(() => dir));
|
|
6211
|
+
if (seen.has(real)) return;
|
|
6212
|
+
seen.add(real);
|
|
6213
|
+
const names = yield* fs.readDirectory(path.join(root, dir)).pipe(Effect.orElseSucceed(() => []));
|
|
6214
|
+
for (const name of [...names].sort()) {
|
|
6215
|
+
const rel = dir === "" ? name : `${dir}/${name}`;
|
|
6216
|
+
const info = yield* fs.stat(path.join(root, rel)).pipe(Effect.option);
|
|
6217
|
+
if (info._tag === "None") continue;
|
|
6218
|
+
if (info.value.type === "Directory") {
|
|
6219
|
+
if (pruned.has(name) || rel === ".netherite" || rel === contentFolder) continue;
|
|
6220
|
+
if (name.startsWith(".") && !hiddenKept.has(name)) continue;
|
|
6221
|
+
if (isIgnored(rel)) continue;
|
|
6222
|
+
if (managedFolders.has(name)) {
|
|
6223
|
+
yield* countManaged(rel);
|
|
6224
|
+
continue;
|
|
6225
|
+
}
|
|
6226
|
+
yield* walk(rel);
|
|
6227
|
+
} else if (info.value.type === "File") {
|
|
6228
|
+
scanned++;
|
|
6229
|
+
if (!isIgnored(rel)) files.push(rel);
|
|
6230
|
+
}
|
|
6231
|
+
}
|
|
6232
|
+
});
|
|
6233
|
+
const countManaged = (dir) => Effect.gen(function* () {
|
|
6234
|
+
const entries = yield* fs.readDirectory(path.join(root, dir), { recursive: true }).pipe(Effect.orElseSucceed(() => []));
|
|
6235
|
+
for (const entry of entries) if (markdown.test(entry)) {
|
|
6236
|
+
scanned++;
|
|
6237
|
+
skip("managed-by-a-tool", `${dir}/${entry.split(path.sep).join("/")}`);
|
|
6238
|
+
}
|
|
6239
|
+
});
|
|
6240
|
+
yield* walk("");
|
|
6241
|
+
const gitMap = options.git === void 0 ? void 0 : yield* pathMap(options.git, root);
|
|
6242
|
+
const typed = [];
|
|
6243
|
+
for (const file of files) {
|
|
6244
|
+
const name = file.split("/").pop() ?? file;
|
|
6245
|
+
const isConvention = agentFile.test(name) || name === ".cursorrules" || /^\.cursor\/rules\//.test(file) || file === ".github/copilot-instructions.md" || /^\.ai\/context\//.test(file);
|
|
6246
|
+
if (!markdown.test(name) && name !== ".cursorrules") continue;
|
|
6247
|
+
if (!isConvention && file.startsWith(".claude/")) continue;
|
|
6248
|
+
if (!isConvention && file.startsWith(".github/")) continue;
|
|
6249
|
+
if (notDocs.test(name)) {
|
|
6250
|
+
skip("not-documentation", file);
|
|
6251
|
+
continue;
|
|
6252
|
+
}
|
|
6253
|
+
if (templateName.test(name) || /(^|\/)templates?\//i.test(file)) {
|
|
6254
|
+
skip("template", file);
|
|
6255
|
+
continue;
|
|
6256
|
+
}
|
|
6257
|
+
if (/(^|\/)(tests?|__tests__|__fixtures__|fixtures?|golden|__snapshots__)\//i.test(file)) {
|
|
6258
|
+
skip("test-data", file);
|
|
6259
|
+
continue;
|
|
6260
|
+
}
|
|
6261
|
+
if (adrFolder.test(file) && /^(readme|index)\.[a-z]+$/i.test(name)) {
|
|
6262
|
+
skip("decision-index", file);
|
|
6263
|
+
continue;
|
|
6264
|
+
}
|
|
6265
|
+
if (imported.has(file)) {
|
|
6266
|
+
skip("already-imported", file);
|
|
6267
|
+
continue;
|
|
6268
|
+
}
|
|
6269
|
+
const depth = file.split("/").length;
|
|
6270
|
+
if (agentFile.test(name) && depth > 3 && options.nestedAgentFiles !== true) {
|
|
6271
|
+
skip("nested-agent-file", file);
|
|
6272
|
+
continue;
|
|
6273
|
+
}
|
|
6274
|
+
const info = yield* fs.stat(path.join(root, file)).pipe(Effect.option);
|
|
6275
|
+
if (info._tag === "Some" && Number(info.value.size) > maxBytes) {
|
|
6276
|
+
skip("too-large", file);
|
|
6277
|
+
continue;
|
|
6278
|
+
}
|
|
6279
|
+
const text = yield* fs.readFileString(path.join(root, file)).pipe(Effect.orElseSucceed(() => void 0));
|
|
6280
|
+
if (text === void 0) continue;
|
|
6281
|
+
if (secretPattern.test(text)) {
|
|
6282
|
+
skip("maybe-a-secret", file);
|
|
6283
|
+
continue;
|
|
6284
|
+
}
|
|
6285
|
+
if (isStub(text)) {
|
|
6286
|
+
skip("stub", file);
|
|
6287
|
+
continue;
|
|
6288
|
+
}
|
|
6289
|
+
const lower = file.toLowerCase();
|
|
6290
|
+
let type;
|
|
6291
|
+
if (isConvention) type = "context";
|
|
6292
|
+
else if (adrFolder.test(lower) && !/(^|\/)(readme|index)\.[a-z]+$/i.test(lower)) type = "adr";
|
|
6293
|
+
else if (runbookWords.test(lower)) type = "runbook";
|
|
6294
|
+
else type = "doc";
|
|
6295
|
+
typed.push({
|
|
6296
|
+
entry: {
|
|
6297
|
+
source: file,
|
|
6298
|
+
type
|
|
6299
|
+
},
|
|
6300
|
+
text
|
|
6301
|
+
});
|
|
6302
|
+
}
|
|
6303
|
+
const notes = [];
|
|
6304
|
+
const numbers = /* @__PURE__ */ new Map();
|
|
6305
|
+
const entries = [];
|
|
6306
|
+
const padding = 5;
|
|
6307
|
+
for (const { entry, text } of typed) {
|
|
6308
|
+
if (entry.type !== "adr") {
|
|
6309
|
+
entries.push(entry);
|
|
6310
|
+
continue;
|
|
6311
|
+
}
|
|
6312
|
+
const name = entry.source.split("/").pop() ?? entry.source;
|
|
6313
|
+
const n = adrNumber(name);
|
|
6314
|
+
const fields = {};
|
|
6315
|
+
const status = adrStatus(text);
|
|
6316
|
+
if (status !== void 0) fields.status = status;
|
|
6317
|
+
let date = adrDate(text);
|
|
6318
|
+
if (date === void 0 && options.git !== void 0 && gitMap !== void 0) date = (yield* options.git.firstCommit(gitMap.toGit(entry.source)).pipe(Effect.orElseSucceed(() => void 0)))?.date.slice(0, 10);
|
|
6319
|
+
if (date !== void 0) fields.date = date;
|
|
6320
|
+
let id;
|
|
6321
|
+
if (n !== void 0) {
|
|
6322
|
+
const taken = numbers.get(n);
|
|
6323
|
+
if (taken === void 0) {
|
|
6324
|
+
numbers.set(n, entry.source);
|
|
6325
|
+
id = `ADR-${pad(n, padding)}`;
|
|
6326
|
+
} else notes.push(`${entry.source} has the number ${n}, like ${taken}: it will get the next free ID unless you give it one`);
|
|
6327
|
+
}
|
|
6328
|
+
entries.push({
|
|
6329
|
+
source: entry.source,
|
|
6330
|
+
type: "adr",
|
|
6331
|
+
...id === void 0 ? {} : { id },
|
|
6332
|
+
...Object.keys(fields).length === 0 ? {} : { fields }
|
|
6333
|
+
});
|
|
6334
|
+
}
|
|
6335
|
+
const order = {
|
|
6336
|
+
adr: 0,
|
|
6337
|
+
doc: 1,
|
|
6338
|
+
runbook: 2,
|
|
6339
|
+
context: 3
|
|
6340
|
+
};
|
|
6341
|
+
const depthOf = (s) => s.source.split("/").length;
|
|
6342
|
+
entries.sort((a, b) => order[a.type] - order[b.type] || depthOf(a) - depthOf(b) || a.source.localeCompare(b.source));
|
|
6343
|
+
const groups = [...skipped.entries()].map(([reason, list]) => ({
|
|
6344
|
+
reason,
|
|
6345
|
+
label: labels[reason],
|
|
6346
|
+
count: list.length,
|
|
6347
|
+
examples: list.slice(0, examplesPerGroup)
|
|
6348
|
+
}));
|
|
6349
|
+
const rank = Object.keys(labels);
|
|
6350
|
+
groups.sort((a, b) => rank.indexOf(a.reason) - rank.indexOf(b.reason));
|
|
6351
|
+
return {
|
|
6352
|
+
scanned,
|
|
6353
|
+
entries,
|
|
6354
|
+
skipped: groups,
|
|
6355
|
+
notes
|
|
6356
|
+
};
|
|
6357
|
+
});
|
|
6082
6358
|
const day = (iso) => iso === void 0 ? void 0 : new Date(iso).toISOString().slice(0, 10);
|
|
6083
6359
|
const holdsOf = (spans) => spans.filter((s) => s.phase === "blocked").map((s) => ({
|
|
6084
6360
|
from: day(s.from),
|
|
@@ -9755,7 +10031,7 @@ const makeState = (repo, options = {}) => Effect.gen(function* () {
|
|
|
9755
10031
|
}
|
|
9756
10032
|
};
|
|
9757
10033
|
});
|
|
9758
|
-
// ../../packages/cli/dist/cli-
|
|
10034
|
+
// ../../packages/cli/dist/cli-BK4CDEKf.mjs
|
|
9759
10035
|
const exitCodes = {
|
|
9760
10036
|
ok: 0,
|
|
9761
10037
|
findings: 1,
|
|
@@ -9799,10 +10075,10 @@ const reportFailure = (env, error) => {
|
|
|
9799
10075
|
return exitCodes.findings;
|
|
9800
10076
|
}
|
|
9801
10077
|
};
|
|
9802
|
-
const count = (n) => n.toLocaleString("en-US");
|
|
10078
|
+
const count$1 = (n) => n.toLocaleString("en-US");
|
|
9803
10079
|
const describe$1 = (t, check) => {
|
|
9804
10080
|
const state = check ? t.stale ? "out of date" : "up to date" : t.status;
|
|
9805
|
-
return `${t.path.padEnd(10)} ${state.padEnd(11)} ≈${count(t.tokens)} of ${count(t.budget)} tokens, ${t.included.length} ${t.included.length === 1 ? "item" : "items"}`;
|
|
10081
|
+
return `${t.path.padEnd(10)} ${state.padEnd(11)} ≈${count$1(t.tokens)} of ${count$1(t.budget)} tokens, ${t.included.length} ${t.included.length === 1 ? "item" : "items"}`;
|
|
9806
10082
|
};
|
|
9807
10083
|
/** Renders the context items into `CLAUDE.md` and `AGENTS.md`, or with `--check` only compares. */
|
|
9808
10084
|
const contextSync = (env, options) => Effect.gen(function* () {
|
|
@@ -9817,8 +10093,8 @@ const contextSync = (env, options) => Effect.gen(function* () {
|
|
|
9817
10093
|
}
|
|
9818
10094
|
for (const t of report.targets) {
|
|
9819
10095
|
if (t.omitted.length === 0) continue;
|
|
9820
|
-
env.out.error(`${t.path}: ${t.omitted.length} ${t.omitted.length === 1 ? "item does" : "items do"} not fit in the ${count(t.budget)}-token budget:`);
|
|
9821
|
-
for (const o of t.omitted) env.out.error(` ${o.id || o.path} ${o.title} (${o.priority}, ≈${count(o.tokens)} tokens)`);
|
|
10096
|
+
env.out.error(`${t.path}: ${t.omitted.length} ${t.omitted.length === 1 ? "item does" : "items do"} not fit in the ${count$1(t.budget)}-token budget:`);
|
|
10097
|
+
for (const o of t.omitted) env.out.error(` ${o.id || o.path} ${o.title} (${o.priority}, ≈${count$1(o.tokens)} tokens)`);
|
|
9822
10098
|
}
|
|
9823
10099
|
if (options.check && report.stale) {
|
|
9824
10100
|
env.out.error("Context is out of date. Run `netherite context sync` and commit the result.");
|
|
@@ -9838,7 +10114,7 @@ const describe = (r, dryRun) => {
|
|
|
9838
10114
|
case "failed": return `failed ${where} ${r.reason ?? ""}`.trimEnd();
|
|
9839
10115
|
}
|
|
9840
10116
|
};
|
|
9841
|
-
const summary = (report) => {
|
|
10117
|
+
const summary$1 = (report) => {
|
|
9842
10118
|
const { counts } = report;
|
|
9843
10119
|
return [
|
|
9844
10120
|
`${counts.created} ${report.dryRun ? "to create" : "created"}`,
|
|
@@ -9892,7 +10168,7 @@ const importCommand = (env, options) => Effect.gen(function* () {
|
|
|
9892
10168
|
else {
|
|
9893
10169
|
if (report.dryRun) env.out.line("Dry run: nothing was written.");
|
|
9894
10170
|
for (const r of report.results) env.out.line(describe(r, report.dryRun));
|
|
9895
|
-
env.out.line(summary(report));
|
|
10171
|
+
env.out.line(summary$1(report));
|
|
9896
10172
|
if (report.relinked.links > 0) env.out.line(`Linked ${report.relinked.links} references between imported files, in ${report.relinked.items} items.`);
|
|
9897
10173
|
if (!report.dryRun && report.counts.created + report.counts.updated > 0) env.out.line("Nothing is committed. Run `netherite lint`, then review the changes in git.");
|
|
9898
10174
|
}
|
|
@@ -10099,7 +10375,9 @@ This skill needs two things beyond the \`netherite\` MCP tools: to read files in
|
|
|
10099
10375
|
|
|
10100
10376
|
## 1. Survey (read only)
|
|
10101
10377
|
|
|
10102
|
-
|
|
10378
|
+
Start with \`netherite scan --json\`. It walks the repository, writes nothing, and returns a draft batch (\`batch\`), the files it left out with the reason (\`skipped\`), and things to check (\`notes\`). It already skips \`node_modules\`, build output, vendored code, changelogs and licences, templates, test fixtures, files that look like they hold a secret, per-folder agent files, stubs, folders another tool manages, and what is already imported; it gives decision records their numbers, statuses and dates. Read what it returns instead of walking the tree yourself, then check it by hand against the list below: it classifies by path and file name, so it can be wrong about a type, and it does not read the text for meaning.
|
|
10379
|
+
|
|
10380
|
+
What to look for, and what the scan may have missed:
|
|
10103
10381
|
|
|
10104
10382
|
- **Docs:** \`README*\`, \`docs/\`, \`doc/\`, \`wiki/\`, guides, architecture notes, \`CONTRIBUTING.md\`.
|
|
10105
10383
|
- **Runbooks:** \`runbooks/\`, \`playbooks/\`, \`ops/\`, on-call and deploy notes, step-by-step operations.
|
|
@@ -10114,7 +10392,7 @@ Some finds are not for import, and you say so in the table, with a count:
|
|
|
10114
10392
|
- **Folders managed by another tool** (\`openspec/\`, changesets, generated reference docs, an indexed wiki): ask, and recommend skip. The tool owns them and keeps rewriting them.
|
|
10115
10393
|
- **Blocks written by another tool** inside a file (\`<!-- BEGIN:... -->\`): the tool re-adds them, so leave them out of the text you import.
|
|
10116
10394
|
|
|
10117
|
-
Show the person the list as a table with counts per type, and what
|
|
10395
|
+
Show the person the scan's list as a table with counts per type, and what it left out.
|
|
10118
10396
|
|
|
10119
10397
|
## 2. Ask, one question at a time
|
|
10120
10398
|
|
|
@@ -10132,7 +10410,7 @@ Use the \`netherite-plan\` skill: one project, one task per batch, and in each t
|
|
|
10132
10410
|
|
|
10133
10411
|
## 4. Each batch
|
|
10134
10412
|
|
|
10135
|
-
1. Set the task \`doing\`. Write the batch as JSON to \`.netherite/local/migration-<n>.json\` (it is not committed). See https://vicentelyrio.github.io/netherite/reference/import for the format; \`netherite import --help\` lists the flags. When many files share one format (dozens of decision records, say), write a small script that builds the batch from them instead of typing entries, show the person the table and three entries in full, and run it again for the next batch.
|
|
10413
|
+
1. Set the task \`doing\`. Write the batch as JSON to \`.netherite/local/migration-<n>.json\` (it is not committed), starting from the scan's \`batch\` (\`netherite scan --out .netherite/local/migration-<n>.json\` writes one) and taking the entries of this batch from it. See https://vicentelyrio.github.io/netherite/reference/import for the format; \`netherite import --help\` lists the flags. When many files share one format (dozens of decision records, say), write a small script that builds the batch from them instead of typing entries, show the person the table and three entries in full, and run it again for the next batch.
|
|
10136
10414
|
2. **Entries.**
|
|
10137
10415
|
- ADRs: \`id\` from the number in the file name, written with the workspace's padding (\`ADR-00004\`). Read the status line the way the record means it, and map it to \`fields.status\` (\`accepted\`, \`proposed\`, \`deprecated\`, \`superseded\`): a record that is wholly "superseded by" another is \`superseded\`, \`retired\` is \`deprecated\`, and a record that is only amended or partly superseded stays \`accepted\`. Do not lose the nuance: keep the original status line in \`body\` as \`**Status as written:** ...\`, put a word such as \`provisional\` in \`fields.tags\`, and keep a "Revisit when" line. Put the record's date in \`fields.date\`; if it has none, use the date the file was first committed (\`git log --diff-filter=A --format=%as -1 -- <path>\`). A record with no stated status is \`proposed\`: never invent \`accepted\`. Leave out \`deciders\` unless the record names them: the person running the import did not decide it. When a record's status and date are lines at the top rather than headings, take them out of \`body\` once they are fields.
|
|
10138
10416
|
- Docs and runbooks: \`covers\` is what the doc **describes**, not everything it mentions, and it decides when the doc shows as stale. Propose globs (\`src/auth/**\`) only for paths that exist and that the doc is about, and leave \`covers\` out when unsure: a wrong one means false alarms. Never cover absolute paths, \`.env*\` files, or the code the doc says it is moving away from. In one real monorepo, 59 of 90 docs mentioned an existing path and few were about it.
|
|
@@ -10888,6 +11166,90 @@ const renumberCommand = (env, options) => Effect.gen(function* () {
|
|
|
10888
11166
|
});
|
|
10889
11167
|
return yield* Effect.catch(run, (error) => Effect.succeed(reportFailure(env, error)));
|
|
10890
11168
|
});
|
|
11169
|
+
const defaultOut = ".netherite/local/scan.json";
|
|
11170
|
+
const typeNames = {
|
|
11171
|
+
adr: "decision records",
|
|
11172
|
+
doc: "docs",
|
|
11173
|
+
runbook: "runbooks",
|
|
11174
|
+
context: "agent and convention files"
|
|
11175
|
+
};
|
|
11176
|
+
const count = (n) => n.toLocaleString("en-US");
|
|
11177
|
+
const leftOut = (survey) => {
|
|
11178
|
+
const lines = [];
|
|
11179
|
+
if (survey.skipped.length > 0) {
|
|
11180
|
+
lines.push("", "Left out:");
|
|
11181
|
+
for (const group of survey.skipped) {
|
|
11182
|
+
lines.push(` ${String(group.count).padStart(4)} ${group.label}`);
|
|
11183
|
+
const shown = group.examples.join(", ");
|
|
11184
|
+
lines.push(` e.g. ${shown}${group.count > group.examples.length ? ", ..." : ""}`);
|
|
11185
|
+
}
|
|
11186
|
+
}
|
|
11187
|
+
return lines;
|
|
11188
|
+
};
|
|
11189
|
+
const summary = (survey, out) => {
|
|
11190
|
+
const lines = [];
|
|
11191
|
+
const by = (type) => survey.entries.filter((e) => e.type === type).length;
|
|
11192
|
+
const parts = Object.keys(typeNames).filter((t) => by(t) > 0).map((t) => `${by(t)} ${typeNames[t]}`);
|
|
11193
|
+
lines.push(`Looked at ${count(survey.scanned)} files and found ${survey.entries.length} to bring in: ${parts.join(", ")}.`, ...leftOut(survey));
|
|
11194
|
+
if (survey.notes.length > 0) {
|
|
11195
|
+
lines.push("", "Look at these before importing:");
|
|
11196
|
+
for (const note of survey.notes) lines.push(` - ${note}`);
|
|
11197
|
+
}
|
|
11198
|
+
lines.push("", `The draft is in ${out}. It is not committed, and .netherite/local is ignored by git.`, "Open it, take out what you do not want, add a \"title\" or \"fields\" where you know better, then:", ` netherite import ${out} --dry-run`, "", "A decision record takes the status and date it states, or the date of its first commit.", "Nothing has been written to your documents.");
|
|
11199
|
+
return lines;
|
|
11200
|
+
};
|
|
11201
|
+
const skipHint = { "nested-agent-file": "Add --nested-agent-files to include them." };
|
|
11202
|
+
/**
|
|
11203
|
+
* Looks through the repository for what is already written down (docs, runbooks, decision
|
|
11204
|
+
* records, the files agents read) and writes a draft batch for `netherite import`. It only reads:
|
|
11205
|
+
* the one file it writes is the draft.
|
|
11206
|
+
*/
|
|
11207
|
+
const scanCommand = (env, options) => Effect.gen(function* () {
|
|
11208
|
+
const fs = yield* FileSystem.FileSystem;
|
|
11209
|
+
const path = yield* Path.Path;
|
|
11210
|
+
const run = Effect.gen(function* () {
|
|
11211
|
+
const repo = yield* openRepo(path.resolve(env.cwd, options.dir));
|
|
11212
|
+
const git = yield* workspaceGit(repo.workspace);
|
|
11213
|
+
const survey = yield* surveyRepository(repo.workspace, repo.index, {
|
|
11214
|
+
git,
|
|
11215
|
+
nestedAgentFiles: options.nestedAgentFiles
|
|
11216
|
+
});
|
|
11217
|
+
const batch = {
|
|
11218
|
+
mode: "copy",
|
|
11219
|
+
entries: survey.entries
|
|
11220
|
+
};
|
|
11221
|
+
if (options.json) {
|
|
11222
|
+
json(env.out, {
|
|
11223
|
+
batch,
|
|
11224
|
+
scanned: survey.scanned,
|
|
11225
|
+
skipped: survey.skipped,
|
|
11226
|
+
notes: survey.notes
|
|
11227
|
+
});
|
|
11228
|
+
return exitCodes.ok;
|
|
11229
|
+
}
|
|
11230
|
+
if (survey.entries.length === 0) {
|
|
11231
|
+
env.out.line(`Looked at ${count(survey.scanned)} files and found nothing to bring in.`);
|
|
11232
|
+
for (const line of leftOut(survey)) env.out.line(line);
|
|
11233
|
+
return exitCodes.ok;
|
|
11234
|
+
}
|
|
11235
|
+
const target = options.out ?? defaultOut;
|
|
11236
|
+
const file = path.resolve(repo.workspace.root, target);
|
|
11237
|
+
if ((yield* fs.exists(file)) && !options.force) {
|
|
11238
|
+
env.out.error(`${target} already exists. Pass --force to replace it, or --out to write somewhere else.`);
|
|
11239
|
+
return exitCodes.usage;
|
|
11240
|
+
}
|
|
11241
|
+
yield* fs.makeDirectory(path.dirname(file), { recursive: true });
|
|
11242
|
+
yield* fs.writeFileString(file, `${JSON.stringify(batch, null, 2)}\n`);
|
|
11243
|
+
const shown = path.relative(env.cwd, file).split(path.sep).join("/") || target;
|
|
11244
|
+
for (const line of summary(survey, shown)) env.out.line(line);
|
|
11245
|
+
for (const group of survey.skipped) {
|
|
11246
|
+
const hint = skipHint[group.reason];
|
|
11247
|
+
if (hint !== void 0) env.out.line(` (${group.label.split(" (")[0]}: ${hint})`);
|
|
11248
|
+
}
|
|
11249
|
+
return exitCodes.ok;
|
|
11250
|
+
});
|
|
11251
|
+
return yield* Effect.catch(run, (error) => Effect.succeed(reportFailure(env, error)));
|
|
11252
|
+
});
|
|
10891
11253
|
/** The app bundled next to the program (see the release packaging), if there is one. */
|
|
10892
11254
|
const bundledApp = () => fileURLToPath(new URL("./app", import.meta.url));
|
|
10893
11255
|
/** Opens a page with the operating system's default browser. */
|
|
@@ -11034,9 +11396,22 @@ const makeCli = (env, state, version = "0.0.0") => {
|
|
|
11034
11396
|
template: c.template,
|
|
11035
11397
|
json: c.json
|
|
11036
11398
|
}), finish)).pipe(Command.withDescription("Create an item with the next free ID."));
|
|
11399
|
+
const scanCmd = Command.make("scan", {
|
|
11400
|
+
dir,
|
|
11401
|
+
out: Flag.String("out").pipe(Flag.optional, Flag.withDescription("Where to write the draft batch, from the workspace root. Default: .netherite/local/scan.json.")),
|
|
11402
|
+
nestedAgentFiles: Flag.Boolean("nested-agent-files").pipe(Flag.withDefault(false), Flag.withDescription("Include the AGENTS.md and CLAUDE.md files deep in the tree. They are left out by default: agents read them in place, and they would fill the context budget.")),
|
|
11403
|
+
force: Flag.Boolean("force").pipe(Flag.withDefault(false), Flag.withDescription("Replace a draft that is already there.")),
|
|
11404
|
+
json: jsonFlag
|
|
11405
|
+
}, (c) => Effect.flatMap(scanCommand(env, {
|
|
11406
|
+
dir: c.dir,
|
|
11407
|
+
out: Option.getOrUndefined(c.out),
|
|
11408
|
+
nestedAgentFiles: c.nestedAgentFiles,
|
|
11409
|
+
force: c.force,
|
|
11410
|
+
json: c.json
|
|
11411
|
+
}), finish)).pipe(Command.withDescription("Look through the repository for docs, runbooks, decision records and agent files, and write a draft batch for `netherite import`. It only reads, and writes nothing but the draft."));
|
|
11037
11412
|
const importCmd = Command.make("import", {
|
|
11038
11413
|
dir,
|
|
11039
|
-
file: Argument.String("batch").pipe(Argument.withDescription("A JSON file: a list of entries, or { \"mode\": \"copy\", \"entries\": [...] }. Each entry has source, type (doc, runbook, context or adr) and optionally title, id, fields, body and mode.")),
|
|
11414
|
+
file: Argument.String("batch").pipe(Argument.withDescription("A JSON file: a list of entries, or { \"mode\": \"copy\", \"entries\": [...] }. Each entry has source, type (doc, runbook, context or adr) and optionally title, id, fields, body and mode. `netherite scan` writes a draft of one.")),
|
|
11040
11415
|
update: Flag.Boolean("update").pipe(Flag.withDefault(false), Flag.withDescription("Bring items that were already imported up to date with their source.")),
|
|
11041
11416
|
dryRun: Flag.Boolean("dry-run").pipe(Flag.withDefault(false), Flag.withDescription("Say what would happen and write nothing.")),
|
|
11042
11417
|
stub: Flag.Boolean("stub").pipe(Flag.withDefault(false), Flag.withDescription("With mode \"move\", leave a one-line note where the original was.")),
|
|
@@ -11142,6 +11517,7 @@ const makeCli = (env, state, version = "0.0.0") => {
|
|
|
11142
11517
|
return Command.make("netherite", serveFlags, serve).pipe(Command.withDescription("Typed, linked knowledge in your git repository. Documentation: https://vicentelyrio.github.io/netherite/"), Command.withSubcommands([
|
|
11143
11518
|
initCmd,
|
|
11144
11519
|
newCmd,
|
|
11520
|
+
scanCmd,
|
|
11145
11521
|
importCmd,
|
|
11146
11522
|
templatesCmd,
|
|
11147
11523
|
lintCmd,
|
|
@@ -11163,7 +11539,7 @@ const runCli = (argv, env, version = "0.0.0") => {
|
|
|
11163
11539
|
return Command.runWith(root, { version })(argv).pipe(Effect.map(() => state.code), Effect.catch(() => Effect.succeed(exitCodes.usage)));
|
|
11164
11540
|
};
|
|
11165
11541
|
// package.json
|
|
11166
|
-
var version = "1.
|
|
11542
|
+
var version = "1.1.0";
|
|
11167
11543
|
// src/bin.ts
|
|
11168
11544
|
const stop = new AbortController();
|
|
11169
11545
|
for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => stop.abort());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vicentelyrio/netherite",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "A typed, linked knowledge base in Markdown and git: a CLI, an MCP server for agents, and a local app.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"@netherite/schema": "0.0.0",
|
|
71
71
|
"@netherite/server": "0.0.0",
|
|
72
72
|
"@netherite/web": "0.0.0",
|
|
73
|
-
"@types/node": "^
|
|
73
|
+
"@types/node": "^26.0.0",
|
|
74
74
|
"tsdown": "^0.23.4",
|
|
75
75
|
"typescript": "^7.0.2",
|
|
76
76
|
"vitest": "^5.0.5"
|