diff --git a/package.json b/package.json
index 1307c8d..150b571 100644
--- a/package.json
+++ b/package.json
@@ -60,6 +60,7 @@
"lint": "eslint .",
"lock:complete": "node bin/torch-lock.mjs complete",
"validate:scheduler": "node scripts/validate-scheduler-roster.mjs && node scripts/validate-prompt-contract.mjs && node scripts/validate-scheduler-flow-parity.mjs && npm run validate:scheduler-failure-schema",
+ "validate:docs": "node scripts/validate-docs-links.mjs",
"scheduler:daily": "node scripts/agent/run-scheduler-cycle.mjs daily",
"scheduler:weekly": "node scripts/agent/run-scheduler-cycle.mjs weekly",
"report:lock-reliability": "node scripts/agent/summarize-lock-reliability.mjs --since-days 30",
diff --git a/scripts/validate-docs-links.mjs b/scripts/validate-docs-links.mjs
new file mode 100644
index 0000000..61df7f9
--- /dev/null
+++ b/scripts/validate-docs-links.mjs
@@ -0,0 +1,185 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const ROOT_DIR = path.resolve(__dirname, '..');
+
+const IGNORE_DIRS = [
+ 'node_modules',
+ '.git',
+ 'dist',
+ 'coverage',
+ 'test_logs',
+ 'task-logs',
+ 'reports',
+ 'artifacts',
+ 'bin'
+];
+
+const IGNORE_FILES = [
+ 'package-lock.json',
+ 'yarn.lock'
+];
+
+function getAllFiles(dirPath, arrayOfFiles) {
+ const files = fs.readdirSync(dirPath);
+
+ arrayOfFiles = arrayOfFiles || [];
+
+ files.forEach(function(file) {
+ if (IGNORE_DIRS.includes(file) || IGNORE_FILES.includes(file)) {
+ return;
+ }
+ const fullPath = path.join(dirPath, file);
+ if (fs.statSync(fullPath).isDirectory()) {
+ arrayOfFiles = getAllFiles(fullPath, arrayOfFiles);
+ } else {
+ if (file.endsWith('.md')) {
+ arrayOfFiles.push(fullPath);
+ }
+ }
+ });
+
+ return arrayOfFiles;
+}
+
+function slugify(text) {
+ return text
+ .toLowerCase()
+ .trim()
+ .replace(/[^\w\s-]/g, '') // Remove all non-word chars
+ .replace(/[\s_-]+/g, '-') // Replace spaces and underscores with -
+ .replace(/^-+|-+$/g, ''); // Remove leading/trailing -
+}
+
+function getAnchors(filePath) {
+ const content = fs.readFileSync(filePath, 'utf8');
+ const anchors = new Set();
+
+ // Markdown headings
+ const headingRegex = /^(#{1,6})\s+(.*)$/gm;
+ let match;
+ while ((match = headingRegex.exec(content)) !== null) {
+ anchors.add(slugify(match[2]));
+ }
+
+ // HTML anchors or
+ const htmlAnchorRegex = / {
+ const content = fs.readFileSync(file, 'utf8');
+ const relativeFilePath = path.relative(ROOT_DIR, file);
+
+ const processLink = (link) => {
+ link = link.trim();
+ // Remove title
+ if (link.includes(' "') || link.includes(" '")) {
+ const parts = link.split(/\s+["']/);
+ link = parts[0];
+ }
+ // Remove wrapping < >
+ if (link.startsWith('<') && link.endsWith('>')) {
+ link = link.slice(1, -1);
+ }
+ validateLink(link, file, relativeFilePath, brokenLinks);
+ };
+
+ // Regex for [text](url)
+ const inlineLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
+ let match;
+ while ((match = inlineLinkRegex.exec(content)) !== null) {
+ processLink(match[2]);
+ }
+
+ // Regex for [ref]: url
+ const refLinkRegex = /^\[([^\]]+)\]:\s*(\S+)/gm;
+ while ((match = refLinkRegex.exec(content)) !== null) {
+ processLink(match[2]);
+ }
+ });
+
+ return brokenLinks;
+}
+
+function validateLink(link, currentFile, relativeFilePath, brokenLinks) {
+ if (!link) return;
+ if (link.startsWith('http://') || link.startsWith('https://') || link.startsWith('mailto:') || link.startsWith('data:')) return;
+
+ const [linkPath, anchor] = link.split('#');
+
+ let targetPath;
+
+ if (!linkPath) {
+ // Internal anchor link: #header
+ targetPath = currentFile;
+ } else if (linkPath.startsWith('/')) {
+ targetPath = path.join(ROOT_DIR, linkPath);
+ } else {
+ targetPath = path.resolve(path.dirname(currentFile), linkPath);
+ }
+
+ if (!fs.existsSync(targetPath)) {
+ brokenLinks.push({
+ file: relativeFilePath,
+ link: link,
+ target: path.relative(ROOT_DIR, targetPath),
+ reason: 'File not found'
+ });
+ return;
+ }
+
+ // Check anchor if present and if target is a markdown file
+ if (anchor && targetPath.endsWith('.md')) {
+ const anchors = getFileAnchors(targetPath);
+ if (!anchors.has(anchor)) {
+ // Try decoding URI component just in case
+ try {
+ if (anchors.has(decodeURIComponent(anchor))) return;
+ } catch {
+ // Ignore decoding errors
+ }
+
+ brokenLinks.push({
+ file: relativeFilePath,
+ link: link,
+ target: path.relative(ROOT_DIR, targetPath),
+ reason: `Anchor '#${anchor}' not found`
+ });
+ }
+ }
+}
+
+console.log('Scanning for markdown files...');
+const files = getAllFiles(ROOT_DIR);
+console.log(`Found ${files.length} markdown files. Checking links (including anchors)...`);
+const brokenLinks = checkLinks(files);
+
+if (brokenLinks.length > 0) {
+ console.error(`Found ${brokenLinks.length} broken links:`);
+ brokenLinks.forEach(item => {
+ console.error(` ${item.file}: '${item.link}' -> ${item.reason}`);
+ });
+ process.exit(1);
+} else {
+ console.log('No broken links found.');
+}
diff --git a/src/todo/TODO_2026-02-20T02-09-00Z.md b/src/todo/TODO_2026-02-20T02-09-00Z.md
index 6202ea1..384387e 100644
--- a/src/todo/TODO_2026-02-20T02-09-00Z.md
+++ b/src/todo/TODO_2026-02-20T02-09-00Z.md
@@ -13,8 +13,8 @@ Executing prompt: `src/prompts/daily/docs-agent.md`
## Pending
- [ ] Verify package.json scripts against docs.
-- [ ] Check for broken links to missing docs.
## Completed
+- [x] Check for broken links to missing docs.
- [x] Initial audit of README.md and CONTRIBUTING.md.
- [x] Update docs-agent.md to remove stale audit targets.