@phren/cli 0.1.13 → 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.
- package/dist/cli/hooks-session.d.ts +18 -36
- package/dist/cli/hooks-session.js +21 -1482
- package/dist/cli/namespaces-findings.d.ts +1 -0
- package/dist/cli/namespaces-findings.js +208 -0
- package/dist/cli/namespaces-profile.d.ts +1 -0
- package/dist/cli/namespaces-profile.js +76 -0
- package/dist/cli/namespaces-projects.d.ts +1 -0
- package/dist/cli/namespaces-projects.js +370 -0
- package/dist/cli/namespaces-review.d.ts +1 -0
- package/dist/cli/namespaces-review.js +45 -0
- package/dist/cli/namespaces-skills.d.ts +4 -0
- package/dist/cli/namespaces-skills.js +550 -0
- package/dist/cli/namespaces-store.d.ts +2 -0
- package/dist/cli/namespaces-store.js +367 -0
- package/dist/cli/namespaces-tasks.d.ts +1 -0
- package/dist/cli/namespaces-tasks.js +369 -0
- package/dist/cli/namespaces-utils.d.ts +4 -0
- package/dist/cli/namespaces-utils.js +47 -0
- package/dist/cli/namespaces.d.ts +7 -11
- package/dist/cli/namespaces.js +8 -2011
- package/dist/cli/session-background.d.ts +3 -0
- package/dist/cli/session-background.js +176 -0
- package/dist/cli/session-git.d.ts +17 -0
- package/dist/cli/session-git.js +181 -0
- package/dist/cli/session-metrics.d.ts +2 -0
- package/dist/cli/session-metrics.js +67 -0
- package/dist/cli/session-start.d.ts +3 -0
- package/dist/cli/session-start.js +289 -0
- package/dist/cli/session-stop.d.ts +8 -0
- package/dist/cli/session-stop.js +468 -0
- package/dist/cli/session-tool-hook.d.ts +18 -0
- package/dist/cli/session-tool-hook.js +376 -0
- package/dist/tools/search.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session start hook handler and onboarding notices.
|
|
3
|
+
* Extracted from hooks-session.ts for modularity.
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import * as crypto from "crypto";
|
|
8
|
+
import { buildHookContext, handleGuardSkip, debugLog, appendAuditLog, runtimeFile, sessionMarker, getProjectDirs, findProjectNameCaseInsensitive, updateRuntimeHealth, withFileLock, detectProject, isProjectHookEnabled, readProjectConfig, getProjectSourcePath, detectProjectDir, ensureLocalGitRepo, isProjectTracked, repairPreexistingInstall, errorMessage, resolveRuntimeProfile, } from "./hooks-context.js";
|
|
9
|
+
import { TASKS_FILENAME } from "../data/tasks.js";
|
|
10
|
+
import { readInstallPreferences } from "../init/preferences.js";
|
|
11
|
+
import { logger } from "../logger.js";
|
|
12
|
+
import { sessionFileForId, readSessionStateFile, writeSessionStateFile, } from "../session/utils.js";
|
|
13
|
+
import { runBestEffortGit, countUnsyncedCommits } from "./session-git.js";
|
|
14
|
+
import { scheduleBackgroundMaintenance } from "./session-background.js";
|
|
15
|
+
import { runDoctor } from "./hooks-context.js";
|
|
16
|
+
const SESSION_START_ONBOARDING_MARKER = "session-start-onboarding-v1";
|
|
17
|
+
const SYNC_WARN_MARKER = "sync-broken-warned-v1";
|
|
18
|
+
function projectHasBootstrapSignals(phrenPath, project) {
|
|
19
|
+
const projectDir = path.join(phrenPath, project);
|
|
20
|
+
const findingsPath = path.join(projectDir, "FINDINGS.md");
|
|
21
|
+
if (fs.existsSync(findingsPath)) {
|
|
22
|
+
const findings = fs.readFileSync(findingsPath, "utf8");
|
|
23
|
+
if (/^-\s+/m.test(findings))
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
const tasksPath = path.join(projectDir, TASKS_FILENAME);
|
|
27
|
+
if (fs.existsSync(tasksPath)) {
|
|
28
|
+
const tasks = fs.readFileSync(tasksPath, "utf8");
|
|
29
|
+
if (/^-\s+\[(?: |x|X)\]/m.test(tasks))
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
export function getUntrackedProjectNotice(phrenPath, cwd) {
|
|
35
|
+
const profile = resolveRuntimeProfile(phrenPath);
|
|
36
|
+
const projectDir = detectProjectDir(cwd, phrenPath);
|
|
37
|
+
if (!projectDir)
|
|
38
|
+
return null;
|
|
39
|
+
const activeProfile = profile || undefined;
|
|
40
|
+
// Check the exact current working directory against projects in the active profile.
|
|
41
|
+
// This avoids prompting when cwd is already inside a tracked sourcePath.
|
|
42
|
+
if (detectProject(phrenPath, cwd, activeProfile))
|
|
43
|
+
return null;
|
|
44
|
+
if (detectProject(phrenPath, projectDir, activeProfile))
|
|
45
|
+
return null;
|
|
46
|
+
const projectName = path.basename(projectDir).toLowerCase().replace(/[^a-z0-9_-]/g, "-");
|
|
47
|
+
if (isProjectTracked(phrenPath, projectName, activeProfile)) {
|
|
48
|
+
const trackedName = getProjectDirs(phrenPath, activeProfile)
|
|
49
|
+
.map((dir) => path.basename(dir))
|
|
50
|
+
.find((name) => name.toLowerCase() === projectName)
|
|
51
|
+
|| findProjectNameCaseInsensitive(phrenPath, projectName)
|
|
52
|
+
|| projectName;
|
|
53
|
+
const config = readProjectConfig(phrenPath, trackedName);
|
|
54
|
+
const sourcePath = getProjectSourcePath(phrenPath, trackedName, config);
|
|
55
|
+
if (!sourcePath)
|
|
56
|
+
return null;
|
|
57
|
+
const resolvedProjectDir = path.resolve(projectDir);
|
|
58
|
+
const sameSource = resolvedProjectDir === sourcePath || resolvedProjectDir.startsWith(sourcePath + path.sep);
|
|
59
|
+
if (sameSource)
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return [
|
|
63
|
+
"<phren-notice>",
|
|
64
|
+
"This project directory is not tracked by phren yet.",
|
|
65
|
+
"Run `phren add` to track it now.",
|
|
66
|
+
`Suggested command: \`phren add "${projectDir}"\``,
|
|
67
|
+
"Ask the user whether they want to add it to phren now.",
|
|
68
|
+
"If they say no, tell them they can always run `phren add` later.",
|
|
69
|
+
"If they say yes, also ask whether phren should manage repo instruction files or leave their existing repo-owned CLAUDE/AGENTS files alone.",
|
|
70
|
+
`Then use the \`add_project\` MCP tool with path="${projectDir}" and ownership="phren-managed"|"detached"|"repo-managed", or run \`phren add\` from that directory.`,
|
|
71
|
+
"After onboarding, run `phren doctor` if hooks or MCP tools are not responding.",
|
|
72
|
+
"<phren-notice>",
|
|
73
|
+
"",
|
|
74
|
+
].join("\n");
|
|
75
|
+
}
|
|
76
|
+
export function getSessionStartOnboardingNotice(phrenPath, cwd, activeProject) {
|
|
77
|
+
const markerPath = sessionMarker(phrenPath, SESSION_START_ONBOARDING_MARKER);
|
|
78
|
+
if (fs.existsSync(markerPath))
|
|
79
|
+
return null;
|
|
80
|
+
if (getUntrackedProjectNotice(phrenPath, cwd))
|
|
81
|
+
return null;
|
|
82
|
+
const profile = resolveRuntimeProfile(phrenPath);
|
|
83
|
+
const trackedProjects = getProjectDirs(phrenPath, profile).filter((dir) => path.basename(dir) !== "global");
|
|
84
|
+
if (trackedProjects.length === 0) {
|
|
85
|
+
return [
|
|
86
|
+
"<phren-notice>",
|
|
87
|
+
"Phren onboarding: no tracked projects are active for this workspace yet.",
|
|
88
|
+
"Start in a project repo and run `phren add` so SessionStart can inject project context.",
|
|
89
|
+
"Run `phren doctor` to verify hooks and MCP wiring after setup.",
|
|
90
|
+
"<phren-notice>",
|
|
91
|
+
"",
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
94
|
+
if (!activeProject)
|
|
95
|
+
return null;
|
|
96
|
+
if (projectHasBootstrapSignals(phrenPath, activeProject))
|
|
97
|
+
return null;
|
|
98
|
+
return [
|
|
99
|
+
"<phren-notice>",
|
|
100
|
+
`Phren onboarding: project "${activeProject}" is tracked but memory is still empty.`,
|
|
101
|
+
"Capture one finding with `add_finding` and one task with `add_task` to seed future SessionStart context.",
|
|
102
|
+
"Run `phren doctor` if setup seems incomplete.",
|
|
103
|
+
"<phren-notice>",
|
|
104
|
+
"",
|
|
105
|
+
].join("\n");
|
|
106
|
+
}
|
|
107
|
+
export async function handleHookSessionStart() {
|
|
108
|
+
const startedAt = new Date().toISOString();
|
|
109
|
+
const ctx = buildHookContext();
|
|
110
|
+
const { phrenPath, cwd, activeProject, manifest } = ctx;
|
|
111
|
+
// Check common guards (hooks enabled, tool enabled)
|
|
112
|
+
if (!ctx.hooksEnabled) {
|
|
113
|
+
handleGuardSkip(ctx, "hook_session_start", "disabled", { lastSessionStartAt: startedAt });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (!ctx.toolHookEnabled) {
|
|
117
|
+
handleGuardSkip(ctx, "hook_session_start", `tool_disabled tool=${ctx.hookTool}`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
repairPreexistingInstall(phrenPath);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
debugLog(`hook-session-start repair failed: ${errorMessage(err)}`);
|
|
125
|
+
}
|
|
126
|
+
if (!isProjectHookEnabled(phrenPath, activeProject, "SessionStart")) {
|
|
127
|
+
handleGuardSkip(ctx, "hook_session_start", `project_disabled project=${activeProject}`, { lastSessionStartAt: startedAt });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (manifest?.installMode === "project-local") {
|
|
131
|
+
updateRuntimeHealth(phrenPath, {
|
|
132
|
+
lastSessionStartAt: startedAt,
|
|
133
|
+
lastSync: {
|
|
134
|
+
lastPullAt: startedAt,
|
|
135
|
+
lastPullStatus: "ok",
|
|
136
|
+
lastPullDetail: "project-local mode does not manage git sync",
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
appendAuditLog(phrenPath, "hook_session_start", "status=skipped-local");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const gitRepo = ensureLocalGitRepo(phrenPath);
|
|
143
|
+
const remotes = gitRepo.ok ? await runBestEffortGit(["remote"], phrenPath) : { ok: false, error: gitRepo.detail };
|
|
144
|
+
const hasRemote = Boolean(remotes.ok && remotes.output && remotes.output.trim());
|
|
145
|
+
const pull = !gitRepo.ok
|
|
146
|
+
? { ok: false, error: gitRepo.detail }
|
|
147
|
+
: hasRemote
|
|
148
|
+
? await runBestEffortGit(["pull", "--rebase", "--quiet"], phrenPath)
|
|
149
|
+
: {
|
|
150
|
+
ok: true,
|
|
151
|
+
output: gitRepo.initialized
|
|
152
|
+
? "initialized local git repo; no remote configured"
|
|
153
|
+
: "local-only repo; no remote configured",
|
|
154
|
+
};
|
|
155
|
+
const doctor = await runDoctor(phrenPath, false);
|
|
156
|
+
const maintenanceScheduled = scheduleBackgroundMaintenance(phrenPath);
|
|
157
|
+
const unsyncedCommits = hasRemote ? await countUnsyncedCommits(phrenPath) : 0;
|
|
158
|
+
try {
|
|
159
|
+
const { trackSession } = await import("../telemetry.js");
|
|
160
|
+
trackSession(phrenPath);
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
logger.debug("hooks-session", `hookSessionStart trackSession: ${errorMessage(err)}`);
|
|
164
|
+
}
|
|
165
|
+
updateRuntimeHealth(phrenPath, {
|
|
166
|
+
lastSessionStartAt: startedAt,
|
|
167
|
+
lastSync: {
|
|
168
|
+
lastPullAt: startedAt,
|
|
169
|
+
lastPullStatus: pull.ok ? "ok" : "error",
|
|
170
|
+
lastPullDetail: pull.ok ? (pull.output || "pull ok") : (pull.error || "pull failed"),
|
|
171
|
+
lastSuccessfulPullAt: pull.ok && hasRemote ? startedAt : undefined,
|
|
172
|
+
unsyncedCommits,
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
appendAuditLog(phrenPath, "hook_session_start", `pull=${hasRemote ? (pull.ok ? "ok" : "fail") : "skipped-local"} doctor=${doctor.ok ? "ok" : "issues"} maintenance=${maintenanceScheduled ? "scheduled" : "skipped"}`);
|
|
176
|
+
// Pull non-primary stores from store registry (best-effort, non-blocking)
|
|
177
|
+
try {
|
|
178
|
+
const { getNonPrimaryStores } = await import("../store-registry.js");
|
|
179
|
+
const otherStores = getNonPrimaryStores(phrenPath);
|
|
180
|
+
for (const store of otherStores) {
|
|
181
|
+
if (!fs.existsSync(store.path) || !fs.existsSync(path.join(store.path, ".git")))
|
|
182
|
+
continue;
|
|
183
|
+
try {
|
|
184
|
+
await runBestEffortGit(["pull", "--rebase", "--quiet"], store.path);
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
debugLog(`session-start store-pull ${store.name}: ${errorMessage(err)}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// store-registry not available or no stores — skip silently
|
|
193
|
+
}
|
|
194
|
+
// Sync intent warning: if the user intended sync but remote is missing or pull failed, warn once
|
|
195
|
+
try {
|
|
196
|
+
const syncPrefs = readInstallPreferences(phrenPath);
|
|
197
|
+
const syncBroken = syncPrefs.syncIntent === "sync" && (!hasRemote || !pull.ok);
|
|
198
|
+
if (syncBroken) {
|
|
199
|
+
const syncWarnPath = sessionMarker(phrenPath, SYNC_WARN_MARKER);
|
|
200
|
+
if (!fs.existsSync(syncWarnPath)) {
|
|
201
|
+
const reason = !hasRemote
|
|
202
|
+
? "no git remote is connected"
|
|
203
|
+
: `pull failed: ${pull.error || "unknown error"}`;
|
|
204
|
+
process.stdout.write([
|
|
205
|
+
"<phren-notice>",
|
|
206
|
+
`Sync is configured but ${reason}. Your phren data is local-only.`,
|
|
207
|
+
`To fix: cd ${phrenPath} && git remote add origin <YOUR_REPO_URL> && git push -u origin main`,
|
|
208
|
+
"<phren-notice>",
|
|
209
|
+
"",
|
|
210
|
+
].join("\n"));
|
|
211
|
+
try {
|
|
212
|
+
fs.writeFileSync(syncWarnPath, `${startedAt}\n`);
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
debugLog(`sync-warn marker write failed: ${errorMessage(err)}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
debugLog(`sync-intent check failed: ${errorMessage(err)}`);
|
|
222
|
+
}
|
|
223
|
+
// Untracked project detection: suggest `phren add` if CWD looks like a project but isn't tracked
|
|
224
|
+
try {
|
|
225
|
+
const notice = getUntrackedProjectNotice(phrenPath, cwd);
|
|
226
|
+
if (notice) {
|
|
227
|
+
process.stdout.write(notice);
|
|
228
|
+
debugLog(`untracked project detected at ${cwd}`);
|
|
229
|
+
}
|
|
230
|
+
const onboarding = getSessionStartOnboardingNotice(phrenPath, cwd, activeProject);
|
|
231
|
+
if (onboarding) {
|
|
232
|
+
process.stdout.write(onboarding);
|
|
233
|
+
try {
|
|
234
|
+
fs.writeFileSync(sessionMarker(phrenPath, SESSION_START_ONBOARDING_MARKER), `${startedAt}\n`);
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
debugLog(`session-start onboarding marker write failed: ${errorMessage(err)}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
debugLog(`session-start onboarding detection failed: ${errorMessage(err)}`);
|
|
243
|
+
}
|
|
244
|
+
// ── Bridge: create a real session record so session_history tracks hook sessions ──
|
|
245
|
+
// Uses a file lock to prevent concurrent SessionStart hooks from racing on
|
|
246
|
+
// the active-hook-session pointer (read previous ID, end it, write new ID).
|
|
247
|
+
try {
|
|
248
|
+
const activeSessionFile = runtimeFile(phrenPath, "active-hook-session");
|
|
249
|
+
withFileLock(activeSessionFile, () => {
|
|
250
|
+
// Retroactively end the previous hook session (if any)
|
|
251
|
+
try {
|
|
252
|
+
const prevId = fs.readFileSync(activeSessionFile, "utf-8").trim();
|
|
253
|
+
if (prevId) {
|
|
254
|
+
const prevFile = sessionFileForId(phrenPath, prevId);
|
|
255
|
+
const prevState = readSessionStateFile(prevFile);
|
|
256
|
+
if (prevState && !prevState.endedAt) {
|
|
257
|
+
writeSessionStateFile(prevFile, { ...prevState, endedAt: startedAt });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
// ENOENT is expected on the very first session — only log other errors
|
|
263
|
+
if (err.code !== "ENOENT") {
|
|
264
|
+
debugLog(`session-bridge end-previous failed: ${errorMessage(err)}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Create new session record
|
|
268
|
+
const sessionId = crypto.randomUUID();
|
|
269
|
+
const sessionState = {
|
|
270
|
+
sessionId,
|
|
271
|
+
project: activeProject || undefined,
|
|
272
|
+
startedAt,
|
|
273
|
+
findingsAdded: 0,
|
|
274
|
+
tasksCompleted: 0,
|
|
275
|
+
hookCreated: true,
|
|
276
|
+
};
|
|
277
|
+
writeSessionStateFile(sessionFileForId(phrenPath, sessionId), sessionState);
|
|
278
|
+
// Write active session ID atomically so other hooks (stop, tool) can reference it.
|
|
279
|
+
// Plain text format (not JSON) because the reader uses readFileSync + trim.
|
|
280
|
+
const tmpPath = `${activeSessionFile}.${process.pid}.${Date.now()}.tmp`;
|
|
281
|
+
fs.writeFileSync(tmpPath, sessionId + "\n");
|
|
282
|
+
fs.renameSync(tmpPath, activeSessionFile);
|
|
283
|
+
debugLog(`session-bridge created session ${sessionId.slice(0, 8)} for hook-driven session`);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
debugLog(`session-bridge failed: ${errorMessage(err)}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract potential insights from conversation text using keyword heuristics.
|
|
3
|
+
* Returns lines that contain insight-signal words and look like actionable knowledge.
|
|
4
|
+
*/
|
|
5
|
+
export declare function extractConversationInsights(text: string): string[];
|
|
6
|
+
export declare function filterConversationInsightsForProactivity(insights: string[], level?: "high" | "medium" | "low"): string[];
|
|
7
|
+
export declare function handleHookStop(): Promise<void>;
|
|
8
|
+
export declare function handleBackgroundSync(): Promise<void>;
|