@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.
Files changed (3) hide show
  1. package/README.md +4 -0
  2. package/dist/bin.mjs +388 -12
  3. 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-C6627ZbR.mjs
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
- Look for what is already written down. Skip \`node_modules\`, build output, vendored code, \`CHANGELOG\`, \`LICENSE\`, generated files and the Netherite content folder itself.
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 you left out.
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.0.0";
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.0.0",
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": "^24.0.0",
73
+ "@types/node": "^26.0.0",
74
74
  "tsdown": "^0.23.4",
75
75
  "typescript": "^7.0.2",
76
76
  "vitest": "^5.0.5"