project-librarian 0.5.3 → 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.
@@ -7,6 +7,7 @@ const hooks_1 = require("./hooks");
7
7
  const install_skill_1 = require("./install-skill");
8
8
  const modes_1 = require("./modes");
9
9
  const migration_1 = require("./migration");
10
+ const session_handoff_1 = require("./session-handoff");
10
11
  const templates_1 = require("./templates");
11
12
  const wiki_visualizer_1 = require("./wiki-visualizer");
12
13
  const workspace_1 = require("./workspace");
@@ -16,7 +17,7 @@ function codeIndex() {
16
17
  function printUsage() {
17
18
  console.log(`Usage:
18
19
  project-librarian [init|update] [options]
19
- 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]
20
21
  project-librarian mcp
21
22
 
22
23
  Options:
@@ -33,12 +34,23 @@ Options:
33
34
  --issue-draft Print a problem/side-effect GitHub issue body draft.
34
35
  --issue-body-file <path> With --issue-create, use an existing Markdown body file.
35
36
  --issue-title <title> Override the generated issue draft title.
37
+ --dry-run With install, preview copied skill files without writing them.
36
38
  --query <terms> Search wiki paths, metadata, titles, and bodies (answer-shaped, capped output).
37
39
  --wiki-impact <page-or-term> Show wiki backlinks, decision_ref citations, and router depth for matching pages.
38
40
  --wiki-visualize Write a static wiki graph visualizer to .project-wiki/wiki-graph.html.
39
41
  --wiki-visualize-out <path> With --wiki-visualize, write under a custom .project-wiki/ path.
40
42
  --refresh-index Update the managed auto-discovered wiki index block.
41
43
  --capture-inbox Append a candidate note with --title, --content, and optional --category.
44
+ --handoff-save Save local generated session handoff state under .project-wiki/session/.
45
+ --handoff-show Print the current local session handoff.
46
+ --handoff-status Print JSON status for the local session handoff.
47
+ --handoff-clear Remove generated session handoff files.
48
+ --handoff-promote-inbox Promote selected generated handoff facts into wiki/inbox/project-candidates.md.
49
+ --handoff-injection-enable Opt in to capped full handoff injection in startup hooks.
50
+ --handoff-injection-disable Remove the generated full handoff injection opt-in.
51
+ --handoff-injection-status Print JSON status for the full handoff injection experiment.
52
+ --goal, --state, --blocked With --handoff-save, provide resume context fields.
53
+ --next, --decision With --handoff-save, repeat for next actions and decisions.
42
54
  --glossary-init Create and route the optional glossary page.
43
55
  --agents <list> With init/update, write only selected agent surfaces: codex, claude, cursor, gemini, or all. Existing project skill/setup surfaces are preserved by default.
44
56
  --prune-check Report active pages with stale or unresolved signals.
@@ -161,6 +173,24 @@ if (args_1.codeIndexIncrementalMode && args_1.codeIndexFullMode) {
161
173
  console.error("Use one code index update mode at a time: --incremental or --code-index-full.");
162
174
  process.exit(1);
163
175
  }
176
+ const activeHandoffModes = [
177
+ args_1.handoffSaveMode ? "--handoff-save" : "",
178
+ args_1.handoffShowMode ? "--handoff-show" : "",
179
+ args_1.handoffStatusMode ? "--handoff-status" : "",
180
+ args_1.handoffClearMode ? "--handoff-clear" : "",
181
+ args_1.handoffPromoteInboxMode ? "--handoff-promote-inbox" : "",
182
+ args_1.handoffInjectionEnableMode ? "--handoff-injection-enable" : "",
183
+ args_1.handoffInjectionDisableMode ? "--handoff-injection-disable" : "",
184
+ args_1.handoffInjectionStatusMode ? "--handoff-injection-status" : "",
185
+ ].filter(Boolean);
186
+ if (activeHandoffModes.length > 1) {
187
+ console.error(`Use one session handoff mode at a time: ${activeHandoffModes.join(", ")}.`);
188
+ process.exit(1);
189
+ }
190
+ if (args_1.handoffInputMode && !args_1.handoffSaveMode) {
191
+ console.error("--goal, --state, --blocked, --next, --decision, --open-question, --last-success-command, --last-failure-command, and --verification are only supported with --handoff-save.");
192
+ process.exit(1);
193
+ }
164
194
  if (args_1.command === "install" || args_1.command === "install-skill") {
165
195
  (0, install_skill_1.runInstallSkillMode)();
166
196
  process.exit(0);
@@ -176,6 +206,33 @@ else {
176
206
  runInitCommand();
177
207
  }
178
208
  function runInitCommand() {
209
+ const activeHandoffMode = activeHandoffModes[0];
210
+ if (activeHandoffMode) {
211
+ try {
212
+ if (args_1.handoffSaveMode)
213
+ (0, session_handoff_1.runHandoffSaveMode)();
214
+ else if (args_1.handoffShowMode)
215
+ (0, session_handoff_1.runHandoffShowMode)();
216
+ else if (args_1.handoffStatusMode)
217
+ (0, session_handoff_1.runHandoffStatusMode)();
218
+ else if (args_1.handoffClearMode)
219
+ (0, session_handoff_1.runHandoffClearMode)();
220
+ else if (args_1.handoffPromoteInboxMode)
221
+ (0, session_handoff_1.runHandoffPromoteInboxMode)();
222
+ else if (args_1.handoffInjectionEnableMode)
223
+ (0, session_handoff_1.runHandoffInjectionEnableMode)();
224
+ else if (args_1.handoffInjectionDisableMode)
225
+ (0, session_handoff_1.runHandoffInjectionDisableMode)();
226
+ else if (args_1.handoffInjectionStatusMode)
227
+ (0, session_handoff_1.runHandoffInjectionStatusMode)();
228
+ exitAfterStdoutDrain(0);
229
+ }
230
+ catch (error) {
231
+ console.error(error instanceof Error ? error.message : String(error));
232
+ process.exit(1);
233
+ }
234
+ return;
235
+ }
179
236
  const activeCodeModes = activeCodeEvidenceCliModes();
180
237
  if (activeCodeModes.length > 1) {
181
238
  console.error("Use one code evidence mode at a time: --code-index, --code-index-health, --code-query, --code-report, --code-status, --code-files, --code-impact, --code-context-pack, or --code-search-symbol.");
@@ -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/modes.js CHANGED
@@ -37,6 +37,7 @@ exports.buildRefreshIndexBlock = buildRefreshIndexBlock;
37
37
  exports.runQueryMode = runQueryMode;
38
38
  exports.runWikiImpactMode = runWikiImpactMode;
39
39
  exports.projectCandidatesContent = projectCandidatesContent;
40
+ exports.appendProjectCandidate = appendProjectCandidate;
40
41
  exports.appendCaptureInbox = appendCaptureInbox;
41
42
  exports.runIssueDraftMode = runIssueDraftMode;
42
43
  exports.runIssueCreateMode = runIssueCreateMode;
@@ -338,23 +339,29 @@ function projectCandidatesContent() {
338
339
  | --- | --- | --- | --- | --- |
339
340
  `;
340
341
  }
341
- function appendCaptureInbox() {
342
+ function appendProjectCandidate(input = {}) {
342
343
  (0, workspace_1.mkdirp)("wiki/inbox");
343
344
  const relativePath = "wiki/inbox/project-candidates.md";
344
345
  const existed = (0, workspace_1.exists)(relativePath);
345
346
  if (!existed)
346
347
  (0, workspace_1.write)(relativePath, projectCandidatesContent());
347
- if (!args_1.captureTitle && !args_1.captureContent)
348
+ const candidateTitle = input.title ?? args_1.captureTitle;
349
+ const candidateContent = input.content ?? args_1.captureContent;
350
+ const candidateCategory = input.category ?? args_1.captureCategory;
351
+ if (!candidateTitle && !candidateContent)
348
352
  return existed ? "exists" : "created";
349
- const title = (args_1.captureTitle || "Untitled candidate").replace(/\|/g, "/");
350
- const content = (args_1.captureContent || "").replace(/\r?\n/g, "<br>").replace(/\|/g, "/");
351
- const row = `| ${workspace_1.today} | ${title} | ${args_1.captureCategory.replace(/\|/g, "/")} | ${content} | pending |`;
353
+ const title = (candidateTitle || "Untitled candidate").replace(/\|/g, "/");
354
+ const content = (candidateContent || "").replace(/\r?\n/g, "<br>").replace(/\|/g, "/");
355
+ const row = `| ${workspace_1.today} | ${title} | ${candidateCategory.replace(/\|/g, "/")} | ${content} | pending |`;
352
356
  const current = (0, workspace_1.read)(relativePath);
353
357
  if (current.includes(row))
354
358
  return "exists";
355
359
  (0, workspace_1.write)(relativePath, `${current.trimEnd()}\n${row}\n`);
356
360
  return "updated";
357
361
  }
362
+ function appendCaptureInbox() {
363
+ return appendProjectCandidate();
364
+ }
358
365
  function gitOutput(args) {
359
366
  try {
360
367
  return childProcess.execFileSync("git", args, {