project-librarian 0.2.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/dist/modes.js ADDED
@@ -0,0 +1,823 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.buildRefreshIndexBlock = buildRefreshIndexBlock;
37
+ exports.runQueryMode = runQueryMode;
38
+ exports.projectCandidatesContent = projectCandidatesContent;
39
+ exports.appendCaptureInbox = appendCaptureInbox;
40
+ exports.runIssueDraftMode = runIssueDraftMode;
41
+ exports.runIssueCreateMode = runIssueCreateMode;
42
+ exports.runPruneCheckMode = runPruneCheckMode;
43
+ exports.collectLinkDiagnostics = collectLinkDiagnostics;
44
+ exports.collectQualityDiagnostics = collectQualityDiagnostics;
45
+ exports.runLinkCheckMode = runLinkCheckMode;
46
+ exports.runQualityCheckMode = runQualityCheckMode;
47
+ exports.runDoctorMode = runDoctorMode;
48
+ exports.runLintMode = runLintMode;
49
+ const fs = __importStar(require("node:fs"));
50
+ const childProcess = __importStar(require("node:child_process"));
51
+ const os = __importStar(require("node:os"));
52
+ const path = __importStar(require("node:path"));
53
+ const args_1 = require("./args");
54
+ const workspace_1 = require("./workspace");
55
+ const templates_1 = require("./templates");
56
+ const wiki_files_1 = require("./wiki-files");
57
+ const scopedAutoIndexThreshold = 40;
58
+ const scopedAutoIndexMarker = "<!-- PROJECT-WIKI-SCOPED-AUTO-INDEX -->";
59
+ function isScopedAutoIndex(file) {
60
+ return /^wiki\/indexes\/auto-[a-z0-9-]+\.md$/.test(file);
61
+ }
62
+ function slugPart(value) {
63
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "misc";
64
+ }
65
+ function routeAreaForWikiFile(file) {
66
+ const base = path.basename(file, path.extname(file));
67
+ const parts = base.split(/[-_]+/).filter(Boolean);
68
+ if (parts.length >= 3 && ["apps", "libs", "packages", "services"].includes(parts[0] ?? ""))
69
+ return slugPart(parts.slice(0, 3).join("-"));
70
+ const wikiParts = file.replace(/^wiki\//, "").replace(/\.md$/, "").split(/[\\/]+/).filter(Boolean);
71
+ const routeParts = wikiParts[0] && ["canonical", "decisions", "inbox", "meta", "sources"].includes(wikiParts[0]) ? wikiParts.slice(1) : wikiParts;
72
+ const monorepoRootIndex = routeParts.findIndex((part) => ["apps", "libs", "packages", "services"].includes(part));
73
+ if (monorepoRootIndex >= 0 && routeParts[monorepoRootIndex + 1]) {
74
+ return slugPart(routeParts.slice(monorepoRootIndex, monorepoRootIndex + 2).join("-"));
75
+ }
76
+ const directory = path.dirname(file).replace(/^wiki\//, "");
77
+ if (directory && directory !== ".")
78
+ return slugPart(directory);
79
+ return "misc";
80
+ }
81
+ function scopedIndexPath(area) {
82
+ return `wiki/indexes/auto-${slugPart(area)}.md`;
83
+ }
84
+ function scopedIndexContent(area, files) {
85
+ const rows = files.map((file) => {
86
+ const meta = (0, wiki_files_1.metadataSummary)(file, (0, workspace_1.read)(file));
87
+ return `| ${(0, wiki_files_1.wikiLinkForFile)(file)} | ${meta.scope} | ${meta.status} | ${meta.budget} |`;
88
+ }).join("\n");
89
+ return `${(0, templates_1.metadata)("wiki-router", "medium", "wiki/meta/wiki-ops-v1-decisions.md", "auto-discovered scoped routes change")}${scopedAutoIndexMarker}
90
+ # Auto Index: ${area}
91
+
92
+ ## TL;DR
93
+
94
+ - Generated scoped router for auto-discovered wiki pages.
95
+ - Managed by \`--refresh-index\`; move durable routes into \`wiki/index.md\` when they become normal project routes.
96
+
97
+ | Document | Scope | Status | Token Budget |
98
+ | --- | --- | --- | --- |
99
+ ${rows}
100
+ `;
101
+ }
102
+ function removeStaleScopedAutoIndexes(keepPaths) {
103
+ if (!(0, workspace_1.exists)("wiki/indexes"))
104
+ return;
105
+ for (const file of (0, workspace_1.walkFilesUnder)("wiki/indexes", isScopedAutoIndex)) {
106
+ if (keepPaths.has(file))
107
+ continue;
108
+ if ((0, workspace_1.read)(file).includes(scopedAutoIndexMarker))
109
+ fs.unlinkSync((0, workspace_1.abs)(file));
110
+ }
111
+ }
112
+ function syncScopedAutoIndexes(files) {
113
+ const groups = new Map();
114
+ for (const file of files) {
115
+ const area = routeAreaForWikiFile(file);
116
+ groups.set(area, [...(groups.get(area) ?? []), file]);
117
+ }
118
+ const summaries = Array.from(groups.entries()).map(([area, areaFiles]) => ({
119
+ area,
120
+ count: areaFiles.length,
121
+ file: scopedIndexPath(area),
122
+ files: areaFiles.sort(),
123
+ })).sort((left, right) => right.count - left.count || left.area.localeCompare(right.area));
124
+ const keepPaths = new Set(summaries.map((summary) => summary.file));
125
+ removeStaleScopedAutoIndexes(keepPaths);
126
+ for (const summary of summaries)
127
+ (0, workspace_1.write)(summary.file, scopedIndexContent(summary.area, summary.files));
128
+ return summaries.map(({ area, count, file }) => ({ area, count, file }));
129
+ }
130
+ function buildRefreshIndexBlock() {
131
+ const indexText = (0, workspace_1.exists)("wiki/index.md") ? (0, workspace_1.read)("wiki/index.md") : "";
132
+ const comparableIndex = (0, wiki_files_1.stripMarkedSection)(indexText, "<!-- PROJECT-WIKI-AUTO-INDEX:START -->", "<!-- PROJECT-WIKI-AUTO-INDEX:END -->");
133
+ const files = (0, wiki_files_1.wikiMarkdownFiles)().filter((file) => !["wiki/index.md", "wiki/startup.md", "wiki/README.md"].includes(file) && !isScopedAutoIndex(file));
134
+ const missing = files.filter((file) => !comparableIndex.includes((0, wiki_files_1.wikiLinkForFile)(file)));
135
+ if (missing.length > scopedAutoIndexThreshold) {
136
+ const summaries = syncScopedAutoIndexes(missing);
137
+ const rows = summaries.map((summary) => `| ${(0, wiki_files_1.wikiLinkForFile)(summary.file)} | ${summary.area} | ${summary.count} |`).join("\n");
138
+ return `<!-- PROJECT-WIKI-AUTO-INDEX:START -->
139
+ ## Auto-Discovered Pages
140
+
141
+ This block is managed by \`--refresh-index\`. Large route sets are split into scoped generated routers to keep \`wiki/index.md\` within startup-hook budget.
142
+
143
+ | Scoped Router | Area | Pages |
144
+ | --- | --- | ---: |
145
+ ${rows}
146
+ <!-- PROJECT-WIKI-AUTO-INDEX:END -->`;
147
+ }
148
+ removeStaleScopedAutoIndexes(new Set());
149
+ const rows = missing.length === 0
150
+ ? "| none | - | - | - |\n"
151
+ : missing.map((file) => {
152
+ const meta = (0, wiki_files_1.metadataSummary)(file, (0, workspace_1.read)(file));
153
+ return `| ${(0, wiki_files_1.wikiLinkForFile)(file)} | ${meta.scope} | ${meta.status} | ${meta.budget} |`;
154
+ }).join("\n") + "\n";
155
+ return `<!-- PROJECT-WIKI-AUTO-INDEX:START -->
156
+ ## Auto-Discovered Pages
157
+
158
+ This block is managed by \`--refresh-index\`. Move useful rows into a hand-written section when they become part of the normal route.
159
+
160
+ | Document | Scope | Status | Token Budget |
161
+ | --- | --- | --- | --- |
162
+ ${rows}<!-- PROJECT-WIKI-AUTO-INDEX:END -->`;
163
+ }
164
+ function runQueryMode() {
165
+ if (!args_1.queryTerm.trim()) {
166
+ console.error("missing query: use --query \"search terms\"");
167
+ process.exit(1);
168
+ }
169
+ const terms = args_1.queryTerm.toLowerCase().split(/\s+/).filter(Boolean);
170
+ const results = (0, wiki_files_1.wikiMarkdownFiles)().map((file) => {
171
+ const text = (0, workspace_1.read)(file);
172
+ const body = (0, workspace_1.stripMetadataHeader)(text);
173
+ const title = (0, wiki_files_1.wikiTitleForFile)(file, text);
174
+ const meta = (0, wiki_files_1.metadataSummary)(file, text);
175
+ const weighted = `${file}\n${title}\n${meta.scope}\n${(0, workspace_1.metadataValue)(text, "tags")}\n${body}`.toLowerCase();
176
+ const score = terms.reduce((sum, term) => sum + (weighted.split(term).length - 1) + (file.toLowerCase().includes(term) ? 3 : 0) + (title.toLowerCase().includes(term) ? 5 : 0), 0);
177
+ return { file, title, score, ...meta };
178
+ }).filter((item) => item.score > 0).sort((a, b) => b.score - a.score || a.file.localeCompare(b.file)).slice(0, 10);
179
+ console.log(`Project wiki query: ${args_1.queryTerm}`);
180
+ if (results.length === 0)
181
+ console.log("no matches");
182
+ for (const item of results)
183
+ console.log(`${item.score.toString().padStart(3)} ${item.file} ${item.scope} ${item.status} ${item.title}`);
184
+ }
185
+ function projectCandidatesContent() {
186
+ return `${(0, templates_1.metadata)("inbox", "on-demand", "wiki/meta/wiki-ops-v1-decisions.md", "candidates are adopted, rejected, or stale")}
187
+ # Project Candidates Inbox
188
+
189
+ ## TL;DR
190
+
191
+ - This file temporarily stores project-canonical candidates from conversation.
192
+ - This file is not canonical truth.
193
+ - After review, move useful content into canonical/decision/source/meta docs or mark it rejected/resolved.
194
+
195
+ | Date | Title | Category | Content | Status |
196
+ | --- | --- | --- | --- | --- |
197
+ `;
198
+ }
199
+ function appendCaptureInbox() {
200
+ (0, workspace_1.mkdirp)("wiki/inbox");
201
+ const relativePath = "wiki/inbox/project-candidates.md";
202
+ const existed = (0, workspace_1.exists)(relativePath);
203
+ if (!existed)
204
+ (0, workspace_1.write)(relativePath, projectCandidatesContent());
205
+ if (!args_1.captureTitle && !args_1.captureContent)
206
+ return existed ? "exists" : "created";
207
+ const title = (args_1.captureTitle || "Untitled candidate").replace(/\|/g, "/");
208
+ const content = (args_1.captureContent || "").replace(/\r?\n/g, "<br>").replace(/\|/g, "/");
209
+ const row = `| ${workspace_1.today} | ${title} | ${args_1.captureCategory.replace(/\|/g, "/")} | ${content} | pending |`;
210
+ const current = (0, workspace_1.read)(relativePath);
211
+ if (current.includes(row))
212
+ return "exists";
213
+ (0, workspace_1.write)(relativePath, `${current.trimEnd()}\n${row}\n`);
214
+ return "updated";
215
+ }
216
+ function gitOutput(args) {
217
+ try {
218
+ return childProcess.execFileSync("git", args, {
219
+ cwd: workspace_1.root,
220
+ encoding: "utf8",
221
+ stdio: ["ignore", "pipe", "ignore"],
222
+ }).trim();
223
+ }
224
+ catch {
225
+ return "";
226
+ }
227
+ }
228
+ function markdownList(items, empty) {
229
+ if (items.length === 0)
230
+ return `- ${empty}`;
231
+ return items.map((item) => `- ${item}`).join("\n");
232
+ }
233
+ function redactedPath(value) {
234
+ if (!value || value === "unset" || value === "not a git repository")
235
+ return value;
236
+ return path.isAbsolute(value) ? "<absolute-path>" : value;
237
+ }
238
+ function runtimePackageVersion() {
239
+ try {
240
+ const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
241
+ return packageJson.version ?? "unknown";
242
+ }
243
+ catch {
244
+ return "unknown";
245
+ }
246
+ }
247
+ function existingFileList(files) {
248
+ return files.map((file) => `${(0, workspace_1.exists)(file) ? "[x]" : "[ ]"} \`${file}\``);
249
+ }
250
+ function issueReportTitle() {
251
+ const title = args_1.issueDraftTitle.replace(/\r?\n/g, " ").trim();
252
+ if (title)
253
+ return title;
254
+ return "Report project-librarian problem or side effect";
255
+ }
256
+ function issueDraftMarkdown() {
257
+ const gitRepo = (0, workspace_1.isGitRepository)();
258
+ const statusLines = gitRepo ? gitOutput(["status", "--short"]).split(/\r?\n/).filter(Boolean) : [];
259
+ const branch = gitRepo ? gitOutput(["rev-parse", "--abbrev-ref", "HEAD"]) || "unknown" : "not a git repository";
260
+ const hooksPath = gitRepo ? gitOutput(["config", "--get", "core.hooksPath"]) || "unset" : "not a git repository";
261
+ const remoteNames = gitRepo ? gitOutput(["remote"]).split(/\r?\n/).filter(Boolean) : [];
262
+ const generatedFiles = existingFileList([
263
+ "AGENTS.md",
264
+ "CLAUDE.md",
265
+ "wiki/AGENTS.md",
266
+ "wiki/startup.md",
267
+ "wiki/index.md",
268
+ ".codex/hooks.json",
269
+ ".codex/hooks/wiki-session-start.js",
270
+ ".claude/settings.json",
271
+ ".claude/hooks/wiki-session-start.js",
272
+ ".githooks/prepare-commit-msg",
273
+ ".githooks/wiki-commit-trailers.js",
274
+ ]);
275
+ const title = issueReportTitle();
276
+ const environment = [
277
+ `project-librarian version: ${runtimePackageVersion()}`,
278
+ `node version: ${process.version}`,
279
+ `working directory: ${redactedPath(workspace_1.root)}`,
280
+ `git branch: ${branch}`,
281
+ `git local changes: ${gitRepo ? statusLines.length : "not available"}`,
282
+ `git remotes configured: ${remoteNames.length}`,
283
+ `git core.hooksPath: ${redactedPath(hooksPath)}`,
284
+ ];
285
+ const verification = [
286
+ "Run `npx project-librarian --lint` and paste the output.",
287
+ "If generated wiki links or document quality are involved, run `npx project-librarian --doctor` and paste the output.",
288
+ "If the problem involves code evidence indexing, include the exact `--code-*` command and whether the runtime supports `node:sqlite`.",
289
+ ];
290
+ return `# ${title}
291
+
292
+ ## Summary
293
+
294
+ Describe the problem, side effect, confusing behavior, or edge case found while using project-librarian.
295
+
296
+ ## What You Were Trying To Do
297
+
298
+ - Command or natural-language skill request:
299
+ - Target project type:
300
+ - Expected project-librarian behavior:
301
+
302
+ ## What Happened Instead
303
+
304
+ - Actual behavior:
305
+ - Error output or surprising generated content:
306
+ - Whether rerunning changed the result:
307
+
308
+ ## Reproduction Steps
309
+
310
+ 1.
311
+ 2.
312
+ 3.
313
+
314
+ ## Side Effects Or Risk
315
+
316
+ - Files unexpectedly changed:
317
+ - Existing content that may have been overwritten or moved:
318
+ - Hooks, git config, or agent startup context affected:
319
+ - User-visible confusion or workflow breakage:
320
+
321
+ ## Affected Generated Files
322
+
323
+ ${markdownList(generatedFiles, "No standard generated files detected yet.")}
324
+
325
+ ## Environment
326
+
327
+ ${markdownList(environment, "Environment unavailable.")}
328
+
329
+ ## Diagnostics To Attach
330
+
331
+ ${markdownList(verification, "Add the exact validation commands and results before filing.")}
332
+
333
+ ## Workaround
334
+
335
+ - Current workaround, if any:
336
+ - Whether the workaround is safe to repeat:
337
+
338
+ ## Notes
339
+
340
+ - This draft is read-only and does not create a GitHub issue.
341
+ - To create a GitHub issue after explicit user approval, use \`project-librarian --issue-create --issue-title "${title.replace(/"/g, "\\\"")}"\` or \`gh issue create --title "${title.replace(/"/g, "\\\"")}" --body-file <draft.md>\`.
342
+ - If local git changes are present, try to reproduce on a clean checkout before filing when practical.
343
+ `;
344
+ }
345
+ function runIssueDraftMode() {
346
+ console.log(issueDraftMarkdown());
347
+ }
348
+ function githubRemoteConfigured() {
349
+ if (!(0, workspace_1.isGitRepository)())
350
+ return false;
351
+ const remotes = gitOutput(["remote", "-v"]);
352
+ return /github\.com[:/]/i.test(remotes);
353
+ }
354
+ function runGh(args) {
355
+ return childProcess.spawnSync("gh", args, {
356
+ cwd: workspace_1.root,
357
+ encoding: "utf8",
358
+ });
359
+ }
360
+ function printGhFailure(result, action) {
361
+ if (result.error)
362
+ console.error(`gh ${action} failed: ${result.error.message}`);
363
+ if (result.stdout)
364
+ process.stdout.write(result.stdout);
365
+ if (result.stderr)
366
+ process.stderr.write(result.stderr);
367
+ process.exit(result.status && result.status > 0 ? result.status : 1);
368
+ }
369
+ function issueBodyFilePath() {
370
+ if (args_1.issueBodyFile.trim())
371
+ return { file: path.resolve(workspace_1.root, args_1.issueBodyFile), cleanupDir: null };
372
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "project-wiki-issue-"));
373
+ const file = path.join(tempDir, "issue-body.md");
374
+ fs.writeFileSync(file, issueDraftMarkdown(), "utf8");
375
+ return { file, cleanupDir: tempDir };
376
+ }
377
+ function runIssueCreateMode() {
378
+ if (!(0, workspace_1.isGitRepository)()) {
379
+ console.error("--issue-create requires a git repository with a GitHub remote.");
380
+ process.exit(1);
381
+ }
382
+ if (!githubRemoteConfigured()) {
383
+ console.error("--issue-create requires a GitHub remote so gh can infer the target repository.");
384
+ process.exit(1);
385
+ }
386
+ const auth = runGh(["auth", "status"]);
387
+ if (auth.status !== 0 || auth.error)
388
+ printGhFailure(auth, "auth status");
389
+ const body = issueBodyFilePath();
390
+ try {
391
+ const created = runGh(["issue", "create", "--title", issueReportTitle(), "--body-file", body.file]);
392
+ if (created.status !== 0 || created.error)
393
+ printGhFailure(created, "issue create");
394
+ if (created.stdout)
395
+ process.stdout.write(created.stdout);
396
+ if (created.stderr)
397
+ process.stderr.write(created.stderr);
398
+ }
399
+ finally {
400
+ if (body.cleanupDir)
401
+ fs.rmSync(body.cleanupDir, { recursive: true, force: true });
402
+ }
403
+ }
404
+ function runPruneCheckMode() {
405
+ const candidates = [];
406
+ for (const file of (0, wiki_files_1.wikiMarkdownFiles)()) {
407
+ const text = (0, workspace_1.read)(file);
408
+ const status = (0, workspace_1.metadataValue)(text, "status");
409
+ const updated = (0, workspace_1.metadataValue)(text, "updated");
410
+ const trigger = (0, workspace_1.metadataValue)(text, "review_trigger");
411
+ const scope = (0, workspace_1.metadataValue)(text, "scope");
412
+ const body = (0, workspace_1.stripMetadataHeader)(text);
413
+ const reasons = [];
414
+ const lifecycleScope = /project-canonical|project-decisions|inbox|migration-inbox/.test(scope);
415
+ if (status === "active" && lifecycleScope && /pending|proposed|undecided|TODO|TBD|미정/i.test(body))
416
+ reasons.push("contains pending/proposed/undecided signal");
417
+ if (status === "active" && trigger && /stale|old|expired|due|오래|도래|만료/i.test(trigger))
418
+ reasons.push(`review trigger: ${trigger}`);
419
+ if (updated && updated < workspace_1.today && status === "active")
420
+ reasons.push(`updated before today: ${updated}`);
421
+ if (reasons.length > 0)
422
+ candidates.push({ file, status, updated, reasons });
423
+ }
424
+ console.log("Project wiki prune-check");
425
+ if (candidates.length === 0)
426
+ console.log("no candidates");
427
+ for (const item of candidates) {
428
+ console.log(`${item.file} status=${item.status || "-"} updated=${item.updated || "-"}`);
429
+ for (const reason of item.reasons)
430
+ console.log(` - ${reason}`);
431
+ }
432
+ }
433
+ function printDiagnostics(title, diagnostics, checked) {
434
+ console.log(title);
435
+ for (const item of diagnostics) {
436
+ console.log(`${item.severity} ${item.code} ${item.file} ${item.message}`);
437
+ }
438
+ const errors = diagnostics.filter((item) => item.severity === "error").length;
439
+ const warnings = diagnostics.length - errors;
440
+ if (errors > 0) {
441
+ console.log(`failed: ${errors} errors, ${warnings} warnings, ${checked} wiki markdown files checked`);
442
+ return false;
443
+ }
444
+ console.log(`passed: ${checked} wiki markdown files checked, ${warnings} warnings`);
445
+ return true;
446
+ }
447
+ function collectWikiLinkReferences(files) {
448
+ return files.flatMap((file) => (0, wiki_files_1.extractWikiLinks)(file, (0, workspace_1.read)(file)));
449
+ }
450
+ function collectLinkDiagnostics() {
451
+ const diagnostics = [];
452
+ const files = (0, wiki_files_1.wikiMarkdownFiles)();
453
+ const fileSet = new Set(files);
454
+ const links = collectWikiLinkReferences(files);
455
+ for (const link of links) {
456
+ if (!fileSet.has(link.normalizedTarget)) {
457
+ diagnostics.push({
458
+ code: "broken-link",
459
+ severity: "error",
460
+ file: link.file,
461
+ message: `${link.kind} ${link.target} resolves to missing ${link.normalizedTarget}`,
462
+ });
463
+ }
464
+ }
465
+ if ((0, workspace_1.exists)("wiki/index.md")) {
466
+ const indexLinks = (0, wiki_files_1.extractWikiLinks)("wiki/index.md", (0, workspace_1.read)("wiki/index.md"));
467
+ const indexTargets = new Map();
468
+ for (const link of indexLinks)
469
+ indexTargets.set(link.normalizedTarget, (indexTargets.get(link.normalizedTarget) ?? 0) + 1);
470
+ for (const [target, count] of indexTargets) {
471
+ if (count > 1) {
472
+ diagnostics.push({
473
+ code: "duplicate-route",
474
+ severity: "warn",
475
+ file: "wiki/index.md",
476
+ message: `${count} index routes resolve to ${target}`,
477
+ });
478
+ }
479
+ }
480
+ }
481
+ const incoming = new Map();
482
+ for (const link of links)
483
+ incoming.set(link.normalizedTarget, (incoming.get(link.normalizedTarget) ?? 0) + 1);
484
+ const orphanExemptions = new Set(["wiki/index.md", "wiki/startup.md", "wiki/README.md"]);
485
+ for (const file of files) {
486
+ if (orphanExemptions.has(file))
487
+ continue;
488
+ if ((incoming.get(file) ?? 0) === 0) {
489
+ diagnostics.push({
490
+ code: "orphan-page",
491
+ severity: "warn",
492
+ file,
493
+ message: "no incoming wiki links; route it from wiki/index.md or remove/merge it",
494
+ });
495
+ }
496
+ }
497
+ return diagnostics.sort((a, b) => a.severity.localeCompare(b.severity) || a.file.localeCompare(b.file) || a.code.localeCompare(b.code));
498
+ }
499
+ function legacyWikiRoots() {
500
+ if (!fs.existsSync(workspace_1.root))
501
+ return [];
502
+ return fs.readdirSync(workspace_1.root, { withFileTypes: true })
503
+ .filter((entry) => entry.isDirectory() && /^wiki_legacy(?:_|$)/.test(entry.name))
504
+ .map((entry) => entry.name)
505
+ .sort();
506
+ }
507
+ function normalizeMigrationCopyText(text) {
508
+ return (0, workspace_1.stripMetadataHeader)(text)
509
+ .replace(/<!--[\s\S]*?-->/g, " ")
510
+ .replace(/\r?\n/g, " ")
511
+ .replace(/\s+/g, " ")
512
+ .trim()
513
+ .toLowerCase();
514
+ }
515
+ function migrationCopyTokens(text) {
516
+ return normalizeMigrationCopyText(text).match(/[\p{L}\p{N}_./-]+/gu) ?? [];
517
+ }
518
+ function tokenOverlapScore(left, right) {
519
+ if (left.length === 0 || right.length === 0)
520
+ return 0;
521
+ const counts = new Map();
522
+ for (const token of right)
523
+ counts.set(token, (counts.get(token) ?? 0) + 1);
524
+ let overlap = 0;
525
+ for (const token of left) {
526
+ const count = counts.get(token) ?? 0;
527
+ if (count <= 0)
528
+ continue;
529
+ overlap += 1;
530
+ if (count === 1)
531
+ counts.delete(token);
532
+ else
533
+ counts.set(token, count - 1);
534
+ }
535
+ return overlap / Math.max(left.length, right.length);
536
+ }
537
+ function shouldGuardAgainstMigrationCopy(file, text) {
538
+ if (!/^wiki\/(?:canonical|decisions|sources)\//.test(file))
539
+ return false;
540
+ if (file.endsWith("/migration-inbox.md"))
541
+ return false;
542
+ const starter = templates_1.starterFiles[file];
543
+ return !starter || normalizeMigrationCopyText(starter) !== normalizeMigrationCopyText(text);
544
+ }
545
+ function migrationCopyDiagnostics(files) {
546
+ const roots = legacyWikiRoots();
547
+ if (roots.length === 0)
548
+ return [];
549
+ const guardedFiles = files.filter((file) => shouldGuardAgainstMigrationCopy(file, (0, workspace_1.read)(file)));
550
+ if (guardedFiles.length === 0)
551
+ return [];
552
+ const legacyEntries = roots
553
+ .flatMap((legacyRoot) => (0, wiki_files_1.walkMarkdownFiles)((0, workspace_1.abs)(legacyRoot), [], (0, workspace_1.abs)(legacyRoot)))
554
+ .map((legacyFile) => {
555
+ const text = (0, workspace_1.read)(legacyFile.path);
556
+ return {
557
+ file: legacyFile.path,
558
+ basePath: legacyFile.basePath,
559
+ basename: path.basename(legacyFile.basePath).toLowerCase(),
560
+ normalized: normalizeMigrationCopyText(text),
561
+ tokens: migrationCopyTokens(text),
562
+ };
563
+ })
564
+ .filter((entry) => entry.normalized.length >= 200);
565
+ const diagnostics = [];
566
+ for (const file of guardedFiles) {
567
+ const text = (0, workspace_1.read)(file);
568
+ const normalized = normalizeMigrationCopyText(text);
569
+ if (normalized.length < 200)
570
+ continue;
571
+ const tokens = migrationCopyTokens(text);
572
+ const basename = path.basename(file).toLowerCase();
573
+ const relativeWithinWiki = file.replace(/^wiki\//, "");
574
+ for (const legacy of legacyEntries) {
575
+ if (normalized === legacy.normalized) {
576
+ diagnostics.push({
577
+ code: "migration-copy-risk",
578
+ severity: "error",
579
+ file,
580
+ message: `body matches legacy document ${legacy.file}; rewrite project truth instead of copying legacy files`,
581
+ });
582
+ break;
583
+ }
584
+ if (tokens.length >= 80 && legacy.tokens.length >= 80) {
585
+ const score = tokenOverlapScore(tokens, legacy.tokens);
586
+ if (score >= 0.92) {
587
+ diagnostics.push({
588
+ code: "migration-copy-risk",
589
+ severity: "error",
590
+ file,
591
+ message: `body is ${Math.round(score * 100)}% token-similar to legacy document ${legacy.file}; rewrite and cite current-project evidence`,
592
+ });
593
+ break;
594
+ }
595
+ }
596
+ if (relativeWithinWiki === legacy.basePath || basename === legacy.basename) {
597
+ diagnostics.push({
598
+ code: "migration-filename-reuse",
599
+ severity: "warn",
600
+ file,
601
+ message: `filename also exists in legacy document ${legacy.file}; verify this is a rewrite, not a file copy`,
602
+ });
603
+ break;
604
+ }
605
+ }
606
+ }
607
+ return diagnostics;
608
+ }
609
+ function collectQualityDiagnostics() {
610
+ const diagnostics = [];
611
+ const files = (0, wiki_files_1.wikiMarkdownFiles)();
612
+ const titles = new Map();
613
+ for (const file of files) {
614
+ const text = (0, workspace_1.read)(file);
615
+ const body = (0, workspace_1.stripMetadataHeader)(text);
616
+ const title = (0, wiki_files_1.wikiTitleForFile)(file, text).toLowerCase();
617
+ titles.set(title, [...(titles.get(title) ?? []), file]);
618
+ const status = (0, workspace_1.metadataValue)(text, "status");
619
+ const updated = (0, workspace_1.metadataValue)(text, "updated");
620
+ const scope = (0, workspace_1.metadataValue)(text, "scope");
621
+ const budget = (0, workspace_1.metadataValue)(text, "read_budget");
622
+ const tldrExpected = !/startup-router|wiki-router|wiki-entry|project-decision-template/.test(scope);
623
+ if (tldrExpected && !/##\s+TL;DR/.test(body)) {
624
+ diagnostics.push({ code: "missing-tldr", severity: "warn", file, message: "add a compact TL;DR near the top" });
625
+ }
626
+ if (status === "active" && updated && updated < workspace_1.today && /project-canonical|project-decisions|source-summary|wiki-meta/.test(scope)) {
627
+ diagnostics.push({ code: "stale-review", severity: "warn", file, message: `updated before today: ${updated}` });
628
+ }
629
+ if (status === "active" && !/inbox|migration-inbox/.test(scope) && /proposed|undecided|TODO|TBD|미정/i.test(body)) {
630
+ diagnostics.push({ code: "unresolved-signal", severity: "warn", file, message: "contains pending/proposed/undecided language" });
631
+ }
632
+ const shortLimit = file === "wiki/index.md" ? 4500 : 3500;
633
+ if (budget === "short" && text.length > shortLimit) {
634
+ diagnostics.push({ code: "budget-drift", severity: "warn", file, message: `${text.length}/${shortLimit} chars for short read_budget` });
635
+ }
636
+ else if (budget === "medium" && text.length > 8000) {
637
+ diagnostics.push({ code: "budget-drift", severity: "warn", file, message: `${text.length}/8000 chars for medium read_budget` });
638
+ }
639
+ if (file.startsWith("wiki/canonical/") && /Code-proven behavior:/i.test(body) && !/evidence:\s*`?[\w./-]+/i.test(body)) {
640
+ diagnostics.push({ code: "missing-evidence", severity: "warn", file, message: "code-proven canonical claims should cite concrete evidence paths" });
641
+ }
642
+ if (scope === "source-summary" && !/https?:\/\//.test(body)) {
643
+ diagnostics.push({ code: "missing-source-link", severity: "warn", file, message: "source summaries should retain at least one source URL" });
644
+ }
645
+ }
646
+ for (const [title, titleFiles] of titles) {
647
+ if (titleFiles.length > 1) {
648
+ for (const file of titleFiles) {
649
+ diagnostics.push({ code: "duplicate-title", severity: "warn", file, message: `title also appears in ${titleFiles.filter((item) => item !== file).join(", ")}` });
650
+ }
651
+ }
652
+ }
653
+ diagnostics.push(...migrationCopyDiagnostics(files));
654
+ return diagnostics.sort((a, b) => a.file.localeCompare(b.file) || a.code.localeCompare(b.code));
655
+ }
656
+ function runLinkCheckMode() {
657
+ const ok = printDiagnostics("Project wiki link-check", collectLinkDiagnostics(), (0, wiki_files_1.wikiMarkdownFiles)().length);
658
+ if (!ok)
659
+ process.exit(1);
660
+ }
661
+ function runQualityCheckMode() {
662
+ const ok = printDiagnostics("Project wiki quality-check", collectQualityDiagnostics(), (0, wiki_files_1.wikiMarkdownFiles)().length);
663
+ if (!ok)
664
+ process.exit(1);
665
+ }
666
+ function runDoctorMode(fix) {
667
+ if (fix) {
668
+ console.log("Project wiki doctor --fix");
669
+ if ((0, workspace_1.exists)("wiki/index.md")) {
670
+ (0, workspace_1.upsertMarkedSection)("wiki/index.md", "<!-- PROJECT-WIKI-AUTO-INDEX:START -->", "<!-- PROJECT-WIKI-AUTO-INDEX:END -->", buildRefreshIndexBlock());
671
+ console.log("updated wiki/index.md auto-discovered pages");
672
+ }
673
+ else {
674
+ console.log("skipped wiki/index.md auto-discovered pages: missing wiki/index.md");
675
+ }
676
+ }
677
+ const files = (0, wiki_files_1.wikiMarkdownFiles)();
678
+ const linkOk = printDiagnostics("Project wiki link-check", collectLinkDiagnostics(), files.length);
679
+ const qualityOk = printDiagnostics("Project wiki quality-check", collectQualityDiagnostics(), files.length);
680
+ runLintMode();
681
+ if (!linkOk || !qualityOk)
682
+ process.exit(1);
683
+ }
684
+ function runLintMode() {
685
+ const errors = [];
686
+ const warnings = [];
687
+ const requiredFiles = [
688
+ "AGENTS.md",
689
+ "CLAUDE.md",
690
+ "wiki/AGENTS.md",
691
+ "wiki/startup.md",
692
+ "wiki/index.md",
693
+ "wiki/canonical/project-brief.md",
694
+ "wiki/canonical/open-questions.md",
695
+ "wiki/canonical/assumptions.md",
696
+ "wiki/canonical/risks.md",
697
+ "wiki/decisions/log.md",
698
+ "wiki/decisions/recent.md",
699
+ "wiki/meta/operating-model.md",
700
+ "wiki/meta/decision-policy.md",
701
+ "wiki/meta/wiki-ops-v1-decisions.md",
702
+ ".githooks/prepare-commit-msg",
703
+ ".githooks/wiki-commit-trailers.js",
704
+ ".codex/hooks/wiki-session-start.js",
705
+ ".codex/hooks.json",
706
+ ".claude/hooks/wiki-session-start.js",
707
+ ".claude/settings.json",
708
+ ];
709
+ for (const file of requiredFiles) {
710
+ if (!(0, workspace_1.exists)(file))
711
+ errors.push(`missing required file: ${file}`);
712
+ }
713
+ const files = (0, wiki_files_1.wikiMarkdownFiles)();
714
+ const requiredMetadataKeys = ["status", "updated", "scope", "read_budget", "decision_ref", "review_trigger"];
715
+ for (const file of files) {
716
+ const text = (0, workspace_1.read)(file);
717
+ if (!(0, workspace_1.hasMetadataHeader)(text)) {
718
+ errors.push(`missing metadata header: ${file}`);
719
+ continue;
720
+ }
721
+ for (const key of requiredMetadataKeys) {
722
+ if (!(0, workspace_1.metadataValue)(text, key))
723
+ errors.push(`missing metadata key ${key}: ${file}`);
724
+ }
725
+ }
726
+ const startupLength = (0, workspace_1.exists)("wiki/startup.md") ? (0, workspace_1.read)("wiki/startup.md").length : 0;
727
+ const indexLength = (0, workspace_1.exists)("wiki/index.md") ? (0, workspace_1.read)("wiki/index.md").length : 0;
728
+ if (startupLength > 3500)
729
+ warnings.push(`startup exceeds hook budget: ${startupLength}/3500 chars`);
730
+ if (indexLength > 4500)
731
+ warnings.push(`index exceeds hook budget: ${indexLength}/4500 chars`);
732
+ if ((0, workspace_1.exists)("wiki/startup.md") && /##\s+Always Read First/.test((0, workspace_1.read)("wiki/startup.md")))
733
+ warnings.push("startup uses Always Read First; prefer Read On Demand routing");
734
+ if ((0, workspace_1.exists)("AGENTS.md") && !(0, workspace_1.read)("AGENTS.md").includes("wiki/AGENTS.md"))
735
+ warnings.push("root AGENTS.md should point detailed wiki editing rules to wiki/AGENTS.md");
736
+ if ((0, workspace_1.exists)("CLAUDE.md") && !(0, workspace_1.read)("CLAUDE.md").includes("@AGENTS.md"))
737
+ errors.push("CLAUDE.md should import AGENTS.md for Claude Code compatibility");
738
+ if ((0, workspace_1.exists)("wiki/AGENTS.md") && !(0, workspace_1.read)("wiki/AGENTS.md").includes("Language policy"))
739
+ warnings.push("wiki/AGENTS.md is missing language policy");
740
+ for (const legacyFile of ["wiki/canonical/wiki-operating-model.md", "wiki/canonical/decision-policy.md", "wiki/decisions/wiki-v1-decisions.md"]) {
741
+ if ((0, workspace_1.exists)(legacyFile))
742
+ errors.push(`legacy wiki-ops file must move out of project canonical/decisions: ${legacyFile}`);
743
+ }
744
+ if ((0, workspace_1.exists)(".codex/hooks/wiki-session-start.js")) {
745
+ const hook = (0, workspace_1.read)(".codex/hooks/wiki-session-start.js");
746
+ if (!hook.includes('["wiki/startup.md", 3500]') || !hook.includes('["wiki/index.md", 4500]'))
747
+ errors.push("startup hook does not clearly inject only startup/index with expected budgets");
748
+ }
749
+ if ((0, workspace_1.exists)(".claude/hooks/wiki-session-start.js")) {
750
+ const hook = (0, workspace_1.read)(".claude/hooks/wiki-session-start.js");
751
+ if (!hook.includes('["wiki/startup.md", 3500]') || !hook.includes('["wiki/index.md", 4500]'))
752
+ errors.push("Claude startup hook does not clearly inject only startup/index with expected budgets");
753
+ }
754
+ if ((0, workspace_1.exists)(".claude/settings.json")) {
755
+ const command = "node .claude/hooks/wiki-session-start.js";
756
+ try {
757
+ const settings = (0, workspace_1.parseJson)(".claude/settings.json", { hooks: {} });
758
+ if (!settings.hooks || typeof settings.hooks !== "object" || Array.isArray(settings.hooks)) {
759
+ throw new Error(".claude/settings.json has invalid hooks object");
760
+ }
761
+ const sessionStart = settings.hooks.SessionStart ?? [];
762
+ const configuredMatchers = new Set(sessionStart
763
+ .filter((entry) => Array.isArray(entry.hooks) && entry.hooks.some((hook) => hook.command === command))
764
+ .map((entry) => entry.matcher));
765
+ for (const matcher of ["startup", "resume", "clear", "compact"]) {
766
+ if (!configuredMatchers.has(matcher))
767
+ errors.push(`.claude/settings.json is missing the project wiki SessionStart hook for ${matcher}`);
768
+ }
769
+ }
770
+ catch (error) {
771
+ const message = error instanceof Error ? error.message : String(error);
772
+ errors.push(message);
773
+ }
774
+ }
775
+ for (const file of [".githooks/prepare-commit-msg", ".githooks/wiki-commit-trailers.js"]) {
776
+ if ((0, workspace_1.exists)(file) && (fs.statSync((0, workspace_1.abs)(file)).mode & 0o111) === 0)
777
+ errors.push(`${file} is not executable`);
778
+ }
779
+ if ((0, workspace_1.isGitRepository)() && !args_1.noGitConfigMode) {
780
+ let hooksPath = "";
781
+ try {
782
+ hooksPath = childProcess.execFileSync("git", ["config", "--get", "core.hooksPath"], {
783
+ cwd: workspace_1.root,
784
+ encoding: "utf8",
785
+ stdio: ["ignore", "pipe", "ignore"],
786
+ }).trim();
787
+ }
788
+ catch {
789
+ hooksPath = "";
790
+ }
791
+ if (hooksPath !== ".githooks")
792
+ warnings.push(`git core.hooksPath is not .githooks: ${hooksPath || "unset"}`);
793
+ }
794
+ if ((0, workspace_1.exists)("wiki/index.md") && !(0, workspace_1.read)("wiki/index.md").includes("## Language Policy"))
795
+ errors.push("index is missing Language Policy section");
796
+ if ((0, workspace_1.exists)("wiki/canonical/glossary.md")) {
797
+ const glossaryText = (0, workspace_1.read)("wiki/canonical/glossary.md");
798
+ if (!(0, wiki_files_1.hasGlossaryTable)(glossaryText))
799
+ errors.push("glossary is missing required table header: | Term | Definition | Avoid | Related Canonical Doc | Status |");
800
+ if ((0, workspace_1.exists)("wiki/index.md") && !(0, workspace_1.read)("wiki/index.md").includes("[[canonical/glossary]]"))
801
+ errors.push("glossary exists but index is missing glossary routing");
802
+ }
803
+ else if ((0, wiki_files_1.hasGlossaryNeedSignal)((0, wiki_files_1.canonicalBodyForLint)())) {
804
+ warnings.push("project canonical docs contain naming/model signals; consider running --glossary-init");
805
+ }
806
+ if ((0, workspace_1.exists)("wiki/meta/wiki-ops-v1-decisions.md")) {
807
+ const ops = (0, workspace_1.read)("wiki/meta/wiki-ops-v1-decisions.md");
808
+ for (const phrase of ["metadata headers", "Read On Demand", "language", "--no-git-config", "needs-human-review", "Wiki-scope"]) {
809
+ if (!ops.includes(phrase))
810
+ warnings.push(`wiki ops decision pack may be missing decision phrase: ${phrase}`);
811
+ }
812
+ }
813
+ console.log("Project wiki lint");
814
+ for (const warning of warnings)
815
+ console.log(`warn ${warning}`);
816
+ for (const error of errors)
817
+ console.log(`error ${error}`);
818
+ if (errors.length > 0) {
819
+ console.log(`failed: ${errors.length} errors, ${warnings.length} warnings`);
820
+ process.exit(1);
821
+ }
822
+ console.log(`passed: ${files.length} wiki markdown files checked, ${warnings.length} warnings`);
823
+ }