@noir-ai/cli 1.17.0 → 1.18.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.
@@ -15,6 +15,14 @@ var HYGIENE_MARKDOWN_EXTENSIONS = /\.(?:md|mdx)$/i;
15
15
  var HYGIENE_SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "coverage"]);
16
16
  var HYGIENE_EXCLUDED_DIRS = /* @__PURE__ */ new Set(["docs/internal", "docs/decisions", "docs/roadmap"]);
17
17
  var HYGIENE_EXCLUDED_FILES = /* @__PURE__ */ new Set(["CHANGELOG.md"]);
18
+ var HYGIENE_EXCLUDED_PATHS = /* @__PURE__ */ new Set([
19
+ "docs/README.md",
20
+ "docs/reference/cli.md",
21
+ "docs/reference/config.md",
22
+ "docs/reference/mcp-tools.md",
23
+ "docs/reference/packages.md",
24
+ "docs/reference/skills.md"
25
+ ]);
18
26
  function readDirEntries(dir) {
19
27
  try {
20
28
  return readdirSync(dir, { withFileTypes: true });
@@ -45,7 +53,8 @@ function hasScannableLayout(root) {
45
53
  }
46
54
  function addHygieneFile(rel, walk) {
47
55
  const name = rel.slice(rel.lastIndexOf("/") + 1);
48
- if (name.startsWith(".") || HYGIENE_EXCLUDED_FILES.has(name)) return;
56
+ if (name.startsWith(".") || HYGIENE_EXCLUDED_FILES.has(name) || HYGIENE_EXCLUDED_PATHS.has(rel))
57
+ return;
49
58
  const kind = hygieneKindOf(rel);
50
59
  if (kind === null) return;
51
60
  if (walk.files.length >= HYGIENE_MAX_FILES) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/hygiene-scan.ts"],"sourcesContent":["// Output hygiene — the repository-facing scan.\n//\n// The repository's own source and documents are checked against the hygiene\n// rules @noir-ai/skills declares: the patterns that make text read as machine\n// output. A fail-tier pattern is objectively mechanical (a divider drawn in\n// punctuation, numbered narration, a decorative emoji, a forbidden residue\n// token), so it blocks; a warn-tier pattern is a judgement call (a long comment\n// block, a marker nobody can act on), which would stall legitimate work if it\n// blocked, so it warns.\n//\n// The scan reads the repository's own text only. Generated and vendored trees,\n// the planning corpus, and formats the rules cannot read (JSON, an image\n// fixture) are out of scope; a file states its own exemption with the marker\n// the rules honour, so no path list is kept here.\n//\n// The scan runs only where the layout it is written for exists: a package\n// tree, `scripts/`, or `docs/`. Anywhere else — an ordinary application\n// repository, which is a README and little more — it reports an empty result\n// and reads nothing, root documents included. These rules judge the text this\n// project writes, not the prose of whoever happened to run the command.\n//\n// This module is the single source of truth for that scope: which files are\n// read, which directories are excluded, and the caps. `noir doctor` (via\n// `checkOutputHygiene` in ./commands/doctor.ts) and the CI gate\n// (scripts/hygiene-gate.mjs) both call `scanOutputHygiene`, so the two cannot\n// drift apart.\n\nimport { type Dirent, readdirSync, readFileSync, statSync } from 'node:fs';\nimport { join } from 'node:path';\nimport {\n checkHygiene,\n type HygieneKind,\n type HygieneTier,\n hasHygieneExemption,\n humanizerFindings,\n slopFindings,\n} from '@noir-ai/skills';\n\n/** How many findings the detail cell names before it reports the rest as a\n * count. A tree that has drifted can carry hundreds, and the row is a signal\n * to act on, not an inventory; the counts still cover every finding. */\nexport const HYGIENE_FINDING_CAP = 5;\n\n/** The largest file the scan reads. A file this big is generated data or a\n * bundle rather than the source and prose the rules are written for. */\nexport const HYGIENE_MAX_FILE_BYTES = 512 * 1024;\n\n/** The most files one scan reads, so the check stays responsive in a tree far\n * larger than this repository's. Reaching it is reported in the row. */\nexport const HYGIENE_MAX_FILES = 2000;\n\n/** The source extensions the check reads. Anything else is skipped: the rules\n * are written for comments and for prose, and a data format (JSON, YAML) has\n * no comment for them to read, while a binary fixture would only produce\n * nonsense. */\nconst HYGIENE_CODE_EXTENSIONS = /\\.(?:ts|tsx|js|jsx|mjs|cjs|sh)$/i;\n\n/** The document extensions the check reads as prose. */\nconst HYGIENE_MARKDOWN_EXTENSIONS = /\\.(?:md|mdx)$/i;\n\n/** Directories the walk never descends into: dependencies, build output and\n * coverage. A dot-directory is skipped too (`.git`, `.noir`, local scratch\n * directories), which is how the maintainer's exclusion of session scratch is\n * honoured by construction rather than by naming it here. */\nconst HYGIENE_SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage']);\n\n/** Directories excluded from the scan by the maintainer's decision: the\n * planning corpus, where decisions are recorded in the maintainer's own\n * shorthand. Root-relative POSIX paths. */\nconst HYGIENE_EXCLUDED_DIRS = new Set(['docs/internal', 'docs/decisions', 'docs/roadmap']);\n\n/** The release log, excluded by basename wherever it sits: the root copy is the\n * single source of truth and `docs/CHANGELOG.md` is a pointer to it. */\nconst HYGIENE_EXCLUDED_FILES = new Set(['CHANGELOG.md']);\n\n/** A file the scan will read, with the kind of text it holds. */\ninterface HygieneScanFile {\n /** Root-relative POSIX path. */\n path: string;\n kind: HygieneKind;\n}\n\n/** One finding, reduced to what the report needs. */\nexport interface HygieneScanFinding {\n path: string;\n /** 1-based line number. */\n line: number;\n /** The rule id, exactly as the rule table declares it. */\n id: string;\n tier: HygieneTier;\n}\n\nexport interface HygieneScanResult {\n /** Files read. */\n scanned: number;\n /** Files the scan did not read because they exceed the size cap. */\n skipped: number;\n /** True when the file cap stopped the scan before it read the whole tree, so\n * the counts below describe a prefix of it. */\n truncated: boolean;\n /** Every finding, failures first, then in reading order across the tree. */\n findings: HygieneScanFinding[];\n fail: number;\n warn: number;\n}\n\n/** A walk in progress: the files it has accepted, and whether the file cap has\n * stopped it. */\ninterface HygieneWalk {\n files: HygieneScanFile[];\n truncated: boolean;\n}\n\n/** `readdirSync` entries, or none when the directory does not exist. */\nfunction readDirEntries(dir: string): Dirent[] {\n try {\n return readdirSync(dir, { withFileTypes: true });\n } catch {\n return []; // absent or unreadable — there is nothing to scan\n }\n}\n\n/** Whether `path` is an existing directory. */\nfunction isDirectory(path: string): boolean {\n try {\n return statSync(path).isDirectory();\n } catch {\n return false;\n }\n}\n\n/** The kind of text a path holds, or `null` when the rules cannot read it. */\nfunction hygieneKindOf(path: string): HygieneKind | null {\n if (HYGIENE_MARKDOWN_EXTENSIONS.test(path)) return 'markdown';\n if (HYGIENE_CODE_EXTENSIONS.test(path)) return 'code';\n return null;\n}\n\n/** The layout this check exists for: a package source or test tree, `scripts/`,\n * or a documentation tree. A repository that has none of them is not the\n * audience of these rules, and nothing is read there. */\nfunction hasScannableLayout(root: string): boolean {\n if (isDirectory(join(root, 'docs')) || isDirectory(join(root, 'scripts'))) return true;\n for (const entry of readDirEntries(join(root, 'packages'))) {\n if (!entry.isDirectory()) continue;\n if (isDirectory(join(root, 'packages', entry.name, 'src'))) return true;\n if (isDirectory(join(root, 'packages', entry.name, 'test'))) return true;\n }\n return false;\n}\n\n/** Adds `rel` to the walk when it is a file the rules can read and the walk has\n * room for it. A dot-file is never read (a dot-directory is skipped by the\n * walk itself), and once the file cap is reached the walk records the\n * truncation instead of adding more. */\nfunction addHygieneFile(rel: string, walk: HygieneWalk): void {\n const name = rel.slice(rel.lastIndexOf('/') + 1);\n if (name.startsWith('.') || HYGIENE_EXCLUDED_FILES.has(name)) return;\n const kind = hygieneKindOf(rel);\n if (kind === null) return;\n if (walk.files.length >= HYGIENE_MAX_FILES) {\n walk.truncated = true;\n return;\n }\n walk.files.push({ path: rel, kind });\n}\n\n/** Offers every readable file under `root`/`relDir` to the walk. Symlinked\n * directories are not followed: a link out of the tree is not the\n * repository's own source, and a link to an ancestor would not terminate. */\nfunction walkScannable(\n root: string,\n relDir: string,\n skip: ReadonlySet<string>,\n walk: HygieneWalk,\n): void {\n if (walk.truncated) return;\n for (const entry of readDirEntries(join(root, relDir))) {\n if (walk.truncated) return;\n const rel = `${relDir}/${entry.name}`;\n if (entry.isDirectory()) {\n if (HYGIENE_SKIP_DIRS.has(entry.name) || entry.name.startsWith('.') || skip.has(rel)) {\n continue;\n }\n walkScannable(root, rel, skip, walk);\n } else if (entry.isFile()) {\n addHygieneFile(rel, walk);\n }\n }\n}\n\nconst NO_SKIP_DIRS: ReadonlySet<string> = new Set();\n\n/** The files the check reads: documents at the root and under `docs/` (minus\n * the planning corpus), the repository-authored agent skills, each package's\n * sources and tests, and `scripts/`. Sorted by path so a report is the same on\n * every run. Called only where {@link hasScannableLayout} holds. */\nfunction collectHygieneFiles(root: string): HygieneWalk {\n const walk: HygieneWalk = { files: [], truncated: false };\n for (const entry of readDirEntries(root)) {\n if (entry.isFile()) addHygieneFile(entry.name, walk);\n }\n walkScannable(root, 'docs', HYGIENE_EXCLUDED_DIRS, walk);\n walkScannable(root, '.claude/skills', NO_SKIP_DIRS, walk);\n for (const entry of readDirEntries(join(root, 'packages'))) {\n if (!entry.isDirectory()) continue;\n walkScannable(root, `packages/${entry.name}/src`, NO_SKIP_DIRS, walk);\n walkScannable(root, `packages/${entry.name}/test`, NO_SKIP_DIRS, walk);\n }\n walkScannable(root, 'scripts', NO_SKIP_DIRS, walk);\n walk.files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));\n return walk;\n}\n\n/** What the scan left out, in one parenthesis. Empty when it left out nothing,\n * so a complete report carries no note. */\nfunction hygieneOmissions(result: HygieneScanResult): string {\n const notes: string[] = [];\n if (result.skipped > 0) {\n notes.push(\n `${result.skipped} file${result.skipped === 1 ? '' : 's'} over ${HYGIENE_MAX_FILE_BYTES / 1024} KiB skipped`,\n );\n }\n if (result.truncated) notes.push(`stopped at ${HYGIENE_MAX_FILES} files`);\n return notes.length > 0 ? ` (${notes.join('; ')})` : '';\n}\n\n/** The counts line: how many findings of each tier, in how many files, and what\n * the scan itself omitted. It names no location, so a caller that prints every\n * finding on a line of its own pairs this with those lines instead of printing\n * the same findings twice. */\nexport function hygieneCounts(result: HygieneScanResult): string {\n const omitted = hygieneOmissions(result);\n if (result.scanned === 0) {\n return `nothing to scan (no packages/*/src, packages/*/test, scripts/ or documents found)${omitted}`;\n }\n if (result.findings.length === 0) {\n return `clean — ${result.scanned} file${result.scanned === 1 ? '' : 's'} scanned${omitted}`;\n }\n const files = new Set(result.findings.map((f) => f.path)).size;\n return `${result.fail} fail, ${result.warn} warn in ${files} file${files === 1 ? '' : 's'}${omitted}`;\n}\n\n/** The counts line followed by the locations that carry them, up to the cap.\n * Failures are named before warnings, so a truncated list still shows what\n * blocks. This is the doctor row, which has one line to work with. */\nexport function hygieneDetail(result: HygieneScanResult): string {\n const counts = hygieneCounts(result);\n if (result.scanned === 0 || result.findings.length === 0) return counts;\n const named = result.findings.slice(0, HYGIENE_FINDING_CAP);\n const hidden = result.findings.length - named.length;\n const where = named.map((f) => `${f.path}:${f.line} ${f.id}`).join('; ');\n return `${counts} — ${where}${hidden > 0 ? ` (+${hidden} more)` : ''}`;\n}\n\n/**\n * Reads the repository's own source and documents through `checkHygiene` and\n * returns the scan result: the files read, the findings (failures first), and\n * the fail/warn counts. It pushes no doctor row — that is the caller's\n * business (`checkOutputHygiene` in the doctor command, or the CI gate's own\n * report).\n *\n * Nothing is read unless {@link hasScannableLayout} holds, so a repository\n * without the layout gets an empty result and pays one `stat` per candidate.\n *\n * Each file is read once, and a file that carries the exemption marker comes\n * back from `checkHygiene` with no findings at all, so the marker costs one\n * scan of a file's text rather than a path list kept here. Files past the size\n * cap and files past the count cap are left out; the result says so.\n *\n * The scan never throws and never writes: an unreadable file is skipped, and\n * the commands that repair the tree (`noir skills lint`, the repository's own\n * sweep) are the caller's business.\n */\nexport function scanOutputHygiene(root: string): HygieneScanResult {\n const empty: HygieneScanResult = {\n scanned: 0,\n skipped: 0,\n truncated: false,\n findings: [],\n fail: 0,\n warn: 0,\n };\n if (!hasScannableLayout(root)) return empty;\n const walk = collectHygieneFiles(root);\n const findings: HygieneScanFinding[] = [];\n let scanned = 0;\n let skipped = 0;\n for (const file of walk.files) {\n const abs = join(root, file.path);\n try {\n if (statSync(abs).size > HYGIENE_MAX_FILE_BYTES) {\n skipped++;\n continue;\n }\n const text = readFileSync(abs, 'utf8');\n scanned++;\n for (const found of checkHygiene(text, file.kind)) {\n findings.push({ path: file.path, line: found.line, id: found.id, tier: found.tier });\n }\n if (file.kind === 'markdown' && !hasHygieneExemption(text, file.kind)) {\n for (const found of [...slopFindings(text), ...humanizerFindings(text)]) {\n findings.push({ path: file.path, line: found.line, id: found.id, tier: found.tier });\n }\n }\n } catch {\n // An unreadable path says nothing about hygiene — the store check owns\n // broken files — so the scan moves on to the next one.\n }\n }\n findings.sort((a, b) => {\n if (a.tier !== b.tier) return a.tier === 'fail' ? -1 : 1;\n if (a.path !== b.path) return a.path < b.path ? -1 : 1;\n return a.line - b.line;\n });\n const fail = findings.filter((f) => f.tier === 'fail').length;\n return {\n scanned,\n skipped,\n truncated: walk.truncated,\n findings,\n fail,\n warn: findings.length - fail,\n };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/hygiene-scan.ts"],"sourcesContent":["// Output hygiene — the repository-facing scan.\n//\n// The repository's own source and documents are checked against the hygiene\n// rules @noir-ai/skills declares: the patterns that make text read as machine\n// output. A fail-tier pattern is objectively mechanical (a divider drawn in\n// punctuation, numbered narration, a decorative emoji, a forbidden residue\n// token), so it blocks; a warn-tier pattern is a judgement call (a long comment\n// block, a marker nobody can act on), which would stall legitimate work if it\n// blocked, so it warns.\n//\n// The scan reads the repository's own text only. Generated and vendored trees,\n// the planning corpus, and formats the rules cannot read (JSON, an image\n// fixture) are out of scope; a file states its own exemption with the marker\n// the rules honour, so no path list is kept here.\n//\n// The scan runs only where the layout it is written for exists: a package\n// tree, `scripts/`, or `docs/`. Anywhere else — an ordinary application\n// repository, which is a README and little more — it reports an empty result\n// and reads nothing, root documents included. These rules judge the text this\n// project writes, not the prose of whoever happened to run the command.\n//\n// This module is the single source of truth for that scope: which files are\n// read, which directories are excluded, and the caps. `noir doctor` (via\n// `checkOutputHygiene` in ./commands/doctor.ts) and the CI gate\n// (scripts/hygiene-gate.mjs) both call `scanOutputHygiene`, so the two cannot\n// drift apart.\n\nimport { type Dirent, readdirSync, readFileSync, statSync } from 'node:fs';\nimport { join } from 'node:path';\nimport {\n checkHygiene,\n type HygieneKind,\n type HygieneTier,\n hasHygieneExemption,\n humanizerFindings,\n slopFindings,\n} from '@noir-ai/skills';\n\n/** How many findings the detail cell names before it reports the rest as a\n * count. A tree that has drifted can carry hundreds, and the row is a signal\n * to act on, not an inventory; the counts still cover every finding. */\nexport const HYGIENE_FINDING_CAP = 5;\n\n/** The largest file the scan reads. A file this big is generated data or a\n * bundle rather than the source and prose the rules are written for. */\nexport const HYGIENE_MAX_FILE_BYTES = 512 * 1024;\n\n/** The most files one scan reads, so the check stays responsive in a tree far\n * larger than this repository's. Reaching it is reported in the row. */\nexport const HYGIENE_MAX_FILES = 2000;\n\n/** The source extensions the check reads. Anything else is skipped: the rules\n * are written for comments and for prose, and a data format (JSON, YAML) has\n * no comment for them to read, while a binary fixture would only produce\n * nonsense. */\nconst HYGIENE_CODE_EXTENSIONS = /\\.(?:ts|tsx|js|jsx|mjs|cjs|sh)$/i;\n\n/** The document extensions the check reads as prose. */\nconst HYGIENE_MARKDOWN_EXTENSIONS = /\\.(?:md|mdx)$/i;\n\n/** Directories the walk never descends into: dependencies, build output and\n * coverage. A dot-directory is skipped too (`.git`, `.noir`, local scratch\n * directories), which is how the maintainer's exclusion of session scratch is\n * honoured by construction rather than by naming it here. */\nconst HYGIENE_SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage']);\n\n/** Directories excluded from the scan by the maintainer's decision: the\n * planning corpus, where decisions are recorded in the maintainer's own\n * shorthand. Root-relative POSIX paths. */\nconst HYGIENE_EXCLUDED_DIRS = new Set(['docs/internal', 'docs/decisions', 'docs/roadmap']);\n\n/** The release log, excluded by basename wherever it sits: the root copy is the\n * single source of truth and `docs/CHANGELOG.md` is a pointer to it. */\nconst HYGIENE_EXCLUDED_FILES = new Set(['CHANGELOG.md']);\n\n/** Generated documentation, excluded by path: the reference pages and the\n * `docs/README.md` index are regenerated from schemas, skill descriptions and\n * tool definitions, so their prose is not the hand-written text the rules are\n * written for. A rule finding there reflects the source, not a slop slip, and\n * an edit would be overwritten by the next `pnpm docs:generate`. Root-relative\n * POSIX paths. */\nconst HYGIENE_EXCLUDED_PATHS = new Set([\n 'docs/README.md',\n 'docs/reference/cli.md',\n 'docs/reference/config.md',\n 'docs/reference/mcp-tools.md',\n 'docs/reference/packages.md',\n 'docs/reference/skills.md',\n]);\n\n/** A file the scan will read, with the kind of text it holds. */\ninterface HygieneScanFile {\n /** Root-relative POSIX path. */\n path: string;\n kind: HygieneKind;\n}\n\n/** One finding, reduced to what the report needs. */\nexport interface HygieneScanFinding {\n path: string;\n /** 1-based line number. */\n line: number;\n /** The rule id, exactly as the rule table declares it. */\n id: string;\n tier: HygieneTier;\n}\n\nexport interface HygieneScanResult {\n /** Files read. */\n scanned: number;\n /** Files the scan did not read because they exceed the size cap. */\n skipped: number;\n /** True when the file cap stopped the scan before it read the whole tree, so\n * the counts below describe a prefix of it. */\n truncated: boolean;\n /** Every finding, failures first, then in reading order across the tree. */\n findings: HygieneScanFinding[];\n fail: number;\n warn: number;\n}\n\n/** A walk in progress: the files it has accepted, and whether the file cap has\n * stopped it. */\ninterface HygieneWalk {\n files: HygieneScanFile[];\n truncated: boolean;\n}\n\n/** `readdirSync` entries, or none when the directory does not exist. */\nfunction readDirEntries(dir: string): Dirent[] {\n try {\n return readdirSync(dir, { withFileTypes: true });\n } catch {\n return []; // absent or unreadable — there is nothing to scan\n }\n}\n\n/** Whether `path` is an existing directory. */\nfunction isDirectory(path: string): boolean {\n try {\n return statSync(path).isDirectory();\n } catch {\n return false;\n }\n}\n\n/** The kind of text a path holds, or `null` when the rules cannot read it. */\nfunction hygieneKindOf(path: string): HygieneKind | null {\n if (HYGIENE_MARKDOWN_EXTENSIONS.test(path)) return 'markdown';\n if (HYGIENE_CODE_EXTENSIONS.test(path)) return 'code';\n return null;\n}\n\n/** The layout this check exists for: a package source or test tree, `scripts/`,\n * or a documentation tree. A repository that has none of them is not the\n * audience of these rules, and nothing is read there. */\nfunction hasScannableLayout(root: string): boolean {\n if (isDirectory(join(root, 'docs')) || isDirectory(join(root, 'scripts'))) return true;\n for (const entry of readDirEntries(join(root, 'packages'))) {\n if (!entry.isDirectory()) continue;\n if (isDirectory(join(root, 'packages', entry.name, 'src'))) return true;\n if (isDirectory(join(root, 'packages', entry.name, 'test'))) return true;\n }\n return false;\n}\n\n/** Adds `rel` to the walk when it is a file the rules can read and the walk has\n * room for it. A dot-file is never read (a dot-directory is skipped by the\n * walk itself), and once the file cap is reached the walk records the\n * truncation instead of adding more. */\nfunction addHygieneFile(rel: string, walk: HygieneWalk): void {\n const name = rel.slice(rel.lastIndexOf('/') + 1);\n if (name.startsWith('.') || HYGIENE_EXCLUDED_FILES.has(name) || HYGIENE_EXCLUDED_PATHS.has(rel))\n return;\n const kind = hygieneKindOf(rel);\n if (kind === null) return;\n if (walk.files.length >= HYGIENE_MAX_FILES) {\n walk.truncated = true;\n return;\n }\n walk.files.push({ path: rel, kind });\n}\n\n/** Offers every readable file under `root`/`relDir` to the walk. Symlinked\n * directories are not followed: a link out of the tree is not the\n * repository's own source, and a link to an ancestor would not terminate. */\nfunction walkScannable(\n root: string,\n relDir: string,\n skip: ReadonlySet<string>,\n walk: HygieneWalk,\n): void {\n if (walk.truncated) return;\n for (const entry of readDirEntries(join(root, relDir))) {\n if (walk.truncated) return;\n const rel = `${relDir}/${entry.name}`;\n if (entry.isDirectory()) {\n if (HYGIENE_SKIP_DIRS.has(entry.name) || entry.name.startsWith('.') || skip.has(rel)) {\n continue;\n }\n walkScannable(root, rel, skip, walk);\n } else if (entry.isFile()) {\n addHygieneFile(rel, walk);\n }\n }\n}\n\nconst NO_SKIP_DIRS: ReadonlySet<string> = new Set();\n\n/** The files the check reads: documents at the root and under `docs/` (minus\n * the planning corpus), the repository-authored agent skills, each package's\n * sources and tests, and `scripts/`. Sorted by path so a report is the same on\n * every run. Called only where {@link hasScannableLayout} holds. */\nfunction collectHygieneFiles(root: string): HygieneWalk {\n const walk: HygieneWalk = { files: [], truncated: false };\n for (const entry of readDirEntries(root)) {\n if (entry.isFile()) addHygieneFile(entry.name, walk);\n }\n walkScannable(root, 'docs', HYGIENE_EXCLUDED_DIRS, walk);\n walkScannable(root, '.claude/skills', NO_SKIP_DIRS, walk);\n for (const entry of readDirEntries(join(root, 'packages'))) {\n if (!entry.isDirectory()) continue;\n walkScannable(root, `packages/${entry.name}/src`, NO_SKIP_DIRS, walk);\n walkScannable(root, `packages/${entry.name}/test`, NO_SKIP_DIRS, walk);\n }\n walkScannable(root, 'scripts', NO_SKIP_DIRS, walk);\n walk.files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));\n return walk;\n}\n\n/** What the scan left out, in one parenthesis. Empty when it left out nothing,\n * so a complete report carries no note. */\nfunction hygieneOmissions(result: HygieneScanResult): string {\n const notes: string[] = [];\n if (result.skipped > 0) {\n notes.push(\n `${result.skipped} file${result.skipped === 1 ? '' : 's'} over ${HYGIENE_MAX_FILE_BYTES / 1024} KiB skipped`,\n );\n }\n if (result.truncated) notes.push(`stopped at ${HYGIENE_MAX_FILES} files`);\n return notes.length > 0 ? ` (${notes.join('; ')})` : '';\n}\n\n/** The counts line: how many findings of each tier, in how many files, and what\n * the scan itself omitted. It names no location, so a caller that prints every\n * finding on a line of its own pairs this with those lines instead of printing\n * the same findings twice. */\nexport function hygieneCounts(result: HygieneScanResult): string {\n const omitted = hygieneOmissions(result);\n if (result.scanned === 0) {\n return `nothing to scan (no packages/*/src, packages/*/test, scripts/ or documents found)${omitted}`;\n }\n if (result.findings.length === 0) {\n return `clean — ${result.scanned} file${result.scanned === 1 ? '' : 's'} scanned${omitted}`;\n }\n const files = new Set(result.findings.map((f) => f.path)).size;\n return `${result.fail} fail, ${result.warn} warn in ${files} file${files === 1 ? '' : 's'}${omitted}`;\n}\n\n/** The counts line followed by the locations that carry them, up to the cap.\n * Failures are named before warnings, so a truncated list still shows what\n * blocks. This is the doctor row, which has one line to work with. */\nexport function hygieneDetail(result: HygieneScanResult): string {\n const counts = hygieneCounts(result);\n if (result.scanned === 0 || result.findings.length === 0) return counts;\n const named = result.findings.slice(0, HYGIENE_FINDING_CAP);\n const hidden = result.findings.length - named.length;\n const where = named.map((f) => `${f.path}:${f.line} ${f.id}`).join('; ');\n return `${counts} — ${where}${hidden > 0 ? ` (+${hidden} more)` : ''}`;\n}\n\n/**\n * Reads the repository's own source and documents through `checkHygiene` and\n * returns the scan result: the files read, the findings (failures first), and\n * the fail/warn counts. It pushes no doctor row — that is the caller's\n * business (`checkOutputHygiene` in the doctor command, or the CI gate's own\n * report).\n *\n * Nothing is read unless {@link hasScannableLayout} holds, so a repository\n * without the layout gets an empty result and pays one `stat` per candidate.\n *\n * Each file is read once, and a file that carries the exemption marker comes\n * back from `checkHygiene` with no findings at all, so the marker costs one\n * scan of a file's text rather than a path list kept here. Files past the size\n * cap and files past the count cap are left out; the result says so.\n *\n * The scan never throws and never writes: an unreadable file is skipped, and\n * the commands that repair the tree (`noir skills lint`, the repository's own\n * sweep) are the caller's business.\n */\nexport function scanOutputHygiene(root: string): HygieneScanResult {\n const empty: HygieneScanResult = {\n scanned: 0,\n skipped: 0,\n truncated: false,\n findings: [],\n fail: 0,\n warn: 0,\n };\n if (!hasScannableLayout(root)) return empty;\n const walk = collectHygieneFiles(root);\n const findings: HygieneScanFinding[] = [];\n let scanned = 0;\n let skipped = 0;\n for (const file of walk.files) {\n const abs = join(root, file.path);\n try {\n if (statSync(abs).size > HYGIENE_MAX_FILE_BYTES) {\n skipped++;\n continue;\n }\n const text = readFileSync(abs, 'utf8');\n scanned++;\n for (const found of checkHygiene(text, file.kind)) {\n findings.push({ path: file.path, line: found.line, id: found.id, tier: found.tier });\n }\n if (file.kind === 'markdown' && !hasHygieneExemption(text, file.kind)) {\n for (const found of [...slopFindings(text), ...humanizerFindings(text)]) {\n findings.push({ path: file.path, line: found.line, id: found.id, tier: found.tier });\n }\n }\n } catch {\n // An unreadable path says nothing about hygiene — the store check owns\n // broken files — so the scan moves on to the next one.\n }\n }\n findings.sort((a, b) => {\n if (a.tier !== b.tier) return a.tier === 'fail' ? -1 : 1;\n if (a.path !== b.path) return a.path < b.path ? -1 : 1;\n return a.line - b.line;\n });\n const fail = findings.filter((f) => f.tier === 'fail').length;\n return {\n scanned,\n skipped,\n truncated: walk.truncated,\n findings,\n fail,\n warn: findings.length - fail,\n };\n}\n"],"mappings":"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","names":[]}
package/dist/index.js CHANGED
@@ -3103,6 +3103,7 @@ function collectLeaves(cmd, path, out) {
3103
3103
  const isLeaf = hasAction(cmd) && children.length === 0;
3104
3104
  if (isLeaf) {
3105
3105
  const argv = path;
3106
+ if (INTERNAL_LEAF_COMMANDS.has(argv.join(" "))) return;
3106
3107
  const id = argv.join(" ");
3107
3108
  const description = cmd.description() || argv.join(" ");
3108
3109
  out.push({
@@ -3126,7 +3127,7 @@ function buildPaletteCommands(program2) {
3126
3127
  collectLeaves(program2, [], out);
3127
3128
  return out;
3128
3129
  }
3129
- var DESTRUCTIVE_PREFIXES;
3130
+ var DESTRUCTIVE_PREFIXES, INTERNAL_LEAF_COMMANDS;
3130
3131
  var init_registry = __esm({
3131
3132
  "src/tui/commands/registry.ts"() {
3132
3133
  "use strict";
@@ -3148,6 +3149,7 @@ var init_registry = __esm({
3148
3149
  ["update"],
3149
3150
  ["run"]
3150
3151
  ];
3152
+ INTERNAL_LEAF_COMMANDS = /* @__PURE__ */ new Set(["hook"]);
3151
3153
  }
3152
3154
  });
3153
3155
 
@@ -3607,8 +3609,55 @@ var init_home = __esm({
3607
3609
  }
3608
3610
  });
3609
3611
 
3612
+ // src/commands/hook.ts
3613
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
3614
+ import { join as join3 } from "path";
3615
+ function hook() {
3616
+ let eventName = "SessionStart";
3617
+ let root = process.cwd();
3618
+ try {
3619
+ const input = JSON.parse(readFileSync2(0, "utf8"));
3620
+ if (typeof input.hook_event_name === "string" && input.hook_event_name !== "") {
3621
+ eventName = input.hook_event_name;
3622
+ }
3623
+ if (typeof input.cwd === "string" && input.cwd !== "") root = input.cwd;
3624
+ } catch {
3625
+ }
3626
+ process.stdout.write(JSON.stringify(hookOutput(eventName, root)));
3627
+ }
3628
+ function hookOutput(eventName, root) {
3629
+ let routerPath = null;
3630
+ let rulesPath = null;
3631
+ for (let dir = root; ; dir = join3(dir, "..")) {
3632
+ const router = join3(dir, ".noir", "router.md");
3633
+ const rules = join3(dir, ".noir", "rules", "anti-slop.md");
3634
+ if (existsSync2(router) || existsSync2(rules)) {
3635
+ routerPath = router;
3636
+ rulesPath = rules;
3637
+ break;
3638
+ }
3639
+ const parent = join3(dir, "..");
3640
+ if (parent === dir) break;
3641
+ }
3642
+ const parts = [];
3643
+ for (const path of [routerPath, rulesPath]) {
3644
+ if (path === null) continue;
3645
+ try {
3646
+ const text = readFileSync2(path, "utf8").trim();
3647
+ if (text !== "") parts.push(text);
3648
+ } catch {
3649
+ }
3650
+ }
3651
+ return parts.length === 0 ? { hookSpecificOutput: { hookEventName: eventName } } : { hookSpecificOutput: { hookEventName: eventName, additionalContext: parts.join("\n\n") } };
3652
+ }
3653
+ var init_hook = __esm({
3654
+ "src/commands/hook.ts"() {
3655
+ "use strict";
3656
+ }
3657
+ });
3658
+
3610
3659
  // src/commands/memory.ts
3611
- import { readFileSync as readFileSync2 } from "fs";
3660
+ import { readFileSync as readFileSync3 } from "fs";
3612
3661
  import { loadProjectInfo as loadProjectInfo11 } from "@noir-ai/core";
3613
3662
  function resolveWorkspace() {
3614
3663
  const routing = workspaceRoutingTarget(process.cwd());
@@ -3826,14 +3875,14 @@ async function resolveCaptureContent(opts) {
3826
3875
  if (typeof opts.content === "string" && opts.content.length > 0) return opts.content;
3827
3876
  if (typeof opts.file === "string" && opts.file.length > 0) {
3828
3877
  try {
3829
- return readFileSync2(opts.file, "utf8");
3878
+ return readFileSync3(opts.file, "utf8");
3830
3879
  } catch {
3831
3880
  fail(EXIT.USAGE, `memory capture could not read --file ${opts.file}`, opts);
3832
3881
  }
3833
3882
  }
3834
3883
  if (!process.stdin.isTTY) {
3835
3884
  try {
3836
- return readFileSync2(0, "utf8");
3885
+ return readFileSync3(0, "utf8");
3837
3886
  } catch {
3838
3887
  fail(
3839
3888
  EXIT.USAGE,
@@ -5216,8 +5265,8 @@ var init_run_status = __esm({
5216
5265
  });
5217
5266
 
5218
5267
  // src/commands/run.ts
5219
- import { existsSync as existsSync2, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
5220
- import { join as join3 } from "path";
5268
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
5269
+ import { join as join4 } from "path";
5221
5270
  import { SUPPORTED_HOSTS } from "@noir-ai/adapters";
5222
5271
  import { isDeniedEnvKey as isDeniedEnvKey2, loadNoirEnv as loadNoirEnv2, NOIR_DIR as NOIR_DIR2, parseConfig, paths } from "@noir-ai/core";
5223
5272
  function credentialNote(env2, sources) {
@@ -5277,7 +5326,7 @@ async function runOnce(prompt, opts, resumeSessionId) {
5277
5326
  const customBinary = opts.command ?? profile.binary;
5278
5327
  const extraArgs = resumeSessionId === void 0 ? profile.args : [...profile.args ?? [], "--resume", resumeSessionId];
5279
5328
  const env2 = profile.env ? mergeEnv(process.env, profile.env) : void 0;
5280
- if (!existsSync2(join3(root, NOIR_DIR2, ".env"))) {
5329
+ if (!existsSync3(join4(root, NOIR_DIR2, ".env"))) {
5281
5330
  log(
5282
5331
  `This project has no ${NOIR_DIR2}/.env \u2014 that is where Noir reads project credentials and run config from. Run \`noir init\` to scaffold one (\`noir run\` works without it).`,
5283
5332
  opts
@@ -5440,7 +5489,7 @@ function safeTranscript(host, lines) {
5440
5489
  function writeTranscript(host, lines, root = process.cwd()) {
5441
5490
  const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
5442
5491
  const dir = paths.transcriptsDir(root);
5443
- const file = join3(dir, `${host}-${ts}.jsonl`);
5492
+ const file = join4(dir, `${host}-${ts}.jsonl`);
5444
5493
  try {
5445
5494
  mkdirSync3(dir, { recursive: true, mode: 448 });
5446
5495
  writeFileSync4(file, `${lines.join("\n")}${lines.length > 0 ? "\n" : ""}`, { mode: 384 });
@@ -5576,8 +5625,8 @@ var init_conflict = __esm({
5576
5625
  });
5577
5626
 
5578
5627
  // src/dedup-write.ts
5579
- import { mkdirSync as mkdirSync4, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync5 } from "fs";
5580
- import { join as join4 } from "path";
5628
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync5 } from "fs";
5629
+ import { join as join5 } from "path";
5581
5630
  import { sha256Hex as sha256Hex2, sha256Hex12 } from "@noir-ai/core";
5582
5631
  async function checkWritePathDedup(root, res, opts) {
5583
5632
  const result = { found: [], conflicts: [] };
@@ -5588,10 +5637,10 @@ async function checkWritePathDedup(root, res, opts) {
5588
5637
  if (embed === void 0) return result;
5589
5638
  const cachedEmbed = makeCachedEmbed(cache, embed);
5590
5639
  for (const proposedRel of writtenHostFiles) {
5591
- const proposedAbs = join4(root, proposedRel);
5640
+ const proposedAbs = join5(root, proposedRel);
5592
5641
  let proposedText;
5593
5642
  try {
5594
- proposedText = readFileSync3(proposedAbs, "utf8");
5643
+ proposedText = readFileSync4(proposedAbs, "utf8");
5595
5644
  } catch {
5596
5645
  continue;
5597
5646
  }
@@ -5635,7 +5684,7 @@ async function checkWritePathDedup(root, res, opts) {
5635
5684
  `
5636
5685
  );
5637
5686
  }
5638
- const matchedText = safeRead(join4(root, matchedRel));
5687
+ const matchedText = safeRead(join5(root, matchedRel));
5639
5688
  result.conflicts.push({
5640
5689
  path: proposedRel,
5641
5690
  mode: "artifact",
@@ -5719,21 +5768,21 @@ async function promptDedupAction(root, proposedRel, matchedRel, proposedText, si
5719
5768
  if (clack.isCancel(choice)) return "preserve";
5720
5769
  if (choice === "skip") {
5721
5770
  try {
5722
- rmSync(join4(root, proposedRel), { force: true });
5771
+ rmSync(join5(root, proposedRel), { force: true });
5723
5772
  } catch {
5724
5773
  }
5725
5774
  return "preserve";
5726
5775
  } else if (choice === "replace") {
5727
5776
  try {
5728
- writeFileSync5(join4(root, matchedRel), proposedText, "utf8");
5777
+ writeFileSync5(join5(root, matchedRel), proposedText, "utf8");
5729
5778
  } catch {
5730
5779
  }
5731
5780
  return "replace";
5732
5781
  } else if (choice === "mirror") {
5733
5782
  try {
5734
- const existing = readFileSync3(join4(root, matchedRel), "utf8");
5783
+ const existing = readFileSync4(join5(root, matchedRel), "utf8");
5735
5784
  writeFileSync5(
5736
- join4(root, matchedRel),
5785
+ join5(root, matchedRel),
5737
5786
  `${existing}
5738
5787
 
5739
5788
  <!-- mirrored from ${proposedRel} -->
@@ -5751,11 +5800,11 @@ function emptyCache() {
5751
5800
  return { version: DEDUP_CACHE_VERSION, model: "", entries: {} };
5752
5801
  }
5753
5802
  function cachePath(root) {
5754
- return join4(root, ".noir", "dedup-cache.json");
5803
+ return join5(root, ".noir", "dedup-cache.json");
5755
5804
  }
5756
5805
  function loadCache(root) {
5757
5806
  try {
5758
- const raw = readFileSync3(cachePath(root), "utf8");
5807
+ const raw = readFileSync4(cachePath(root), "utf8");
5759
5808
  const parsed = JSON.parse(raw);
5760
5809
  if (parsed.version !== DEDUP_CACHE_VERSION) return emptyCache();
5761
5810
  if (typeof parsed.entries !== "object" || parsed.entries === null) return emptyCache();
@@ -5770,7 +5819,7 @@ function loadCache(root) {
5770
5819
  }
5771
5820
  function saveCache(root, cache) {
5772
5821
  try {
5773
- mkdirSync4(join4(root, ".noir"), { recursive: true });
5822
+ mkdirSync4(join5(root, ".noir"), { recursive: true });
5774
5823
  writeFileSync5(cachePath(root), JSON.stringify(cache), "utf8");
5775
5824
  } catch {
5776
5825
  }
@@ -5787,7 +5836,7 @@ function makeCachedEmbed(cache, embed) {
5787
5836
  }
5788
5837
  function safeRead(abs) {
5789
5838
  try {
5790
- return readFileSync3(abs, "utf8");
5839
+ return readFileSync4(abs, "utf8");
5791
5840
  } catch {
5792
5841
  return void 0;
5793
5842
  }
@@ -5825,7 +5874,7 @@ var init_dedup_write = __esm({
5825
5874
  });
5826
5875
 
5827
5876
  // src/init.ts
5828
- import { existsSync as existsSync3 } from "fs";
5877
+ import { existsSync as existsSync4 } from "fs";
5829
5878
  import { resolveAdapter as resolveAdapter2 } from "@noir-ai/adapters";
5830
5879
  import { loadProjectInfo as loadProjectInfo13, paths as paths2 } from "@noir-ai/core";
5831
5880
  import { scaffold } from "@noir-ai/create";
@@ -5890,7 +5939,7 @@ async function init(root, opts) {
5890
5939
  }
5891
5940
  function resolveInitHost(root, opts) {
5892
5941
  if (opts.host !== void 0) return opts.host;
5893
- if (!existsSync3(paths2.config(root))) return "claude";
5942
+ if (!existsSync4(paths2.config(root))) return "claude";
5894
5943
  try {
5895
5944
  return loadProjectInfo13(root).config.host;
5896
5945
  } catch {
@@ -6362,7 +6411,7 @@ var sync_exports = {};
6362
6411
  __export(sync_exports, {
6363
6412
  sync: () => sync
6364
6413
  });
6365
- import { existsSync as existsSync4 } from "fs";
6414
+ import { existsSync as existsSync5 } from "fs";
6366
6415
  import { resolveAdapter as resolveAdapter5 } from "@noir-ai/adapters";
6367
6416
  import { loadProjectInfo as loadProjectInfo17, paths as paths3 } from "@noir-ai/core";
6368
6417
  import { scaffold as scaffold3 } from "@noir-ai/create";
@@ -6435,7 +6484,7 @@ async function sync(root, opts = {}) {
6435
6484
  }
6436
6485
  function resolveSyncHost(root, opts) {
6437
6486
  if (opts.host !== void 0) return opts.host;
6438
- if (!existsSync4(paths3.config(root))) return "claude";
6487
+ if (!existsSync5(paths3.config(root))) return "claude";
6439
6488
  try {
6440
6489
  return loadProjectInfo17(root).config.host;
6441
6490
  } catch {
@@ -6794,6 +6843,9 @@ function createProgram() {
6794
6843
  program2.command("env").description("which configuration is in effect and where each value comes from").action(async (...args) => {
6795
6844
  await env(toCliOptions(actionGlobals(args)));
6796
6845
  });
6846
+ program2.command("hook", { hidden: true }).description("host hook runner (SessionStart / SubagentStart context injection)").action(async () => {
6847
+ await hook();
6848
+ });
6797
6849
  const contextGrp = program2.command("context").description("context engine");
6798
6850
  contextGrp.command("search").description("hybrid search over the indexed context").argument("<query>", "search query").option("--limit <n>", "max results", "10").action(async (...args) => {
6799
6851
  const cmd = trailingCmd(args);
@@ -7182,6 +7234,7 @@ var init_bin = __esm({
7182
7234
  init_env();
7183
7235
  init_handoff();
7184
7236
  init_home();
7237
+ init_hook();
7185
7238
  init_memory();
7186
7239
  init_release();
7187
7240
  init_run();
@@ -7626,8 +7679,8 @@ var init_daemon_client = __esm({
7626
7679
  });
7627
7680
 
7628
7681
  // src/hygiene-scan.ts
7629
- import { readdirSync as readdirSync2, readFileSync as readFileSync4, statSync } from "fs";
7630
- import { join as join5 } from "path";
7682
+ import { readdirSync as readdirSync2, readFileSync as readFileSync5, statSync } from "fs";
7683
+ import { join as join6 } from "path";
7631
7684
  import {
7632
7685
  checkHygiene,
7633
7686
  hasHygieneExemption,
@@ -7654,17 +7707,18 @@ function hygieneKindOf(path) {
7654
7707
  return null;
7655
7708
  }
7656
7709
  function hasScannableLayout(root) {
7657
- if (isDirectory(join5(root, "docs")) || isDirectory(join5(root, "scripts"))) return true;
7658
- for (const entry of readDirEntries(join5(root, "packages"))) {
7710
+ if (isDirectory(join6(root, "docs")) || isDirectory(join6(root, "scripts"))) return true;
7711
+ for (const entry of readDirEntries(join6(root, "packages"))) {
7659
7712
  if (!entry.isDirectory()) continue;
7660
- if (isDirectory(join5(root, "packages", entry.name, "src"))) return true;
7661
- if (isDirectory(join5(root, "packages", entry.name, "test"))) return true;
7713
+ if (isDirectory(join6(root, "packages", entry.name, "src"))) return true;
7714
+ if (isDirectory(join6(root, "packages", entry.name, "test"))) return true;
7662
7715
  }
7663
7716
  return false;
7664
7717
  }
7665
7718
  function addHygieneFile(rel, walk) {
7666
7719
  const name = rel.slice(rel.lastIndexOf("/") + 1);
7667
- if (name.startsWith(".") || HYGIENE_EXCLUDED_FILES.has(name)) return;
7720
+ if (name.startsWith(".") || HYGIENE_EXCLUDED_FILES.has(name) || HYGIENE_EXCLUDED_PATHS.has(rel))
7721
+ return;
7668
7722
  const kind = hygieneKindOf(rel);
7669
7723
  if (kind === null) return;
7670
7724
  if (walk.files.length >= HYGIENE_MAX_FILES) {
@@ -7675,7 +7729,7 @@ function addHygieneFile(rel, walk) {
7675
7729
  }
7676
7730
  function walkScannable(root, relDir, skip, walk) {
7677
7731
  if (walk.truncated) return;
7678
- for (const entry of readDirEntries(join5(root, relDir))) {
7732
+ for (const entry of readDirEntries(join6(root, relDir))) {
7679
7733
  if (walk.truncated) return;
7680
7734
  const rel = `${relDir}/${entry.name}`;
7681
7735
  if (entry.isDirectory()) {
@@ -7695,7 +7749,7 @@ function collectHygieneFiles(root) {
7695
7749
  }
7696
7750
  walkScannable(root, "docs", HYGIENE_EXCLUDED_DIRS, walk);
7697
7751
  walkScannable(root, ".claude/skills", NO_SKIP_DIRS, walk);
7698
- for (const entry of readDirEntries(join5(root, "packages"))) {
7752
+ for (const entry of readDirEntries(join6(root, "packages"))) {
7699
7753
  if (!entry.isDirectory()) continue;
7700
7754
  walkScannable(root, `packages/${entry.name}/src`, NO_SKIP_DIRS, walk);
7701
7755
  walkScannable(root, `packages/${entry.name}/test`, NO_SKIP_DIRS, walk);
@@ -7748,13 +7802,13 @@ function scanOutputHygiene(root) {
7748
7802
  let scanned = 0;
7749
7803
  let skipped = 0;
7750
7804
  for (const file of walk.files) {
7751
- const abs = join5(root, file.path);
7805
+ const abs = join6(root, file.path);
7752
7806
  try {
7753
7807
  if (statSync(abs).size > HYGIENE_MAX_FILE_BYTES) {
7754
7808
  skipped++;
7755
7809
  continue;
7756
7810
  }
7757
- const text = readFileSync4(abs, "utf8");
7811
+ const text = readFileSync5(abs, "utf8");
7758
7812
  scanned++;
7759
7813
  for (const found of checkHygiene(text, file.kind)) {
7760
7814
  findings.push({ path: file.path, line: found.line, id: found.id, tier: found.tier });
@@ -7782,7 +7836,7 @@ function scanOutputHygiene(root) {
7782
7836
  warn: findings.length - fail2
7783
7837
  };
7784
7838
  }
7785
- var HYGIENE_FINDING_CAP, HYGIENE_MAX_FILE_BYTES, HYGIENE_MAX_FILES, HYGIENE_CODE_EXTENSIONS, HYGIENE_MARKDOWN_EXTENSIONS, HYGIENE_SKIP_DIRS, HYGIENE_EXCLUDED_DIRS, HYGIENE_EXCLUDED_FILES, NO_SKIP_DIRS;
7839
+ var HYGIENE_FINDING_CAP, HYGIENE_MAX_FILE_BYTES, HYGIENE_MAX_FILES, HYGIENE_CODE_EXTENSIONS, HYGIENE_MARKDOWN_EXTENSIONS, HYGIENE_SKIP_DIRS, HYGIENE_EXCLUDED_DIRS, HYGIENE_EXCLUDED_FILES, HYGIENE_EXCLUDED_PATHS, NO_SKIP_DIRS;
7786
7840
  var init_hygiene_scan = __esm({
7787
7841
  "src/hygiene-scan.ts"() {
7788
7842
  "use strict";
@@ -7794,14 +7848,22 @@ var init_hygiene_scan = __esm({
7794
7848
  HYGIENE_SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "coverage"]);
7795
7849
  HYGIENE_EXCLUDED_DIRS = /* @__PURE__ */ new Set(["docs/internal", "docs/decisions", "docs/roadmap"]);
7796
7850
  HYGIENE_EXCLUDED_FILES = /* @__PURE__ */ new Set(["CHANGELOG.md"]);
7851
+ HYGIENE_EXCLUDED_PATHS = /* @__PURE__ */ new Set([
7852
+ "docs/README.md",
7853
+ "docs/reference/cli.md",
7854
+ "docs/reference/config.md",
7855
+ "docs/reference/mcp-tools.md",
7856
+ "docs/reference/packages.md",
7857
+ "docs/reference/skills.md"
7858
+ ]);
7797
7859
  NO_SKIP_DIRS = /* @__PURE__ */ new Set();
7798
7860
  }
7799
7861
  });
7800
7862
 
7801
7863
  // src/commands/doctor.ts
7802
7864
  import { spawnSync as spawnSync3 } from "child_process";
7803
- import { existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync5, statSync as statSync2 } from "fs";
7804
- import { dirname, join as join6, relative } from "path";
7865
+ import { existsSync as existsSync6, readdirSync as readdirSync3, readFileSync as readFileSync6, statSync as statSync2 } from "fs";
7866
+ import { dirname, join as join7, relative } from "path";
7805
7867
  import { fileURLToPath } from "url";
7806
7868
  import { mcpConfigPathFor as mcpConfigPathFor2, resolveAdapter as resolveAdapter6 } from "@noir-ai/adapters";
7807
7869
  import {
@@ -7860,7 +7922,7 @@ async function checkRuntime(checks) {
7860
7922
  }
7861
7923
  function checkConfig(root) {
7862
7924
  const configPath = paths4.config(root);
7863
- if (!existsSync5(configPath)) {
7925
+ if (!existsSync6(configPath)) {
7864
7926
  return {
7865
7927
  project: void 0,
7866
7928
  result: {
@@ -7960,7 +8022,7 @@ async function checkStore(checks, project, root) {
7960
8022
  return;
7961
8023
  }
7962
8024
  const dbPath = paths4.storeDb(root, project.id);
7963
- if (!existsSync5(dbPath)) {
8025
+ if (!existsSync6(dbPath)) {
7964
8026
  checks.push({
7965
8027
  name: "store",
7966
8028
  status: "warn",
@@ -7999,7 +8061,7 @@ function describeEnvMode(cause, mode) {
7999
8061
  return cause === "legacy" ? `${lead} Nothing has changed its mode since the file was written, so the file predates the owner-only contract (an older Noir seeded it, a clone delivered it, it was created by hand, or it was saved by a tool that replaces the file) \u2014 this is not a change you made.` : `${lead} Its mode was changed after the file was written (a \`chmod\`, a \`chown\`, or a restore that re-applied metadata), so no older Noir left it this way.`;
8000
8062
  }
8001
8063
  function checkNoirEnv(checks, root, loaded) {
8002
- const path = join6(paths4.noirDir(root), ".env");
8064
+ const path = join7(paths4.noirDir(root), ".env");
8003
8065
  let st;
8004
8066
  try {
8005
8067
  st = statSync2(path);
@@ -8118,10 +8180,10 @@ function checkScaffoldVersion(checks, root) {
8118
8180
  return { onDisk, current, drift };
8119
8181
  }
8120
8182
  function checkSeedDrift(checks, root) {
8121
- const seedPath = join6(root, ".noir", ".env.example");
8183
+ const seedPath = join7(root, ".noir", ".env.example");
8122
8184
  let bytes;
8123
8185
  try {
8124
- bytes = readFileSync5(seedPath, "utf8");
8186
+ bytes = readFileSync6(seedPath, "utf8");
8125
8187
  } catch {
8126
8188
  return;
8127
8189
  }
@@ -8148,7 +8210,7 @@ function checkRulesMdBudget(checks, root, project) {
8148
8210
  return null;
8149
8211
  }
8150
8212
  const rulesPath = paths4.rulesMd(root);
8151
- if (!existsSync5(rulesPath)) {
8213
+ if (!existsSync6(rulesPath)) {
8152
8214
  checks.push({
8153
8215
  name: "rules budget",
8154
8216
  status: "ok",
@@ -8158,7 +8220,7 @@ function checkRulesMdBudget(checks, root, project) {
8158
8220
  }
8159
8221
  let content;
8160
8222
  try {
8161
- content = readFileSync5(rulesPath, "utf8");
8223
+ content = readFileSync6(rulesPath, "utf8");
8162
8224
  } catch (e) {
8163
8225
  checks.push({
8164
8226
  name: "rules budget",
@@ -8212,7 +8274,7 @@ function checkHostArtifacts(checks, root, project) {
8212
8274
  if (host === "claude") expected.push("CLAUDE.md");
8213
8275
  else if (host === "gemini") expected.push("GEMINI.md");
8214
8276
  expected.push(relOr(mcpConfigPathFor2(host, root), root));
8215
- const missing = expected.filter((p) => !existsSync5(joinRoot(root, p)));
8277
+ const missing = expected.filter((p) => !existsSync6(joinRoot(root, p)));
8216
8278
  const status2 = missing.length === 0 ? "ok" : "warn";
8217
8279
  const detail = missing.length === 0 ? `host=${host} \xB7 all ${expected.length} primary artifacts present` : `host=${host} \xB7 missing ${missing.join(", ")} (run \`noir sync\` to restore)`;
8218
8280
  checks.push({ name: "host artifacts", status: status2, detail });
@@ -8229,7 +8291,7 @@ function checkNestedNoir(checks, root) {
8229
8291
  ".noir/.npmignore",
8230
8292
  ".noir/.prettierignore"
8231
8293
  ];
8232
- const found = candidates.filter((rel) => existsSync5(join6(root, rel)));
8294
+ const found = candidates.filter((rel) => existsSync6(join7(root, rel)));
8233
8295
  const detected = found.length > 0;
8234
8296
  checks.push({
8235
8297
  name: "nested .noir",
@@ -8251,11 +8313,11 @@ function collectDedupCandidates(root) {
8251
8313
  const rels = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", ".noir/rules/RULES.md"];
8252
8314
  const out = [];
8253
8315
  for (const rel of rels) {
8254
- const abs = join6(root, rel);
8255
- if (!existsSync5(abs)) continue;
8316
+ const abs = join7(root, rel);
8317
+ if (!existsSync6(abs)) continue;
8256
8318
  let text;
8257
8319
  try {
8258
- text = readFileSync5(abs, "utf8");
8320
+ text = readFileSync6(abs, "utf8");
8259
8321
  } catch {
8260
8322
  continue;
8261
8323
  }
@@ -8312,13 +8374,13 @@ async function checkSemanticDupDoctor(checks, root, project, opts = {}) {
8312
8374
  function resolveWorkspacePackagesDir() {
8313
8375
  let here = dirname(fileURLToPath(import.meta.url));
8314
8376
  for (let i = 0; i < 8; i++) {
8315
- const pkgPath = join6(here, "package.json");
8316
- if (existsSync5(pkgPath)) {
8377
+ const pkgPath = join7(here, "package.json");
8378
+ if (existsSync6(pkgPath)) {
8317
8379
  try {
8318
- const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
8380
+ const pkg = JSON.parse(readFileSync6(pkgPath, "utf8"));
8319
8381
  if (pkg.name === "@noir-ai/cli") {
8320
8382
  const packagesDir = dirname(here);
8321
- if (existsSync5(join6(dirname(packagesDir), "pnpm-workspace.yaml"))) {
8383
+ if (existsSync6(join7(dirname(packagesDir), "pnpm-workspace.yaml"))) {
8322
8384
  return packagesDir;
8323
8385
  }
8324
8386
  return null;
@@ -8376,11 +8438,11 @@ function checkPublish(checks, packagesDir, opts = {}) {
8376
8438
  let checked = 0;
8377
8439
  let cliDir = null;
8378
8440
  for (const name of entries) {
8379
- const pkgPath = join6(packagesDir, name, "package.json");
8380
- if (!existsSync5(pkgPath)) continue;
8441
+ const pkgPath = join7(packagesDir, name, "package.json");
8442
+ if (!existsSync6(pkgPath)) continue;
8381
8443
  let pkg;
8382
8444
  try {
8383
- pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
8445
+ pkg = JSON.parse(readFileSync6(pkgPath, "utf8"));
8384
8446
  } catch (e) {
8385
8447
  issues.push(`${name}/package.json: unparseable (${msg(e)})`);
8386
8448
  checked++;
@@ -8402,7 +8464,7 @@ function checkPublish(checks, packagesDir, opts = {}) {
8402
8464
  if (pkg.bin == null || typeof pkg.bin === "object" && Object.keys(pkg.bin).length === 0) {
8403
8465
  issues.push(`${name}/package.json: bin missing (cli package must declare a bin)`);
8404
8466
  }
8405
- cliDir = join6(packagesDir, name);
8467
+ cliDir = join7(packagesDir, name);
8406
8468
  }
8407
8469
  }
8408
8470
  let packNote = "";
@@ -8484,11 +8546,11 @@ function renderHuman2(payload, opts) {
8484
8546
  }
8485
8547
  function fixOwnerOnlyPermissions(root, project) {
8486
8548
  const fixes = [
8487
- { path: ".noir/.env", outcome: ensureOwnerOnly(join6(paths4.noirDir(root), ".env")) }
8549
+ { path: ".noir/.env", outcome: ensureOwnerOnly(join7(paths4.noirDir(root), ".env")) }
8488
8550
  ];
8489
8551
  if (project) {
8490
8552
  const dbRel = `.noir/store/${project.id}.db`;
8491
- fixes.push({ path: dbRel, outcome: ensureOwnerOnly(join6(root, dbRel)) });
8553
+ fixes.push({ path: dbRel, outcome: ensureOwnerOnly(join7(root, dbRel)) });
8492
8554
  fixes.push({ path: ".noir/store", outcome: ensureOwnerOnlyDir(paths4.storeDir(root)) });
8493
8555
  }
8494
8556
  return fixes;