@phren/cli 0.0.1
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/LICENSE +21 -0
- package/README.md +590 -0
- package/mcp/dist/capabilities/cli.js +61 -0
- package/mcp/dist/capabilities/index.js +15 -0
- package/mcp/dist/capabilities/mcp.js +61 -0
- package/mcp/dist/capabilities/types.js +57 -0
- package/mcp/dist/capabilities/vscode.js +61 -0
- package/mcp/dist/capabilities/web-ui.js +61 -0
- package/mcp/dist/cli-actions.js +302 -0
- package/mcp/dist/cli-config.js +580 -0
- package/mcp/dist/cli-extract.js +305 -0
- package/mcp/dist/cli-govern.js +371 -0
- package/mcp/dist/cli-graph.js +169 -0
- package/mcp/dist/cli-hooks-citations.js +44 -0
- package/mcp/dist/cli-hooks-context.js +56 -0
- package/mcp/dist/cli-hooks-globs.js +83 -0
- package/mcp/dist/cli-hooks-output.js +130 -0
- package/mcp/dist/cli-hooks-retrieval.js +2 -0
- package/mcp/dist/cli-hooks-session.js +1402 -0
- package/mcp/dist/cli-hooks.js +350 -0
- package/mcp/dist/cli-namespaces.js +989 -0
- package/mcp/dist/cli-ops.js +253 -0
- package/mcp/dist/cli-search.js +407 -0
- package/mcp/dist/cli.js +108 -0
- package/mcp/dist/content-archive.js +278 -0
- package/mcp/dist/content-citation.js +391 -0
- package/mcp/dist/content-dedup.js +622 -0
- package/mcp/dist/content-learning.js +472 -0
- package/mcp/dist/content-metadata.js +186 -0
- package/mcp/dist/content-validate.js +462 -0
- package/mcp/dist/core-finding.js +54 -0
- package/mcp/dist/core-project.js +36 -0
- package/mcp/dist/core-search.js +50 -0
- package/mcp/dist/data-access.js +400 -0
- package/mcp/dist/data-tasks.js +821 -0
- package/mcp/dist/embedding.js +344 -0
- package/mcp/dist/entrypoint.js +387 -0
- package/mcp/dist/finding-context.js +172 -0
- package/mcp/dist/finding-impact.js +181 -0
- package/mcp/dist/finding-journal.js +122 -0
- package/mcp/dist/finding-lifecycle.js +259 -0
- package/mcp/dist/governance-audit.js +22 -0
- package/mcp/dist/governance-locks.js +96 -0
- package/mcp/dist/governance-policy.js +648 -0
- package/mcp/dist/governance-scores.js +355 -0
- package/mcp/dist/hooks.js +449 -0
- package/mcp/dist/impact-scoring.js +22 -0
- package/mcp/dist/index-query.js +168 -0
- package/mcp/dist/index.js +205 -0
- package/mcp/dist/init-config.js +336 -0
- package/mcp/dist/init-preferences.js +62 -0
- package/mcp/dist/init-setup.js +1305 -0
- package/mcp/dist/init-shared.js +29 -0
- package/mcp/dist/init.js +1730 -0
- package/mcp/dist/link-checksums.js +62 -0
- package/mcp/dist/link-context.js +257 -0
- package/mcp/dist/link-doctor.js +591 -0
- package/mcp/dist/link-skills.js +212 -0
- package/mcp/dist/link.js +596 -0
- package/mcp/dist/logger.js +15 -0
- package/mcp/dist/machine-identity.js +38 -0
- package/mcp/dist/mcp-config.js +254 -0
- package/mcp/dist/mcp-data.js +315 -0
- package/mcp/dist/mcp-extract-facts.js +78 -0
- package/mcp/dist/mcp-extract.js +133 -0
- package/mcp/dist/mcp-finding.js +557 -0
- package/mcp/dist/mcp-graph.js +339 -0
- package/mcp/dist/mcp-hooks.js +256 -0
- package/mcp/dist/mcp-memory.js +58 -0
- package/mcp/dist/mcp-ops.js +328 -0
- package/mcp/dist/mcp-search.js +628 -0
- package/mcp/dist/mcp-session.js +651 -0
- package/mcp/dist/mcp-skills.js +189 -0
- package/mcp/dist/mcp-tasks.js +551 -0
- package/mcp/dist/mcp-types.js +7 -0
- package/mcp/dist/memory-ui-assets.js +6 -0
- package/mcp/dist/memory-ui-data.js +513 -0
- package/mcp/dist/memory-ui-graph.js +1910 -0
- package/mcp/dist/memory-ui-page.js +353 -0
- package/mcp/dist/memory-ui-scripts.js +1387 -0
- package/mcp/dist/memory-ui-server.js +1218 -0
- package/mcp/dist/memory-ui-styles.js +555 -0
- package/mcp/dist/memory-ui.js +9 -0
- package/mcp/dist/package-metadata.js +13 -0
- package/mcp/dist/phren-art.js +52 -0
- package/mcp/dist/phren-core.js +108 -0
- package/mcp/dist/phren-dotenv.js +67 -0
- package/mcp/dist/phren-paths.js +476 -0
- package/mcp/dist/proactivity.js +172 -0
- package/mcp/dist/profile-store.js +228 -0
- package/mcp/dist/project-config.js +85 -0
- package/mcp/dist/project-locator.js +25 -0
- package/mcp/dist/project-topics.js +1134 -0
- package/mcp/dist/provider-adapters.js +176 -0
- package/mcp/dist/runtime-profile.js +18 -0
- package/mcp/dist/session-checkpoints.js +131 -0
- package/mcp/dist/session-utils.js +68 -0
- package/mcp/dist/shared-content.js +8 -0
- package/mcp/dist/shared-embedding-cache.js +143 -0
- package/mcp/dist/shared-fragment-graph.js +456 -0
- package/mcp/dist/shared-governance.js +4 -0
- package/mcp/dist/shared-index.js +1334 -0
- package/mcp/dist/shared-ollama.js +192 -0
- package/mcp/dist/shared-paths.js +1 -0
- package/mcp/dist/shared-retrieval.js +796 -0
- package/mcp/dist/shared-search-fallback.js +375 -0
- package/mcp/dist/shared-sqljs.js +42 -0
- package/mcp/dist/shared-stemmer.js +171 -0
- package/mcp/dist/shared-vector-index.js +199 -0
- package/mcp/dist/shared.js +114 -0
- package/mcp/dist/shell-entry.js +209 -0
- package/mcp/dist/shell-input.js +943 -0
- package/mcp/dist/shell-palette.js +119 -0
- package/mcp/dist/shell-render.js +252 -0
- package/mcp/dist/shell-state-store.js +81 -0
- package/mcp/dist/shell-types.js +13 -0
- package/mcp/dist/shell-view-list.js +14 -0
- package/mcp/dist/shell-view.js +707 -0
- package/mcp/dist/shell.js +352 -0
- package/mcp/dist/skill-files.js +117 -0
- package/mcp/dist/skill-registry.js +279 -0
- package/mcp/dist/skill-state.js +28 -0
- package/mcp/dist/startup-embedding.js +57 -0
- package/mcp/dist/status.js +323 -0
- package/mcp/dist/synonyms.json +670 -0
- package/mcp/dist/task-hygiene.js +251 -0
- package/mcp/dist/task-lifecycle.js +347 -0
- package/mcp/dist/tasks-github.js +76 -0
- package/mcp/dist/telemetry.js +165 -0
- package/mcp/dist/test-global-setup.js +37 -0
- package/mcp/dist/tool-registry.js +104 -0
- package/mcp/dist/update.js +97 -0
- package/mcp/dist/utils.js +543 -0
- package/package.json +67 -0
- package/skills/README.md +7 -0
- package/skills/consolidate/SKILL.md +152 -0
- package/skills/discover/SKILL.md +175 -0
- package/skills/init/SKILL.md +216 -0
- package/skills/profiles/SKILL.md +121 -0
- package/skills/sync/SKILL.md +261 -0
- package/starter/README.md +74 -0
- package/starter/global/CLAUDE.md +89 -0
- package/starter/global/skills/humanize.md +30 -0
- package/starter/global/skills/pipeline.md +35 -0
- package/starter/global/skills/release.md +35 -0
- package/starter/machines.yaml +8 -0
- package/starter/my-api/.claude/skills/README.md +7 -0
- package/starter/my-api/CLAUDE.md +33 -0
- package/starter/my-api/FINDINGS.md +9 -0
- package/starter/my-api/summary.md +7 -0
- package/starter/my-api/tasks.md +7 -0
- package/starter/my-first-project/.claude/skills/README.md +7 -0
- package/starter/my-first-project/CLAUDE.md +49 -0
- package/starter/my-first-project/FINDINGS.md +24 -0
- package/starter/my-first-project/summary.md +11 -0
- package/starter/my-first-project/tasks.md +25 -0
- package/starter/my-frontend/.claude/skills/README.md +7 -0
- package/starter/my-frontend/CLAUDE.md +33 -0
- package/starter/my-frontend/FINDINGS.md +9 -0
- package/starter/my-frontend/summary.md +7 -0
- package/starter/my-frontend/tasks.md +7 -0
- package/starter/profiles/default.yaml +4 -0
- package/starter/profiles/personal.yaml +4 -0
- package/starter/profiles/work.yaml +4 -0
- package/starter/templates/README.md +7 -0
- package/starter/templates/frontend/CLAUDE.md +23 -0
- package/starter/templates/frontend/FINDINGS.md +7 -0
- package/starter/templates/frontend/reference/README.md +4 -0
- package/starter/templates/frontend/summary.md +7 -0
- package/starter/templates/frontend/tasks.md +11 -0
- package/starter/templates/library/CLAUDE.md +22 -0
- package/starter/templates/library/FINDINGS.md +7 -0
- package/starter/templates/library/reference/README.md +4 -0
- package/starter/templates/library/summary.md +7 -0
- package/starter/templates/library/tasks.md +11 -0
- package/starter/templates/monorepo/CLAUDE.md +21 -0
- package/starter/templates/monorepo/FINDINGS.md +7 -0
- package/starter/templates/monorepo/reference/README.md +4 -0
- package/starter/templates/monorepo/summary.md +7 -0
- package/starter/templates/monorepo/tasks.md +11 -0
- package/starter/templates/python-project/CLAUDE.md +21 -0
- package/starter/templates/python-project/FINDINGS.md +7 -0
- package/starter/templates/python-project/reference/README.md +4 -0
- package/starter/templates/python-project/summary.md +7 -0
- package/starter/templates/python-project/tasks.md +10 -0
|
@@ -0,0 +1,1402 @@
|
|
|
1
|
+
import { buildHookContext, handleGuardSkip, debugLog, appendAuditLog, runtimeFile, sessionMarker, EXEC_TIMEOUT_MS, getPhrenPath, getProjectDirs, findProjectNameCaseInsensitive, homePath, updateRuntimeHealth, withFileLock, getWorkflowPolicy, appendReviewQueue, recordFeedback, getQualityMultiplier, detectProject, isProjectHookEnabled, readProjectConfig, getProjectSourcePath, detectProjectDir, ensureLocalGitRepo, isProjectTracked, repairPreexistingInstall, getProactivityLevelForTask, getProactivityLevelForFindings, hasExplicitFindingSignal, shouldAutoCaptureFindingsForLevel, FINDING_SENSITIVITY_CONFIG, isFeatureEnabled, errorMessage, bootstrapPhrenDotEnv, finalizeTaskSession, appendFindingJournal, runDoctor, resolveRuntimeProfile, } from "./cli-hooks-context.js";
|
|
2
|
+
import { sessionMetricsFile, qualityMarkers, } from "./shared.js";
|
|
3
|
+
import { autoMergeConflicts, mergeTask, mergeFindings, } from "./shared-content.js";
|
|
4
|
+
import { runGit } from "./utils.js";
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import * as os from "os";
|
|
8
|
+
import { execFileSync, spawn } from "child_process";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
import { isTaskFileName, TASKS_FILENAME } from "./data-tasks.js";
|
|
11
|
+
import { buildIndex, queryRows, } from "./shared-index.js";
|
|
12
|
+
import { filterTaskByPriority } from "./shared-retrieval.js";
|
|
13
|
+
function getRuntimeProfile() {
|
|
14
|
+
return resolveRuntimeProfile(getPhrenPath());
|
|
15
|
+
}
|
|
16
|
+
export { buildHookContext, checkHookGuard, handleGuardSkip } from "./cli-hooks-context.js";
|
|
17
|
+
/** Read JSON from stdin if it's not a TTY. Returns null if stdin is a TTY or parsing fails. */
|
|
18
|
+
function readStdinJson() {
|
|
19
|
+
if (process.stdin.isTTY)
|
|
20
|
+
return null;
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(fs.readFileSync(0, "utf-8"));
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
if (process.env.PHREN_DEBUG)
|
|
26
|
+
process.stderr.write(`[phren] readStdinJson: ${errorMessage(err)}\n`);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Validate that a transcript path points to a safe, expected location.
|
|
31
|
+
* Uses realpathSync to dereference symlinks, preventing traversal attacks
|
|
32
|
+
* where a symlink inside a safe dir points outside it.
|
|
33
|
+
*/
|
|
34
|
+
function isSafeTranscriptPath(p) {
|
|
35
|
+
// Resolve symlinks so a link like ~/.claude/evil -> /etc/passwd is caught
|
|
36
|
+
let normalized;
|
|
37
|
+
try {
|
|
38
|
+
normalized = fs.realpathSync.native(p);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// If the file doesn't exist yet, fall back to lexical resolution
|
|
42
|
+
try {
|
|
43
|
+
normalized = fs.realpathSync.native(path.dirname(p));
|
|
44
|
+
normalized = path.join(normalized, path.basename(p));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
normalized = path.resolve(p);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const safePrefixes = [
|
|
51
|
+
path.resolve(os.tmpdir()),
|
|
52
|
+
path.resolve(homePath(".claude")),
|
|
53
|
+
path.resolve(homePath(".config", "claude")),
|
|
54
|
+
];
|
|
55
|
+
return safePrefixes.some(prefix => normalized.startsWith(prefix + path.sep) || normalized === prefix);
|
|
56
|
+
}
|
|
57
|
+
export function getUntrackedProjectNotice(phrenPath, cwd) {
|
|
58
|
+
const profile = resolveRuntimeProfile(phrenPath);
|
|
59
|
+
const projectDir = detectProjectDir(cwd, phrenPath);
|
|
60
|
+
if (!projectDir)
|
|
61
|
+
return null;
|
|
62
|
+
const activeProfile = profile || undefined;
|
|
63
|
+
// Check the exact current working directory against projects in the active profile.
|
|
64
|
+
// This avoids prompting when cwd is already inside a tracked sourcePath.
|
|
65
|
+
if (detectProject(phrenPath, cwd, activeProfile))
|
|
66
|
+
return null;
|
|
67
|
+
if (detectProject(phrenPath, projectDir, activeProfile))
|
|
68
|
+
return null;
|
|
69
|
+
const projectName = path.basename(projectDir).toLowerCase().replace(/[^a-z0-9_-]/g, "-");
|
|
70
|
+
if (isProjectTracked(phrenPath, projectName, activeProfile)) {
|
|
71
|
+
const trackedName = getProjectDirs(phrenPath, activeProfile)
|
|
72
|
+
.map((dir) => path.basename(dir))
|
|
73
|
+
.find((name) => name.toLowerCase() === projectName)
|
|
74
|
+
|| findProjectNameCaseInsensitive(phrenPath, projectName)
|
|
75
|
+
|| projectName;
|
|
76
|
+
const config = readProjectConfig(phrenPath, trackedName);
|
|
77
|
+
const sourcePath = getProjectSourcePath(phrenPath, trackedName, config);
|
|
78
|
+
if (!sourcePath)
|
|
79
|
+
return null;
|
|
80
|
+
const resolvedProjectDir = path.resolve(projectDir);
|
|
81
|
+
const sameSource = resolvedProjectDir === sourcePath || resolvedProjectDir.startsWith(sourcePath + path.sep);
|
|
82
|
+
if (sameSource)
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return [
|
|
86
|
+
"<phren-notice>",
|
|
87
|
+
"This project directory is not tracked by phren yet.",
|
|
88
|
+
"Run `npx phren add` to track it now.",
|
|
89
|
+
`Suggested command: \`npx phren add \"${projectDir}\"\``,
|
|
90
|
+
"Ask the user whether they want to add it to phren now.",
|
|
91
|
+
"If they say no, tell them they can always run `npx phren add` later.",
|
|
92
|
+
"If they say yes, also ask whether phren should manage repo instruction files or leave their existing repo-owned CLAUDE/AGENTS files alone.",
|
|
93
|
+
`Then use the \`add_project\` MCP tool with path="${projectDir}" and ownership="phren-managed"|"detached"|"repo-managed", or run \`npx phren add\` from that directory.`,
|
|
94
|
+
"After onboarding, run `npx phren doctor` if hooks or MCP tools are not responding.",
|
|
95
|
+
"<phren-notice>",
|
|
96
|
+
"",
|
|
97
|
+
].join("\n");
|
|
98
|
+
}
|
|
99
|
+
const SESSION_START_ONBOARDING_MARKER = "session-start-onboarding-v1";
|
|
100
|
+
function projectHasBootstrapSignals(phrenPath, project) {
|
|
101
|
+
const projectDir = path.join(phrenPath, project);
|
|
102
|
+
const findingsPath = path.join(projectDir, "FINDINGS.md");
|
|
103
|
+
if (fs.existsSync(findingsPath)) {
|
|
104
|
+
const findings = fs.readFileSync(findingsPath, "utf8");
|
|
105
|
+
if (/^-\s+/m.test(findings))
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
const tasksPath = path.join(projectDir, TASKS_FILENAME);
|
|
109
|
+
if (fs.existsSync(tasksPath)) {
|
|
110
|
+
const tasks = fs.readFileSync(tasksPath, "utf8");
|
|
111
|
+
if (/^-\s+\[(?: |x|X)\]/m.test(tasks))
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
export function getSessionStartOnboardingNotice(phrenPath, cwd, activeProject) {
|
|
117
|
+
const markerPath = sessionMarker(phrenPath, SESSION_START_ONBOARDING_MARKER);
|
|
118
|
+
if (fs.existsSync(markerPath))
|
|
119
|
+
return null;
|
|
120
|
+
if (getUntrackedProjectNotice(phrenPath, cwd))
|
|
121
|
+
return null;
|
|
122
|
+
const profile = resolveRuntimeProfile(phrenPath);
|
|
123
|
+
const trackedProjects = getProjectDirs(phrenPath, profile).filter((dir) => path.basename(dir) !== "global");
|
|
124
|
+
if (trackedProjects.length === 0) {
|
|
125
|
+
return [
|
|
126
|
+
"<phren-notice>",
|
|
127
|
+
"Phren onboarding: no tracked projects are active for this workspace yet.",
|
|
128
|
+
"Start in a project repo and run `npx phren add` so SessionStart can inject project context.",
|
|
129
|
+
"Run `npx phren doctor` to verify hooks and MCP wiring after setup.",
|
|
130
|
+
"<phren-notice>",
|
|
131
|
+
"",
|
|
132
|
+
].join("\n");
|
|
133
|
+
}
|
|
134
|
+
if (!activeProject)
|
|
135
|
+
return null;
|
|
136
|
+
if (projectHasBootstrapSignals(phrenPath, activeProject))
|
|
137
|
+
return null;
|
|
138
|
+
return [
|
|
139
|
+
"<phren-notice>",
|
|
140
|
+
`Phren onboarding: project "${activeProject}" is tracked but memory is still empty.`,
|
|
141
|
+
"Capture one finding with `add_finding` and one task with `add_task` to seed future SessionStart context.",
|
|
142
|
+
"Run `npx phren doctor` if setup seems incomplete.",
|
|
143
|
+
"<phren-notice>",
|
|
144
|
+
"",
|
|
145
|
+
].join("\n");
|
|
146
|
+
}
|
|
147
|
+
export function getGitContext(cwd) {
|
|
148
|
+
if (!cwd)
|
|
149
|
+
return null;
|
|
150
|
+
const git = (args) => runGit(cwd, args, EXEC_TIMEOUT_MS, debugLog);
|
|
151
|
+
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
152
|
+
if (!branch)
|
|
153
|
+
return null;
|
|
154
|
+
const changedFiles = new Set();
|
|
155
|
+
for (const changed of [
|
|
156
|
+
git(["diff", "--name-only"]),
|
|
157
|
+
git(["diff", "--name-only", "--cached"]),
|
|
158
|
+
]) {
|
|
159
|
+
if (!changed)
|
|
160
|
+
continue;
|
|
161
|
+
for (const line of changed.split("\n").map((s) => s.trim()).filter(Boolean)) {
|
|
162
|
+
changedFiles.add(line);
|
|
163
|
+
const basename = path.basename(line);
|
|
164
|
+
if (basename)
|
|
165
|
+
changedFiles.add(basename);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { branch, changedFiles };
|
|
169
|
+
}
|
|
170
|
+
function parseSessionMetrics(phrenPathLocal) {
|
|
171
|
+
const file = sessionMetricsFile(phrenPathLocal);
|
|
172
|
+
if (!fs.existsSync(file))
|
|
173
|
+
return {};
|
|
174
|
+
try {
|
|
175
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
debugLog(`parseSessionMetrics: failed to read ${file}: ${errorMessage(err)}`);
|
|
179
|
+
return {};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function writeSessionMetrics(phrenPathLocal, data) {
|
|
183
|
+
const file = sessionMetricsFile(phrenPathLocal);
|
|
184
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
185
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
|
|
186
|
+
}
|
|
187
|
+
function updateSessionMetrics(phrenPathLocal, updater) {
|
|
188
|
+
const file = sessionMetricsFile(phrenPathLocal);
|
|
189
|
+
withFileLock(file, () => {
|
|
190
|
+
const metrics = parseSessionMetrics(phrenPathLocal);
|
|
191
|
+
updater(metrics);
|
|
192
|
+
writeSessionMetrics(phrenPathLocal, metrics);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
export function trackSessionMetrics(phrenPathLocal, sessionId, selected) {
|
|
196
|
+
updateSessionMetrics(phrenPathLocal, (metrics) => {
|
|
197
|
+
if (!metrics[sessionId])
|
|
198
|
+
metrics[sessionId] = { prompts: 0, keys: {}, lastChangedCount: 0, lastKeys: [] };
|
|
199
|
+
metrics[sessionId].prompts += 1;
|
|
200
|
+
const injectedKeys = [];
|
|
201
|
+
for (const injected of selected) {
|
|
202
|
+
injectedKeys.push(injected.key);
|
|
203
|
+
const key = injected.key;
|
|
204
|
+
const seen = metrics[sessionId].keys[key] || 0;
|
|
205
|
+
metrics[sessionId].keys[key] = seen + 1;
|
|
206
|
+
if (seen >= 1)
|
|
207
|
+
recordFeedback(phrenPathLocal, key, "reprompt");
|
|
208
|
+
}
|
|
209
|
+
const relevantCount = selected.filter((s) => getQualityMultiplier(phrenPathLocal, s.key) > 0.5).length;
|
|
210
|
+
const prevRelevant = metrics[sessionId].lastChangedCount || 0;
|
|
211
|
+
const prevKeys = metrics[sessionId].lastKeys || [];
|
|
212
|
+
if (relevantCount > prevRelevant) {
|
|
213
|
+
for (const prevKey of prevKeys) {
|
|
214
|
+
recordFeedback(phrenPathLocal, prevKey, "helpful");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
metrics[sessionId].lastChangedCount = relevantCount;
|
|
218
|
+
metrics[sessionId].lastKeys = injectedKeys;
|
|
219
|
+
metrics[sessionId].lastSeen = new Date().toISOString();
|
|
220
|
+
const thirtyDaysAgo = Date.now() - 30 * 86400000;
|
|
221
|
+
for (const sid of Object.keys(metrics)) {
|
|
222
|
+
const seen = metrics[sid].lastSeen;
|
|
223
|
+
if (seen && new Date(seen).getTime() < thirtyDaysAgo) {
|
|
224
|
+
delete metrics[sid];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
// ── Background maintenance ───────────────────────────────────────────────────
|
|
230
|
+
export function resolveSubprocessArgs(command) {
|
|
231
|
+
const distEntry = path.join(path.dirname(fileURLToPath(import.meta.url)), "index.js");
|
|
232
|
+
if (fs.existsSync(distEntry))
|
|
233
|
+
return [distEntry, command];
|
|
234
|
+
const sourceEntry = process.argv.find((a) => /[\\/]index\.(ts|js)$/.test(a) && fs.existsSync(a));
|
|
235
|
+
const runner = process.argv[1];
|
|
236
|
+
if (sourceEntry && runner)
|
|
237
|
+
return [runner, sourceEntry, command];
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
function scheduleBackgroundSync(phrenPathLocal) {
|
|
241
|
+
const lockPath = runtimeFile(phrenPathLocal, "background-sync.lock");
|
|
242
|
+
const logPath = runtimeFile(phrenPathLocal, "background-sync.log");
|
|
243
|
+
const spawnArgs = resolveSubprocessArgs("background-sync");
|
|
244
|
+
if (!spawnArgs)
|
|
245
|
+
return false;
|
|
246
|
+
try {
|
|
247
|
+
if (fs.existsSync(lockPath)) {
|
|
248
|
+
const ageMs = Date.now() - fs.statSync(lockPath).mtimeMs;
|
|
249
|
+
if (ageMs <= 10 * 60 * 1000)
|
|
250
|
+
return false;
|
|
251
|
+
fs.unlinkSync(lockPath);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
debugLog(`scheduleBackgroundSync: lock check failed: ${errorMessage(err)}`);
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
fs.writeFileSync(lockPath, JSON.stringify({ startedAt: new Date().toISOString(), pid: process.pid }) + "\n", { flag: "wx" });
|
|
260
|
+
const logFd = fs.openSync(logPath, "a");
|
|
261
|
+
fs.writeSync(logFd, `[${new Date().toISOString()}] spawn ${process.execPath} ${spawnArgs.join(" ")}\n`);
|
|
262
|
+
const child = spawn(process.execPath, spawnArgs, {
|
|
263
|
+
cwd: process.cwd(),
|
|
264
|
+
detached: true,
|
|
265
|
+
stdio: ["ignore", logFd, logFd],
|
|
266
|
+
env: {
|
|
267
|
+
...process.env,
|
|
268
|
+
PHREN_PATH: phrenPathLocal,
|
|
269
|
+
PHREN_PROFILE: getRuntimeProfile(),
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
child.unref();
|
|
273
|
+
fs.closeSync(logFd);
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
try {
|
|
278
|
+
fs.unlinkSync(lockPath);
|
|
279
|
+
}
|
|
280
|
+
catch { }
|
|
281
|
+
debugLog(`scheduleBackgroundSync: spawn failed: ${errorMessage(err)}`);
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function scheduleBackgroundMaintenance(phrenPathLocal, project) {
|
|
286
|
+
if (!isFeatureEnabled("PHREN_FEATURE_DAILY_MAINTENANCE", true))
|
|
287
|
+
return false;
|
|
288
|
+
const markers = qualityMarkers(phrenPathLocal);
|
|
289
|
+
if (fs.existsSync(markers.done))
|
|
290
|
+
return false;
|
|
291
|
+
if (fs.existsSync(markers.lock)) {
|
|
292
|
+
try {
|
|
293
|
+
const ageMs = Date.now() - fs.statSync(markers.lock).mtimeMs;
|
|
294
|
+
if (ageMs <= 2 * 60 * 60 * 1000)
|
|
295
|
+
return false;
|
|
296
|
+
fs.unlinkSync(markers.lock);
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
debugLog(`maybeRunBackgroundMaintenance: lock check failed: ${errorMessage(err)}`);
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const spawnArgs = resolveSubprocessArgs("background-maintenance");
|
|
304
|
+
if (!spawnArgs)
|
|
305
|
+
return false;
|
|
306
|
+
try {
|
|
307
|
+
// Use exclusive open (O_EXCL) to atomically claim the lock; if another process
|
|
308
|
+
// already holds it this throws and we return false without spawning a duplicate.
|
|
309
|
+
const lockContent = JSON.stringify({
|
|
310
|
+
startedAt: new Date().toISOString(),
|
|
311
|
+
project: project || "all",
|
|
312
|
+
pid: process.pid,
|
|
313
|
+
}) + "\n";
|
|
314
|
+
let fd;
|
|
315
|
+
try {
|
|
316
|
+
fd = fs.openSync(markers.lock, "wx");
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
// Another process already claimed the lock
|
|
320
|
+
if (process.env.PHREN_DEBUG)
|
|
321
|
+
process.stderr.write(`[phren] backgroundMaintenance lockClaim: ${errorMessage(err)}\n`);
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
fs.writeSync(fd, lockContent);
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
fs.closeSync(fd);
|
|
329
|
+
}
|
|
330
|
+
if (project)
|
|
331
|
+
spawnArgs.push(project);
|
|
332
|
+
const logDir = path.join(phrenPathLocal, ".governance");
|
|
333
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
334
|
+
const logPath = path.join(logDir, "background-maintenance.log");
|
|
335
|
+
const logFd = fs.openSync(logPath, "a");
|
|
336
|
+
fs.writeSync(logFd, `[${new Date().toISOString()}] spawn ${process.execPath} ${spawnArgs.join(" ")}\n`);
|
|
337
|
+
const child = spawn(process.execPath, spawnArgs, {
|
|
338
|
+
cwd: process.cwd(),
|
|
339
|
+
detached: true,
|
|
340
|
+
stdio: ["ignore", logFd, logFd],
|
|
341
|
+
env: {
|
|
342
|
+
...process.env,
|
|
343
|
+
PHREN_PATH: phrenPathLocal,
|
|
344
|
+
PHREN_PROFILE: getRuntimeProfile(),
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
child.on("exit", (code, signal) => {
|
|
348
|
+
const msg = `[${new Date().toISOString()}] exit code=${code ?? "null"} signal=${signal ?? "none"}\n`;
|
|
349
|
+
try {
|
|
350
|
+
fs.appendFileSync(logPath, msg);
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
if (process.env.PHREN_DEBUG)
|
|
354
|
+
process.stderr.write(`[phren] backgroundMaintenance exitLog: ${errorMessage(err)}\n`);
|
|
355
|
+
}
|
|
356
|
+
if (code === 0) {
|
|
357
|
+
try {
|
|
358
|
+
fs.writeFileSync(markers.done, new Date().toISOString() + "\n");
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
if (process.env.PHREN_DEBUG)
|
|
362
|
+
process.stderr.write(`[phren] backgroundMaintenance doneMarker: ${errorMessage(err)}\n`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
fs.unlinkSync(markers.lock);
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
if (process.env.PHREN_DEBUG)
|
|
370
|
+
process.stderr.write(`[phren] backgroundMaintenance unlockOnExit: ${errorMessage(err)}\n`);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
child.on("error", (spawnErr) => {
|
|
374
|
+
const msg = `[${new Date().toISOString()}] spawn error: ${spawnErr.message}\n`;
|
|
375
|
+
try {
|
|
376
|
+
fs.appendFileSync(logPath, msg);
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
if (process.env.PHREN_DEBUG)
|
|
380
|
+
process.stderr.write(`[phren] backgroundMaintenance errorLog: ${errorMessage(err)}\n`);
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
fs.unlinkSync(markers.lock);
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
if (process.env.PHREN_DEBUG)
|
|
387
|
+
process.stderr.write(`[phren] backgroundMaintenance unlockOnError: ${errorMessage(err)}\n`);
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
fs.closeSync(logFd);
|
|
391
|
+
child.unref();
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
catch (err) {
|
|
395
|
+
const errMsg = errorMessage(err);
|
|
396
|
+
try {
|
|
397
|
+
const logDir = path.join(phrenPathLocal, ".governance");
|
|
398
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
399
|
+
fs.appendFileSync(path.join(logDir, "background-maintenance.log"), `[${new Date().toISOString()}] spawn failed: ${errMsg}\n`);
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
if (process.env.PHREN_DEBUG)
|
|
403
|
+
process.stderr.write(`[phren] backgroundMaintenance logSpawnFailure: ${errorMessage(err)}\n`);
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
fs.unlinkSync(markers.lock);
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
if (process.env.PHREN_DEBUG)
|
|
410
|
+
process.stderr.write(`[phren] backgroundMaintenance unlockOnFailure: ${errorMessage(err)}\n`);
|
|
411
|
+
}
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// ── Git command helpers for hooks ────────────────────────────────────────────
|
|
416
|
+
function isTransientGitError(message) {
|
|
417
|
+
return /(timed out|connection|network|could not resolve host|rpc failed|429|502|503|504|service unavailable)/i.test(message);
|
|
418
|
+
}
|
|
419
|
+
function shouldRetryGitCommand(args) {
|
|
420
|
+
const cmd = args[0] || "";
|
|
421
|
+
return cmd === "push" || cmd === "pull" || cmd === "fetch";
|
|
422
|
+
}
|
|
423
|
+
async function runBestEffortGit(args, cwd) {
|
|
424
|
+
const retries = shouldRetryGitCommand(args) ? 2 : 0;
|
|
425
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
426
|
+
try {
|
|
427
|
+
const output = execFileSync("git", args, {
|
|
428
|
+
cwd,
|
|
429
|
+
encoding: "utf8",
|
|
430
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
431
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
432
|
+
}).trim();
|
|
433
|
+
return { ok: true, output };
|
|
434
|
+
}
|
|
435
|
+
catch (err) {
|
|
436
|
+
const message = errorMessage(err);
|
|
437
|
+
if (attempt < retries && isTransientGitError(message)) {
|
|
438
|
+
const delayMs = 500 * (attempt + 1);
|
|
439
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
return { ok: false, error: message };
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return { ok: false, error: "git command failed" };
|
|
446
|
+
}
|
|
447
|
+
async function countUnsyncedCommits(cwd) {
|
|
448
|
+
const upstream = await runBestEffortGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
|
|
449
|
+
if (!upstream.ok || !upstream.output)
|
|
450
|
+
return 0;
|
|
451
|
+
const ahead = await runBestEffortGit(["rev-list", "--count", `${upstream.output.trim()}..HEAD`], cwd);
|
|
452
|
+
if (!ahead.ok || !ahead.output)
|
|
453
|
+
return 0;
|
|
454
|
+
const parsed = Number.parseInt(ahead.output.trim(), 10);
|
|
455
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
456
|
+
}
|
|
457
|
+
function isMergeableMarkdown(relPath) {
|
|
458
|
+
const filename = path.basename(relPath).toLowerCase();
|
|
459
|
+
return filename === "findings.md" || isTaskFileName(filename);
|
|
460
|
+
}
|
|
461
|
+
async function snapshotLocalMergeableFiles(cwd) {
|
|
462
|
+
const upstream = await runBestEffortGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
|
|
463
|
+
if (!upstream.ok || !upstream.output)
|
|
464
|
+
return new Map();
|
|
465
|
+
const changed = await runBestEffortGit(["diff", "--name-only", `${upstream.output.trim()}..HEAD`], cwd);
|
|
466
|
+
if (!changed.ok || !changed.output)
|
|
467
|
+
return new Map();
|
|
468
|
+
const snapshots = new Map();
|
|
469
|
+
for (const relPath of changed.output.split("\n").map((line) => line.trim()).filter(Boolean)) {
|
|
470
|
+
if (!isMergeableMarkdown(relPath))
|
|
471
|
+
continue;
|
|
472
|
+
const fullPath = path.join(cwd, relPath);
|
|
473
|
+
if (!fs.existsSync(fullPath))
|
|
474
|
+
continue;
|
|
475
|
+
snapshots.set(relPath, fs.readFileSync(fullPath, "utf8"));
|
|
476
|
+
}
|
|
477
|
+
return snapshots;
|
|
478
|
+
}
|
|
479
|
+
async function reconcileMergeableFiles(cwd, snapshots) {
|
|
480
|
+
let changedAny = false;
|
|
481
|
+
for (const [relPath, localBeforePull] of snapshots.entries()) {
|
|
482
|
+
const fullPath = path.join(cwd, relPath);
|
|
483
|
+
if (!fs.existsSync(fullPath))
|
|
484
|
+
continue;
|
|
485
|
+
const current = fs.readFileSync(fullPath, "utf8");
|
|
486
|
+
const filename = path.basename(relPath).toLowerCase();
|
|
487
|
+
const merged = filename === "findings.md"
|
|
488
|
+
? mergeFindings(current, localBeforePull)
|
|
489
|
+
: mergeTask(current, localBeforePull);
|
|
490
|
+
if (merged === current)
|
|
491
|
+
continue;
|
|
492
|
+
fs.writeFileSync(fullPath, merged);
|
|
493
|
+
changedAny = true;
|
|
494
|
+
}
|
|
495
|
+
if (!changedAny)
|
|
496
|
+
return false;
|
|
497
|
+
const add = await runBestEffortGit(["add", "--", ...snapshots.keys()], cwd);
|
|
498
|
+
if (!add.ok)
|
|
499
|
+
return false;
|
|
500
|
+
const commit = await runBestEffortGit(["commit", "-m", "auto-merge markdown recovery"], cwd);
|
|
501
|
+
return commit.ok;
|
|
502
|
+
}
|
|
503
|
+
async function recoverPushConflict(cwd) {
|
|
504
|
+
const localSnapshots = await snapshotLocalMergeableFiles(cwd);
|
|
505
|
+
const pull = await runBestEffortGit(["pull", "--rebase", "--quiet"], cwd);
|
|
506
|
+
if (pull.ok) {
|
|
507
|
+
const reconciled = await reconcileMergeableFiles(cwd, localSnapshots);
|
|
508
|
+
const retryPush = await runBestEffortGit(["push"], cwd);
|
|
509
|
+
return {
|
|
510
|
+
ok: retryPush.ok,
|
|
511
|
+
detail: retryPush.ok
|
|
512
|
+
? (reconciled ? "commit pushed after pull --rebase and markdown reconciliation" : "commit pushed after pull --rebase")
|
|
513
|
+
: (retryPush.error || "push failed after pull --rebase"),
|
|
514
|
+
pullStatus: "ok",
|
|
515
|
+
pullDetail: pull.output || "pull --rebase ok",
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
const conflicted = await runBestEffortGit(["diff", "--name-only", "--diff-filter=U"], cwd);
|
|
519
|
+
const conflictedOutput = conflicted.output?.trim() || "";
|
|
520
|
+
if (!conflicted.ok || !conflictedOutput) {
|
|
521
|
+
await runBestEffortGit(["rebase", "--abort"], cwd);
|
|
522
|
+
return {
|
|
523
|
+
ok: false,
|
|
524
|
+
detail: pull.error || "pull --rebase failed",
|
|
525
|
+
pullStatus: "error",
|
|
526
|
+
pullDetail: pull.error || "pull --rebase failed",
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
if (!autoMergeConflicts(cwd)) {
|
|
530
|
+
await runBestEffortGit(["rebase", "--abort"], cwd);
|
|
531
|
+
return {
|
|
532
|
+
ok: false,
|
|
533
|
+
detail: `rebase conflicts require manual resolution: ${conflictedOutput}`,
|
|
534
|
+
pullStatus: "error",
|
|
535
|
+
pullDetail: `rebase conflicts require manual resolution: ${conflictedOutput}`,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
const continued = await runBestEffortGit(["-c", "core.editor=true", "rebase", "--continue"], cwd);
|
|
539
|
+
if (!continued.ok) {
|
|
540
|
+
await runBestEffortGit(["rebase", "--abort"], cwd);
|
|
541
|
+
return {
|
|
542
|
+
ok: false,
|
|
543
|
+
detail: continued.error || "rebase --continue failed",
|
|
544
|
+
pullStatus: "error",
|
|
545
|
+
pullDetail: continued.error || "rebase --continue failed",
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
const retryPush = await runBestEffortGit(["push"], cwd);
|
|
549
|
+
return {
|
|
550
|
+
ok: retryPush.ok,
|
|
551
|
+
detail: retryPush.ok ? "commit pushed after auto-merge recovery" : (retryPush.error || "push failed after auto-merge recovery"),
|
|
552
|
+
pullStatus: "ok",
|
|
553
|
+
pullDetail: "pull --rebase recovered via auto-merge",
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
// ── Hook handlers ────────────────────────────────────────────────────────────
|
|
557
|
+
export async function handleHookSessionStart() {
|
|
558
|
+
const startedAt = new Date().toISOString();
|
|
559
|
+
const ctx = buildHookContext();
|
|
560
|
+
const { phrenPath, cwd, activeProject, manifest } = ctx;
|
|
561
|
+
// Check common guards (hooks enabled, tool enabled)
|
|
562
|
+
if (!ctx.hooksEnabled) {
|
|
563
|
+
handleGuardSkip(ctx, "hook_session_start", "disabled", { lastSessionStartAt: startedAt });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (!ctx.toolHookEnabled) {
|
|
567
|
+
handleGuardSkip(ctx, "hook_session_start", `tool_disabled tool=${ctx.hookTool}`);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
repairPreexistingInstall(phrenPath);
|
|
572
|
+
}
|
|
573
|
+
catch (err) {
|
|
574
|
+
debugLog(`hook-session-start repair failed: ${errorMessage(err)}`);
|
|
575
|
+
}
|
|
576
|
+
if (!isProjectHookEnabled(phrenPath, activeProject, "SessionStart")) {
|
|
577
|
+
handleGuardSkip(ctx, "hook_session_start", `project_disabled project=${activeProject}`, { lastSessionStartAt: startedAt });
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (manifest?.installMode === "project-local") {
|
|
581
|
+
updateRuntimeHealth(phrenPath, {
|
|
582
|
+
lastSessionStartAt: startedAt,
|
|
583
|
+
lastSync: {
|
|
584
|
+
lastPullAt: startedAt,
|
|
585
|
+
lastPullStatus: "ok",
|
|
586
|
+
lastPullDetail: "project-local mode does not manage git sync",
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
appendAuditLog(phrenPath, "hook_session_start", "status=skipped-local");
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
const gitRepo = ensureLocalGitRepo(phrenPath);
|
|
593
|
+
const remotes = gitRepo.ok ? await runBestEffortGit(["remote"], phrenPath) : { ok: false, error: gitRepo.detail };
|
|
594
|
+
const hasRemote = Boolean(remotes.ok && remotes.output && remotes.output.trim());
|
|
595
|
+
const pull = !gitRepo.ok
|
|
596
|
+
? { ok: false, error: gitRepo.detail }
|
|
597
|
+
: hasRemote
|
|
598
|
+
? await runBestEffortGit(["pull", "--rebase", "--quiet"], phrenPath)
|
|
599
|
+
: {
|
|
600
|
+
ok: true,
|
|
601
|
+
output: gitRepo.initialized
|
|
602
|
+
? "initialized local git repo; no remote configured"
|
|
603
|
+
: "local-only repo; no remote configured",
|
|
604
|
+
};
|
|
605
|
+
const doctor = await runDoctor(phrenPath, false);
|
|
606
|
+
const maintenanceScheduled = scheduleBackgroundMaintenance(phrenPath);
|
|
607
|
+
const unsyncedCommits = hasRemote ? await countUnsyncedCommits(phrenPath) : 0;
|
|
608
|
+
try {
|
|
609
|
+
const { trackSession } = await import("./telemetry.js");
|
|
610
|
+
trackSession(phrenPath);
|
|
611
|
+
}
|
|
612
|
+
catch (err) {
|
|
613
|
+
if (process.env.PHREN_DEBUG)
|
|
614
|
+
process.stderr.write(`[phren] hookSessionStart trackSession: ${errorMessage(err)}\n`);
|
|
615
|
+
}
|
|
616
|
+
updateRuntimeHealth(phrenPath, {
|
|
617
|
+
lastSessionStartAt: startedAt,
|
|
618
|
+
lastSync: {
|
|
619
|
+
lastPullAt: startedAt,
|
|
620
|
+
lastPullStatus: pull.ok ? "ok" : "error",
|
|
621
|
+
lastPullDetail: pull.ok ? (pull.output || "pull ok") : (pull.error || "pull failed"),
|
|
622
|
+
lastSuccessfulPullAt: pull.ok && hasRemote ? startedAt : undefined,
|
|
623
|
+
unsyncedCommits,
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
appendAuditLog(phrenPath, "hook_session_start", `pull=${hasRemote ? (pull.ok ? "ok" : "fail") : "skipped-local"} doctor=${doctor.ok ? "ok" : "issues"} maintenance=${maintenanceScheduled ? "scheduled" : "skipped"}`);
|
|
627
|
+
// Untracked project detection: suggest `phren add` if CWD looks like a project but isn't tracked
|
|
628
|
+
try {
|
|
629
|
+
const notice = getUntrackedProjectNotice(phrenPath, cwd);
|
|
630
|
+
if (notice) {
|
|
631
|
+
process.stdout.write(notice);
|
|
632
|
+
debugLog(`untracked project detected at ${cwd}`);
|
|
633
|
+
}
|
|
634
|
+
const onboarding = getSessionStartOnboardingNotice(phrenPath, cwd, activeProject);
|
|
635
|
+
if (onboarding) {
|
|
636
|
+
process.stdout.write(onboarding);
|
|
637
|
+
try {
|
|
638
|
+
fs.writeFileSync(sessionMarker(phrenPath, SESSION_START_ONBOARDING_MARKER), `${startedAt}\n`);
|
|
639
|
+
}
|
|
640
|
+
catch (err) {
|
|
641
|
+
debugLog(`session-start onboarding marker write failed: ${errorMessage(err)}`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
catch (err) {
|
|
646
|
+
debugLog(`session-start onboarding detection failed: ${errorMessage(err)}`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
// ── Q21: Conversation memory capture ─────────────────────────────────────────
|
|
650
|
+
const INSIGHT_KEYWORDS = [
|
|
651
|
+
"always", "never", "important", "pitfall", "gotcha", "trick", "workaround",
|
|
652
|
+
"careful", "caveat", "beware", "note that", "make sure",
|
|
653
|
+
"don't forget", "remember to", "must", "avoid", "prefer",
|
|
654
|
+
];
|
|
655
|
+
const INSIGHT_KEYWORD_RE = new RegExp(`\\b(${INSIGHT_KEYWORDS.join("|")})\\b`, "i");
|
|
656
|
+
/**
|
|
657
|
+
* Extract potential insights from conversation text using keyword heuristics.
|
|
658
|
+
* Returns lines that contain insight-signal words and look like actionable knowledge.
|
|
659
|
+
*/
|
|
660
|
+
export function extractConversationInsights(text) {
|
|
661
|
+
const lines = text.split("\n").filter(l => l.trim().length > 20 && l.trim().length < 300);
|
|
662
|
+
const insights = [];
|
|
663
|
+
const seen = new Set();
|
|
664
|
+
for (const line of lines) {
|
|
665
|
+
const trimmed = line.trim();
|
|
666
|
+
// Skip code-only lines, headers, etc.
|
|
667
|
+
if (trimmed.startsWith("```") || trimmed.startsWith("#") || trimmed.startsWith("//"))
|
|
668
|
+
continue;
|
|
669
|
+
if (trimmed.startsWith("$") || trimmed.startsWith(">"))
|
|
670
|
+
continue;
|
|
671
|
+
if (INSIGHT_KEYWORD_RE.test(trimmed) || hasExplicitFindingSignal(trimmed)) {
|
|
672
|
+
// Normalize for dedup
|
|
673
|
+
const normalized = trimmed.toLowerCase().replace(/\s+/g, " ");
|
|
674
|
+
if (!seen.has(normalized)) {
|
|
675
|
+
seen.add(normalized);
|
|
676
|
+
insights.push(trimmed);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
// Cap to prevent flooding
|
|
681
|
+
return insights.slice(0, 5);
|
|
682
|
+
}
|
|
683
|
+
export function filterConversationInsightsForProactivity(insights, level = getProactivityLevelForFindings(getPhrenPath())) {
|
|
684
|
+
if (level === "high")
|
|
685
|
+
return insights;
|
|
686
|
+
return insights.filter((insight) => shouldAutoCaptureFindingsForLevel(level, insight));
|
|
687
|
+
}
|
|
688
|
+
export async function handleHookStop() {
|
|
689
|
+
const ctx = buildHookContext();
|
|
690
|
+
const { phrenPath, activeProject, manifest } = ctx;
|
|
691
|
+
const now = new Date().toISOString();
|
|
692
|
+
bootstrapPhrenDotEnv(phrenPath);
|
|
693
|
+
if (!ctx.hooksEnabled) {
|
|
694
|
+
handleGuardSkip(ctx, "hook_stop", "disabled", {
|
|
695
|
+
lastStopAt: now,
|
|
696
|
+
lastAutoSave: { at: now, status: "clean", detail: "hooks disabled by preference" },
|
|
697
|
+
});
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
if (!ctx.toolHookEnabled) {
|
|
701
|
+
handleGuardSkip(ctx, "hook_stop", `tool_disabled tool=${ctx.hookTool}`);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (!isProjectHookEnabled(phrenPath, activeProject, "Stop")) {
|
|
705
|
+
handleGuardSkip(ctx, "hook_stop", `project_disabled project=${activeProject}`, {
|
|
706
|
+
lastStopAt: now,
|
|
707
|
+
lastAutoSave: { at: now, status: "clean", detail: `hooks disabled for project ${activeProject}` },
|
|
708
|
+
});
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
// Read stdin early — it's a stream and can only be consumed once.
|
|
712
|
+
// Needed for auto-capture transcript_path parsing.
|
|
713
|
+
const stdinPayload = readStdinJson();
|
|
714
|
+
const taskSessionId = typeof stdinPayload?.session_id === "string" ? stdinPayload.session_id : undefined;
|
|
715
|
+
const taskLevel = getProactivityLevelForTask(phrenPath);
|
|
716
|
+
if (taskSessionId && taskLevel !== "high") {
|
|
717
|
+
debugLog(`hook-stop task proactivity=${taskLevel}`);
|
|
718
|
+
}
|
|
719
|
+
// Auto-capture BEFORE git operations so captured insights get committed and pushed.
|
|
720
|
+
// Gated behind PHREN_FEATURE_AUTO_CAPTURE=1.
|
|
721
|
+
const findingsLevel = getProactivityLevelForFindings(phrenPath);
|
|
722
|
+
if (isFeatureEnabled("PHREN_FEATURE_AUTO_CAPTURE", false) && findingsLevel !== "low") {
|
|
723
|
+
try {
|
|
724
|
+
let captureInput = process.env.PHREN_CONVERSATION_CONTEXT || "";
|
|
725
|
+
if (!captureInput && stdinPayload?.transcript_path) {
|
|
726
|
+
const transcriptPath = stdinPayload.transcript_path;
|
|
727
|
+
if (!isSafeTranscriptPath(transcriptPath)) {
|
|
728
|
+
debugLog(`auto-capture: skipping unsafe transcript_path: ${transcriptPath}`);
|
|
729
|
+
}
|
|
730
|
+
else if (fs.existsSync(transcriptPath)) {
|
|
731
|
+
// Cap at last 500 lines (~50 KB) to bound memory usage for long sessions
|
|
732
|
+
const raw = fs.readFileSync(transcriptPath, "utf-8");
|
|
733
|
+
const allLines = raw.split("\n").filter(Boolean);
|
|
734
|
+
const lines = allLines.length > 500 ? allLines.slice(-500) : allLines;
|
|
735
|
+
const assistantTexts = [];
|
|
736
|
+
for (const line of lines) {
|
|
737
|
+
try {
|
|
738
|
+
const msg = JSON.parse(line);
|
|
739
|
+
if (msg.role !== "assistant")
|
|
740
|
+
continue;
|
|
741
|
+
if (typeof msg.content === "string")
|
|
742
|
+
assistantTexts.push(msg.content);
|
|
743
|
+
else if (Array.isArray(msg.content)) {
|
|
744
|
+
for (const block of msg.content) {
|
|
745
|
+
if (block.type === "text" && block.text)
|
|
746
|
+
assistantTexts.push(block.text);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
catch (err) {
|
|
751
|
+
if (process.env.PHREN_DEBUG)
|
|
752
|
+
process.stderr.write(`[phren] hookSessionStart transcriptParse: ${errorMessage(err)}\n`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
captureInput = assistantTexts.join("\n");
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
if (captureInput) {
|
|
759
|
+
if (activeProject) {
|
|
760
|
+
// Check session cap before extracting — same guard as PostToolUse hook
|
|
761
|
+
let capReached = false;
|
|
762
|
+
if (taskSessionId) {
|
|
763
|
+
try {
|
|
764
|
+
const capFile = sessionMarker(phrenPath, `tool-findings-${taskSessionId}`);
|
|
765
|
+
let count = 0;
|
|
766
|
+
if (fs.existsSync(capFile)) {
|
|
767
|
+
count = Number.parseInt(fs.readFileSync(capFile, "utf8").trim(), 10) || 0;
|
|
768
|
+
}
|
|
769
|
+
const sessionCap = getSessionCap();
|
|
770
|
+
if (count >= sessionCap) {
|
|
771
|
+
debugLog(`hook-stop: session cap reached (${count}/${sessionCap}), skipping extraction`);
|
|
772
|
+
capReached = true;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
catch (err) {
|
|
776
|
+
if (process.env.PHREN_DEBUG)
|
|
777
|
+
process.stderr.write(`[phren] hookStop sessionCapCheck: ${errorMessage(err)}\n`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
if (!capReached) {
|
|
781
|
+
const insights = filterConversationInsightsForProactivity(extractConversationInsights(captureInput), findingsLevel);
|
|
782
|
+
for (const insight of insights) {
|
|
783
|
+
appendFindingJournal(phrenPath, activeProject, `[pattern] ${insight}`, {
|
|
784
|
+
source: "hook",
|
|
785
|
+
sessionId: `hook-stop-${Date.now()}`,
|
|
786
|
+
});
|
|
787
|
+
debugLog(`auto-capture: saved insight for ${activeProject}: ${insight.slice(0, 60)}`);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
catch (err) {
|
|
794
|
+
debugLog(`auto-capture failed: ${errorMessage(err)}`);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
else if (isFeatureEnabled("PHREN_FEATURE_AUTO_CAPTURE", false)) {
|
|
798
|
+
debugLog("auto-capture: skipped because findings proactivity is low");
|
|
799
|
+
}
|
|
800
|
+
// Wrap git operations in a file lock to prevent concurrent agents from fighting
|
|
801
|
+
const gitOpLockPath = path.join(phrenPath, ".runtime", "git-op");
|
|
802
|
+
await withFileLock(gitOpLockPath, async () => {
|
|
803
|
+
if (manifest?.installMode === "project-local") {
|
|
804
|
+
updateRuntimeHealth(phrenPath, {
|
|
805
|
+
lastStopAt: now,
|
|
806
|
+
lastAutoSave: { at: now, status: "saved-local", detail: "project-local mode writes files only" },
|
|
807
|
+
lastSync: {
|
|
808
|
+
lastPushAt: now,
|
|
809
|
+
lastPushStatus: "saved-local",
|
|
810
|
+
lastPushDetail: "project-local mode does not manage git sync",
|
|
811
|
+
},
|
|
812
|
+
});
|
|
813
|
+
appendAuditLog(phrenPath, "hook_stop", "status=skipped-local");
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
const gitRepo = ensureLocalGitRepo(phrenPath);
|
|
817
|
+
if (!gitRepo.ok) {
|
|
818
|
+
finalizeTaskSession({
|
|
819
|
+
phrenPath,
|
|
820
|
+
sessionId: taskSessionId,
|
|
821
|
+
status: "error",
|
|
822
|
+
detail: gitRepo.detail,
|
|
823
|
+
});
|
|
824
|
+
updateRuntimeHealth(phrenPath, {
|
|
825
|
+
lastStopAt: now,
|
|
826
|
+
lastAutoSave: { at: now, status: "error", detail: gitRepo.detail },
|
|
827
|
+
lastSync: {
|
|
828
|
+
lastPushAt: now,
|
|
829
|
+
lastPushStatus: "error",
|
|
830
|
+
lastPushDetail: gitRepo.detail,
|
|
831
|
+
},
|
|
832
|
+
});
|
|
833
|
+
appendAuditLog(phrenPath, "hook_stop", `status=error detail=${JSON.stringify(gitRepo.detail)}`);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
const status = await runBestEffortGit(["status", "--porcelain"], phrenPath);
|
|
837
|
+
if (!status.ok) {
|
|
838
|
+
finalizeTaskSession({
|
|
839
|
+
phrenPath,
|
|
840
|
+
sessionId: taskSessionId,
|
|
841
|
+
status: "error",
|
|
842
|
+
detail: status.error || "git status failed",
|
|
843
|
+
});
|
|
844
|
+
updateRuntimeHealth(phrenPath, {
|
|
845
|
+
lastStopAt: now,
|
|
846
|
+
lastAutoSave: { at: now, status: "error", detail: status.error || "git status failed" },
|
|
847
|
+
lastSync: {
|
|
848
|
+
lastPushAt: now,
|
|
849
|
+
lastPushStatus: "error",
|
|
850
|
+
lastPushDetail: status.error || "git status failed",
|
|
851
|
+
},
|
|
852
|
+
});
|
|
853
|
+
appendAuditLog(phrenPath, "hook_stop", `status=error detail=${JSON.stringify(status.error || "git status failed")}`);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if (!status.output) {
|
|
857
|
+
updateRuntimeHealth(phrenPath, {
|
|
858
|
+
lastStopAt: now,
|
|
859
|
+
lastAutoSave: { at: now, status: "clean", detail: "no changes" },
|
|
860
|
+
lastSync: {
|
|
861
|
+
lastPushAt: now,
|
|
862
|
+
lastPushStatus: "saved-pushed",
|
|
863
|
+
lastPushDetail: "no changes",
|
|
864
|
+
unsyncedCommits: 0,
|
|
865
|
+
},
|
|
866
|
+
});
|
|
867
|
+
appendAuditLog(phrenPath, "hook_stop", "status=clean");
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
// Exclude sensitive files from staging: .env files and private keys should
|
|
871
|
+
// never be committed to the phren git repository.
|
|
872
|
+
const add = await runBestEffortGit(["add", "-A", "--", ":(exclude).env", ":(exclude)**/.env", ":(exclude)*.pem", ":(exclude)*.key"], phrenPath);
|
|
873
|
+
let commitMsg = "auto-save phren";
|
|
874
|
+
if (add.ok) {
|
|
875
|
+
const diff = await runBestEffortGit(["diff", "--cached", "--stat", "--no-color"], phrenPath);
|
|
876
|
+
if (diff.ok && diff.output) {
|
|
877
|
+
// Parse "project/file.md | 3 +++" lines into project names and file types
|
|
878
|
+
const changes = new Map();
|
|
879
|
+
for (const line of diff.output.split("\n")) {
|
|
880
|
+
const m = line.match(/^\s*([^/]+)\/([^|]+)\s*\|/);
|
|
881
|
+
if (!m)
|
|
882
|
+
continue;
|
|
883
|
+
const proj = m[1].trim();
|
|
884
|
+
if (proj.startsWith("."))
|
|
885
|
+
continue; // skip .governance, .runtime, etc.
|
|
886
|
+
const file = m[2].trim();
|
|
887
|
+
if (!changes.has(proj))
|
|
888
|
+
changes.set(proj, new Set());
|
|
889
|
+
if (/findings/i.test(file))
|
|
890
|
+
changes.get(proj).add("findings");
|
|
891
|
+
else if (/tasks/i.test(file))
|
|
892
|
+
changes.get(proj).add("task");
|
|
893
|
+
else if (/CLAUDE/i.test(file))
|
|
894
|
+
changes.get(proj).add("config");
|
|
895
|
+
else if (/summary/i.test(file))
|
|
896
|
+
changes.get(proj).add("summary");
|
|
897
|
+
else if (/skill/i.test(file))
|
|
898
|
+
changes.get(proj).add("skills");
|
|
899
|
+
else if (/reference/i.test(file))
|
|
900
|
+
changes.get(proj).add("reference");
|
|
901
|
+
else
|
|
902
|
+
changes.get(proj).add("update");
|
|
903
|
+
}
|
|
904
|
+
if (changes.size > 0) {
|
|
905
|
+
const parts = [...changes.entries()].map(([proj, types]) => `${proj}(${[...types].join(",")})`);
|
|
906
|
+
commitMsg = `phren: ${parts.join(" ")}`;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
const commit = add.ok ? await runBestEffortGit(["commit", "-m", commitMsg], phrenPath) : { ok: false, error: add.error };
|
|
911
|
+
if (!add.ok || !commit.ok) {
|
|
912
|
+
finalizeTaskSession({
|
|
913
|
+
phrenPath,
|
|
914
|
+
sessionId: taskSessionId,
|
|
915
|
+
status: "error",
|
|
916
|
+
detail: add.error || commit.error || "git add/commit failed",
|
|
917
|
+
});
|
|
918
|
+
updateRuntimeHealth(phrenPath, {
|
|
919
|
+
lastStopAt: now,
|
|
920
|
+
lastAutoSave: {
|
|
921
|
+
at: now,
|
|
922
|
+
status: "error",
|
|
923
|
+
detail: add.error || commit.error || "git add/commit failed",
|
|
924
|
+
},
|
|
925
|
+
lastSync: {
|
|
926
|
+
lastPushAt: now,
|
|
927
|
+
lastPushStatus: "error",
|
|
928
|
+
lastPushDetail: add.error || commit.error || "git add/commit failed",
|
|
929
|
+
},
|
|
930
|
+
});
|
|
931
|
+
appendAuditLog(phrenPath, "hook_stop", `status=error detail=${JSON.stringify(add.error || commit.error || "git add/commit failed")}`);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const remotes = await runBestEffortGit(["remote"], phrenPath);
|
|
935
|
+
if (!remotes.ok || !remotes.output) {
|
|
936
|
+
finalizeTaskSession({
|
|
937
|
+
phrenPath,
|
|
938
|
+
sessionId: taskSessionId,
|
|
939
|
+
status: "saved-local",
|
|
940
|
+
detail: "commit created; no remote configured",
|
|
941
|
+
});
|
|
942
|
+
const unsyncedCommits = await countUnsyncedCommits(phrenPath);
|
|
943
|
+
updateRuntimeHealth(phrenPath, {
|
|
944
|
+
lastStopAt: now,
|
|
945
|
+
lastAutoSave: { at: now, status: "saved-local", detail: "commit created; no remote configured" },
|
|
946
|
+
lastSync: {
|
|
947
|
+
lastPushAt: now,
|
|
948
|
+
lastPushStatus: "saved-local",
|
|
949
|
+
lastPushDetail: "commit created; no remote configured",
|
|
950
|
+
unsyncedCommits,
|
|
951
|
+
},
|
|
952
|
+
});
|
|
953
|
+
appendAuditLog(phrenPath, "hook_stop", "status=saved-local");
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
const unsyncedCommits = await countUnsyncedCommits(phrenPath);
|
|
957
|
+
const scheduled = scheduleBackgroundSync(phrenPath);
|
|
958
|
+
const syncDetail = scheduled
|
|
959
|
+
? "commit saved; background sync scheduled"
|
|
960
|
+
: "commit saved; background sync already running";
|
|
961
|
+
finalizeTaskSession({
|
|
962
|
+
phrenPath,
|
|
963
|
+
sessionId: taskSessionId,
|
|
964
|
+
status: "saved-local",
|
|
965
|
+
detail: syncDetail,
|
|
966
|
+
});
|
|
967
|
+
updateRuntimeHealth(phrenPath, {
|
|
968
|
+
lastStopAt: now,
|
|
969
|
+
lastAutoSave: { at: now, status: "saved-local", detail: syncDetail },
|
|
970
|
+
lastSync: {
|
|
971
|
+
lastPushAt: now,
|
|
972
|
+
lastPushStatus: "saved-local",
|
|
973
|
+
lastPushDetail: syncDetail,
|
|
974
|
+
unsyncedCommits,
|
|
975
|
+
},
|
|
976
|
+
});
|
|
977
|
+
appendAuditLog(phrenPath, "hook_stop", `status=saved-local detail=${JSON.stringify(syncDetail)}`);
|
|
978
|
+
}); // end withFileLock(gitOpLockPath)
|
|
979
|
+
// Auto governance scheduling (non-blocking)
|
|
980
|
+
scheduleWeeklyGovernance();
|
|
981
|
+
}
|
|
982
|
+
export async function handleBackgroundSync() {
|
|
983
|
+
const phrenPathLocal = getPhrenPath();
|
|
984
|
+
const now = new Date().toISOString();
|
|
985
|
+
const lockPath = runtimeFile(phrenPathLocal, "background-sync.lock");
|
|
986
|
+
try {
|
|
987
|
+
const remotes = await runBestEffortGit(["remote"], phrenPathLocal);
|
|
988
|
+
if (!remotes.ok || !remotes.output) {
|
|
989
|
+
const unsyncedCommits = await countUnsyncedCommits(phrenPathLocal);
|
|
990
|
+
updateRuntimeHealth(phrenPathLocal, {
|
|
991
|
+
lastAutoSave: { at: now, status: "saved-local", detail: "background sync skipped; no remote configured" },
|
|
992
|
+
lastSync: {
|
|
993
|
+
lastPushAt: now,
|
|
994
|
+
lastPushStatus: "saved-local",
|
|
995
|
+
lastPushDetail: "background sync skipped; no remote configured",
|
|
996
|
+
unsyncedCommits,
|
|
997
|
+
},
|
|
998
|
+
});
|
|
999
|
+
appendAuditLog(phrenPathLocal, "background_sync", "status=saved-local detail=no_remote");
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
const push = await runBestEffortGit(["push"], phrenPathLocal);
|
|
1003
|
+
if (push.ok) {
|
|
1004
|
+
updateRuntimeHealth(phrenPathLocal, {
|
|
1005
|
+
lastAutoSave: { at: now, status: "saved-pushed", detail: "commit pushed by background sync" },
|
|
1006
|
+
lastSync: {
|
|
1007
|
+
lastPushAt: now,
|
|
1008
|
+
lastPushStatus: "saved-pushed",
|
|
1009
|
+
lastPushDetail: "commit pushed by background sync",
|
|
1010
|
+
unsyncedCommits: 0,
|
|
1011
|
+
},
|
|
1012
|
+
});
|
|
1013
|
+
appendAuditLog(phrenPathLocal, "background_sync", "status=saved-pushed");
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
const recovered = await recoverPushConflict(phrenPathLocal);
|
|
1017
|
+
if (recovered.ok) {
|
|
1018
|
+
updateRuntimeHealth(phrenPathLocal, {
|
|
1019
|
+
lastAutoSave: { at: now, status: "saved-pushed", detail: recovered.detail },
|
|
1020
|
+
lastSync: {
|
|
1021
|
+
lastPullAt: now,
|
|
1022
|
+
lastPullStatus: recovered.pullStatus,
|
|
1023
|
+
lastPullDetail: recovered.pullDetail,
|
|
1024
|
+
lastSuccessfulPullAt: now,
|
|
1025
|
+
lastPushAt: now,
|
|
1026
|
+
lastPushStatus: "saved-pushed",
|
|
1027
|
+
lastPushDetail: recovered.detail,
|
|
1028
|
+
unsyncedCommits: 0,
|
|
1029
|
+
},
|
|
1030
|
+
});
|
|
1031
|
+
appendAuditLog(phrenPathLocal, "background_sync", `status=saved-pushed detail=${JSON.stringify(recovered.detail)}`);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
const unsyncedCommits = await countUnsyncedCommits(phrenPathLocal);
|
|
1035
|
+
updateRuntimeHealth(phrenPathLocal, {
|
|
1036
|
+
lastAutoSave: { at: now, status: "saved-local", detail: recovered.detail || push.error || "background sync push failed" },
|
|
1037
|
+
lastSync: {
|
|
1038
|
+
lastPullAt: now,
|
|
1039
|
+
lastPullStatus: recovered.pullStatus,
|
|
1040
|
+
lastPullDetail: recovered.pullDetail,
|
|
1041
|
+
lastPushAt: now,
|
|
1042
|
+
lastPushStatus: "saved-local",
|
|
1043
|
+
lastPushDetail: recovered.detail || push.error || "background sync push failed",
|
|
1044
|
+
unsyncedCommits,
|
|
1045
|
+
},
|
|
1046
|
+
});
|
|
1047
|
+
appendAuditLog(phrenPathLocal, "background_sync", `status=saved-local detail=${JSON.stringify(recovered.detail || push.error || "background sync push failed")}`);
|
|
1048
|
+
}
|
|
1049
|
+
finally {
|
|
1050
|
+
try {
|
|
1051
|
+
fs.unlinkSync(lockPath);
|
|
1052
|
+
}
|
|
1053
|
+
catch { }
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
function scheduleWeeklyGovernance() {
|
|
1057
|
+
try {
|
|
1058
|
+
const lastGovPath = runtimeFile(getPhrenPath(), "last-governance.txt");
|
|
1059
|
+
const lastRun = fs.existsSync(lastGovPath) ? parseInt(fs.readFileSync(lastGovPath, "utf8"), 10) : 0;
|
|
1060
|
+
const daysSince = (Date.now() - lastRun) / 86_400_000;
|
|
1061
|
+
if (daysSince >= 7) {
|
|
1062
|
+
const spawnArgs = resolveSubprocessArgs("background-maintenance");
|
|
1063
|
+
if (spawnArgs) {
|
|
1064
|
+
const child = spawn(process.execPath, spawnArgs, { detached: true, stdio: "ignore" });
|
|
1065
|
+
child.unref();
|
|
1066
|
+
fs.writeFileSync(lastGovPath, Date.now().toString());
|
|
1067
|
+
debugLog("hook_stop: scheduled weekly governance run");
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
catch (err) {
|
|
1072
|
+
debugLog(`hook_stop: governance scheduling failed: ${errorMessage(err)}`);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
export async function handleHookContext() {
|
|
1076
|
+
const ctx = buildHookContext();
|
|
1077
|
+
if (!ctx.hooksEnabled) {
|
|
1078
|
+
process.exit(0);
|
|
1079
|
+
}
|
|
1080
|
+
let cwd = ctx.cwd;
|
|
1081
|
+
const ctxStdin = readStdinJson();
|
|
1082
|
+
if (ctxStdin?.cwd)
|
|
1083
|
+
cwd = ctxStdin.cwd;
|
|
1084
|
+
const project = cwd !== ctx.cwd ? detectProject(ctx.phrenPath, cwd, ctx.profile) : ctx.activeProject;
|
|
1085
|
+
if (!isProjectHookEnabled(ctx.phrenPath, project, "UserPromptSubmit")) {
|
|
1086
|
+
process.exit(0);
|
|
1087
|
+
}
|
|
1088
|
+
const db = await buildIndex(ctx.phrenPath, ctx.profile);
|
|
1089
|
+
const contextLabel = project ? `\u25c6 phren \u00b7 ${project} \u00b7 context` : `\u25c6 phren \u00b7 context`;
|
|
1090
|
+
const parts = [contextLabel, "<phren-context>"];
|
|
1091
|
+
if (project) {
|
|
1092
|
+
const summaryRow = queryRows(db, "SELECT content FROM docs WHERE project = ? AND type = 'summary'", [project]);
|
|
1093
|
+
if (summaryRow) {
|
|
1094
|
+
parts.push(`# ${project}`);
|
|
1095
|
+
parts.push(summaryRow[0][0]);
|
|
1096
|
+
parts.push("");
|
|
1097
|
+
}
|
|
1098
|
+
const findingsRow = queryRows(db, "SELECT content FROM docs WHERE project = ? AND type = 'findings'", [project]);
|
|
1099
|
+
if (findingsRow) {
|
|
1100
|
+
const content = findingsRow[0][0];
|
|
1101
|
+
const bullets = content.split("\n").filter(l => l.startsWith("- ")).slice(0, 10);
|
|
1102
|
+
if (bullets.length > 0) {
|
|
1103
|
+
parts.push("## Recent findings");
|
|
1104
|
+
parts.push(bullets.join("\n"));
|
|
1105
|
+
parts.push("");
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
const taskRow = queryRows(db, "SELECT content FROM docs WHERE project = ? AND type = 'task'", [project]);
|
|
1109
|
+
if (taskRow) {
|
|
1110
|
+
const content = taskRow[0][0];
|
|
1111
|
+
const activeItems = content.split("\n").filter(l => l.startsWith("- "));
|
|
1112
|
+
const filtered = filterTaskByPriority(activeItems);
|
|
1113
|
+
const trimmed = filtered.slice(0, 5);
|
|
1114
|
+
if (trimmed.length > 0) {
|
|
1115
|
+
parts.push("## Active tasks");
|
|
1116
|
+
parts.push(trimmed.join("\n"));
|
|
1117
|
+
parts.push("");
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
else {
|
|
1122
|
+
const projectRows = queryRows(db, "SELECT DISTINCT project FROM docs ORDER BY project", []);
|
|
1123
|
+
if (projectRows) {
|
|
1124
|
+
parts.push("# Phren projects");
|
|
1125
|
+
parts.push(projectRows.map(r => `- ${r[0]}`).join("\n"));
|
|
1126
|
+
parts.push("");
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
parts.push("<phren-context>");
|
|
1130
|
+
if (parts.length > 2) {
|
|
1131
|
+
console.log(parts.join("\n"));
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
// ── PostToolUse hook ─────────────────────────────────────────────────────────
|
|
1135
|
+
const INTERESTING_TOOLS = new Set(["Read", "Write", "Edit", "Bash", "Glob", "Grep"]);
|
|
1136
|
+
const COOLDOWN_MS = parseInt(process.env.PHREN_AUTOCAPTURE_COOLDOWN_MS ?? "30000", 10);
|
|
1137
|
+
function getSessionCap() {
|
|
1138
|
+
if (process.env.PHREN_AUTOCAPTURE_SESSION_CAP) {
|
|
1139
|
+
return parseInt(process.env.PHREN_AUTOCAPTURE_SESSION_CAP, 10);
|
|
1140
|
+
}
|
|
1141
|
+
try {
|
|
1142
|
+
const policy = getWorkflowPolicy(getPhrenPath());
|
|
1143
|
+
const sensitivity = policy.findingSensitivity ?? "balanced";
|
|
1144
|
+
return FINDING_SENSITIVITY_CONFIG[sensitivity]?.sessionCap ?? 10;
|
|
1145
|
+
}
|
|
1146
|
+
catch {
|
|
1147
|
+
return 10;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
function flattenToolResponseText(value, maxChars = 4000) {
|
|
1151
|
+
if (typeof value === "string")
|
|
1152
|
+
return value;
|
|
1153
|
+
const queue = [value];
|
|
1154
|
+
const parts = [];
|
|
1155
|
+
let length = 0;
|
|
1156
|
+
while (queue.length > 0 && length < maxChars) {
|
|
1157
|
+
const current = queue.shift();
|
|
1158
|
+
if (typeof current === "string") {
|
|
1159
|
+
const trimmed = current.trim();
|
|
1160
|
+
if (!trimmed)
|
|
1161
|
+
continue;
|
|
1162
|
+
parts.push(trimmed);
|
|
1163
|
+
length += trimmed.length + 1;
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
if (Array.isArray(current)) {
|
|
1167
|
+
queue.unshift(...current);
|
|
1168
|
+
continue;
|
|
1169
|
+
}
|
|
1170
|
+
if (current && typeof current === "object") {
|
|
1171
|
+
queue.unshift(...Object.values(current));
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
if (parts.length > 0)
|
|
1175
|
+
return parts.join("\n").slice(0, maxChars);
|
|
1176
|
+
return JSON.stringify(value ?? "").slice(0, maxChars);
|
|
1177
|
+
}
|
|
1178
|
+
export async function handleHookTool() {
|
|
1179
|
+
const ctx = buildHookContext();
|
|
1180
|
+
if (!ctx.hooksEnabled) {
|
|
1181
|
+
process.exit(0);
|
|
1182
|
+
}
|
|
1183
|
+
try {
|
|
1184
|
+
const start = Date.now();
|
|
1185
|
+
let raw = "";
|
|
1186
|
+
if (!process.stdin.isTTY) {
|
|
1187
|
+
try {
|
|
1188
|
+
raw = fs.readFileSync(0, "utf-8");
|
|
1189
|
+
}
|
|
1190
|
+
catch (err) {
|
|
1191
|
+
if (process.env.PHREN_DEBUG)
|
|
1192
|
+
process.stderr.write(`[phren] hookTool stdinRead: ${errorMessage(err)}\n`);
|
|
1193
|
+
process.exit(0);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
let data;
|
|
1197
|
+
try {
|
|
1198
|
+
data = JSON.parse(raw);
|
|
1199
|
+
}
|
|
1200
|
+
catch (err) {
|
|
1201
|
+
if (process.env.PHREN_DEBUG)
|
|
1202
|
+
process.stderr.write(`[phren] hookTool stdinParse: ${errorMessage(err)}\n`);
|
|
1203
|
+
process.exit(0);
|
|
1204
|
+
}
|
|
1205
|
+
const toolName = String(data.tool_name ?? data.tool ?? "");
|
|
1206
|
+
if (!INTERESTING_TOOLS.has(toolName)) {
|
|
1207
|
+
process.exit(0);
|
|
1208
|
+
}
|
|
1209
|
+
const sessionId = data.session_id;
|
|
1210
|
+
const input = (data.tool_input ?? {});
|
|
1211
|
+
const entry = {
|
|
1212
|
+
at: new Date().toISOString(),
|
|
1213
|
+
session_id: sessionId,
|
|
1214
|
+
tool: toolName,
|
|
1215
|
+
};
|
|
1216
|
+
if (toolName === "Read" || toolName === "Write" || toolName === "Edit") {
|
|
1217
|
+
const filePath = input.file_path ?? input.path ?? undefined;
|
|
1218
|
+
if (filePath)
|
|
1219
|
+
entry.file = String(filePath);
|
|
1220
|
+
}
|
|
1221
|
+
else if (toolName === "Bash") {
|
|
1222
|
+
const cmd = input.command ?? undefined;
|
|
1223
|
+
if (cmd)
|
|
1224
|
+
entry.command = String(cmd).slice(0, 200);
|
|
1225
|
+
}
|
|
1226
|
+
else if (toolName === "Glob") {
|
|
1227
|
+
const pattern = input.pattern ?? undefined;
|
|
1228
|
+
if (pattern)
|
|
1229
|
+
entry.file = String(pattern);
|
|
1230
|
+
}
|
|
1231
|
+
else if (toolName === "Grep") {
|
|
1232
|
+
const pattern = input.pattern ?? undefined;
|
|
1233
|
+
const searchPath = input.path ?? undefined;
|
|
1234
|
+
if (pattern)
|
|
1235
|
+
entry.command = `grep ${pattern}${searchPath ? ` in ${searchPath}` : ""}`.slice(0, 200);
|
|
1236
|
+
}
|
|
1237
|
+
const responseStr = flattenToolResponseText(data.tool_response ?? "");
|
|
1238
|
+
if (/(error|exception|failed|no such file|ENOENT)/i.test(responseStr)) {
|
|
1239
|
+
entry.error = responseStr.slice(0, 300);
|
|
1240
|
+
}
|
|
1241
|
+
const cwd = (data.cwd ?? input.cwd ?? undefined);
|
|
1242
|
+
let activeProject = cwd ? detectProject(ctx.phrenPath, cwd, ctx.profile) : null;
|
|
1243
|
+
if (!isProjectHookEnabled(ctx.phrenPath, activeProject, "PostToolUse")) {
|
|
1244
|
+
appendAuditLog(ctx.phrenPath, "hook_tool", `status=project_disabled project=${activeProject}`);
|
|
1245
|
+
process.exit(0);
|
|
1246
|
+
}
|
|
1247
|
+
try {
|
|
1248
|
+
const logFile = runtimeFile(ctx.phrenPath, "tool-log.jsonl");
|
|
1249
|
+
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
|
1250
|
+
fs.appendFileSync(logFile, JSON.stringify(entry) + "\n");
|
|
1251
|
+
}
|
|
1252
|
+
catch (err) {
|
|
1253
|
+
if (process.env.PHREN_DEBUG)
|
|
1254
|
+
process.stderr.write(`[phren] hookTool toolLog: ${errorMessage(err)}\n`);
|
|
1255
|
+
}
|
|
1256
|
+
const cooldownFile = runtimeFile(ctx.phrenPath, "hook-tool-cooldown");
|
|
1257
|
+
try {
|
|
1258
|
+
if (fs.existsSync(cooldownFile)) {
|
|
1259
|
+
const age = Date.now() - fs.statSync(cooldownFile).mtimeMs;
|
|
1260
|
+
if (age < COOLDOWN_MS) {
|
|
1261
|
+
debugLog(`hook-tool: cooldown active (${Math.round(age / 1000)}s < ${Math.round(COOLDOWN_MS / 1000)}s), skipping extraction`);
|
|
1262
|
+
activeProject = null;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
catch (err) {
|
|
1267
|
+
if (process.env.PHREN_DEBUG)
|
|
1268
|
+
process.stderr.write(`[phren] hookTool cooldownStat: ${errorMessage(err)}\n`);
|
|
1269
|
+
}
|
|
1270
|
+
if (activeProject && sessionId) {
|
|
1271
|
+
try {
|
|
1272
|
+
const capFile = sessionMarker(ctx.phrenPath, `tool-findings-${sessionId}`);
|
|
1273
|
+
let count = 0;
|
|
1274
|
+
if (fs.existsSync(capFile)) {
|
|
1275
|
+
count = Number.parseInt(fs.readFileSync(capFile, "utf8").trim(), 10) || 0;
|
|
1276
|
+
}
|
|
1277
|
+
const sessionCap = getSessionCap();
|
|
1278
|
+
if (count >= sessionCap) {
|
|
1279
|
+
debugLog(`hook-tool: session cap reached (${count}/${sessionCap}), skipping extraction`);
|
|
1280
|
+
activeProject = null;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
catch (err) {
|
|
1284
|
+
if (process.env.PHREN_DEBUG)
|
|
1285
|
+
process.stderr.write(`[phren] hookTool sessionCapCheck: ${errorMessage(err)}\n`);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
const findingsLevelForTool = getProactivityLevelForFindings(ctx.phrenPath);
|
|
1289
|
+
if (activeProject && findingsLevelForTool !== "low") {
|
|
1290
|
+
try {
|
|
1291
|
+
const candidates = filterToolFindingsForProactivity(extractToolFindings(toolName, input, responseStr), findingsLevelForTool);
|
|
1292
|
+
for (const { text, confidence } of candidates) {
|
|
1293
|
+
appendReviewQueue(ctx.phrenPath, activeProject, "Review", [text]);
|
|
1294
|
+
debugLog(`hook-tool: queued candidate for review (conf=${confidence}): ${text.slice(0, 60)}`);
|
|
1295
|
+
}
|
|
1296
|
+
if (candidates.length > 0) {
|
|
1297
|
+
try {
|
|
1298
|
+
fs.writeFileSync(cooldownFile, Date.now().toString());
|
|
1299
|
+
}
|
|
1300
|
+
catch (err) {
|
|
1301
|
+
if (process.env.PHREN_DEBUG)
|
|
1302
|
+
process.stderr.write(`[phren] hookTool cooldownWrite: ${errorMessage(err)}\n`);
|
|
1303
|
+
}
|
|
1304
|
+
if (sessionId) {
|
|
1305
|
+
try {
|
|
1306
|
+
const capFile = sessionMarker(ctx.phrenPath, `tool-findings-${sessionId}`);
|
|
1307
|
+
let count = 0;
|
|
1308
|
+
try {
|
|
1309
|
+
count = Number.parseInt(fs.readFileSync(capFile, "utf8").trim(), 10) || 0;
|
|
1310
|
+
}
|
|
1311
|
+
catch (err) {
|
|
1312
|
+
if (process.env.PHREN_DEBUG)
|
|
1313
|
+
process.stderr.write(`[phren] hookTool capFileRead: ${errorMessage(err)}\n`);
|
|
1314
|
+
}
|
|
1315
|
+
count += candidates.length;
|
|
1316
|
+
fs.writeFileSync(capFile, count.toString());
|
|
1317
|
+
}
|
|
1318
|
+
catch (err) {
|
|
1319
|
+
if (process.env.PHREN_DEBUG)
|
|
1320
|
+
process.stderr.write(`[phren] hookTool capFileWrite: ${errorMessage(err)}\n`);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
catch (err) {
|
|
1326
|
+
debugLog(`hook-tool: finding extraction failed: ${errorMessage(err)}`);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
else if (activeProject) {
|
|
1330
|
+
debugLog("hook-tool: skipped because findings proactivity is low");
|
|
1331
|
+
}
|
|
1332
|
+
const elapsed = Date.now() - start;
|
|
1333
|
+
debugLog(`hook-tool: ${toolName} logged in ${elapsed}ms`);
|
|
1334
|
+
process.exit(0);
|
|
1335
|
+
}
|
|
1336
|
+
catch (err) {
|
|
1337
|
+
debugLog(`hook-tool: unhandled error: ${err instanceof Error ? err.stack || err.message : String(err)}`);
|
|
1338
|
+
process.exit(0);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
const EXPLICIT_TAG_PATTERN = /\[(pitfall|decision|pattern|tradeoff|architecture|bug)\]\s*(.+)/i;
|
|
1342
|
+
export function filterToolFindingsForProactivity(candidates, level = getProactivityLevelForFindings(getPhrenPath())) {
|
|
1343
|
+
if (level === "high")
|
|
1344
|
+
return candidates;
|
|
1345
|
+
if (level === "low")
|
|
1346
|
+
return [];
|
|
1347
|
+
return candidates.filter((candidate) => candidate.explicit === true);
|
|
1348
|
+
}
|
|
1349
|
+
export function extractToolFindings(toolName, input, responseStr) {
|
|
1350
|
+
const candidates = [];
|
|
1351
|
+
const changedContent = (toolName === "Edit" || toolName === "Write")
|
|
1352
|
+
? String(input.new_string ?? input.content ?? "")
|
|
1353
|
+
: "";
|
|
1354
|
+
const explicitSource = changedContent || responseStr;
|
|
1355
|
+
const tagMatches = explicitSource.matchAll(new RegExp(EXPLICIT_TAG_PATTERN.source, "gi"));
|
|
1356
|
+
for (const m of tagMatches) {
|
|
1357
|
+
const tag = m[1].toLowerCase();
|
|
1358
|
+
const content = m[2].replace(/\s+/g, " ").trim().slice(0, 200);
|
|
1359
|
+
if (content) {
|
|
1360
|
+
candidates.push({ text: `[${tag}] ${content}`, confidence: 0.85, explicit: true });
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
if (toolName === "Edit" || toolName === "Write") {
|
|
1364
|
+
const filePath = String(input.file_path ?? input.path ?? "unknown");
|
|
1365
|
+
const filename = path.basename(filePath);
|
|
1366
|
+
if (/\b(TODO|FIXME)\b/.test(changedContent)) {
|
|
1367
|
+
const firstLine = changedContent.split("\n").find((l) => /\b(TODO|FIXME)\b/.test(l));
|
|
1368
|
+
if (firstLine) {
|
|
1369
|
+
candidates.push({
|
|
1370
|
+
text: `[pitfall] ${filename}: ${firstLine.trim().slice(0, 150)}`,
|
|
1371
|
+
confidence: 0.45,
|
|
1372
|
+
explicit: false,
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
if (/\btry\s*\{[\s\S]*?\bcatch\b/.test(changedContent)) {
|
|
1377
|
+
const meaningfulLine = changedContent.split("\n").find((l) => l.trim().length > 10 && !/^\s*(try|catch|\{|\})/.test(l));
|
|
1378
|
+
if (meaningfulLine) {
|
|
1379
|
+
candidates.push({
|
|
1380
|
+
text: `[pitfall] ${filename}: error handling added near "${meaningfulLine.trim().slice(0, 100)}"`,
|
|
1381
|
+
confidence: 0.45,
|
|
1382
|
+
explicit: false,
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
if (toolName === "Bash") {
|
|
1388
|
+
const cmd = String(input.command ?? "").slice(0, 30);
|
|
1389
|
+
const hasError = /(error|exception|failed|ENOENT|command not found|permission denied)/i.test(responseStr);
|
|
1390
|
+
if (hasError && cmd) {
|
|
1391
|
+
const firstErrorLine = responseStr.split("\n").find((l) => /(error|exception|failed|ENOENT|command not found|permission denied)/i.test(l));
|
|
1392
|
+
if (firstErrorLine) {
|
|
1393
|
+
candidates.push({
|
|
1394
|
+
text: `[bug] command '${cmd}' failed: ${firstErrorLine.trim().slice(0, 150)}`,
|
|
1395
|
+
confidence: 0.55,
|
|
1396
|
+
explicit: false,
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
return candidates;
|
|
1402
|
+
}
|