@phren/cli 0.0.27 → 0.0.32
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/mcp/dist/capabilities/cli.js +2 -5
- package/mcp/dist/capabilities/mcp.js +5 -8
- package/mcp/dist/capabilities/types.js +2 -5
- package/mcp/dist/capabilities/vscode.js +2 -5
- package/mcp/dist/capabilities/web-ui.js +2 -5
- package/mcp/dist/{cli-actions.js → cli/actions.js} +22 -21
- package/mcp/dist/{cli.js → cli/cli.js} +13 -13
- package/mcp/dist/{cli-config.js → cli/config.js} +9 -9
- package/mcp/dist/{cli-extract.js → cli/extract.js} +8 -8
- package/mcp/dist/{cli-govern.js → cli/govern.js} +10 -9
- package/mcp/dist/{cli-graph.js → cli/graph.js} +10 -9
- package/mcp/dist/{cli-hooks-citations.js → cli/hooks-citations.js} +2 -2
- package/mcp/dist/{cli-hooks-context.js → cli/hooks-context.js} +23 -23
- package/mcp/dist/{cli-hooks-globs.js → cli/hooks-globs.js} +4 -4
- package/mcp/dist/{cli-hooks-output.js → cli/hooks-output.js} +9 -10
- package/mcp/dist/{cli-hooks-session.js → cli/hooks-session.js} +42 -57
- package/mcp/dist/{cli-hooks.js → cli/hooks.js} +27 -26
- package/mcp/dist/{cli-namespaces.js → cli/namespaces.js} +25 -24
- package/mcp/dist/{cli-ops.js → cli/ops.js} +9 -9
- package/mcp/dist/{cli-search.js → cli/search.js} +8 -7
- package/mcp/dist/cli-hooks-git.js +243 -0
- package/mcp/dist/cli-hooks-prompt.js +319 -0
- package/mcp/dist/cli-hooks-session-handlers.js +349 -0
- package/mcp/dist/cli-hooks-stop.js +557 -0
- package/mcp/dist/{content-archive.js → content/archive.js} +8 -9
- package/mcp/dist/{content-citation.js → content/citation.js} +5 -5
- package/mcp/dist/{content-dedup.js → content/dedup.js} +9 -12
- package/mcp/dist/{content-learning.js → content/learning.js} +12 -12
- package/mcp/dist/{content-validate.js → content/validate.js} +5 -5
- package/mcp/dist/{core-finding.js → core/finding.js} +4 -4
- package/mcp/dist/{core-project.js → core/project.js} +4 -4
- package/mcp/dist/{core-search.js → core/search.js} +2 -2
- package/mcp/dist/{data-access.js → data/access.js} +131 -13
- package/mcp/dist/{data-tasks.js → data/tasks.js} +7 -5
- package/mcp/dist/embedding.js +9 -14
- package/mcp/dist/entrypoint.js +13 -11
- package/mcp/dist/{finding-context.js → finding/context.js} +2 -2
- package/mcp/dist/{finding-impact.js → finding/impact.js} +3 -3
- package/mcp/dist/{finding-journal.js → finding/journal.js} +4 -4
- package/mcp/dist/{finding-lifecycle.js → finding/lifecycle.js} +4 -4
- package/mcp/dist/{governance-audit.js → governance/audit.js} +2 -2
- package/mcp/dist/{governance-locks.js → governance/locks.js} +14 -9
- package/mcp/dist/{governance-policy.js → governance/policy.js} +10 -12
- package/mcp/dist/{governance-rbac.js → governance/rbac.js} +3 -3
- package/mcp/dist/{governance-scores.js → governance/scores.js} +8 -10
- package/mcp/dist/hooks.js +39 -31
- package/mcp/dist/index-query.js +4 -1
- package/mcp/dist/index.js +53 -29
- package/mcp/dist/{init-config.js → init/config.js} +6 -6
- package/mcp/dist/{init.js → init/init.js} +28 -29
- package/mcp/dist/{init-preferences.js → init/preferences.js} +3 -3
- package/mcp/dist/{init-setup.js → init/setup.js} +17 -19
- package/mcp/dist/{init-shared.js → init/shared.js} +3 -3
- package/mcp/dist/init-bootstrap.js +68 -0
- package/mcp/dist/init-detect.js +38 -0
- package/mcp/dist/init-dryrun.js +55 -0
- package/mcp/dist/init-env.js +114 -0
- package/mcp/dist/init-fresh.js +239 -0
- package/mcp/dist/init-hooks.js +26 -0
- package/mcp/dist/init-mcp.js +65 -0
- package/mcp/dist/init-migrate.js +51 -0
- package/mcp/dist/init-modes.js +135 -0
- package/mcp/dist/init-npm.js +37 -0
- package/mcp/dist/init-project-local.js +99 -0
- package/mcp/dist/init-semantic.js +48 -0
- package/mcp/dist/init-types.js +1 -0
- package/mcp/dist/init-uninstall.js +482 -0
- package/mcp/dist/init-update.js +96 -0
- package/mcp/dist/init-walkthrough-merge.js +90 -0
- package/mcp/dist/init-walkthrough.js +529 -0
- package/mcp/dist/{link-checksums.js → link/checksums.js} +5 -5
- package/mcp/dist/{link-context.js → link/context.js} +4 -4
- package/mcp/dist/{link-doctor.js → link/doctor.js} +20 -22
- package/mcp/dist/{link.js → link/link.js} +26 -31
- package/mcp/dist/{link-skills.js → link/skills.js} +10 -10
- package/mcp/dist/logger.js +11 -3
- package/mcp/dist/phren-art.js +0 -6
- package/mcp/dist/phren-paths.js +30 -12
- package/mcp/dist/proactivity.js +2 -2
- package/mcp/dist/profile-store.js +5 -6
- package/mcp/dist/project-config.js +2 -2
- package/mcp/dist/project-topics.js +1 -1
- package/mcp/dist/query-correlation.js +1 -1
- package/mcp/dist/{session-checkpoints.js → session/checkpoints.js} +3 -3
- package/mcp/dist/{session-utils.js → session/utils.js} +1 -1
- package/mcp/dist/{shared-content.js → shared/content.js} +7 -7
- package/mcp/dist/{shared-data-utils.js → shared/data-utils.js} +3 -3
- package/mcp/dist/{shared-embedding-cache.js → shared/embedding-cache.js} +3 -3
- package/mcp/dist/{shared-fragment-graph.js → shared/fragment-graph.js} +15 -24
- package/mcp/dist/shared/governance.js +4 -0
- package/mcp/dist/{shared-index.js → shared/index.js} +92 -123
- package/mcp/dist/{shared-ollama.js → shared/ollama.js} +2 -2
- package/mcp/dist/{shared-retrieval.js → shared/retrieval.js} +16 -21
- package/mcp/dist/{shared-search-fallback.js → shared/search-fallback.js} +17 -20
- package/mcp/dist/{shared-sqljs.js → shared/sqljs.js} +3 -3
- package/mcp/dist/{shared-vector-index.js → shared/vector-index.js} +3 -3
- package/mcp/dist/shared.js +4 -59
- package/mcp/dist/{shell-entry.js → shell/entry.js} +6 -6
- package/mcp/dist/{shell-input.js → shell/input.js} +13 -13
- package/mcp/dist/{shell-palette.js → shell/palette.js} +3 -3
- package/mcp/dist/{shell-render.js → shell/render.js} +1 -1
- package/mcp/dist/{shell.js → shell/shell.js} +11 -11
- package/mcp/dist/{shell-state-store.js → shell/state-store.js} +5 -5
- package/mcp/dist/{shell-view-list.js → shell/view-list.js} +1 -1
- package/mcp/dist/{shell-view.js → shell/view.js} +13 -13
- package/mcp/dist/{skill-files.js → skill/files.js} +9 -9
- package/mcp/dist/{skill-registry.js → skill/registry.js} +4 -4
- package/mcp/dist/{skill-state.js → skill/state.js} +1 -1
- package/mcp/dist/startup-embedding.js +2 -2
- package/mcp/dist/status.js +15 -14
- package/mcp/dist/{tasks-github.js → task/github.js} +2 -2
- package/mcp/dist/{task-hygiene.js → task/hygiene.js} +4 -4
- package/mcp/dist/{task-lifecycle.js → task/lifecycle.js} +7 -7
- package/mcp/dist/telemetry.js +3 -4
- package/mcp/dist/tool-registry.js +29 -17
- package/mcp/dist/tools/config.js +515 -0
- package/mcp/dist/{mcp-data.js → tools/data.js} +8 -10
- package/mcp/dist/{mcp-extract-facts.js → tools/extract-facts.js} +6 -6
- package/mcp/dist/{mcp-extract.js → tools/extract.js} +6 -6
- package/mcp/dist/{mcp-finding.js → tools/finding.js} +97 -124
- package/mcp/dist/{mcp-graph.js → tools/graph.js} +11 -14
- package/mcp/dist/{mcp-hooks.js → tools/hooks.js} +6 -6
- package/mcp/dist/{mcp-memory.js → tools/memory.js} +5 -5
- package/mcp/dist/{mcp-ops.js → tools/ops.js} +169 -71
- package/mcp/dist/{mcp-search.js → tools/search.js} +19 -23
- package/mcp/dist/{mcp-session.js → tools/session.js} +48 -23
- package/mcp/dist/{mcp-skills.js → tools/skills.js} +33 -35
- package/mcp/dist/{mcp-tasks.js → tools/tasks.js} +155 -282
- package/mcp/dist/{memory-ui-data.js → ui/data.js} +31 -17
- package/mcp/dist/{memory-ui.js → ui/memory-ui.js} +3 -3
- package/mcp/dist/{memory-ui-page.js → ui/page.js} +4 -6
- package/mcp/dist/{memory-ui-server.js → ui/server.js} +30 -22
- package/mcp/dist/update.js +2 -2
- package/mcp/dist/utils.js +51 -11
- package/package.json +17 -11
- package/scripts/preuninstall.mjs +139 -0
- package/starter/global/CLAUDE.md +3 -2
- package/mcp/dist/mcp-config.js +0 -551
- package/mcp/dist/shared-governance.js +0 -4
- package/starter/global/skills/pipeline.md +0 -35
- package/starter/global/skills/release.md +0 -35
- /package/mcp/dist/{content-metadata.js → content/metadata.js} +0 -0
- /package/mcp/dist/{shared-stemmer.js → shared/stemmer.js} +0 -0
- /package/mcp/dist/{shell-types.js → shell/types.js} +0 -0
- /package/mcp/dist/{mcp-types.js → tools/types.js} +0 -0
- /package/mcp/dist/{memory-ui-assets.js → ui/assets.js} +0 -0
- /package/mcp/dist/{memory-ui-graph.js → ui/graph.js} +0 -0
- /package/mcp/dist/{memory-ui-scripts.js → ui/scripts.js} +0 -0
- /package/mcp/dist/{memory-ui-styles.js → ui/styles.js} +0 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { debugLog, EXEC_TIMEOUT_MS, withFileLock, recordFeedback, getQualityMultiplier, errorMessage, } from "./cli/hooks-context.js";
|
|
2
|
+
import { sessionMetricsFile, } from "./shared.js";
|
|
3
|
+
import { autoMergeConflicts, mergeTask, mergeFindings, } from "./shared/content.js";
|
|
4
|
+
import { runGit } from "./utils.js";
|
|
5
|
+
import { isTaskFileName } from "./data/tasks.js";
|
|
6
|
+
import * as fs from "fs";
|
|
7
|
+
import * as path from "path";
|
|
8
|
+
import { execFileSync } from "child_process";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
export function getGitContext(cwd) {
|
|
11
|
+
if (!cwd)
|
|
12
|
+
return null;
|
|
13
|
+
const git = (args) => runGit(cwd, args, EXEC_TIMEOUT_MS, debugLog);
|
|
14
|
+
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
15
|
+
if (!branch)
|
|
16
|
+
return null;
|
|
17
|
+
const changedFiles = new Set();
|
|
18
|
+
for (const changed of [
|
|
19
|
+
git(["diff", "--name-only"]),
|
|
20
|
+
git(["diff", "--name-only", "--cached"]),
|
|
21
|
+
]) {
|
|
22
|
+
if (!changed)
|
|
23
|
+
continue;
|
|
24
|
+
for (const line of changed.split("\n").map((s) => s.trim()).filter(Boolean)) {
|
|
25
|
+
changedFiles.add(line);
|
|
26
|
+
const basename = path.basename(line);
|
|
27
|
+
if (basename)
|
|
28
|
+
changedFiles.add(basename);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { branch, changedFiles };
|
|
32
|
+
}
|
|
33
|
+
export function parseSessionMetrics(phrenPathLocal) {
|
|
34
|
+
const file = sessionMetricsFile(phrenPathLocal);
|
|
35
|
+
if (!fs.existsSync(file))
|
|
36
|
+
return {};
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
debugLog(`parseSessionMetrics: failed to read ${file}: ${errorMessage(err)}`);
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function writeSessionMetrics(phrenPathLocal, data) {
|
|
46
|
+
const file = sessionMetricsFile(phrenPathLocal);
|
|
47
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
48
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
|
|
49
|
+
}
|
|
50
|
+
export function updateSessionMetrics(phrenPathLocal, updater) {
|
|
51
|
+
const file = sessionMetricsFile(phrenPathLocal);
|
|
52
|
+
withFileLock(file, () => {
|
|
53
|
+
const metrics = parseSessionMetrics(phrenPathLocal);
|
|
54
|
+
updater(metrics);
|
|
55
|
+
writeSessionMetrics(phrenPathLocal, metrics);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export function trackSessionMetrics(phrenPathLocal, sessionId, selected) {
|
|
59
|
+
updateSessionMetrics(phrenPathLocal, (metrics) => {
|
|
60
|
+
if (!metrics[sessionId])
|
|
61
|
+
metrics[sessionId] = { prompts: 0, keys: {}, lastChangedCount: 0, lastKeys: [] };
|
|
62
|
+
metrics[sessionId].prompts += 1;
|
|
63
|
+
const injectedKeys = [];
|
|
64
|
+
for (const injected of selected) {
|
|
65
|
+
injectedKeys.push(injected.key);
|
|
66
|
+
const key = injected.key;
|
|
67
|
+
const seen = metrics[sessionId].keys[key] || 0;
|
|
68
|
+
metrics[sessionId].keys[key] = seen + 1;
|
|
69
|
+
if (seen >= 1)
|
|
70
|
+
recordFeedback(phrenPathLocal, key, "reprompt");
|
|
71
|
+
}
|
|
72
|
+
const relevantCount = selected.filter((s) => getQualityMultiplier(phrenPathLocal, s.key) > 0.5).length;
|
|
73
|
+
const prevRelevant = metrics[sessionId].lastChangedCount || 0;
|
|
74
|
+
const prevKeys = metrics[sessionId].lastKeys || [];
|
|
75
|
+
if (relevantCount > prevRelevant) {
|
|
76
|
+
for (const prevKey of prevKeys) {
|
|
77
|
+
recordFeedback(phrenPathLocal, prevKey, "helpful");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
metrics[sessionId].lastChangedCount = relevantCount;
|
|
81
|
+
metrics[sessionId].lastKeys = injectedKeys;
|
|
82
|
+
metrics[sessionId].lastSeen = new Date().toISOString();
|
|
83
|
+
const thirtyDaysAgo = Date.now() - 30 * 86400000;
|
|
84
|
+
for (const sid of Object.keys(metrics)) {
|
|
85
|
+
const seen = metrics[sid].lastSeen;
|
|
86
|
+
if (seen && new Date(seen).getTime() < thirtyDaysAgo) {
|
|
87
|
+
delete metrics[sid];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
// ── Git command helpers for hooks ────────────────────────────────────────────
|
|
93
|
+
export function isTransientGitError(message) {
|
|
94
|
+
return /(timed out|connection|network|could not resolve host|rpc failed|429|502|503|504|service unavailable)/i.test(message);
|
|
95
|
+
}
|
|
96
|
+
export function shouldRetryGitCommand(args) {
|
|
97
|
+
const cmd = args[0] || "";
|
|
98
|
+
return cmd === "push" || cmd === "pull" || cmd === "fetch";
|
|
99
|
+
}
|
|
100
|
+
export async function runBestEffortGit(args, cwd) {
|
|
101
|
+
const retries = shouldRetryGitCommand(args) ? 2 : 0;
|
|
102
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
103
|
+
try {
|
|
104
|
+
const output = execFileSync("git", args, {
|
|
105
|
+
cwd,
|
|
106
|
+
encoding: "utf8",
|
|
107
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
108
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
109
|
+
}).trim();
|
|
110
|
+
return { ok: true, output };
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
const message = errorMessage(err);
|
|
114
|
+
if (attempt < retries && isTransientGitError(message)) {
|
|
115
|
+
const delayMs = 500 * (attempt + 1);
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
return { ok: false, error: message };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { ok: false, error: "git command failed" };
|
|
123
|
+
}
|
|
124
|
+
export async function countUnsyncedCommits(cwd) {
|
|
125
|
+
const upstream = await runBestEffortGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
|
|
126
|
+
if (!upstream.ok || !upstream.output)
|
|
127
|
+
return 0;
|
|
128
|
+
const ahead = await runBestEffortGit(["rev-list", "--count", `${upstream.output.trim()}..HEAD`], cwd);
|
|
129
|
+
if (!ahead.ok || !ahead.output)
|
|
130
|
+
return 0;
|
|
131
|
+
const parsed = Number.parseInt(ahead.output.trim(), 10);
|
|
132
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
133
|
+
}
|
|
134
|
+
export function isMergeableMarkdown(relPath) {
|
|
135
|
+
const filename = path.basename(relPath).toLowerCase();
|
|
136
|
+
return filename === "findings.md" || isTaskFileName(filename);
|
|
137
|
+
}
|
|
138
|
+
export async function snapshotLocalMergeableFiles(cwd) {
|
|
139
|
+
const upstream = await runBestEffortGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
|
|
140
|
+
if (!upstream.ok || !upstream.output)
|
|
141
|
+
return new Map();
|
|
142
|
+
const changed = await runBestEffortGit(["diff", "--name-only", `${upstream.output.trim()}..HEAD`], cwd);
|
|
143
|
+
if (!changed.ok || !changed.output)
|
|
144
|
+
return new Map();
|
|
145
|
+
const snapshots = new Map();
|
|
146
|
+
for (const relPath of changed.output.split("\n").map((line) => line.trim()).filter(Boolean)) {
|
|
147
|
+
if (!isMergeableMarkdown(relPath))
|
|
148
|
+
continue;
|
|
149
|
+
const fullPath = path.join(cwd, relPath);
|
|
150
|
+
if (!fs.existsSync(fullPath))
|
|
151
|
+
continue;
|
|
152
|
+
snapshots.set(relPath, fs.readFileSync(fullPath, "utf8"));
|
|
153
|
+
}
|
|
154
|
+
return snapshots;
|
|
155
|
+
}
|
|
156
|
+
export async function reconcileMergeableFiles(cwd, snapshots) {
|
|
157
|
+
let changedAny = false;
|
|
158
|
+
for (const [relPath, localBeforePull] of snapshots.entries()) {
|
|
159
|
+
const fullPath = path.join(cwd, relPath);
|
|
160
|
+
if (!fs.existsSync(fullPath))
|
|
161
|
+
continue;
|
|
162
|
+
const current = fs.readFileSync(fullPath, "utf8");
|
|
163
|
+
const filename = path.basename(relPath).toLowerCase();
|
|
164
|
+
const merged = filename === "findings.md"
|
|
165
|
+
? mergeFindings(current, localBeforePull)
|
|
166
|
+
: mergeTask(current, localBeforePull);
|
|
167
|
+
if (merged === current)
|
|
168
|
+
continue;
|
|
169
|
+
fs.writeFileSync(fullPath, merged);
|
|
170
|
+
changedAny = true;
|
|
171
|
+
}
|
|
172
|
+
if (!changedAny)
|
|
173
|
+
return false;
|
|
174
|
+
const add = await runBestEffortGit(["add", "--", ...snapshots.keys()], cwd);
|
|
175
|
+
if (!add.ok)
|
|
176
|
+
return false;
|
|
177
|
+
const commit = await runBestEffortGit(["commit", "-m", "auto-merge markdown recovery"], cwd);
|
|
178
|
+
return commit.ok;
|
|
179
|
+
}
|
|
180
|
+
export async function recoverPushConflict(cwd) {
|
|
181
|
+
const localSnapshots = await snapshotLocalMergeableFiles(cwd);
|
|
182
|
+
const pull = await runBestEffortGit(["pull", "--rebase", "--quiet"], cwd);
|
|
183
|
+
if (pull.ok) {
|
|
184
|
+
const reconciled = await reconcileMergeableFiles(cwd, localSnapshots);
|
|
185
|
+
const retryPush = await runBestEffortGit(["push"], cwd);
|
|
186
|
+
return {
|
|
187
|
+
ok: retryPush.ok,
|
|
188
|
+
detail: retryPush.ok
|
|
189
|
+
? (reconciled ? "commit pushed after pull --rebase and markdown reconciliation" : "commit pushed after pull --rebase")
|
|
190
|
+
: (retryPush.error || "push failed after pull --rebase"),
|
|
191
|
+
pullStatus: "ok",
|
|
192
|
+
pullDetail: pull.output || "pull --rebase ok",
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const conflicted = await runBestEffortGit(["diff", "--name-only", "--diff-filter=U"], cwd);
|
|
196
|
+
const conflictedOutput = conflicted.output?.trim() || "";
|
|
197
|
+
if (!conflicted.ok || !conflictedOutput) {
|
|
198
|
+
await runBestEffortGit(["rebase", "--abort"], cwd);
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
detail: pull.error || "pull --rebase failed",
|
|
202
|
+
pullStatus: "error",
|
|
203
|
+
pullDetail: pull.error || "pull --rebase failed",
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (!autoMergeConflicts(cwd)) {
|
|
207
|
+
await runBestEffortGit(["rebase", "--abort"], cwd);
|
|
208
|
+
return {
|
|
209
|
+
ok: false,
|
|
210
|
+
detail: `rebase conflicts require manual resolution: ${conflictedOutput}`,
|
|
211
|
+
pullStatus: "error",
|
|
212
|
+
pullDetail: `rebase conflicts require manual resolution: ${conflictedOutput}`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const continued = await runBestEffortGit(["-c", "core.editor=true", "rebase", "--continue"], cwd);
|
|
216
|
+
if (!continued.ok) {
|
|
217
|
+
await runBestEffortGit(["rebase", "--abort"], cwd);
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
detail: continued.error || "rebase --continue failed",
|
|
221
|
+
pullStatus: "error",
|
|
222
|
+
pullDetail: continued.error || "rebase --continue failed",
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const retryPush = await runBestEffortGit(["push"], cwd);
|
|
226
|
+
return {
|
|
227
|
+
ok: retryPush.ok,
|
|
228
|
+
detail: retryPush.ok ? "commit pushed after auto-merge recovery" : (retryPush.error || "push failed after auto-merge recovery"),
|
|
229
|
+
pullStatus: "ok",
|
|
230
|
+
pullDetail: "pull --rebase recovered via auto-merge",
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
export function resolveSubprocessArgs(command) {
|
|
234
|
+
// Prefer the entry script from process.argv[1] (the index.js that started this process)
|
|
235
|
+
const entry = process.argv[1];
|
|
236
|
+
if (entry && fs.existsSync(entry) && /index\.(ts|js)$/.test(entry))
|
|
237
|
+
return [entry, command];
|
|
238
|
+
// Fallback: look for index.js next to this file
|
|
239
|
+
const distEntry = path.join(path.dirname(fileURLToPath(import.meta.url)), "index.js");
|
|
240
|
+
if (fs.existsSync(distEntry))
|
|
241
|
+
return [distEntry, command];
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* handleHookContext (SessionStart context injection) and handleHookTool (PostToolUse)
|
|
3
|
+
* with tool finding extraction helpers.
|
|
4
|
+
* Extracted from cli-hooks-session.ts for modularity.
|
|
5
|
+
*/
|
|
6
|
+
import { buildHookContext, debugLog, runtimeFile, sessionMarker, getPhrenPath, appendAuditLog, appendReviewQueue, detectProject, isProjectHookEnabled, getProactivityLevelForFindings, errorMessage, } from "./cli/hooks-context.js";
|
|
7
|
+
import * as fs from "fs";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
import { buildIndex, queryRows, } from "./shared/index.js";
|
|
10
|
+
import { filterTaskByPriority } from "./shared/retrieval.js";
|
|
11
|
+
import { readStdinJson, getSessionCap } from "./cli-hooks-stop.js";
|
|
12
|
+
import { logger } from "./logger.js";
|
|
13
|
+
export async function handleHookContext() {
|
|
14
|
+
const ctx = buildHookContext();
|
|
15
|
+
if (!ctx.hooksEnabled) {
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
let cwd = ctx.cwd;
|
|
19
|
+
const ctxStdin = readStdinJson();
|
|
20
|
+
if (ctxStdin?.cwd)
|
|
21
|
+
cwd = ctxStdin.cwd;
|
|
22
|
+
const project = cwd !== ctx.cwd ? detectProject(ctx.phrenPath, cwd, ctx.profile) : ctx.activeProject;
|
|
23
|
+
if (!isProjectHookEnabled(ctx.phrenPath, project, "UserPromptSubmit")) {
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
const db = await buildIndex(ctx.phrenPath, ctx.profile);
|
|
27
|
+
const contextLabel = project ? `\u25c6 phren \u00b7 ${project} \u00b7 context` : `\u25c6 phren \u00b7 context`;
|
|
28
|
+
const parts = [contextLabel, "<phren-context>"];
|
|
29
|
+
if (project) {
|
|
30
|
+
const summaryRow = queryRows(db, "SELECT content FROM docs WHERE project = ? AND type = 'summary'", [project]);
|
|
31
|
+
if (summaryRow) {
|
|
32
|
+
parts.push(`# ${project}`);
|
|
33
|
+
parts.push(summaryRow[0][0]);
|
|
34
|
+
parts.push("");
|
|
35
|
+
}
|
|
36
|
+
const findingsRow = queryRows(db, "SELECT content FROM docs WHERE project = ? AND type = 'findings'", [project]);
|
|
37
|
+
if (findingsRow) {
|
|
38
|
+
const content = findingsRow[0][0];
|
|
39
|
+
const bullets = content.split("\n").filter(l => l.startsWith("- ")).slice(0, 10);
|
|
40
|
+
if (bullets.length > 0) {
|
|
41
|
+
parts.push("## Recent findings");
|
|
42
|
+
parts.push(bullets.join("\n"));
|
|
43
|
+
parts.push("");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const taskRow = queryRows(db, "SELECT content FROM docs WHERE project = ? AND type = 'task'", [project]);
|
|
47
|
+
if (taskRow) {
|
|
48
|
+
const content = taskRow[0][0];
|
|
49
|
+
const activeItems = content.split("\n").filter(l => l.startsWith("- "));
|
|
50
|
+
const filtered = filterTaskByPriority(activeItems);
|
|
51
|
+
const trimmed = filtered.slice(0, 5);
|
|
52
|
+
if (trimmed.length > 0) {
|
|
53
|
+
parts.push("## Active tasks");
|
|
54
|
+
parts.push(trimmed.join("\n"));
|
|
55
|
+
parts.push("");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
const projectRows = queryRows(db, "SELECT DISTINCT project FROM docs ORDER BY project", []);
|
|
61
|
+
if (projectRows) {
|
|
62
|
+
parts.push("# Phren projects");
|
|
63
|
+
parts.push(projectRows.map(r => `- ${r[0]}`).join("\n"));
|
|
64
|
+
parts.push("");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
parts.push("</phren-context>");
|
|
68
|
+
if (parts.length > 2) {
|
|
69
|
+
console.log(parts.join("\n"));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// ── PostToolUse hook ─────────────────────────────────────────────────────────
|
|
73
|
+
const INTERESTING_TOOLS = new Set(["Read", "Write", "Edit", "Bash", "Glob", "Grep"]);
|
|
74
|
+
const COOLDOWN_MS = parseInt(process.env.PHREN_AUTOCAPTURE_COOLDOWN_MS ?? "30000", 10);
|
|
75
|
+
function flattenToolResponseText(value, maxChars = 4000) {
|
|
76
|
+
if (typeof value === "string")
|
|
77
|
+
return value;
|
|
78
|
+
const queue = [value];
|
|
79
|
+
const parts = [];
|
|
80
|
+
let length = 0;
|
|
81
|
+
while (queue.length > 0 && length < maxChars) {
|
|
82
|
+
const current = queue.shift();
|
|
83
|
+
if (typeof current === "string") {
|
|
84
|
+
const trimmed = current.trim();
|
|
85
|
+
if (!trimmed)
|
|
86
|
+
continue;
|
|
87
|
+
parts.push(trimmed);
|
|
88
|
+
length += trimmed.length + 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (Array.isArray(current)) {
|
|
92
|
+
queue.unshift(...current);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (current && typeof current === "object") {
|
|
96
|
+
queue.unshift(...Object.values(current));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (parts.length > 0)
|
|
100
|
+
return parts.join("\n").slice(0, maxChars);
|
|
101
|
+
return JSON.stringify(value ?? "").slice(0, maxChars);
|
|
102
|
+
}
|
|
103
|
+
export async function handleHookTool() {
|
|
104
|
+
const ctx = buildHookContext();
|
|
105
|
+
if (!ctx.hooksEnabled) {
|
|
106
|
+
process.exit(0);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const start = Date.now();
|
|
110
|
+
let raw = "";
|
|
111
|
+
if (!process.stdin.isTTY) {
|
|
112
|
+
try {
|
|
113
|
+
raw = fs.readFileSync(0, "utf-8");
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
logger.debug("hookTool stdinRead", errorMessage(err));
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
let data;
|
|
121
|
+
try {
|
|
122
|
+
data = JSON.parse(raw);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
logger.debug("hookTool stdinParse", errorMessage(err));
|
|
126
|
+
process.exit(0);
|
|
127
|
+
}
|
|
128
|
+
const toolName = String(data.tool_name ?? data.tool ?? "");
|
|
129
|
+
if (!INTERESTING_TOOLS.has(toolName)) {
|
|
130
|
+
process.exit(0);
|
|
131
|
+
}
|
|
132
|
+
const sessionId = data.session_id;
|
|
133
|
+
const input = (data.tool_input ?? {});
|
|
134
|
+
const entry = {
|
|
135
|
+
at: new Date().toISOString(),
|
|
136
|
+
session_id: sessionId,
|
|
137
|
+
tool: toolName,
|
|
138
|
+
};
|
|
139
|
+
if (toolName === "Read" || toolName === "Write" || toolName === "Edit") {
|
|
140
|
+
const filePath = input.file_path ?? input.path ?? undefined;
|
|
141
|
+
if (filePath)
|
|
142
|
+
entry.file = String(filePath);
|
|
143
|
+
}
|
|
144
|
+
else if (toolName === "Bash") {
|
|
145
|
+
const cmd = input.command ?? undefined;
|
|
146
|
+
if (cmd)
|
|
147
|
+
entry.command = String(cmd).slice(0, 200);
|
|
148
|
+
}
|
|
149
|
+
else if (toolName === "Glob") {
|
|
150
|
+
const pattern = input.pattern ?? undefined;
|
|
151
|
+
if (pattern)
|
|
152
|
+
entry.file = String(pattern);
|
|
153
|
+
}
|
|
154
|
+
else if (toolName === "Grep") {
|
|
155
|
+
const pattern = input.pattern ?? undefined;
|
|
156
|
+
const searchPath = input.path ?? undefined;
|
|
157
|
+
if (pattern)
|
|
158
|
+
entry.command = `grep ${pattern}${searchPath ? ` in ${searchPath}` : ""}`.slice(0, 200);
|
|
159
|
+
}
|
|
160
|
+
const responseStr = flattenToolResponseText(data.tool_response ?? "");
|
|
161
|
+
if (/(error|exception|failed|no such file|ENOENT)/i.test(responseStr)) {
|
|
162
|
+
entry.error = responseStr.slice(0, 300);
|
|
163
|
+
}
|
|
164
|
+
const cwd = (data.cwd ?? input.cwd ?? undefined);
|
|
165
|
+
let activeProject = cwd ? detectProject(ctx.phrenPath, cwd, ctx.profile) : null;
|
|
166
|
+
if (!isProjectHookEnabled(ctx.phrenPath, activeProject, "PostToolUse")) {
|
|
167
|
+
appendAuditLog(ctx.phrenPath, "hook_tool", `status=project_disabled project=${activeProject}`);
|
|
168
|
+
process.exit(0);
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const logFile = runtimeFile(ctx.phrenPath, "tool-log.jsonl");
|
|
172
|
+
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
|
173
|
+
fs.appendFileSync(logFile, JSON.stringify(entry) + "\n");
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
logger.debug("hookTool toolLog", errorMessage(err));
|
|
177
|
+
}
|
|
178
|
+
const cooldownFile = runtimeFile(ctx.phrenPath, "hook-tool-cooldown");
|
|
179
|
+
try {
|
|
180
|
+
if (fs.existsSync(cooldownFile)) {
|
|
181
|
+
const age = Date.now() - fs.statSync(cooldownFile).mtimeMs;
|
|
182
|
+
if (age < COOLDOWN_MS) {
|
|
183
|
+
debugLog(`hook-tool: cooldown active (${Math.round(age / 1000)}s < ${Math.round(COOLDOWN_MS / 1000)}s), skipping extraction`);
|
|
184
|
+
activeProject = null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
logger.debug("hookTool cooldownStat", errorMessage(err));
|
|
190
|
+
}
|
|
191
|
+
if (activeProject && sessionId) {
|
|
192
|
+
try {
|
|
193
|
+
const capFile = sessionMarker(ctx.phrenPath, `tool-findings-${sessionId}`);
|
|
194
|
+
let count = 0;
|
|
195
|
+
if (fs.existsSync(capFile)) {
|
|
196
|
+
count = Number.parseInt(fs.readFileSync(capFile, "utf8").trim(), 10) || 0;
|
|
197
|
+
}
|
|
198
|
+
const sessionCap = getSessionCap();
|
|
199
|
+
if (count >= sessionCap) {
|
|
200
|
+
debugLog(`hook-tool: session cap reached (${count}/${sessionCap}), skipping extraction`);
|
|
201
|
+
activeProject = null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
logger.debug("hookTool sessionCapCheck", errorMessage(err));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const findingsLevelForTool = getProactivityLevelForFindings(ctx.phrenPath);
|
|
209
|
+
if (activeProject && findingsLevelForTool !== "low") {
|
|
210
|
+
try {
|
|
211
|
+
const candidates = filterToolFindingsForProactivity(extractToolFindings(toolName, input, responseStr), findingsLevelForTool);
|
|
212
|
+
for (const { text, confidence } of candidates) {
|
|
213
|
+
appendReviewQueue(ctx.phrenPath, activeProject, "Review", [text]);
|
|
214
|
+
debugLog(`hook-tool: queued candidate for review (conf=${confidence}): ${text.slice(0, 60)}`);
|
|
215
|
+
}
|
|
216
|
+
if (candidates.length > 0) {
|
|
217
|
+
try {
|
|
218
|
+
fs.writeFileSync(cooldownFile, Date.now().toString());
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
logger.debug("hookTool cooldownWrite", errorMessage(err));
|
|
222
|
+
}
|
|
223
|
+
if (sessionId) {
|
|
224
|
+
try {
|
|
225
|
+
const capFile = sessionMarker(ctx.phrenPath, `tool-findings-${sessionId}`);
|
|
226
|
+
let count = 0;
|
|
227
|
+
try {
|
|
228
|
+
count = Number.parseInt(fs.readFileSync(capFile, "utf8").trim(), 10) || 0;
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
logger.debug("hookTool capFileRead", errorMessage(err));
|
|
232
|
+
}
|
|
233
|
+
count += candidates.length;
|
|
234
|
+
fs.writeFileSync(capFile, count.toString());
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
logger.debug("hookTool capFileWrite", errorMessage(err));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
debugLog(`hook-tool: finding extraction failed: ${errorMessage(err)}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
else if (activeProject) {
|
|
247
|
+
debugLog("hook-tool: skipped because findings proactivity is low");
|
|
248
|
+
}
|
|
249
|
+
const elapsed = Date.now() - start;
|
|
250
|
+
debugLog(`hook-tool: ${toolName} logged in ${elapsed}ms`);
|
|
251
|
+
process.exit(0);
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
debugLog(`hook-tool: unhandled error: ${err instanceof Error ? err.stack || err.message : String(err)}`);
|
|
255
|
+
process.exit(0);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const EXPLICIT_TAG_PATTERN = /\[(pitfall|decision|pattern|tradeoff|architecture|bug)\]\s*(.+)/i;
|
|
259
|
+
export function filterToolFindingsForProactivity(candidates, level = getProactivityLevelForFindings(getPhrenPath())) {
|
|
260
|
+
if (level === "high")
|
|
261
|
+
return candidates;
|
|
262
|
+
if (level === "low")
|
|
263
|
+
return [];
|
|
264
|
+
return candidates.filter((candidate) => candidate.explicit === true);
|
|
265
|
+
}
|
|
266
|
+
export function extractToolFindings(toolName, input, responseStr) {
|
|
267
|
+
const candidates = [];
|
|
268
|
+
const changedContent = (toolName === "Edit" || toolName === "Write")
|
|
269
|
+
? String(input.new_string ?? input.content ?? "")
|
|
270
|
+
: "";
|
|
271
|
+
const explicitSource = changedContent || responseStr;
|
|
272
|
+
const tagMatches = explicitSource.matchAll(new RegExp(EXPLICIT_TAG_PATTERN.source, "gi"));
|
|
273
|
+
for (const m of tagMatches) {
|
|
274
|
+
const tag = m[1].toLowerCase();
|
|
275
|
+
const content = m[2].replace(/\s+/g, " ").trim().slice(0, 200);
|
|
276
|
+
if (content) {
|
|
277
|
+
candidates.push({ text: `[${tag}] ${content}`, confidence: 0.85, explicit: true });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (toolName === "Edit" || toolName === "Write") {
|
|
281
|
+
const filePath = String(input.file_path ?? input.path ?? "unknown");
|
|
282
|
+
const filename = path.basename(filePath);
|
|
283
|
+
if (/\b(TODO|FIXME)\b/.test(changedContent)) {
|
|
284
|
+
const firstLine = changedContent.split("\n").find((l) => /\b(TODO|FIXME)\b/.test(l));
|
|
285
|
+
if (firstLine) {
|
|
286
|
+
candidates.push({
|
|
287
|
+
text: `[pitfall] ${filename}: ${firstLine.trim().slice(0, 150)}`,
|
|
288
|
+
confidence: 0.45,
|
|
289
|
+
explicit: false,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (/\btry\s*\{[\s\S]*?\bcatch\b/.test(changedContent)) {
|
|
294
|
+
const meaningfulLine = changedContent.split("\n").find((l) => l.trim().length > 10 && !/^\s*(try|catch|\{|\})/.test(l));
|
|
295
|
+
if (meaningfulLine) {
|
|
296
|
+
candidates.push({
|
|
297
|
+
text: `[pitfall] ${filename}: error handling added near "${meaningfulLine.trim().slice(0, 100)}"`,
|
|
298
|
+
confidence: 0.45,
|
|
299
|
+
explicit: false,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (toolName === "Bash") {
|
|
305
|
+
const cmd = String(input.command ?? "").slice(0, 30);
|
|
306
|
+
const hasError = /(error|exception|failed|ENOENT|command not found|permission denied)/i.test(responseStr);
|
|
307
|
+
if (hasError && cmd) {
|
|
308
|
+
const firstErrorLine = responseStr.split("\n").find((l) => /(error|exception|failed|ENOENT|command not found|permission denied)/i.test(l));
|
|
309
|
+
if (firstErrorLine) {
|
|
310
|
+
candidates.push({
|
|
311
|
+
text: `[bug] command '${cmd}' failed: ${firstErrorLine.trim().slice(0, 150)}`,
|
|
312
|
+
confidence: 0.55,
|
|
313
|
+
explicit: false,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return candidates;
|
|
319
|
+
}
|