project-librarian 0.5.4 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -64,7 +64,7 @@ npx project-librarian@latest install --scope user --agents all
64
64
  - **검토 가능한 위키 그래프.** `--wiki-visualize`는 `.project-wiki/` 아래에 독립 실행형 HTML 그래프를 작성해, 시작 컨텍스트를 늘리지 않고 페이지 유형·라우터 깊이·역링크·결정 참조를 보여줍니다.
65
65
  - **막연한 표현이 아니라 측정값.** 모든 성능 주장은 격리된 Codex 벤치마크에서 나오며, 손해를 본 경우도 이긴 경우 바로 옆에 보여 줍니다.
66
66
  - **로컬 세션 핸드오프.** `--handoff-save`는 `.project-wiki/session/` 아래에 생성형 재개 메모를 저장합니다. 시작 훅은 존재만 알려주므로 실행 로그가 canonical 위키 정본으로 섞이지 않습니다.
67
- - **선택적 코드 근거.** 재생성 가능한 SQLite 인덱스와 답변 형태 MCP 도구가 영향·소유권·워크스페이스 그래프 질문에 답하며, 추가 런타임 의존성이 전혀 없습니다.
67
+ - **선택적 코드 근거.** 재생성 가능한 SQLite 인덱스와 답변 형태 MCP 도구가 영향·소유권·워크스페이스 그래프 질문에 답하며, MCP SDK 의존성은 추가하지 않습니다.
68
68
  - **다시 실행해도 안전.** 부트스트랩은 멱등하고 기존 내용 보존을 우선하며, 진단은 깨진 경로·도달 불가 페이지·오래된 사실을 에이전트가 오해하기 전에 잡아냅니다.
69
69
 
70
70
  ## 존재 이유
@@ -297,7 +297,9 @@ MCP 서버 등록 (`mcpServers`에 기존 항목 보존하며 병합):
297
297
 
298
298
  ## 코드 근거 MCP 서버
299
299
 
300
- `project-librarian mcp`는 직접 구현한 stdio MCP 서버(줄바꿈 구분 JSON 위의 JSON-RPC 2.0, 추가 런타임 의존성 없음)를 실행해 기존 `.project-wiki` 코드 근거 인덱스를 읽기 전용으로 제공합니다. 답변 형태 도구 — `code_context_pack`, `code_impact`, `code_ownership`(CODEOWNERS 마지막 일치 우선순위), `code_workspace_graph`, `code_search`, `code_status` — 를 노출하며, 각 응답은 한 줄 답변으로 시작해 간결한 경로/심볼/시그니처 근거가 뒤따르고, 응답마다 길이를 제한하며, `code_status`가 인덱스가 오래되었다고 보고하면 경고를 앞에 붙입니다.
300
+ `project-librarian mcp`는 직접 구현한 stdio MCP 서버(줄바꿈 구분 JSON 위의 JSON-RPC 2.0, MCP SDK 의존성 없음)를 실행해 기존 `.project-wiki` 코드 근거 인덱스를 읽기 전용으로 제공합니다. 패키지의 필수 런타임 의존성은 `typescript`이며, 코드 근거 기능은 Node의 `node:sqlite`도 사용하고 Tree-sitter 문법 패키지는 선택 사항입니다. 답변 형태 도구 — `code_context_pack`, `code_impact`, `code_ownership`(CODEOWNERS 마지막 일치 우선순위), `code_workspace_graph`, `code_search`, `code_status` — 를 노출하며, 각 응답은 한 줄 답변으로 시작해 간결한 경로/심볼/시그니처 근거가 뒤따르고, 응답마다 길이를 제한하며, `code_status`가 인덱스가 오래되었다고 보고하면 경고를 앞에 붙입니다.
301
+
302
+ `--code-report`, `--code-impact`, `--code-context-pack`, MCP 도구 출력을 현재 코드 구조 근거로 인용하기 전에는 `project-librarian --code-status` 또는 MCP `code_status`를 실행해 `stale_files: 0`인지 확인하세요. 오래된 보고서는 재빌드가 필요하다는 신호이지 권위 있는 프로젝트 진실이 아닙니다.
301
303
 
302
304
  서버는 고정 리소스 — `project-librarian://wiki/startup`, `project-librarian://wiki/index`, `project-librarian://code/status` — 와 위키 분류 갱신, 코드 영향 추적, 유지보수 개선 검토, 검색 품질 검토용 프롬프트 템플릿도 제공합니다. 리소스 읽기는 임의 파일 경로가 아니라 고정 URI 레지스트리에서만 처리합니다.
303
305
 
@@ -431,6 +433,8 @@ npm pack --dry-run
431
433
 
432
434
  `npm run test:coverage`는 Node 내장 test coverage에 보수적인 line, branch, function threshold를 적용하므로 coverage를 단순 보고서가 아니라 회귀 게이트로 사용합니다.
433
435
 
436
+ 지원 런타임 하한은 Node.js 22.13+입니다. TypeScript가 지원 사용자에게 없는 API를 허용하지 않도록 개발용 타입 정의는 Node 22 지원 계약에 맞추거나 Node 22 호환성 검사를 함께 둬야 합니다.
437
+
434
438
  `npm run release:check`는 로컬 전용 관리자 게이트입니다. 테스트, Node 내장 coverage, 벤치마크 파서 smoke, real-corpus 오프라인 데모, 벤치마크 release preview, 벤치마크 claim ledger 분류, raw 보관 상태 감사, package dry-run 검사, dist 실행 가능 여부와 소스 동기화, README 벤치마크 claim 경계 문구를 확인합니다. publish하지 않고 raw 벤치마크 산출물을 삭제하지 않으며 measured Codex 벤치마크도 실행하지 않습니다.
435
439
 
436
440
  `release:check` 통과는 런타임 보증이 아니라 재현 가능한 릴리스 준비 근거로 봐야 합니다. 현재 checkout에서 위 로컬 게이트를 통과했음을 증명하며, package dry run이 예상 publish 경계(`agents/`, `dist/`, `LICENSE`, `README.md`, `README.ko.md`, `SKILL.md`) 안에 머물고 소스 파일, 테스트, 저장소 로컬 위키/워크플로 상태, raw 벤치마크 출력, 로컬 캐시를 제외하는지도 확인합니다.
package/README.md CHANGED
@@ -64,7 +64,7 @@ npx project-librarian@latest install --scope user --agents all
64
64
  - **Inspectable wiki graph.** `--wiki-visualize` writes a self-contained HTML graph under `.project-wiki/`, showing page types, router depth, backlinks, and decision references without adding to startup context.
65
65
  - **Measured, not hand-wavy.** Every performance claim comes from hermetic Codex benchmarks — and the cases where it costs *more* are shown right next to the wins.
66
66
  - **Local session handoff.** `--handoff-save` writes generated resume notes under `.project-wiki/session/`; startup hooks only point to it, so rolling execution memory does not become canonical wiki truth.
67
- - **Optional code evidence.** A regenerable SQLite index plus answer-shaped MCP tools answer impact, ownership, and workspace-graph questions, with zero extra runtime dependencies.
67
+ - **Optional code evidence.** A regenerable SQLite index plus answer-shaped MCP tools answer impact, ownership, and workspace-graph questions without adding an MCP SDK dependency.
68
68
  - **Safe to re-run.** Bootstrap is idempotent and preservation-first; diagnostics flag broken routes, unreachable pages, and stale truth before they mislead an agent.
69
69
 
70
70
  ## Why It Exists
@@ -297,7 +297,9 @@ Disposable code evidence cache:
297
297
 
298
298
  ## Code Evidence MCP Server
299
299
 
300
- `project-librarian mcp` runs a hand-rolled stdio MCP server (JSON-RPC 2.0 over newline-delimited JSON, no extra runtime dependencies) that serves the existing `.project-wiki` code-evidence index read-only. It exposes answer-shaped tools — `code_context_pack`, `code_impact`, `code_ownership` (CODEOWNERS last-match precedence), `code_workspace_graph`, `code_search`, and `code_status` — whose responses lead with a one-line answer, follow with compact path/symbol/signature evidence, cap each reply, and prepend a warning when `code_status` reports the index is stale.
300
+ `project-librarian mcp` runs a hand-rolled stdio MCP server (JSON-RPC 2.0 over newline-delimited JSON, no MCP SDK dependency) that serves the existing `.project-wiki` code-evidence index read-only. The package's hard runtime dependency is `typescript`; code evidence also uses Node's `node:sqlite`, with Tree-sitter grammars remaining optional. The server exposes answer-shaped tools — `code_context_pack`, `code_impact`, `code_ownership` (CODEOWNERS last-match precedence), `code_workspace_graph`, `code_search`, and `code_status` — whose responses lead with a one-line answer, follow with compact path/symbol/signature evidence, cap each reply, and prepend a warning when `code_status` reports the index is stale.
301
+
302
+ Before citing `--code-report`, `--code-impact`, `--code-context-pack`, or MCP tool output as current code-structure evidence, run `project-librarian --code-status` or MCP `code_status` and require `stale_files: 0`. Stale reports are pointers for rebuild, not authoritative project truth.
301
303
 
302
304
  The server also exposes fixed resources — `project-librarian://wiki/startup`, `project-librarian://wiki/index`, and `project-librarian://code/status` — plus prompt templates for wiki taxonomy updates, code impact traces, maintenance improvement reviews, and retrieval quality reviews. Resource reads come from a fixed URI registry rather than arbitrary filesystem paths.
303
305
 
@@ -431,6 +433,8 @@ When editing TypeScript under `src/`, rebuild before committing so `dist/` stays
431
433
 
432
434
  `npm run test:coverage` uses Node's native test coverage with conservative line, branch, and function thresholds so coverage is a regression gate, not only a report.
433
435
 
436
+ The supported runtime floor is Node.js 22.13+. Development type definitions should stay aligned with the Node 22 support contract or be backed by a Node 22 compatibility check so TypeScript does not admit APIs unavailable to supported users.
437
+
434
438
  `npm run release:check` is a local-only maintainer gate: it runs tests, native Node coverage, benchmark parser smoke, the real-corpus offline demo, benchmark release preview, benchmark claim-ledger classification, raw hygiene audit, package dry-run inspection, dist executable/parity checks, and README benchmark-claim boundary checks. It never publishes, never deletes raw benchmark artifacts, and never launches a measured Codex benchmark.
435
439
 
436
440
  Treat a green `release:check` as a reproducible release-readiness bundle, not a runtime guarantee: it proves those local gates on the current checkout, including that the package dry run stays inside the expected publish boundary (`agents/`, `dist/`, `LICENSE`, `README.md`, `README.ko.md`, and `SKILL.md`) and excludes source files, tests, repo-local wiki/workflow state, raw benchmark output, and local caches.
@@ -52,8 +52,10 @@ function pathOwnerKey(filePath) {
52
52
  return parts[0] ?? ".";
53
53
  }
54
54
  function readJsonObject(relativePath) {
55
+ if (!(0, workspace_1.containedProjectFileStat)(relativePath))
56
+ return null;
55
57
  try {
56
- const parsed = JSON.parse(fs.readFileSync((0, workspace_1.abs)(relativePath), "utf8"));
58
+ const parsed = JSON.parse((0, workspace_1.read)(relativePath));
57
59
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
58
60
  }
59
61
  catch {
@@ -83,19 +85,19 @@ function workspacePatternCandidates(pattern) {
83
85
  const suffix = normalized.slice(starIndex + 1).replace(/^\/+/, "");
84
86
  const base = prefix || ".";
85
87
  const basePath = (0, workspace_1.abs)(base);
86
- if (!fs.existsSync(basePath) || !fs.statSync(basePath).isDirectory())
88
+ if (!(0, workspace_1.containedProjectDirectoryStat)(base))
87
89
  return [];
88
90
  return fs.readdirSync(basePath, { withFileTypes: true })
89
91
  .filter((entry) => entry.isDirectory())
90
92
  .map((entry) => (0, workspace_1.normalizePath)(path.join(base, entry.name, suffix)))
91
- .filter((candidate) => fs.existsSync((0, workspace_1.abs)(candidate)) && fs.statSync((0, workspace_1.abs)(candidate)).isDirectory());
93
+ .filter((candidate) => (0, workspace_1.containedProjectDirectoryStat)(candidate));
92
94
  }
93
95
  function workspacePackages() {
94
96
  const packages = new Map();
95
97
  for (const pattern of workspacePatternsFromRootPackage()) {
96
98
  for (const candidate of workspacePatternCandidates(pattern)) {
97
99
  const packageJsonPath = (0, workspace_1.normalizePath)(path.join(candidate, "package.json"));
98
- if (!fs.existsSync((0, workspace_1.abs)(packageJsonPath)))
100
+ if (!(0, workspace_1.containedProjectFileStat)(packageJsonPath))
99
101
  continue;
100
102
  const packageJson = readJsonObject(packageJsonPath);
101
103
  const packageName = typeof packageJson?.name === "string" ? packageJson.name : candidate;
@@ -119,9 +121,9 @@ function codeownerRules() {
119
121
  const files = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"];
120
122
  const rules = [];
121
123
  for (const filePath of files) {
122
- if (!fs.existsSync((0, workspace_1.abs)(filePath)))
124
+ if (!(0, workspace_1.containedProjectFileStat)(filePath))
123
125
  continue;
124
- const lines = fs.readFileSync((0, workspace_1.abs)(filePath), "utf8").split(/\r?\n/);
126
+ const lines = (0, workspace_1.read)(filePath).split(/\r?\n/);
125
127
  lines.forEach((lineText, index) => {
126
128
  const trimmed = lineText.trim();
127
129
  if (!trimmed || trimmed.startsWith("#"))
@@ -4,17 +4,27 @@ exports.codeEvidenceNodeRuntimeRequirement = void 0;
4
4
  exports.loadDatabaseSync = loadDatabaseSync;
5
5
  exports.openDatabase = openDatabase;
6
6
  exports.codeEvidenceNodeRuntimeRequirement = "Node.js 22.13+ or 24+; node:sqlite was added in Node.js 22.5.0 and became available without --experimental-sqlite in Node.js 22.13.0";
7
+ function warningType(option) {
8
+ if (typeof option === "string")
9
+ return option;
10
+ if (typeof option !== "object" || option === null || !("type" in option))
11
+ return "";
12
+ const value = option.type;
13
+ return typeof value === "string" ? value : "";
14
+ }
15
+ function isSqliteExperimentalWarning(warning, options) {
16
+ const message = warning instanceof Error ? warning.message : typeof warning === "string" ? warning : "";
17
+ const type = warning instanceof Error ? warning.name : warningType(options[0]);
18
+ return type === "ExperimentalWarning" && message.includes("SQLite");
19
+ }
7
20
  function loadDatabaseSync(fail) {
8
- const previousListeners = process.listeners("warning");
9
- const suppressExperimentalSqliteWarning = (warning) => {
10
- if (warning.name !== "ExperimentalWarning" || !warning.message.includes("SQLite")) {
11
- for (const listener of previousListeners)
12
- listener.call(process, warning);
13
- }
14
- };
21
+ const previousEmitWarning = process.emitWarning;
15
22
  try {
16
- process.removeAllListeners("warning");
17
- process.on("warning", suppressExperimentalSqliteWarning);
23
+ process.emitWarning = ((warning, ...options) => {
24
+ if (isSqliteExperimentalWarning(warning, options))
25
+ return;
26
+ previousEmitWarning.call(process, warning, ...options);
27
+ });
18
28
  const sqlite = require("node:sqlite");
19
29
  return sqlite.DatabaseSync;
20
30
  }
@@ -23,9 +33,7 @@ function loadDatabaseSync(fail) {
23
33
  return fail(`code evidence index requires Node.js 22.13+ because it uses node:sqlite without experimental flags; current Node is ${process.version}. Runtime policy: ${exports.codeEvidenceNodeRuntimeRequirement}. Error: ${message}`);
24
34
  }
25
35
  finally {
26
- process.removeAllListeners("warning");
27
- for (const listener of previousListeners)
28
- process.on("warning", listener);
36
+ process.emitWarning = previousEmitWarning;
29
37
  }
30
38
  }
31
39
  function openDatabase(databasePath, fail) {
@@ -92,6 +92,8 @@ function isBlockedSensitiveConfigFile(relativePath) {
92
92
  const base = path.basename(relativePath).toLowerCase();
93
93
  if (base === ".env.example")
94
94
  return false;
95
+ if (base.startsWith("."))
96
+ return true;
95
97
  return /(^|[._-])(secret|secrets|credential|credentials|token|tokens|private|key|keys)([._-]|$)/i.test(base);
96
98
  }
97
99
  function isJavaScriptLike(relativePath) {
@@ -130,7 +132,9 @@ function walkCodeFiles(relativePath, files = []) {
130
132
  const target = (0, workspace_1.abs)(relativePath);
131
133
  if (!fs.existsSync(target))
132
134
  return files;
133
- const stat = fs.statSync(target);
135
+ const stat = fs.lstatSync(target);
136
+ if (stat.isSymbolicLink())
137
+ return files.sort();
134
138
  if (stat.isFile()) {
135
139
  if (stat.size <= exports.maxIndexedBytes && shouldIndexFile(relativePath))
136
140
  files.push(relativePath);
@@ -143,8 +147,8 @@ function walkCodeFiles(relativePath, files = []) {
143
147
  walkCodeFiles(child, files);
144
148
  }
145
149
  else if (entry.isFile() && shouldIndexFile(child)) {
146
- const childStat = fs.statSync((0, workspace_1.abs)(child));
147
- if (childStat.size <= exports.maxIndexedBytes)
150
+ const childStat = (0, workspace_1.containedProjectFileStat)(child);
151
+ if (childStat && childStat.size <= exports.maxIndexedBytes)
148
152
  files.push(child);
149
153
  }
150
154
  }
@@ -164,13 +168,7 @@ function gitTrackedAndUnignoredFiles(scopes) {
164
168
  }
165
169
  }
166
170
  function indexableFileStat(file) {
167
- try {
168
- const stat = fs.statSync((0, workspace_1.abs)(file));
169
- return stat.isFile() ? stat : null;
170
- }
171
- catch {
172
- return null;
173
- }
171
+ return (0, workspace_1.containedProjectFileStat)(file);
174
172
  }
175
173
  function discoverCodeFiles(scopes) {
176
174
  const gitFiles = gitTrackedAndUnignoredFiles(scopes);
@@ -114,7 +114,7 @@ function normalizedMtimeMs(stat) {
114
114
  return Number(stat.mtimeMs.toFixed(3));
115
115
  }
116
116
  function readCodeFileFingerprint(relativePath) {
117
- const stat = fs.statSync((0, workspace_1.abs)(relativePath));
117
+ const { stat } = (0, workspace_1.requireContainedProjectFile)(relativePath, "code-index file");
118
118
  return {
119
119
  mtimeMs: normalizedMtimeMs(stat),
120
120
  path: relativePath,
@@ -122,7 +122,8 @@ function readCodeFileFingerprint(relativePath) {
122
122
  };
123
123
  }
124
124
  function readCodeFile(relativePath, parserMode = "default") {
125
- const text = fs.readFileSync((0, workspace_1.abs)(relativePath), "utf8");
125
+ const { absolutePath } = (0, workspace_1.requireContainedProjectFile)(relativePath, "code-index file");
126
+ const text = fs.readFileSync(absolutePath, "utf8");
126
127
  const fingerprint = readCodeFileFingerprint(relativePath);
127
128
  const language = (0, code_index_file_policy_1.fileLanguage)(relativePath) || "config";
128
129
  return {
@@ -383,7 +384,7 @@ function prepareOutputPath() {
383
384
  const databasePath = codeEvidenceDatabasePath();
384
385
  (0, workspace_1.mkdirp)(path.dirname(databasePath.relativePath));
385
386
  (0, workspace_1.mkdirp)(code_index_file_policy_1.codeEvidenceDirectory);
386
- fs.writeFileSync((0, workspace_1.abs)(`${code_index_file_policy_1.codeEvidenceDirectory}/.gitignore`), "*\n!.gitignore\n");
387
+ (0, workspace_1.write)(`${code_index_file_policy_1.codeEvidenceDirectory}/.gitignore`, "*\n!.gitignore\n");
387
388
  }
388
389
  function codeIndexModeRuntime() {
389
390
  return {
package/dist/hooks.js CHANGED
@@ -372,15 +372,15 @@ exports.gitPrepareCommitMsgHook = `#!/bin/sh
372
372
  MSG_FILE="$1"
373
373
  SOURCE="$2"
374
374
 
375
+ # Security boundary: prepare-commit-msg runs before the commit is created, so it
376
+ # must not execute scripts from the mutable worktree. Trailer generation remains
377
+ # available as an explicit maintainer action, but this hook is intentionally
378
+ # passive.
375
379
  case "$SOURCE" in
376
- merge|squash|commit)
380
+ merge|squash|commit|*)
377
381
  exit 0
378
382
  ;;
379
383
  esac
380
-
381
- if command -v node >/dev/null 2>&1 && [ -f ".githooks/wiki-commit-trailers.js" ]; then
382
- node .githooks/wiki-commit-trailers.js "$MSG_FILE"
383
- fi
384
384
  `;
385
385
  exports.gitWikiCommitTrailersScript = `#!/usr/bin/env node
386
386
 
@@ -17,7 +17,7 @@ function codeIndex() {
17
17
  function printUsage() {
18
18
  console.log(`Usage:
19
19
  project-librarian [init|update] [options]
20
- project-librarian install [--scope user|project] [--agents codex|claude|cursor|gemini|all]
20
+ project-librarian install [--scope user|project] [--agents codex|claude|cursor|gemini|all] [--dry-run]
21
21
  project-librarian mcp
22
22
 
23
23
  Options:
@@ -34,6 +34,7 @@ Options:
34
34
  --issue-draft Print a problem/side-effect GitHub issue body draft.
35
35
  --issue-body-file <path> With --issue-create, use an existing Markdown body file.
36
36
  --issue-title <title> Override the generated issue draft title.
37
+ --dry-run With install, preview copied skill files without writing them.
37
38
  --query <terms> Search wiki paths, metadata, titles, and bodies (answer-shaped, capped output).
38
39
  --wiki-impact <page-or-term> Show wiki backlinks, decision_ref citations, and router depth for matching pages.
39
40
  --wiki-visualize Write a static wiki graph visualizer to .project-wiki/wiki-graph.html.
@@ -112,26 +112,119 @@ function installTarget(agent, scope) {
112
112
  const base = scope === "user" ? userAgentRoot(agent) : path.join(process.cwd(), projectAgentRoot(agent));
113
113
  return path.join(base, "skills", skillName);
114
114
  }
115
+ function assertNoTargetSymlink(targetRoot, target, includeLeaf) {
116
+ const rootResolved = path.resolve(targetRoot);
117
+ const targetResolved = path.resolve(target);
118
+ if (targetResolved !== rootResolved && !targetResolved.startsWith(`${rootResolved}${path.sep}`)) {
119
+ fail(`skill install target escaped target root: ${target}`);
120
+ }
121
+ if (fs.existsSync(rootResolved) && fs.lstatSync(rootResolved).isSymbolicLink()) {
122
+ fail("skill install refuses to follow destination symlink: .");
123
+ }
124
+ const relative = path.relative(rootResolved, targetResolved);
125
+ const parts = relative ? relative.split(path.sep).filter(Boolean) : [];
126
+ const checkedParts = includeLeaf ? parts : parts.slice(0, -1);
127
+ let current = rootResolved;
128
+ for (const part of checkedParts) {
129
+ current = path.join(current, part);
130
+ if (!fs.existsSync(current))
131
+ continue;
132
+ const stat = fs.lstatSync(current);
133
+ if (stat.isSymbolicLink()) {
134
+ fail(`skill install refuses to follow destination symlink: ${path.relative(rootResolved, current) || "."}`);
135
+ }
136
+ if (current !== targetResolved && !stat.isDirectory()) {
137
+ fail(`skill install target has a non-directory path component: ${path.relative(rootResolved, current)}`);
138
+ }
139
+ }
140
+ }
141
+ function isInsidePath(base, target) {
142
+ const baseResolved = path.resolve(base);
143
+ const targetResolved = path.resolve(target);
144
+ return targetResolved === baseResolved || targetResolved.startsWith(`${baseResolved}${path.sep}`);
145
+ }
146
+ function mkdirFromBaseNoSymlink(base, target) {
147
+ const baseResolved = path.resolve(base);
148
+ const targetResolved = path.resolve(target);
149
+ if (!isInsidePath(baseResolved, targetResolved)) {
150
+ fail(`skill install target escaped checked base: ${target}`);
151
+ }
152
+ let current = baseResolved;
153
+ const parts = path.relative(baseResolved, targetResolved).split(path.sep).filter(Boolean);
154
+ for (const part of parts) {
155
+ current = path.join(current, part);
156
+ if (fs.existsSync(current)) {
157
+ const stat = fs.lstatSync(current);
158
+ if (stat.isSymbolicLink()) {
159
+ fail(`skill install refuses to follow destination symlink: ${path.relative(baseResolved, current)}`);
160
+ }
161
+ if (!stat.isDirectory()) {
162
+ fail(`skill install target has a non-directory path component: ${path.relative(baseResolved, current)}`);
163
+ }
164
+ continue;
165
+ }
166
+ fs.mkdirSync(current);
167
+ }
168
+ }
169
+ function mkdirpNoTargetSymlink(targetRoot, target) {
170
+ const rootResolved = path.resolve(targetRoot);
171
+ const targetResolved = path.resolve(target);
172
+ const cwdResolved = path.resolve(process.cwd());
173
+ if (isInsidePath(cwdResolved, rootResolved)) {
174
+ mkdirFromBaseNoSymlink(cwdResolved, rootResolved);
175
+ }
176
+ else {
177
+ fs.mkdirSync(rootResolved, { recursive: true });
178
+ }
179
+ const relative = path.relative(rootResolved, targetResolved);
180
+ let current = rootResolved;
181
+ if (!relative) {
182
+ if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) {
183
+ fail("skill install refuses to follow destination symlink: .");
184
+ }
185
+ if (!fs.existsSync(current))
186
+ fs.mkdirSync(current);
187
+ return;
188
+ }
189
+ for (const part of relative.split(path.sep).filter(Boolean)) {
190
+ current = path.join(current, part);
191
+ if (fs.existsSync(current)) {
192
+ const stat = fs.lstatSync(current);
193
+ if (stat.isSymbolicLink()) {
194
+ fail(`skill install refuses to follow destination symlink: ${path.relative(rootResolved, current)}`);
195
+ }
196
+ if (!stat.isDirectory()) {
197
+ fail(`skill install target has a non-directory path component: ${path.relative(rootResolved, current)}`);
198
+ }
199
+ continue;
200
+ }
201
+ fs.mkdirSync(current);
202
+ }
203
+ }
115
204
  function sameFile(source, target) {
116
- if (!fs.existsSync(target) || !fs.statSync(target).isFile())
205
+ if (!fs.existsSync(target))
206
+ return false;
207
+ const stat = fs.lstatSync(target);
208
+ if (stat.isSymbolicLink() || !stat.isFile())
117
209
  return false;
118
210
  return fs.readFileSync(source).equals(fs.readFileSync(target));
119
211
  }
120
- function copyPath(source, target, dryRun) {
212
+ function copyPath(source, target, targetRoot, dryRun) {
121
213
  if (!fs.existsSync(source))
122
214
  fail(`missing package file: ${source}`);
123
215
  const existed = fs.existsSync(target);
124
216
  if (dryRun)
125
217
  return "dry-run";
126
218
  const sourceStat = fs.statSync(source);
219
+ assertNoTargetSymlink(targetRoot, target, true);
127
220
  if (sourceStat.isDirectory()) {
128
- fs.mkdirSync(target, { recursive: true });
221
+ mkdirpNoTargetSymlink(targetRoot, target);
129
222
  for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
130
- copyPath(path.join(source, entry.name), path.join(target, entry.name), false);
223
+ copyPath(path.join(source, entry.name), path.join(target, entry.name), targetRoot, false);
131
224
  }
132
225
  return existed ? "updated" : "created";
133
226
  }
134
- fs.mkdirSync(path.dirname(target), { recursive: true });
227
+ mkdirpNoTargetSymlink(targetRoot, path.dirname(target));
135
228
  if (sameFile(source, target))
136
229
  return "exists";
137
230
  fs.copyFileSync(source, target);
@@ -146,7 +239,7 @@ function copyPackageFiles(targetRoot, dryRun, labelRoot = targetRoot) {
146
239
  return packageFiles.map((relativePath) => {
147
240
  const source = path.join(root, relativePath);
148
241
  const target = path.join(targetRoot, relativePath);
149
- return [path.join(labelRoot, relativePath), copyPath(source, target, dryRun)];
242
+ return [path.join(labelRoot, relativePath), copyPath(source, target, targetRoot, dryRun)];
150
243
  });
151
244
  }
152
245
  function syncProjectSkillInstall(agent) {
@@ -1,10 +1,10 @@
1
1
  "use strict";
2
2
  // Hand-rolled MCP stdio server for the code-evidence index.
3
3
  //
4
- // Why hand-rolled: the product holds a zero-runtime-dependency posture (only
5
- // node:sqlite plus optional tree-sitter grammars). Adopting @modelcontextprotocol/sdk
6
- // would add a hard runtime dependency, so we implement the small slice of MCP we
7
- // need directly: JSON-RPC 2.0 over newline-delimited JSON on stdio.
4
+ // Why hand-rolled: the package keeps the MCP surface dependency-light. Code
5
+ // evidence already depends on node:sqlite, the package's TypeScript dependency,
6
+ // and optional tree-sitter grammars; adopting @modelcontextprotocol/sdk would add
7
+ // another hard runtime dependency for a small stdio slice.
8
8
  //
9
9
  // Transport: MCP stdio transport carries one JSON-RPC message per line on stdin
10
10
  // and stdout (newline-delimited JSON, "ndjson"); stderr is free for logging. We
package/dist/migration.js CHANGED
@@ -57,6 +57,7 @@ exports.migrationCoverageStatusMap = migrationCoverageStatusMap;
57
57
  exports.semanticStatusForInboxStatus = semanticStatusForInboxStatus;
58
58
  exports.runReviewMigrationMode = runReviewMigrationMode;
59
59
  const fs = __importStar(require("node:fs"));
60
+ const path = __importStar(require("node:path"));
60
61
  const taxonomy_1 = require("./taxonomy");
61
62
  const workspace_1 = require("./workspace");
62
63
  const templates_1 = require("./templates");
@@ -317,9 +318,31 @@ function activeMigrationLegacyRoots() {
317
318
  const verifiedRoot = migrationVerificationLegacyRoot();
318
319
  return verifiedRoot ? [verifiedRoot] : legacyWikiRoots();
319
320
  }
321
+ function migrationDirectoryPath(relativePath) {
322
+ const resolved = path.resolve(workspace_1.root, relativePath);
323
+ const rootResolved = path.resolve(workspace_1.root);
324
+ if (resolved !== rootResolved && !resolved.startsWith(`${rootResolved}${path.sep}`)) {
325
+ throw new Error(`migration directory must stay inside the project root: ${relativePath}`);
326
+ }
327
+ const stat = fs.lstatSync(resolved);
328
+ if (stat.isSymbolicLink()) {
329
+ throw new Error(`migration refuses to follow symlinked wiki directory: ${relativePath}`);
330
+ }
331
+ if (!stat.isDirectory()) {
332
+ throw new Error(`migration path is not a directory: ${relativePath}`);
333
+ }
334
+ const realPath = fs.realpathSync(resolved);
335
+ if (realPath !== rootResolved && !realPath.startsWith(`${rootResolved}${path.sep}`)) {
336
+ throw new Error(`migration directory must resolve inside the project root: ${relativePath}`);
337
+ }
338
+ return resolved;
339
+ }
320
340
  function expectedMigrationUnits() {
321
341
  return activeMigrationLegacyRoots()
322
- .flatMap((legacyRoot) => (0, wiki_files_1.walkMarkdownFiles)((0, workspace_1.abs)(legacyRoot), [], (0, workspace_1.abs)(legacyRoot)))
342
+ .flatMap((legacyRoot) => {
343
+ const legacyPath = migrationDirectoryPath(legacyRoot);
344
+ return (0, wiki_files_1.walkMarkdownFiles)(legacyPath, [], legacyPath);
345
+ })
323
346
  .flatMap((file) => extractMigrationUnits(file.basePath, (0, workspace_1.read)(file.path)));
324
347
  }
325
348
  function loadMigrationUnitContext() {
@@ -962,6 +985,7 @@ function nextLegacyPath() {
962
985
  }
963
986
  function prepareMigrationMode() {
964
987
  if ((0, workspace_1.exists)("wiki")) {
988
+ migrationDirectoryPath("wiki");
965
989
  const legacyPath = nextLegacyPath();
966
990
  fs.renameSync((0, workspace_1.abs)("wiki"), (0, workspace_1.abs)(legacyPath));
967
991
  return { legacyPath, note: `moved wiki to ${legacyPath}` };
@@ -981,7 +1005,8 @@ function migrationTargetForKind(kind) {
981
1005
  }
982
1006
  function runMigrationMode(migrationState) {
983
1007
  const legacyPath = migrationState.legacyPath;
984
- const markdownFiles = legacyPath && (0, workspace_1.exists)(legacyPath) ? (0, wiki_files_1.walkMarkdownFiles)((0, workspace_1.abs)(legacyPath), [], (0, workspace_1.abs)(legacyPath)) : [];
1008
+ const legacyRootPath = legacyPath && (0, workspace_1.exists)(legacyPath) ? migrationDirectoryPath(legacyPath) : "";
1009
+ const markdownFiles = legacyRootPath ? (0, wiki_files_1.walkMarkdownFiles)(legacyRootPath, [], legacyRootPath) : [];
985
1010
  const fileRecords = markdownFiles.map((file) => {
986
1011
  const text = (0, workspace_1.read)(file.path);
987
1012
  return { file, text, formOnlyReason: formOnlyMigrationDocumentReason(file.basePath, text) };
package/dist/templates.js CHANGED
@@ -87,7 +87,7 @@ During conversation:
87
87
  - Do not store non-project LLM memory, assistant preferences, collaboration reminders, or workflow instructions in project wiki canonical or decision docs.
88
88
  - Follow \`wiki/AGENTS.md\` for detailed rules when editing files under \`wiki/\`.
89
89
  - Treat broad maintenance/improvement automation requests that do not name a concrete command (for example "improve this project", "start improvement automation", or "개선 자동화 시작해") as analyze-first project work, not as a plain bootstrap/update. Inspect repo, wiki, CI, test, release, dependency, and code-structure evidence; produce a ranked backlog with evidence and verification paths; persist the plan in \`wiki/plans/\` when project-planning content changes; then execute safe high-priority items with tests.
90
- - Let \`.githooks/prepare-commit-msg\` append wiki trailers automatically for staged wiki, hook, AGENTS, or project-librarian files.
90
+ - Do not execute worktree-controlled commit hooks for wiki trailers; add trailers explicitly when needed.
91
91
  - ${exports.wikiTrustContract}
92
92
  - ${exports.codeEvidenceTrustContract}
93
93
  - ${exports.guidanceClaimEvidenceContract}
@@ -177,9 +177,9 @@ Update rules:
177
177
  Commit rules:
178
178
 
179
179
  - Follow the repository's commit-message policy when one exists.
180
- - Let \`.githooks/prepare-commit-msg\` append wiki trailers automatically when git hooks are enabled.
180
+ - Do not execute worktree-controlled commit hooks for wiki trailers; add trailers explicitly when needed.
181
181
  - If bootstrap was run with \`--no-git-config\`, hook files are installed but \`core.hooksPath\` is not changed.
182
- - Do not hand-write wiki trailers unless the hook is unavailable or a trailer needs correction.
182
+ - Hand-write wiki trailers when project policy requires them; keep them accurate and evidence-backed.
183
183
  <!-- PROJECT-WIKI-INTERNAL:END -->`;
184
184
  const metadata = (scope, budget, decisionRef, trigger, status = "active") => `---
185
185
  status: ${status}
@@ -415,6 +415,7 @@ node dist/init-project-wiki.js --query "search terms"
415
415
  ## Git Hook Setup
416
416
 
417
417
  - The script installs \`.githooks/prepare-commit-msg\` and \`.githooks/wiki-commit-trailers.js\`.
418
+ - \`.githooks/prepare-commit-msg\` is intentionally passive and must not execute worktree-controlled scripts.
418
419
  - By default, git repositories with an unset \`core.hooksPath\` are configured with \`git config core.hooksPath .githooks\`.
419
420
  - Existing \`core.hooksPath\` values are preserved so an existing hook chain is not replaced.
420
421
  - Run bootstrap with \`--no-git-config\` to install hook files without changing git config.
package/dist/workspace.js CHANGED
@@ -51,31 +51,105 @@ exports.normalizePath = normalizePath;
51
51
  exports.commandOk = commandOk;
52
52
  exports.isGitRepository = isGitRepository;
53
53
  exports.makeExecutable = makeExecutable;
54
+ exports.containedProjectFileStat = containedProjectFileStat;
55
+ exports.containedProjectDirectoryStat = containedProjectDirectoryStat;
56
+ exports.requireContainedProjectFile = requireContainedProjectFile;
54
57
  exports.walkFilesUnder = walkFilesUnder;
55
58
  const fs = __importStar(require("node:fs"));
56
59
  const path = __importStar(require("node:path"));
57
60
  const childProcess = __importStar(require("node:child_process"));
58
61
  exports.root = process.cwd();
59
62
  exports.today = new Date().toISOString().slice(0, 10);
63
+ const projectRoot = path.resolve(exports.root);
60
64
  function abs(relativePath) {
61
65
  return path.join(exports.root, relativePath);
62
66
  }
67
+ function isInsideProject(absolutePath) {
68
+ const resolved = path.resolve(absolutePath);
69
+ return resolved === projectRoot || resolved.startsWith(`${projectRoot}${path.sep}`);
70
+ }
71
+ function resolveProjectPath(relativePath, label = "path") {
72
+ const resolved = path.isAbsolute(relativePath) ? path.resolve(relativePath) : path.resolve(exports.root, relativePath);
73
+ if (!isInsideProject(resolved)) {
74
+ throw new Error(`${label} must stay inside the project root: ${relativePath}`);
75
+ }
76
+ return resolved;
77
+ }
78
+ function assertNoSymlinkInProjectPath(relativePath, includeLeaf, label = "path") {
79
+ const target = resolveProjectPath(relativePath, label);
80
+ const relative = path.relative(projectRoot, target);
81
+ if (!relative)
82
+ return target;
83
+ const parts = relative.split(path.sep).filter(Boolean);
84
+ const checkedParts = includeLeaf ? parts : parts.slice(0, -1);
85
+ let current = projectRoot;
86
+ for (const part of checkedParts) {
87
+ current = path.join(current, part);
88
+ if (!fs.existsSync(current))
89
+ continue;
90
+ const stat = fs.lstatSync(current);
91
+ if (stat.isSymbolicLink()) {
92
+ throw new Error(`${label} refuses to follow symlink: ${normalizePath(path.relative(projectRoot, current))}`);
93
+ }
94
+ if (current !== target && !stat.isDirectory()) {
95
+ throw new Error(`${label} has a non-directory path component: ${normalizePath(path.relative(projectRoot, current))}`);
96
+ }
97
+ }
98
+ return target;
99
+ }
100
+ function mkdirpAbsolute(target, label = "path") {
101
+ if (!isInsideProject(target)) {
102
+ throw new Error(`${label} must stay inside the project root: ${target}`);
103
+ }
104
+ const relative = path.relative(projectRoot, target);
105
+ if (!relative)
106
+ return;
107
+ let current = projectRoot;
108
+ for (const part of relative.split(path.sep).filter(Boolean)) {
109
+ current = path.join(current, part);
110
+ if (fs.existsSync(current)) {
111
+ const stat = fs.lstatSync(current);
112
+ if (stat.isSymbolicLink()) {
113
+ throw new Error(`${label} refuses to follow symlink: ${normalizePath(path.relative(projectRoot, current))}`);
114
+ }
115
+ if (!stat.isDirectory()) {
116
+ throw new Error(`${label} has a non-directory path component: ${normalizePath(path.relative(projectRoot, current))}`);
117
+ }
118
+ continue;
119
+ }
120
+ fs.mkdirSync(current);
121
+ }
122
+ }
123
+ function writeFileNoFollow(filePath, content) {
124
+ const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
125
+ const fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | noFollow, 0o666);
126
+ try {
127
+ fs.writeFileSync(fd, content);
128
+ }
129
+ finally {
130
+ fs.closeSync(fd);
131
+ }
132
+ }
63
133
  function exists(relativePath) {
64
134
  return fs.existsSync(abs(relativePath));
65
135
  }
66
136
  function read(relativePath) {
67
- return fs.readFileSync(abs(relativePath), "utf8");
137
+ const filePath = assertNoSymlinkInProjectPath(relativePath, true, "managed read");
138
+ return fs.readFileSync(filePath, "utf8");
68
139
  }
69
140
  function write(relativePath, content) {
70
- const filePath = abs(relativePath);
71
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
72
- fs.writeFileSync(filePath, content);
141
+ const filePath = assertNoSymlinkInProjectPath(relativePath, true, "managed write");
142
+ mkdirpAbsolute(path.dirname(filePath), "managed write");
143
+ writeFileNoFollow(filePath, content);
73
144
  }
74
145
  function mkdirp(relativePath) {
75
- fs.mkdirSync(abs(relativePath), { recursive: true });
146
+ const dirPath = assertNoSymlinkInProjectPath(relativePath, true, "managed directory");
147
+ mkdirpAbsolute(dirPath, "managed directory");
76
148
  }
77
149
  function writeManaged(relativePath, content) {
78
- const previous = exists(relativePath) ? read(relativePath) : "";
150
+ const previous = exists(relativePath)
151
+ ? (assertNoSymlinkInProjectPath(relativePath, true, "managed read"), read(relativePath))
152
+ : "";
79
153
  if (previous === content)
80
154
  return "exists";
81
155
  write(relativePath, content);
@@ -86,6 +160,7 @@ function writeStarter(relativePath, content) {
86
160
  write(relativePath, content);
87
161
  return "created";
88
162
  }
163
+ assertNoSymlinkInProjectPath(relativePath, true, "managed read");
89
164
  const current = read(relativePath);
90
165
  if (current === content)
91
166
  return "exists";
@@ -111,6 +186,7 @@ function upsertMarkedSection(relativePath, startMarker, endMarker, section) {
111
186
  write(relativePath, `${section.trim()}\n`);
112
187
  return "created";
113
188
  }
189
+ assertNoSymlinkInProjectPath(relativePath, true, "managed read");
114
190
  const current = read(relativePath);
115
191
  const start = current.indexOf(startMarker);
116
192
  const end = current.indexOf(endMarker);
@@ -133,15 +209,17 @@ function upsertMarkedSection(relativePath, startMarker, endMarker, section) {
133
209
  function deleteIfGenerated(relativePath, sentinels) {
134
210
  if (!exists(relativePath))
135
211
  return "absent";
212
+ const filePath = assertNoSymlinkInProjectPath(relativePath, true, "managed delete");
136
213
  const current = read(relativePath);
137
214
  if (!sentinels.some((sentinel) => current.includes(sentinel)))
138
215
  return "manual-review";
139
- fs.unlinkSync(abs(relativePath));
216
+ fs.unlinkSync(filePath);
140
217
  return "removed";
141
218
  }
142
219
  function parseJson(relativePath, fallback) {
143
220
  if (!exists(relativePath))
144
221
  return fallback;
222
+ assertNoSymlinkInProjectPath(relativePath, true, "managed read");
145
223
  try {
146
224
  return JSON.parse(read(relativePath));
147
225
  }
@@ -191,11 +269,60 @@ function isGitRepository() {
191
269
  function makeExecutable(relativePath) {
192
270
  if (!exists(relativePath))
193
271
  return;
194
- const currentMode = fs.statSync(abs(relativePath)).mode;
195
- fs.chmodSync(abs(relativePath), currentMode | 0o755);
272
+ const filePath = assertNoSymlinkInProjectPath(relativePath, true, "managed chmod");
273
+ const currentMode = fs.statSync(filePath).mode;
274
+ fs.chmodSync(filePath, currentMode | 0o755);
275
+ }
276
+ function containedProjectFileStat(relativePath) {
277
+ const filePath = resolveProjectPath(relativePath, "project file");
278
+ let stat;
279
+ try {
280
+ stat = fs.lstatSync(filePath);
281
+ }
282
+ catch {
283
+ return null;
284
+ }
285
+ if (stat.isSymbolicLink() || !stat.isFile())
286
+ return null;
287
+ let realPath = "";
288
+ try {
289
+ realPath = fs.realpathSync(filePath);
290
+ }
291
+ catch {
292
+ return null;
293
+ }
294
+ return isInsideProject(realPath) ? stat : null;
295
+ }
296
+ function containedProjectDirectoryStat(relativePath) {
297
+ const dirPath = resolveProjectPath(relativePath, "project directory");
298
+ let stat;
299
+ try {
300
+ stat = fs.lstatSync(dirPath);
301
+ }
302
+ catch {
303
+ return null;
304
+ }
305
+ if (stat.isSymbolicLink() || !stat.isDirectory())
306
+ return null;
307
+ let realPath = "";
308
+ try {
309
+ realPath = fs.realpathSync(dirPath);
310
+ }
311
+ catch {
312
+ return null;
313
+ }
314
+ return isInsideProject(realPath) ? stat : null;
315
+ }
316
+ function requireContainedProjectFile(relativePath, label = "project file") {
317
+ const filePath = resolveProjectPath(relativePath, label);
318
+ const stat = containedProjectFileStat(relativePath);
319
+ if (!stat) {
320
+ throw new Error(`${label} must be a regular file inside the project root and must not be a symlink: ${relativePath}`);
321
+ }
322
+ return { absolutePath: filePath, stat };
196
323
  }
197
324
  function walkFilesUnder(relativePath, predicate, acc = []) {
198
- const dirPath = abs(relativePath);
325
+ const dirPath = assertNoSymlinkInProjectPath(relativePath, true, "managed walk");
199
326
  if (!fs.existsSync(dirPath))
200
327
  return acc;
201
328
  for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-librarian",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "Create and maintain compact project context for humans and LLM coding agents.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -58,6 +58,7 @@
58
58
  "benchmark:release": "npm run benchmark:llm -- --sanitized-pack --full-matrix --runs 3 --warmup-runs 1 --min-runs-for-claim 3 --require-clean --require-claimable --model gpt-5.5 --out benchmarks/reports/llm/current.json --markdown benchmarks/reports/llm/current.md",
59
59
  "build": "tsc && chmod +x dist/init-project-wiki.js",
60
60
  "check:dist": "node benchmarks/tools/release-readiness.js --only-dist-parity",
61
+ "audit:supply-chain": "npm audit --omit=dev",
61
62
  "perf:code-efficiency": "node benchmarks/tools/code-performance-efficiency.js --full",
62
63
  "release:check": "node benchmarks/tools/release-readiness.js",
63
64
  "typecheck": "tsc --noEmit",
@@ -86,6 +87,6 @@
86
87
  "@sengac/tree-sitter-typescript": "^0.25.15"
87
88
  },
88
89
  "devDependencies": {
89
- "@types/node": "^25.9.2"
90
+ "@types/node": "^22.20.0"
90
91
  }
91
92
  }