@phren/cli 0.1.12 → 0.1.14

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.
Files changed (37) hide show
  1. package/dist/cli/hooks-session.d.ts +18 -36
  2. package/dist/cli/hooks-session.js +21 -1482
  3. package/dist/cli/namespaces-findings.d.ts +1 -0
  4. package/dist/cli/namespaces-findings.js +208 -0
  5. package/dist/cli/namespaces-profile.d.ts +1 -0
  6. package/dist/cli/namespaces-profile.js +76 -0
  7. package/dist/cli/namespaces-projects.d.ts +1 -0
  8. package/dist/cli/namespaces-projects.js +370 -0
  9. package/dist/cli/namespaces-review.d.ts +1 -0
  10. package/dist/cli/namespaces-review.js +45 -0
  11. package/dist/cli/namespaces-skills.d.ts +4 -0
  12. package/dist/cli/namespaces-skills.js +550 -0
  13. package/dist/cli/namespaces-store.d.ts +2 -0
  14. package/dist/cli/namespaces-store.js +367 -0
  15. package/dist/cli/namespaces-tasks.d.ts +1 -0
  16. package/dist/cli/namespaces-tasks.js +369 -0
  17. package/dist/cli/namespaces-utils.d.ts +4 -0
  18. package/dist/cli/namespaces-utils.js +47 -0
  19. package/dist/cli/namespaces.d.ts +7 -11
  20. package/dist/cli/namespaces.js +8 -1991
  21. package/dist/cli/session-background.d.ts +3 -0
  22. package/dist/cli/session-background.js +176 -0
  23. package/dist/cli/session-git.d.ts +17 -0
  24. package/dist/cli/session-git.js +181 -0
  25. package/dist/cli/session-metrics.d.ts +2 -0
  26. package/dist/cli/session-metrics.js +67 -0
  27. package/dist/cli/session-start.d.ts +3 -0
  28. package/dist/cli/session-start.js +289 -0
  29. package/dist/cli/session-stop.d.ts +8 -0
  30. package/dist/cli/session-stop.js +468 -0
  31. package/dist/cli/session-tool-hook.d.ts +18 -0
  32. package/dist/cli/session-tool-hook.js +376 -0
  33. package/dist/profile-store.js +14 -1
  34. package/dist/shared/index.js +22 -3
  35. package/dist/shared/retrieval.js +10 -9
  36. package/dist/tools/search.js +1 -1
  37. package/package.json +1 -1
@@ -0,0 +1,468 @@
1
+ /**
2
+ * Session stop hook handler, background sync, and conversation capture.
3
+ * Extracted from hooks-session.ts for modularity.
4
+ */
5
+ import * as fs from "fs";
6
+ import * as path from "path";
7
+ import * as os from "os";
8
+ import { buildHookContext, handleGuardSkip, debugLog, appendAuditLog, runtimeFile, sessionMarker, getPhrenPath, updateRuntimeHealth, withFileLock, getWorkflowPolicy, getProactivityLevelForTask, getProactivityLevelForFindings, hasExplicitFindingSignal, shouldAutoCaptureFindingsForLevel, FINDING_SENSITIVITY_CONFIG, isFeatureEnabled, errorMessage, homePath, isProjectHookEnabled, ensureLocalGitRepo, bootstrapPhrenDotEnv, finalizeTaskSession, appendFindingJournal, buildSyncStatus, } from "./hooks-context.js";
9
+ import { logger } from "../logger.js";
10
+ import { runBestEffortGit, countUnsyncedCommits, recoverPushConflict, } from "./session-git.js";
11
+ import { resolveSubprocessArgs, scheduleBackgroundSync, } from "./session-background.js";
12
+ import { spawnDetachedChild } from "../shared/process.js";
13
+ // ── Utility ─────────────────────────────────────────────────────────────────
14
+ /** Read JSON from stdin if it's not a TTY. Returns null if stdin is a TTY or parsing fails. */
15
+ function readStdinJson() {
16
+ if (process.stdin.isTTY)
17
+ return null;
18
+ try {
19
+ return JSON.parse(fs.readFileSync(0, "utf-8"));
20
+ }
21
+ catch (err) {
22
+ logger.debug("hooks-session", `readStdinJson: ${errorMessage(err)}`);
23
+ return null;
24
+ }
25
+ }
26
+ /** Validate that a transcript path points to a safe, expected location. */
27
+ function isSafeTranscriptPath(p) {
28
+ let normalized;
29
+ try {
30
+ normalized = fs.realpathSync.native(p);
31
+ }
32
+ catch {
33
+ try {
34
+ normalized = fs.realpathSync.native(path.dirname(p));
35
+ normalized = path.join(normalized, path.basename(p));
36
+ }
37
+ catch {
38
+ normalized = path.resolve(p);
39
+ }
40
+ }
41
+ const safePrefixes = [
42
+ path.resolve(os.tmpdir()),
43
+ path.resolve(homePath(".claude")),
44
+ path.resolve(homePath(".config", "claude")),
45
+ ];
46
+ return safePrefixes.some(prefix => normalized.startsWith(prefix + path.sep) || normalized === prefix);
47
+ }
48
+ // ── Conversation memory capture ─────────────────────────────────────────────
49
+ const INSIGHT_KEYWORDS = [
50
+ "always", "never", "important", "pitfall", "gotcha", "trick", "workaround",
51
+ "careful", "caveat", "beware", "note that", "make sure",
52
+ "don't forget", "remember to", "must", "avoid", "prefer",
53
+ ];
54
+ const INSIGHT_KEYWORD_RE = new RegExp(`\\b(${INSIGHT_KEYWORDS.join("|")})\\b`, "i");
55
+ /**
56
+ * Extract potential insights from conversation text using keyword heuristics.
57
+ * Returns lines that contain insight-signal words and look like actionable knowledge.
58
+ */
59
+ export function extractConversationInsights(text) {
60
+ const lines = text.split("\n").filter(l => l.trim().length > 20 && l.trim().length < 300);
61
+ const insights = [];
62
+ const seen = new Set();
63
+ for (const line of lines) {
64
+ const trimmed = line.trim();
65
+ // Skip code-only lines, headers, etc.
66
+ if (trimmed.startsWith("```") || trimmed.startsWith("#") || trimmed.startsWith("//"))
67
+ continue;
68
+ if (trimmed.startsWith("$") || trimmed.startsWith(">"))
69
+ continue;
70
+ if (INSIGHT_KEYWORD_RE.test(trimmed) || hasExplicitFindingSignal(trimmed)) {
71
+ // Normalize for dedup
72
+ const normalized = trimmed.toLowerCase().replace(/\s+/g, " ");
73
+ if (!seen.has(normalized)) {
74
+ seen.add(normalized);
75
+ insights.push(trimmed);
76
+ }
77
+ }
78
+ }
79
+ // Cap to prevent flooding
80
+ return insights.slice(0, 5);
81
+ }
82
+ export function filterConversationInsightsForProactivity(insights, level = getProactivityLevelForFindings(getPhrenPath())) {
83
+ if (level === "high")
84
+ return insights;
85
+ return insights.filter((insight) => shouldAutoCaptureFindingsForLevel(level, insight));
86
+ }
87
+ // ── Session cap helper ──────────────────────────────────────────────────────
88
+ function getSessionCap() {
89
+ if (process.env.PHREN_AUTOCAPTURE_SESSION_CAP) {
90
+ return parseInt(process.env.PHREN_AUTOCAPTURE_SESSION_CAP, 10);
91
+ }
92
+ try {
93
+ const policy = getWorkflowPolicy(getPhrenPath());
94
+ const sensitivity = policy.findingSensitivity ?? "balanced";
95
+ return FINDING_SENSITIVITY_CONFIG[sensitivity]?.sessionCap ?? 10;
96
+ }
97
+ catch {
98
+ return 10;
99
+ }
100
+ }
101
+ // ── Hook stop handler ───────────────────────────────────────────────────────
102
+ export async function handleHookStop() {
103
+ const ctx = buildHookContext();
104
+ const { phrenPath, activeProject, manifest } = ctx;
105
+ const now = new Date().toISOString();
106
+ bootstrapPhrenDotEnv(phrenPath);
107
+ if (!ctx.hooksEnabled) {
108
+ handleGuardSkip(ctx, "hook_stop", "disabled", {
109
+ lastStopAt: now,
110
+ lastAutoSave: { at: now, status: "clean", detail: "hooks disabled by preference" },
111
+ });
112
+ return;
113
+ }
114
+ if (!ctx.toolHookEnabled) {
115
+ handleGuardSkip(ctx, "hook_stop", `tool_disabled tool=${ctx.hookTool}`);
116
+ return;
117
+ }
118
+ if (!isProjectHookEnabled(phrenPath, activeProject, "Stop")) {
119
+ handleGuardSkip(ctx, "hook_stop", `project_disabled project=${activeProject}`, {
120
+ lastStopAt: now,
121
+ lastAutoSave: { at: now, status: "clean", detail: `hooks disabled for project ${activeProject}` },
122
+ });
123
+ return;
124
+ }
125
+ // Read stdin early — it's a stream and can only be consumed once.
126
+ // Needed for auto-capture transcript_path parsing.
127
+ const stdinPayload = readStdinJson();
128
+ const taskSessionId = typeof stdinPayload?.session_id === "string" ? stdinPayload.session_id : undefined;
129
+ const taskLevel = getProactivityLevelForTask(phrenPath);
130
+ if (taskSessionId && taskLevel !== "high") {
131
+ debugLog(`hook-stop task proactivity=${taskLevel}`);
132
+ }
133
+ // Auto-capture BEFORE git operations so captured insights get committed and pushed.
134
+ // Gated behind PHREN_FEATURE_AUTO_CAPTURE=1.
135
+ const findingsLevel = getProactivityLevelForFindings(phrenPath);
136
+ if (isFeatureEnabled("PHREN_FEATURE_AUTO_CAPTURE", false) && findingsLevel !== "low") {
137
+ try {
138
+ let captureInput = process.env.PHREN_CONVERSATION_CONTEXT || "";
139
+ if (!captureInput && stdinPayload?.transcript_path) {
140
+ const transcriptPath = stdinPayload.transcript_path;
141
+ if (!isSafeTranscriptPath(transcriptPath)) {
142
+ debugLog(`auto-capture: skipping unsafe transcript_path: ${transcriptPath}`);
143
+ }
144
+ else if (fs.existsSync(transcriptPath)) {
145
+ // Cap at last 500 lines (~50 KB) to bound memory usage for long sessions
146
+ const raw = fs.readFileSync(transcriptPath, "utf-8");
147
+ const allLines = raw.split("\n").filter(Boolean);
148
+ const lines = allLines.length > 500 ? allLines.slice(-500) : allLines;
149
+ const assistantTexts = [];
150
+ for (const line of lines) {
151
+ try {
152
+ const msg = JSON.parse(line);
153
+ if (msg.role !== "assistant")
154
+ continue;
155
+ if (typeof msg.content === "string")
156
+ assistantTexts.push(msg.content);
157
+ else if (Array.isArray(msg.content)) {
158
+ for (const block of msg.content) {
159
+ if (block.type === "text" && block.text)
160
+ assistantTexts.push(block.text);
161
+ }
162
+ }
163
+ }
164
+ catch (err) {
165
+ logger.debug("hooks-session", `hookStop transcriptParse: ${errorMessage(err)}`);
166
+ }
167
+ }
168
+ captureInput = assistantTexts.join("\n");
169
+ }
170
+ }
171
+ if (captureInput) {
172
+ if (activeProject) {
173
+ // Check session cap before extracting — same guard as PostToolUse hook
174
+ let capReached = false;
175
+ if (taskSessionId) {
176
+ try {
177
+ const capFile = sessionMarker(phrenPath, `tool-findings-${taskSessionId}`);
178
+ let count = 0;
179
+ if (fs.existsSync(capFile)) {
180
+ count = Number.parseInt(fs.readFileSync(capFile, "utf8").trim(), 10) || 0;
181
+ }
182
+ const sessionCap = getSessionCap();
183
+ if (count >= sessionCap) {
184
+ debugLog(`hook-stop: session cap reached (${count}/${sessionCap}), skipping extraction`);
185
+ capReached = true;
186
+ }
187
+ }
188
+ catch (err) {
189
+ logger.debug("hooks-session", `hookStop sessionCapCheck: ${errorMessage(err)}`);
190
+ }
191
+ }
192
+ if (!capReached) {
193
+ const insights = filterConversationInsightsForProactivity(extractConversationInsights(captureInput), findingsLevel);
194
+ for (const insight of insights) {
195
+ appendFindingJournal(phrenPath, activeProject, `[pattern] ${insight}`, {
196
+ source: "hook",
197
+ sessionId: `hook-stop-${Date.now()}`,
198
+ });
199
+ debugLog(`auto-capture: saved insight for ${activeProject}: ${insight.slice(0, 60)}`);
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+ catch (err) {
206
+ debugLog(`auto-capture failed: ${errorMessage(err)}`);
207
+ }
208
+ }
209
+ else if (isFeatureEnabled("PHREN_FEATURE_AUTO_CAPTURE", false)) {
210
+ debugLog("auto-capture: skipped because findings proactivity is low");
211
+ }
212
+ // Wrap git operations in a file lock to prevent concurrent agents from fighting
213
+ const gitOpLockPath = path.join(phrenPath, ".runtime", "git-op");
214
+ await withFileLock(gitOpLockPath, async () => {
215
+ if (manifest?.installMode === "project-local") {
216
+ updateRuntimeHealth(phrenPath, {
217
+ lastStopAt: now,
218
+ lastAutoSave: { at: now, status: "saved-local", detail: "project-local mode writes files only" },
219
+ lastSync: {
220
+ lastPushAt: now,
221
+ lastPushStatus: "saved-local",
222
+ lastPushDetail: "project-local mode does not manage git sync",
223
+ },
224
+ });
225
+ appendAuditLog(phrenPath, "hook_stop", "status=skipped-local");
226
+ return;
227
+ }
228
+ const gitRepo = ensureLocalGitRepo(phrenPath);
229
+ if (!gitRepo.ok) {
230
+ finalizeTaskSession({
231
+ phrenPath,
232
+ sessionId: taskSessionId,
233
+ status: "error",
234
+ detail: gitRepo.detail,
235
+ });
236
+ updateRuntimeHealth(phrenPath, {
237
+ lastStopAt: now,
238
+ lastAutoSave: { at: now, status: "error", detail: gitRepo.detail },
239
+ lastSync: {
240
+ lastPushAt: now,
241
+ lastPushStatus: "error",
242
+ lastPushDetail: gitRepo.detail,
243
+ },
244
+ });
245
+ appendAuditLog(phrenPath, "hook_stop", `status=error detail=${JSON.stringify(gitRepo.detail)}`);
246
+ return;
247
+ }
248
+ const status = await runBestEffortGit(["status", "--porcelain"], phrenPath);
249
+ if (!status.ok) {
250
+ finalizeTaskSession({
251
+ phrenPath,
252
+ sessionId: taskSessionId,
253
+ status: "error",
254
+ detail: status.error || "git status failed",
255
+ });
256
+ updateRuntimeHealth(phrenPath, {
257
+ lastStopAt: now,
258
+ lastAutoSave: { at: now, status: "error", detail: status.error || "git status failed" },
259
+ lastSync: {
260
+ lastPushAt: now,
261
+ lastPushStatus: "error",
262
+ lastPushDetail: status.error || "git status failed",
263
+ },
264
+ });
265
+ appendAuditLog(phrenPath, "hook_stop", `status=error detail=${JSON.stringify(status.error || "git status failed")}`);
266
+ return;
267
+ }
268
+ if (!status.output) {
269
+ updateRuntimeHealth(phrenPath, {
270
+ lastStopAt: now,
271
+ lastAutoSave: { at: now, status: "clean", detail: "no changes" },
272
+ lastSync: {
273
+ lastPushAt: now,
274
+ lastPushStatus: "saved-pushed",
275
+ lastPushDetail: "no changes",
276
+ unsyncedCommits: 0,
277
+ },
278
+ });
279
+ appendAuditLog(phrenPath, "hook_stop", "status=clean");
280
+ return;
281
+ }
282
+ // Stage all changes first, then unstage any sensitive files that slipped
283
+ // through. Using pathspec exclusions with `git add -A` can fail when
284
+ // excluded paths are also gitignored (git treats the pathspec as an error).
285
+ let add = await runBestEffortGit(["add", "-A"], phrenPath);
286
+ if (add.ok) {
287
+ // Belt-and-suspenders: unstage sensitive files that .gitignore should
288
+ // already block. Failures here are non-fatal (files may not exist).
289
+ await runBestEffortGit(["reset", "HEAD", "--", ".env", "**/.env", "*.pem", "*.key"], phrenPath);
290
+ }
291
+ let commitMsg = "auto-save phren";
292
+ if (add.ok) {
293
+ const diff = await runBestEffortGit(["diff", "--cached", "--stat", "--no-color"], phrenPath);
294
+ if (diff.ok && diff.output) {
295
+ // Parse "project/file.md | 3 +++" lines into project names and file types
296
+ const changes = new Map();
297
+ for (const line of diff.output.split("\n")) {
298
+ const m = line.match(/^\s*([^/]+)\/([^|]+)\s*\|/);
299
+ if (!m)
300
+ continue;
301
+ const proj = m[1].trim();
302
+ if (proj.startsWith("."))
303
+ continue; // skip .config, .runtime, etc.
304
+ const file = m[2].trim();
305
+ if (!changes.has(proj))
306
+ changes.set(proj, new Set());
307
+ if (/findings/i.test(file))
308
+ changes.get(proj).add("findings");
309
+ else if (/tasks/i.test(file))
310
+ changes.get(proj).add("task");
311
+ else if (/CLAUDE/i.test(file))
312
+ changes.get(proj).add("config");
313
+ else if (/summary/i.test(file))
314
+ changes.get(proj).add("summary");
315
+ else if (/skill/i.test(file))
316
+ changes.get(proj).add("skills");
317
+ else if (/reference/i.test(file))
318
+ changes.get(proj).add("reference");
319
+ else
320
+ changes.get(proj).add("update");
321
+ }
322
+ if (changes.size > 0) {
323
+ const parts = [...changes.entries()].map(([proj, types]) => `${proj}(${[...types].join(",")})`);
324
+ commitMsg = `phren: ${parts.join(" ")}`;
325
+ }
326
+ }
327
+ }
328
+ const commit = add.ok ? await runBestEffortGit(["commit", "-m", commitMsg], phrenPath) : { ok: false, error: add.error };
329
+ if (!add.ok || !commit.ok) {
330
+ finalizeTaskSession({
331
+ phrenPath,
332
+ sessionId: taskSessionId,
333
+ status: "error",
334
+ detail: add.error || commit.error || "git add/commit failed",
335
+ });
336
+ updateRuntimeHealth(phrenPath, {
337
+ lastStopAt: now,
338
+ lastAutoSave: {
339
+ at: now,
340
+ status: "error",
341
+ detail: add.error || commit.error || "git add/commit failed",
342
+ },
343
+ lastSync: {
344
+ lastPushAt: now,
345
+ lastPushStatus: "error",
346
+ lastPushDetail: add.error || commit.error || "git add/commit failed",
347
+ },
348
+ });
349
+ appendAuditLog(phrenPath, "hook_stop", `status=error detail=${JSON.stringify(add.error || commit.error || "git add/commit failed")}`);
350
+ return;
351
+ }
352
+ const remotes = await runBestEffortGit(["remote"], phrenPath);
353
+ if (!remotes.ok || !remotes.output) {
354
+ finalizeTaskSession({
355
+ phrenPath,
356
+ sessionId: taskSessionId,
357
+ status: "saved-local",
358
+ detail: "commit created; no remote configured",
359
+ });
360
+ const unsyncedCommits = await countUnsyncedCommits(phrenPath);
361
+ updateRuntimeHealth(phrenPath, {
362
+ lastStopAt: now,
363
+ lastAutoSave: { at: now, status: "saved-local", detail: "commit created; no remote configured" },
364
+ lastSync: {
365
+ lastPushAt: now,
366
+ lastPushStatus: "saved-local",
367
+ lastPushDetail: "commit created; no remote configured",
368
+ unsyncedCommits,
369
+ },
370
+ });
371
+ appendAuditLog(phrenPath, "hook_stop", "status=saved-local");
372
+ return;
373
+ }
374
+ const unsyncedCommits = await countUnsyncedCommits(phrenPath);
375
+ const scheduled = scheduleBackgroundSync(phrenPath);
376
+ const syncDetail = scheduled
377
+ ? "commit saved; background sync scheduled"
378
+ : "commit saved; background sync already running";
379
+ finalizeTaskSession({
380
+ phrenPath,
381
+ sessionId: taskSessionId,
382
+ status: "saved-local",
383
+ detail: syncDetail,
384
+ });
385
+ updateRuntimeHealth(phrenPath, {
386
+ lastStopAt: now,
387
+ lastAutoSave: { at: now, status: "saved-local", detail: syncDetail },
388
+ lastSync: {
389
+ lastPushAt: now,
390
+ lastPushStatus: "saved-local",
391
+ lastPushDetail: syncDetail,
392
+ unsyncedCommits,
393
+ },
394
+ });
395
+ appendAuditLog(phrenPath, "hook_stop", `status=saved-local detail=${JSON.stringify(syncDetail)}`);
396
+ }); // end withFileLock(gitOpLockPath)
397
+ // Auto governance scheduling (non-blocking)
398
+ scheduleWeeklyGovernance();
399
+ }
400
+ // ── Background sync handler ─────────────────────────────────────────────────
401
+ export async function handleBackgroundSync() {
402
+ const phrenPathLocal = getPhrenPath();
403
+ const now = new Date().toISOString();
404
+ const lockPath = runtimeFile(phrenPathLocal, "background-sync.lock");
405
+ try {
406
+ const remotes = await runBestEffortGit(["remote"], phrenPathLocal);
407
+ if (!remotes.ok || !remotes.output) {
408
+ const unsyncedCommits = await countUnsyncedCommits(phrenPathLocal);
409
+ updateRuntimeHealth(phrenPathLocal, {
410
+ lastAutoSave: { at: now, status: "saved-local", detail: "background sync skipped; no remote configured" },
411
+ lastSync: buildSyncStatus({ now, pushStatus: "saved-local", pushDetail: "background sync skipped; no remote configured", unsyncedCommits }),
412
+ });
413
+ appendAuditLog(phrenPathLocal, "background_sync", "status=saved-local detail=no_remote");
414
+ return;
415
+ }
416
+ const push = await runBestEffortGit(["push"], phrenPathLocal);
417
+ if (push.ok) {
418
+ updateRuntimeHealth(phrenPathLocal, {
419
+ lastAutoSave: { at: now, status: "saved-pushed", detail: "commit pushed by background sync" },
420
+ lastSync: buildSyncStatus({ now, pushStatus: "saved-pushed", pushDetail: "commit pushed by background sync", unsyncedCommits: 0 }),
421
+ });
422
+ appendAuditLog(phrenPathLocal, "background_sync", "status=saved-pushed");
423
+ return;
424
+ }
425
+ const recovered = await recoverPushConflict(phrenPathLocal);
426
+ if (recovered.ok) {
427
+ updateRuntimeHealth(phrenPathLocal, {
428
+ lastAutoSave: { at: now, status: "saved-pushed", detail: recovered.detail },
429
+ lastSync: buildSyncStatus({ now, pushStatus: "saved-pushed", pushDetail: recovered.detail, pullAt: now, pullStatus: recovered.pullStatus, pullDetail: recovered.pullDetail, successfulPullAt: now, unsyncedCommits: 0 }),
430
+ });
431
+ appendAuditLog(phrenPathLocal, "background_sync", `status=saved-pushed detail=${JSON.stringify(recovered.detail)}`);
432
+ return;
433
+ }
434
+ const unsyncedCommits = await countUnsyncedCommits(phrenPathLocal);
435
+ const failDetail = recovered.detail || push.error || "background sync push failed";
436
+ updateRuntimeHealth(phrenPathLocal, {
437
+ lastAutoSave: { at: now, status: "saved-local", detail: failDetail },
438
+ lastSync: buildSyncStatus({ now, pushStatus: "saved-local", pushDetail: failDetail, pullAt: now, pullStatus: recovered.pullStatus, pullDetail: recovered.pullDetail, unsyncedCommits }),
439
+ });
440
+ appendAuditLog(phrenPathLocal, "background_sync", `status=saved-local detail=${JSON.stringify(failDetail)}`);
441
+ }
442
+ finally {
443
+ try {
444
+ fs.unlinkSync(lockPath);
445
+ }
446
+ catch { }
447
+ }
448
+ }
449
+ // ── Weekly governance ───────────────────────────────────────────────────────
450
+ function scheduleWeeklyGovernance() {
451
+ try {
452
+ const lastGovPath = runtimeFile(getPhrenPath(), "last-governance.txt");
453
+ const lastRun = fs.existsSync(lastGovPath) ? parseInt(fs.readFileSync(lastGovPath, "utf8"), 10) : 0;
454
+ const daysSince = (Date.now() - lastRun) / 86_400_000;
455
+ if (daysSince >= 7) {
456
+ const spawnArgs = resolveSubprocessArgs("background-maintenance");
457
+ if (spawnArgs) {
458
+ const child = spawnDetachedChild(spawnArgs, { phrenPath: getPhrenPath() });
459
+ child.unref();
460
+ fs.writeFileSync(lastGovPath, Date.now().toString());
461
+ debugLog("hook_stop: scheduled weekly governance run");
462
+ }
463
+ }
464
+ }
465
+ catch (err) {
466
+ debugLog(`hook_stop: governance scheduling failed: ${errorMessage(err)}`);
467
+ }
468
+ }
@@ -0,0 +1,18 @@
1
+ export declare function handleHookTool(): Promise<void>;
2
+ interface LearningCandidate {
3
+ text: string;
4
+ confidence: number;
5
+ explicit?: boolean;
6
+ }
7
+ export declare function filterToolFindingsForProactivity(candidates: Array<{
8
+ text: string;
9
+ confidence: number;
10
+ explicit?: boolean;
11
+ }>, level?: "high" | "medium" | "low"): Array<{
12
+ text: string;
13
+ confidence: number;
14
+ explicit?: boolean;
15
+ }>;
16
+ export declare function extractToolFindings(toolName: string, input: Record<string, unknown>, responseStr: string): LearningCandidate[];
17
+ export declare function handleHookContext(): Promise<void>;
18
+ export {};