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.
@@ -0,0 +1,627 @@
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.handoffSentinel = exports.handoffGeneratedBy = exports.handoffSchema = exports.handoffInjectionStatePath = exports.handoffStatePath = exports.handoffPath = exports.handoffDirectory = void 0;
37
+ exports.redactSecrets = redactSecrets;
38
+ exports.saveHandoffFromCli = saveHandoffFromCli;
39
+ exports.getHandoffStatus = getHandoffStatus;
40
+ exports.showHandoff = showHandoff;
41
+ exports.clearHandoff = clearHandoff;
42
+ exports.promoteHandoffToInbox = promoteHandoffToInbox;
43
+ exports.enableHandoffInjection = enableHandoffInjection;
44
+ exports.disableHandoffInjection = disableHandoffInjection;
45
+ exports.getHandoffInjectionStatus = getHandoffInjectionStatus;
46
+ exports.runHandoffSaveMode = runHandoffSaveMode;
47
+ exports.runHandoffShowMode = runHandoffShowMode;
48
+ exports.runHandoffStatusMode = runHandoffStatusMode;
49
+ exports.runHandoffClearMode = runHandoffClearMode;
50
+ exports.runHandoffPromoteInboxMode = runHandoffPromoteInboxMode;
51
+ exports.runHandoffInjectionEnableMode = runHandoffInjectionEnableMode;
52
+ exports.runHandoffInjectionDisableMode = runHandoffInjectionDisableMode;
53
+ exports.runHandoffInjectionStatusMode = runHandoffInjectionStatusMode;
54
+ const childProcess = __importStar(require("node:child_process"));
55
+ const crypto = __importStar(require("node:crypto"));
56
+ const fs = __importStar(require("node:fs"));
57
+ const path = __importStar(require("node:path"));
58
+ const args_1 = require("./args");
59
+ const modes_1 = require("./modes");
60
+ const templates_1 = require("./templates");
61
+ const workspace_1 = require("./workspace");
62
+ const workspace_2 = require("./workspace");
63
+ exports.handoffDirectory = ".project-wiki/session";
64
+ exports.handoffPath = `${exports.handoffDirectory}/last-handoff.md`;
65
+ exports.handoffStatePath = `${exports.handoffDirectory}/handoff-state.json`;
66
+ exports.handoffInjectionStatePath = `${exports.handoffDirectory}/injection-state.json`;
67
+ exports.handoffSchema = "project-librarian-session-handoff/v1";
68
+ exports.handoffGeneratedBy = "project-librarian-session-handoff";
69
+ exports.handoffSentinel = "<!-- PROJECT-LIBRARIAN-GENERATED: session-handoff/v1 -->";
70
+ const maxHandoffChars = 8000;
71
+ const maxInjectedHandoffChars = 2500;
72
+ const maxFieldChars = 800;
73
+ const maxGitFactsChars = 2000;
74
+ const staleAfterMs = 7 * 24 * 60 * 60 * 1000;
75
+ function rootRealPath() {
76
+ return fs.realpathSync(workspace_1.root);
77
+ }
78
+ function assertInsideRoot(absolutePath) {
79
+ const relative = path.relative(rootRealPath(), absolutePath);
80
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
81
+ throw new Error(`refusing to access path outside project root: ${absolutePath}`);
82
+ }
83
+ }
84
+ function resolveUnderRoot(relativePath) {
85
+ if (path.isAbsolute(relativePath))
86
+ throw new Error(`expected repository-relative path: ${relativePath}`);
87
+ const absolutePath = path.resolve(workspace_1.root, relativePath);
88
+ assertInsideRoot(absolutePath);
89
+ return absolutePath;
90
+ }
91
+ function ensureDirectoryNoSymlink(relativePath) {
92
+ const segments = relativePath.split(/[\\/]+/).filter(Boolean);
93
+ let current = rootRealPath();
94
+ for (const segment of segments) {
95
+ current = path.join(current, segment);
96
+ try {
97
+ const stat = fs.lstatSync(current);
98
+ if (stat.isSymbolicLink())
99
+ throw new Error(`refusing to use symlinked directory: ${path.relative(rootRealPath(), current)}`);
100
+ if (!stat.isDirectory())
101
+ throw new Error(`refusing to use non-directory path: ${path.relative(rootRealPath(), current)}`);
102
+ }
103
+ catch (error) {
104
+ if (error.code !== "ENOENT")
105
+ throw error;
106
+ fs.mkdirSync(current);
107
+ }
108
+ }
109
+ assertInsideRoot(fs.realpathSync(current));
110
+ }
111
+ function assertWritableFilePath(relativePath) {
112
+ const absolutePath = resolveUnderRoot(relativePath);
113
+ ensureDirectoryNoSymlink(path.dirname(relativePath));
114
+ try {
115
+ const stat = fs.lstatSync(absolutePath);
116
+ if (stat.isSymbolicLink())
117
+ throw new Error(`refusing to write symlinked file: ${relativePath}`);
118
+ if (!stat.isFile())
119
+ throw new Error(`refusing to overwrite non-file path: ${relativePath}`);
120
+ }
121
+ catch (error) {
122
+ if (error.code !== "ENOENT")
123
+ throw error;
124
+ }
125
+ return absolutePath;
126
+ }
127
+ function writeFileNoFollow(relativePath, content) {
128
+ const absolutePath = assertWritableFilePath(relativePath);
129
+ const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
130
+ const fd = fs.openSync(absolutePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | noFollow, 0o600);
131
+ try {
132
+ fs.writeFileSync(fd, content, "utf8");
133
+ }
134
+ finally {
135
+ fs.closeSync(fd);
136
+ }
137
+ }
138
+ function readFileNoSymlink(relativePath) {
139
+ const absolutePath = resolveUnderRoot(relativePath);
140
+ const stat = fs.lstatSync(absolutePath);
141
+ if (stat.isSymbolicLink())
142
+ throw new Error(`refusing to read symlinked file: ${relativePath}`);
143
+ if (!stat.isFile())
144
+ throw new Error(`expected file: ${relativePath}`);
145
+ return fs.readFileSync(absolutePath, "utf8");
146
+ }
147
+ function removeGeneratedFile(relativePath, isGenerated) {
148
+ const absolutePath = resolveUnderRoot(relativePath);
149
+ if (!fs.existsSync(absolutePath))
150
+ return "absent";
151
+ const text = readFileNoSymlink(relativePath);
152
+ if (!isGenerated(text))
153
+ throw new Error(`refusing to remove non-generated file: ${relativePath}`);
154
+ fs.unlinkSync(absolutePath);
155
+ return "removed";
156
+ }
157
+ function capText(value, maxChars = maxFieldChars) {
158
+ const compact = value.replace(/\r\n/g, "\n").trim();
159
+ if (compact.length <= maxChars)
160
+ return compact;
161
+ return `${compact.slice(0, Math.max(0, maxChars - 24)).trimEnd()}\n[truncated]`;
162
+ }
163
+ function redactSecrets(text) {
164
+ let next = text;
165
+ next = next.replace(/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g, "[REDACTED_PRIVATE_KEY]");
166
+ next = next.replace(/\bsk-[A-Za-z0-9_-]{16,}\b/g, "[REDACTED_OPENAI_KEY]");
167
+ next = next.replace(/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, "[REDACTED_GITHUB_TOKEN]");
168
+ next = next.replace(/\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g, "[REDACTED_SLACK_TOKEN]");
169
+ next = next.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[REDACTED_AWS_ACCESS_KEY]");
170
+ next = next.replace(/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "[REDACTED_JWT]");
171
+ next = next.replace(/(Authorization\s*[:=]\s*)(?:Bearer\s+)?[A-Za-z0-9._~+/=-]{12,}/gi, "$1[REDACTED_AUTHORIZATION]");
172
+ next = next.replace(/(^|\n)([A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|API_KEY|ACCESS_KEY|PRIVATE_KEY)[A-Z0-9_]*\s*=\s*)([^\n]+)/gi, "$1$2[REDACTED_SECRET]");
173
+ return next;
174
+ }
175
+ function normalizeList(value) {
176
+ if (Array.isArray(value))
177
+ return value.flatMap((item) => normalizeList(item));
178
+ if (typeof value !== "string")
179
+ return [];
180
+ return value.split(/\n/).map((item) => capText(redactSecrets(item))).filter(Boolean);
181
+ }
182
+ function normalizeString(value) {
183
+ return typeof value === "string" ? capText(redactSecrets(value)) : "";
184
+ }
185
+ function arrayValue(record, snakeKey, camelKey) {
186
+ if (snakeKey === camelKey)
187
+ return normalizeList(record[snakeKey]);
188
+ return normalizeList(record[snakeKey]).concat(normalizeList(record[camelKey]));
189
+ }
190
+ function stringValue(record, snakeKey, camelKey) {
191
+ return normalizeString(record[snakeKey] ?? record[camelKey]);
192
+ }
193
+ function payloadFromRecord(record) {
194
+ return {
195
+ blocked: arrayValue(record, "blocked", "blocked").slice(0, 6),
196
+ currentState: stringValue(record, "current_state", "currentState"),
197
+ goal: stringValue(record, "goal", "goal"),
198
+ lastFailureCommand: stringValue(record, "last_failure_command", "lastFailureCommand"),
199
+ lastSuccessCommand: stringValue(record, "last_success_command", "lastSuccessCommand"),
200
+ nextActions: arrayValue(record, "next_actions", "nextActions").slice(0, 3),
201
+ openQuestions: arrayValue(record, "open_questions", "openQuestions").slice(0, 6),
202
+ recentDecisions: arrayValue(record, "recent_decisions", "recentDecisions").slice(0, 6),
203
+ verification: arrayValue(record, "verification", "verification").slice(0, 6),
204
+ };
205
+ }
206
+ function cliPayloadRecord() {
207
+ return {
208
+ blocked: args_1.handoffBlocked,
209
+ current_state: args_1.handoffState,
210
+ goal: args_1.handoffGoal,
211
+ last_failure_command: args_1.handoffLastFailureCommand,
212
+ last_success_command: args_1.handoffLastSuccessCommand,
213
+ next_actions: args_1.handoffNextActions,
214
+ open_questions: args_1.handoffOpenQuestions,
215
+ recent_decisions: args_1.handoffDecisions,
216
+ verification: args_1.handoffVerification,
217
+ };
218
+ }
219
+ function mergePayloads(base, overlay) {
220
+ return {
221
+ blocked: overlay.blocked.length > 0 ? overlay.blocked : base.blocked,
222
+ currentState: overlay.currentState || base.currentState,
223
+ goal: overlay.goal || base.goal,
224
+ lastFailureCommand: overlay.lastFailureCommand || base.lastFailureCommand,
225
+ lastSuccessCommand: overlay.lastSuccessCommand || base.lastSuccessCommand,
226
+ nextActions: overlay.nextActions.length > 0 ? overlay.nextActions : base.nextActions,
227
+ openQuestions: overlay.openQuestions.length > 0 ? overlay.openQuestions : base.openQuestions,
228
+ recentDecisions: overlay.recentDecisions.length > 0 ? overlay.recentDecisions : base.recentDecisions,
229
+ verification: overlay.verification.length > 0 ? overlay.verification : base.verification,
230
+ };
231
+ }
232
+ function readStdinText() {
233
+ try {
234
+ if (process.stdin.isTTY)
235
+ return "";
236
+ return fs.readFileSync(0, "utf8").trim();
237
+ }
238
+ catch {
239
+ return "";
240
+ }
241
+ }
242
+ function payloadFromStdin() {
243
+ const text = readStdinText();
244
+ if (!text)
245
+ return emptyPayload();
246
+ try {
247
+ const parsed = JSON.parse(text);
248
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
249
+ throw new Error("expected a JSON object");
250
+ }
251
+ return payloadFromRecord(parsed);
252
+ }
253
+ catch (error) {
254
+ const message = error instanceof Error ? error.message : String(error);
255
+ throw new Error(`invalid --handoff-save JSON payload: ${message}`);
256
+ }
257
+ }
258
+ function emptyPayload() {
259
+ return {
260
+ blocked: [],
261
+ currentState: "",
262
+ goal: "",
263
+ lastFailureCommand: "",
264
+ lastSuccessCommand: "",
265
+ nextActions: [],
266
+ openQuestions: [],
267
+ recentDecisions: [],
268
+ verification: [],
269
+ };
270
+ }
271
+ function runGit(args) {
272
+ const gitArgs = [
273
+ "--no-pager",
274
+ "-c", "core.fsmonitor=false",
275
+ "-c", "core.hooksPath=/dev/null",
276
+ "-c", "diff.external=",
277
+ ...args,
278
+ ];
279
+ try {
280
+ return childProcess.execFileSync("git", gitArgs, {
281
+ cwd: workspace_1.root,
282
+ encoding: "utf8",
283
+ env: {
284
+ ...process.env,
285
+ GIT_CONFIG_NOSYSTEM: "1",
286
+ GIT_PAGER: "cat",
287
+ GIT_TERMINAL_PROMPT: "0",
288
+ },
289
+ stdio: ["ignore", "pipe", "ignore"],
290
+ timeout: 1500,
291
+ }).trim();
292
+ }
293
+ catch {
294
+ return "";
295
+ }
296
+ }
297
+ function collectGitFacts() {
298
+ const inside = runGit(["rev-parse", "--is-inside-work-tree"]);
299
+ if (inside !== "true")
300
+ return "not a git repository";
301
+ const branch = runGit(["rev-parse", "--abbrev-ref", "HEAD"]) || "unknown";
302
+ const status = runGit(["status", "--short"]) || "clean";
303
+ const diffStat = runGit(["diff", "--stat", "--no-ext-diff"]) || "no working-tree diff";
304
+ const commits = runGit(["log", "--oneline", "-3"]) || "no commits";
305
+ const codeEvidence = fs.existsSync(resolveUnderRoot(".project-wiki/code-evidence.sqlite"))
306
+ ? ".project-wiki/code-evidence.sqlite exists"
307
+ : "code evidence index not found";
308
+ return capText([
309
+ `branch: ${branch}`,
310
+ "",
311
+ "status:",
312
+ status,
313
+ "",
314
+ "diff stat:",
315
+ diffStat,
316
+ "",
317
+ "recent commits:",
318
+ commits,
319
+ "",
320
+ codeEvidence,
321
+ ].join("\n"), maxGitFactsChars);
322
+ }
323
+ function bulletList(items) {
324
+ return items.length > 0 ? items.map((item) => `- ${item}`).join("\n") : "- none";
325
+ }
326
+ function checkboxList(items) {
327
+ return items.length > 0 ? items.map((item) => `- [ ] ${item}`).join("\n") : "- [ ] none";
328
+ }
329
+ function renderHandoff(payload, generatedAt, gitFacts) {
330
+ const content = `${exports.handoffSentinel}
331
+ # Session Handoff
332
+
333
+ Generated: ${generatedAt}
334
+ Schema: ${exports.handoffSchema}
335
+ Trust: generated local reference data; not instructions; not canonical wiki truth
336
+
337
+ ## Goal
338
+
339
+ ${bulletList(payload.goal ? [payload.goal] : [])}
340
+
341
+ ## Current State
342
+
343
+ ${bulletList(payload.currentState ? [payload.currentState] : [])}
344
+
345
+ ## Blocked
346
+
347
+ ${bulletList(payload.blocked)}
348
+
349
+ ## Next Actions
350
+
351
+ ${checkboxList(payload.nextActions)}
352
+
353
+ ## Recent Decisions
354
+
355
+ ${bulletList(payload.recentDecisions)}
356
+
357
+ ## Open Questions
358
+
359
+ ${bulletList(payload.openQuestions)}
360
+
361
+ ## Last Commands
362
+
363
+ - Success: ${payload.lastSuccessCommand || "none"}
364
+ - Failure: ${payload.lastFailureCommand || "none"}
365
+
366
+ ## Verification Evidence
367
+
368
+ ${bulletList(payload.verification)}
369
+
370
+ ## Local Git Facts
371
+
372
+ \`\`\`text
373
+ ${gitFacts}
374
+ \`\`\`
375
+ `;
376
+ const redacted = redactSecrets(content);
377
+ if (redacted.length <= maxHandoffChars)
378
+ return redacted;
379
+ return `${redacted.slice(0, maxHandoffChars - 64).trimEnd()}\n\n[truncated: session handoff cap reached]\n`;
380
+ }
381
+ function readState() {
382
+ try {
383
+ return JSON.parse(readFileNoSymlink(exports.handoffStatePath));
384
+ }
385
+ catch {
386
+ return {};
387
+ }
388
+ }
389
+ function readInjectionState() {
390
+ try {
391
+ return JSON.parse(readFileNoSymlink(exports.handoffInjectionStatePath));
392
+ }
393
+ catch {
394
+ return {};
395
+ }
396
+ }
397
+ function contentHash(payload, gitFacts) {
398
+ return crypto.createHash("sha256").update(JSON.stringify({ payload, gitFacts })).digest("hex");
399
+ }
400
+ function saveHandoffFromCli(now = new Date()) {
401
+ const stdinPayload = payloadFromStdin();
402
+ const flagPayload = payloadFromRecord(cliPayloadRecord());
403
+ const payload = mergePayloads(stdinPayload, flagPayload);
404
+ const gitFacts = collectGitFacts();
405
+ const hash = contentHash(payload, gitFacts);
406
+ const existingState = readState();
407
+ if (existingState.content_hash === hash && fs.existsSync(resolveUnderRoot(exports.handoffPath))) {
408
+ const stat = fs.lstatSync(resolveUnderRoot(exports.handoffPath));
409
+ if (stat.isSymbolicLink())
410
+ throw new Error(`refusing to reuse symlinked file: ${exports.handoffPath}`);
411
+ if (!stat.isFile())
412
+ throw new Error(`refusing to reuse non-file path: ${exports.handoffPath}`);
413
+ return {
414
+ status: "exists",
415
+ handoffPath: exports.handoffPath,
416
+ statePath: exports.handoffStatePath,
417
+ sizeBytes: existingState.size_bytes ?? stat.size,
418
+ updatedAt: existingState.updated_at ?? "",
419
+ };
420
+ }
421
+ const updatedAt = now.toISOString();
422
+ const content = renderHandoff(payload, updatedAt, gitFacts);
423
+ writeFileNoFollow(exports.handoffPath, content);
424
+ const sizeBytes = Buffer.byteLength(content, "utf8");
425
+ const state = {
426
+ content_hash: hash,
427
+ full_injection_enabled: false,
428
+ generated: true,
429
+ generated_by: exports.handoffGeneratedBy,
430
+ path: exports.handoffPath,
431
+ schema: exports.handoffSchema,
432
+ size_bytes: sizeBytes,
433
+ updated_at: updatedAt,
434
+ };
435
+ writeFileNoFollow(exports.handoffStatePath, `${JSON.stringify(state, null, 2)}\n`);
436
+ return { status: "written", handoffPath: exports.handoffPath, statePath: exports.handoffStatePath, sizeBytes, updatedAt };
437
+ }
438
+ function getHandoffStatus(now = new Date()) {
439
+ const absoluteHandoffPath = resolveUnderRoot(exports.handoffPath);
440
+ if (!fs.existsSync(absoluteHandoffPath)) {
441
+ return {
442
+ ageSeconds: null,
443
+ exists: false,
444
+ path: exports.handoffPath,
445
+ reason: "handoff file not found",
446
+ safeToInject: false,
447
+ schema: exports.handoffSchema,
448
+ sizeBytes: 0,
449
+ stale: false,
450
+ statePath: exports.handoffStatePath,
451
+ updatedAt: "",
452
+ };
453
+ }
454
+ const stat = fs.lstatSync(absoluteHandoffPath);
455
+ if (stat.isSymbolicLink()) {
456
+ return {
457
+ ageSeconds: null,
458
+ exists: true,
459
+ path: exports.handoffPath,
460
+ reason: "handoff path is a symlink",
461
+ safeToInject: false,
462
+ schema: exports.handoffSchema,
463
+ sizeBytes: 0,
464
+ stale: true,
465
+ statePath: exports.handoffStatePath,
466
+ updatedAt: "",
467
+ };
468
+ }
469
+ const state = readState();
470
+ const updatedAt = state.updated_at || stat.mtime.toISOString();
471
+ const updatedMs = Date.parse(updatedAt);
472
+ const ageSeconds = Number.isFinite(updatedMs) ? Math.max(0, Math.floor((now.getTime() - updatedMs) / 1000)) : null;
473
+ const stale = ageSeconds === null || ageSeconds * 1000 > staleAfterMs;
474
+ const sizeBytes = stat.size;
475
+ const generated = state.generated === true && state.generated_by === exports.handoffGeneratedBy;
476
+ const safeToInject = generated && !stale && sizeBytes <= maxHandoffChars;
477
+ return {
478
+ ageSeconds,
479
+ exists: true,
480
+ path: exports.handoffPath,
481
+ reason: safeToInject ? "ok" : generated ? "stale or over size cap" : "missing generated state",
482
+ safeToInject,
483
+ schema: state.schema || exports.handoffSchema,
484
+ sizeBytes,
485
+ stale,
486
+ statePath: exports.handoffStatePath,
487
+ updatedAt,
488
+ };
489
+ }
490
+ function showHandoff() {
491
+ const status = getHandoffStatus();
492
+ if (!status.exists)
493
+ return "Project Librarian handoff: none found.\n";
494
+ const text = readFileNoSymlink(exports.handoffPath);
495
+ return [
496
+ `Project Librarian handoff: updated ${status.updatedAt || "unknown"}, ${status.sizeBytes} bytes, stale=${String(status.stale)}`,
497
+ "",
498
+ text,
499
+ ].join("\n");
500
+ }
501
+ function clearHandoff() {
502
+ const handoffResult = removeGeneratedFile(exports.handoffPath, (text) => text.includes(exports.handoffSentinel));
503
+ const stateResult = removeGeneratedFile(exports.handoffStatePath, (text) => {
504
+ try {
505
+ const state = JSON.parse(text);
506
+ return state.generated === true && state.generated_by === exports.handoffGeneratedBy;
507
+ }
508
+ catch {
509
+ return false;
510
+ }
511
+ });
512
+ return `Project Librarian handoff cleared: ${exports.handoffPath}=${handoffResult}, ${exports.handoffStatePath}=${stateResult}`;
513
+ }
514
+ function sectionText(markdown, heading) {
515
+ const pattern = new RegExp(`^## ${heading}\\n\\n([\\s\\S]*?)(?=\\n## |\\n$)`, "m");
516
+ return markdown.match(pattern)?.[1]?.trim() ?? "";
517
+ }
518
+ function compactPromotionContent(markdown) {
519
+ const sections = [
520
+ ["Goal", sectionText(markdown, "Goal")],
521
+ ["Current State", sectionText(markdown, "Current State")],
522
+ ["Blocked", sectionText(markdown, "Blocked")],
523
+ ["Next Actions", sectionText(markdown, "Next Actions")],
524
+ ["Recent Decisions", sectionText(markdown, "Recent Decisions")],
525
+ ["Open Questions", sectionText(markdown, "Open Questions")],
526
+ ["Verification Evidence", sectionText(markdown, "Verification Evidence")],
527
+ ].filter(([, text]) => text && text !== "- none" && text !== "- [ ] none");
528
+ const body = sections.map(([title, text]) => `### ${title}\n${text}`).join("\n\n");
529
+ return capText([
530
+ "Promoted from generated local session handoff. Review before canonicalizing into project truth.",
531
+ "",
532
+ body || "No structured handoff facts were available.",
533
+ ].join("\n"), 1600);
534
+ }
535
+ function promoteHandoffToInbox() {
536
+ const status = getHandoffStatus();
537
+ if (!status.exists)
538
+ throw new Error("cannot promote handoff: no generated handoff found");
539
+ if (!status.safeToInject)
540
+ throw new Error(`cannot promote handoff: ${status.reason}`);
541
+ if (!(0, workspace_2.exists)("wiki/index.md"))
542
+ throw new Error("cannot promote handoff: initialize Project Librarian wiki before writing wiki/inbox");
543
+ const markdown = readFileNoSymlink(exports.handoffPath);
544
+ if (!markdown.includes(exports.handoffSentinel))
545
+ throw new Error(`cannot promote handoff: missing generated sentinel in ${exports.handoffPath}`);
546
+ const firstGoal = sectionText(markdown, "Goal").split(/\r?\n/).find((line) => line.replace(/^[-\s[\]x]+/i, "").trim()) || "Session handoff";
547
+ const title = `Session handoff: ${firstGoal.replace(/^[-\s[\]x]+/i, "").trim() || "untitled"}`;
548
+ const inboxStatus = (0, modes_1.appendProjectCandidate)({
549
+ category: "session-handoff",
550
+ content: compactPromotionContent(markdown),
551
+ title,
552
+ });
553
+ const indexStatus = (0, workspace_2.upsertMarkedSection)("wiki/index.md", "<!-- PROJECT-WIKI-INBOX:START -->", "<!-- PROJECT-WIKI-INBOX:END -->", templates_1.inboxIndexBlock);
554
+ return `Project Librarian handoff promoted to wiki inbox: wiki/inbox/project-candidates.md=${inboxStatus}, wiki/index.md inbox router=${indexStatus}`;
555
+ }
556
+ function enableHandoffInjection(now = new Date()) {
557
+ const updatedAt = now.toISOString();
558
+ const state = {
559
+ content_hash: "",
560
+ full_injection_enabled: true,
561
+ generated: true,
562
+ generated_by: exports.handoffGeneratedBy,
563
+ path: exports.handoffInjectionStatePath,
564
+ schema: exports.handoffSchema,
565
+ size_bytes: 0,
566
+ updated_at: updatedAt,
567
+ };
568
+ const content = `${JSON.stringify(state, null, 2)}\n`;
569
+ state.size_bytes = Buffer.byteLength(content, "utf8");
570
+ writeFileNoFollow(exports.handoffInjectionStatePath, `${JSON.stringify(state, null, 2)}\n`);
571
+ return `Project Librarian handoff full injection enabled: ${exports.handoffInjectionStatePath}`;
572
+ }
573
+ function disableHandoffInjection() {
574
+ const result = removeGeneratedFile(exports.handoffInjectionStatePath, (text) => {
575
+ try {
576
+ const state = JSON.parse(text);
577
+ return state.generated === true && state.generated_by === exports.handoffGeneratedBy;
578
+ }
579
+ catch {
580
+ return false;
581
+ }
582
+ });
583
+ return `Project Librarian handoff full injection disabled: ${exports.handoffInjectionStatePath}=${result}`;
584
+ }
585
+ function getHandoffInjectionStatus() {
586
+ const state = readInjectionState();
587
+ const enabled = state.generated === true && state.generated_by === exports.handoffGeneratedBy && state.full_injection_enabled === true;
588
+ const handoff = getHandoffStatus();
589
+ const safeToInject = enabled && handoff.exists && !handoff.stale && handoff.reason === "ok";
590
+ return {
591
+ enabled,
592
+ handoffPath: exports.handoffPath,
593
+ maxInjectedChars: maxInjectedHandoffChars,
594
+ path: exports.handoffInjectionStatePath,
595
+ reason: !enabled ? "full injection is not enabled" : safeToInject ? "ok" : handoff.reason,
596
+ safeToInject,
597
+ updatedAt: state.updated_at || "",
598
+ };
599
+ }
600
+ function runHandoffSaveMode() {
601
+ const result = saveHandoffFromCli();
602
+ console.log(`Project Librarian handoff ${result.status}: ${result.handoffPath}`);
603
+ console.log(`State: ${result.statePath}`);
604
+ console.log(`Size: ${result.sizeBytes} bytes`);
605
+ console.log("Resume: project-librarian --handoff-show");
606
+ }
607
+ function runHandoffShowMode() {
608
+ process.stdout.write(showHandoff());
609
+ }
610
+ function runHandoffStatusMode() {
611
+ console.log(JSON.stringify(getHandoffStatus(), null, 2));
612
+ }
613
+ function runHandoffClearMode() {
614
+ console.log(clearHandoff());
615
+ }
616
+ function runHandoffPromoteInboxMode() {
617
+ console.log(promoteHandoffToInbox());
618
+ }
619
+ function runHandoffInjectionEnableMode() {
620
+ console.log(enableHandoffInjection());
621
+ }
622
+ function runHandoffInjectionDisableMode() {
623
+ console.log(disableHandoffInjection());
624
+ }
625
+ function runHandoffInjectionStatusMode() {
626
+ console.log(JSON.stringify(getHandoffInjectionStatus(), null, 2));
627
+ }
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.
@@ -725,6 +726,7 @@ Canonical: [[meta/operating-model]], [[meta/decision-policy]], [[meta/document-t
725
726
  | ${workspace_1.today} | Commit automation writes the \`Wiki-scope\` trailer. | Reviewers should see whether a commit touched startup, canonical docs, decisions, or wiki operations. | Leave wiki impact implicit in the diff. | Trailer format becomes too noisy. | [[meta/operating-model]] |
726
727
  | ${workspace_1.today} | Migration may mark rows \`needs-human-review\`. | Ambiguous, risky, or high-impact legacy content should not be closed automatically. | Force every migrated row into adopted/rejected/resolved. | Human review queues become too large. | [[meta/operating-model]] |
727
728
  | ${workspace_1.today} | Capture stores candidates in \`wiki/inbox/\`. | Useful ideas are not lost, but unreviewed content does not become canonical truth. | Save all conversation content directly into canonical docs. | Inbox content is frequently abandoned. | [[meta/operating-model]] |
729
+ | ${workspace_1.today} | Session handoff state lives under \`.project-wiki/session/\`. | Last-session operational memory helps resume work, but it is generated local reference data rather than reviewed project truth. | Store rolling execution memory in \`wiki/startup.md\` or canonical pages. | Handoff data needs to become durable project planning truth. | [[meta/operating-model]] |
728
730
  `,
729
731
  "wiki/meta/document-taxonomy.md": exports.documentTaxonomy,
730
732
  "wiki/decisions/decision-pack-template.md": `${(0, exports.metadata)("project-decision-template", "short", "wiki/meta/decision-policy.md", "decision pack format changes", "template")}