@phren/cli 0.1.13 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/hooks-session.d.ts +18 -36
- package/dist/cli/hooks-session.js +21 -1482
- package/dist/cli/namespaces-findings.d.ts +1 -0
- package/dist/cli/namespaces-findings.js +208 -0
- package/dist/cli/namespaces-profile.d.ts +1 -0
- package/dist/cli/namespaces-profile.js +76 -0
- package/dist/cli/namespaces-projects.d.ts +1 -0
- package/dist/cli/namespaces-projects.js +370 -0
- package/dist/cli/namespaces-review.d.ts +1 -0
- package/dist/cli/namespaces-review.js +45 -0
- package/dist/cli/namespaces-skills.d.ts +4 -0
- package/dist/cli/namespaces-skills.js +550 -0
- package/dist/cli/namespaces-store.d.ts +2 -0
- package/dist/cli/namespaces-store.js +367 -0
- package/dist/cli/namespaces-tasks.d.ts +1 -0
- package/dist/cli/namespaces-tasks.js +369 -0
- package/dist/cli/namespaces-utils.d.ts +4 -0
- package/dist/cli/namespaces-utils.js +47 -0
- package/dist/cli/namespaces.d.ts +7 -11
- package/dist/cli/namespaces.js +8 -2011
- package/dist/cli/session-background.d.ts +3 -0
- package/dist/cli/session-background.js +176 -0
- package/dist/cli/session-git.d.ts +17 -0
- package/dist/cli/session-git.js +181 -0
- package/dist/cli/session-metrics.d.ts +2 -0
- package/dist/cli/session-metrics.js +67 -0
- package/dist/cli/session-start.d.ts +3 -0
- package/dist/cli/session-start.js +289 -0
- package/dist/cli/session-stop.d.ts +8 -0
- package/dist/cli/session-stop.js +468 -0
- package/dist/cli/session-tool-hook.d.ts +18 -0
- package/dist/cli/session-tool-hook.js +376 -0
- package/dist/tools/search.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background sync and maintenance scheduling.
|
|
3
|
+
* Extracted from hooks-session.ts for modularity.
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import { debugLog, errorMessage, runtimeFile, isFeatureEnabled, } from "./hooks-context.js";
|
|
9
|
+
import { qualityMarkers } from "../shared.js";
|
|
10
|
+
import { spawnDetachedChild } from "../shared/process.js";
|
|
11
|
+
import { logger } from "../logger.js";
|
|
12
|
+
const SYNC_LOCK_STALE_MS = 10 * 60 * 1000; // 10 minutes
|
|
13
|
+
const MAINTENANCE_LOCK_STALE_MS = 2 * 60 * 60 * 1000; // 2 hours
|
|
14
|
+
export function resolveSubprocessArgs(command) {
|
|
15
|
+
// Prefer the entry script that started this process
|
|
16
|
+
const entry = process.argv[1];
|
|
17
|
+
if (entry && fs.existsSync(entry) && /index\.(ts|js)$/.test(entry))
|
|
18
|
+
return [entry, command];
|
|
19
|
+
// Fallback: look for index.js next to this file or one level up (for subdirectory builds)
|
|
20
|
+
const thisDir = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
for (const dir of [thisDir, path.dirname(thisDir)]) {
|
|
22
|
+
const candidate = path.join(dir, "index.js");
|
|
23
|
+
if (fs.existsSync(candidate))
|
|
24
|
+
return [candidate, command];
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
export function scheduleBackgroundSync(phrenPathLocal) {
|
|
29
|
+
const lockPath = runtimeFile(phrenPathLocal, "background-sync.lock");
|
|
30
|
+
const logPath = runtimeFile(phrenPathLocal, "background-sync.log");
|
|
31
|
+
const spawnArgs = resolveSubprocessArgs("background-sync");
|
|
32
|
+
if (!spawnArgs)
|
|
33
|
+
return false;
|
|
34
|
+
try {
|
|
35
|
+
if (fs.existsSync(lockPath)) {
|
|
36
|
+
const ageMs = Date.now() - fs.statSync(lockPath).mtimeMs;
|
|
37
|
+
if (ageMs <= SYNC_LOCK_STALE_MS)
|
|
38
|
+
return false;
|
|
39
|
+
fs.unlinkSync(lockPath);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
debugLog(`scheduleBackgroundSync: lock check failed: ${errorMessage(err)}`);
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
fs.writeFileSync(lockPath, JSON.stringify({ startedAt: new Date().toISOString(), pid: process.pid }) + "\n", { flag: "wx" });
|
|
48
|
+
const logFd = fs.openSync(logPath, "a");
|
|
49
|
+
fs.writeSync(logFd, `[${new Date().toISOString()}] spawn ${process.execPath} ${spawnArgs.join(" ")}\n`);
|
|
50
|
+
const child = spawnDetachedChild(spawnArgs, { phrenPath: phrenPathLocal, logFd });
|
|
51
|
+
child.unref();
|
|
52
|
+
fs.closeSync(logFd);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
try {
|
|
57
|
+
fs.unlinkSync(lockPath);
|
|
58
|
+
}
|
|
59
|
+
catch { }
|
|
60
|
+
debugLog(`scheduleBackgroundSync: spawn failed: ${errorMessage(err)}`);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function scheduleBackgroundMaintenance(phrenPathLocal, project) {
|
|
65
|
+
if (!isFeatureEnabled("PHREN_FEATURE_DAILY_MAINTENANCE", true))
|
|
66
|
+
return false;
|
|
67
|
+
const markers = qualityMarkers(phrenPathLocal);
|
|
68
|
+
if (fs.existsSync(markers.done))
|
|
69
|
+
return false;
|
|
70
|
+
if (fs.existsSync(markers.lock)) {
|
|
71
|
+
try {
|
|
72
|
+
const ageMs = Date.now() - fs.statSync(markers.lock).mtimeMs;
|
|
73
|
+
if (ageMs <= MAINTENANCE_LOCK_STALE_MS)
|
|
74
|
+
return false;
|
|
75
|
+
fs.unlinkSync(markers.lock);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
debugLog(`maybeRunBackgroundMaintenance: lock check failed: ${errorMessage(err)}`);
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const spawnArgs = resolveSubprocessArgs("background-maintenance");
|
|
83
|
+
if (!spawnArgs)
|
|
84
|
+
return false;
|
|
85
|
+
try {
|
|
86
|
+
// Use exclusive open (O_EXCL) to atomically claim the lock; if another process
|
|
87
|
+
// already holds it this throws and we return false without spawning a duplicate.
|
|
88
|
+
const lockContent = JSON.stringify({
|
|
89
|
+
startedAt: new Date().toISOString(),
|
|
90
|
+
project: project || "all",
|
|
91
|
+
pid: process.pid,
|
|
92
|
+
}) + "\n";
|
|
93
|
+
let fd;
|
|
94
|
+
try {
|
|
95
|
+
fd = fs.openSync(markers.lock, "wx");
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
// Another process already claimed the lock
|
|
99
|
+
logger.debug("hooks-session", `backgroundMaintenance lockClaim: ${errorMessage(err)}`);
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
fs.writeSync(fd, lockContent);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
fs.closeSync(fd);
|
|
107
|
+
}
|
|
108
|
+
if (project)
|
|
109
|
+
spawnArgs.push(project);
|
|
110
|
+
const logDir = path.join(phrenPathLocal, ".config");
|
|
111
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
112
|
+
const logPath = path.join(logDir, "background-maintenance.log");
|
|
113
|
+
const logFd = fs.openSync(logPath, "a");
|
|
114
|
+
fs.writeSync(logFd, `[${new Date().toISOString()}] spawn ${process.execPath} ${spawnArgs.join(" ")}\n`);
|
|
115
|
+
const child = spawnDetachedChild(spawnArgs, { phrenPath: phrenPathLocal, logFd });
|
|
116
|
+
child.on("exit", (code, signal) => {
|
|
117
|
+
const msg = `[${new Date().toISOString()}] exit code=${code ?? "null"} signal=${signal ?? "none"}\n`;
|
|
118
|
+
try {
|
|
119
|
+
fs.appendFileSync(logPath, msg);
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
logger.debug("hooks-session", `backgroundMaintenance exitLog: ${errorMessage(err)}`);
|
|
123
|
+
}
|
|
124
|
+
if (code === 0) {
|
|
125
|
+
try {
|
|
126
|
+
fs.writeFileSync(markers.done, new Date().toISOString() + "\n");
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
logger.debug("hooks-session", `backgroundMaintenance doneMarker: ${errorMessage(err)}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
fs.unlinkSync(markers.lock);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
logger.debug("hooks-session", `backgroundMaintenance unlockOnExit: ${errorMessage(err)}`);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
child.on("error", (spawnErr) => {
|
|
140
|
+
const msg = `[${new Date().toISOString()}] spawn error: ${spawnErr.message}\n`;
|
|
141
|
+
try {
|
|
142
|
+
fs.appendFileSync(logPath, msg);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
logger.debug("hooks-session", `backgroundMaintenance errorLog: ${errorMessage(err)}`);
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
fs.unlinkSync(markers.lock);
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
logger.debug("hooks-session", `backgroundMaintenance unlockOnError: ${errorMessage(err)}`);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
fs.closeSync(logFd);
|
|
155
|
+
child.unref();
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
const errMsg = errorMessage(err);
|
|
160
|
+
try {
|
|
161
|
+
const logDir = path.join(phrenPathLocal, ".config");
|
|
162
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
163
|
+
fs.appendFileSync(path.join(logDir, "background-maintenance.log"), `[${new Date().toISOString()}] spawn failed: ${errMsg}\n`);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
logger.debug("hooks-session", `backgroundMaintenance logSpawnFailure: ${errorMessage(err)}`);
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
fs.unlinkSync(markers.lock);
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
logger.debug("hooks-session", `backgroundMaintenance unlockOnFailure: ${errorMessage(err)}`);
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface GitContext {
|
|
2
|
+
branch: string;
|
|
3
|
+
changedFiles: Set<string>;
|
|
4
|
+
}
|
|
5
|
+
export declare function getGitContext(cwd?: string): GitContext | null;
|
|
6
|
+
export declare function runBestEffortGit(args: string[], cwd: string): Promise<{
|
|
7
|
+
ok: boolean;
|
|
8
|
+
output?: string;
|
|
9
|
+
error?: string;
|
|
10
|
+
}>;
|
|
11
|
+
export declare function countUnsyncedCommits(cwd: string): Promise<number>;
|
|
12
|
+
export declare function recoverPushConflict(cwd: string): Promise<{
|
|
13
|
+
ok: boolean;
|
|
14
|
+
detail: string;
|
|
15
|
+
pullStatus: "ok" | "error";
|
|
16
|
+
pullDetail: string;
|
|
17
|
+
}>;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git helpers for session hooks.
|
|
3
|
+
* Extracted from hooks-session.ts for modularity.
|
|
4
|
+
*/
|
|
5
|
+
import { execFileSync } from "child_process";
|
|
6
|
+
import * as fs from "fs";
|
|
7
|
+
import * as path from "path";
|
|
8
|
+
import { EXEC_TIMEOUT_MS, debugLog, errorMessage, } from "./hooks-context.js";
|
|
9
|
+
import { runGit } from "../utils.js";
|
|
10
|
+
import { isTaskFileName } from "../data/tasks.js";
|
|
11
|
+
import { autoMergeConflicts, mergeTask, mergeFindings, } from "../shared/content.js";
|
|
12
|
+
export function getGitContext(cwd) {
|
|
13
|
+
if (!cwd)
|
|
14
|
+
return null;
|
|
15
|
+
const git = (args) => runGit(cwd, args, EXEC_TIMEOUT_MS, debugLog);
|
|
16
|
+
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
17
|
+
if (!branch)
|
|
18
|
+
return null;
|
|
19
|
+
const changedFiles = new Set();
|
|
20
|
+
for (const changed of [
|
|
21
|
+
git(["diff", "--name-only"]),
|
|
22
|
+
git(["diff", "--name-only", "--cached"]),
|
|
23
|
+
]) {
|
|
24
|
+
if (!changed)
|
|
25
|
+
continue;
|
|
26
|
+
for (const line of changed.split("\n").map((s) => s.trim()).filter(Boolean)) {
|
|
27
|
+
changedFiles.add(line);
|
|
28
|
+
const basename = path.basename(line);
|
|
29
|
+
if (basename)
|
|
30
|
+
changedFiles.add(basename);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { branch, changedFiles };
|
|
34
|
+
}
|
|
35
|
+
// ── Git command helpers ─────────────────────────────────────────────────────
|
|
36
|
+
function isTransientGitError(message) {
|
|
37
|
+
return /(timed out|connection|network|could not resolve host|rpc failed|429|502|503|504|service unavailable)/i.test(message);
|
|
38
|
+
}
|
|
39
|
+
function shouldRetryGitCommand(args) {
|
|
40
|
+
const cmd = args[0] || "";
|
|
41
|
+
return cmd === "push" || cmd === "pull" || cmd === "fetch";
|
|
42
|
+
}
|
|
43
|
+
export async function runBestEffortGit(args, cwd) {
|
|
44
|
+
const retries = shouldRetryGitCommand(args) ? 2 : 0;
|
|
45
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
46
|
+
try {
|
|
47
|
+
const output = execFileSync("git", args, {
|
|
48
|
+
cwd,
|
|
49
|
+
encoding: "utf8",
|
|
50
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
51
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
52
|
+
}).trim();
|
|
53
|
+
return { ok: true, output };
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
const message = errorMessage(err);
|
|
57
|
+
if (attempt < retries && isTransientGitError(message)) {
|
|
58
|
+
const delayMs = 500 * (attempt + 1);
|
|
59
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
return { ok: false, error: message };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { ok: false, error: "git command failed" };
|
|
66
|
+
}
|
|
67
|
+
export async function countUnsyncedCommits(cwd) {
|
|
68
|
+
const upstream = await runBestEffortGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
|
|
69
|
+
if (!upstream.ok || !upstream.output) {
|
|
70
|
+
const allCommits = await runBestEffortGit(["rev-list", "--count", "HEAD"], cwd);
|
|
71
|
+
if (!allCommits.ok || !allCommits.output)
|
|
72
|
+
return 0;
|
|
73
|
+
const parsed = Number.parseInt(allCommits.output.trim(), 10);
|
|
74
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
75
|
+
}
|
|
76
|
+
const ahead = await runBestEffortGit(["rev-list", "--count", `${upstream.output.trim()}..HEAD`], cwd);
|
|
77
|
+
if (!ahead.ok || !ahead.output)
|
|
78
|
+
return 0;
|
|
79
|
+
const parsed = Number.parseInt(ahead.output.trim(), 10);
|
|
80
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
81
|
+
}
|
|
82
|
+
// ── Merge helpers ───────────────────────────────────────────────────────────
|
|
83
|
+
function isMergeableMarkdown(relPath) {
|
|
84
|
+
const filename = path.basename(relPath).toLowerCase();
|
|
85
|
+
return filename === "findings.md" || isTaskFileName(filename);
|
|
86
|
+
}
|
|
87
|
+
async function snapshotLocalMergeableFiles(cwd) {
|
|
88
|
+
const upstream = await runBestEffortGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
|
|
89
|
+
if (!upstream.ok || !upstream.output)
|
|
90
|
+
return new Map();
|
|
91
|
+
const changed = await runBestEffortGit(["diff", "--name-only", `${upstream.output.trim()}..HEAD`], cwd);
|
|
92
|
+
if (!changed.ok || !changed.output)
|
|
93
|
+
return new Map();
|
|
94
|
+
const snapshots = new Map();
|
|
95
|
+
for (const relPath of changed.output.split("\n").map((line) => line.trim()).filter(Boolean)) {
|
|
96
|
+
if (!isMergeableMarkdown(relPath))
|
|
97
|
+
continue;
|
|
98
|
+
const fullPath = path.join(cwd, relPath);
|
|
99
|
+
if (!fs.existsSync(fullPath))
|
|
100
|
+
continue;
|
|
101
|
+
snapshots.set(relPath, fs.readFileSync(fullPath, "utf8"));
|
|
102
|
+
}
|
|
103
|
+
return snapshots;
|
|
104
|
+
}
|
|
105
|
+
async function reconcileMergeableFiles(cwd, snapshots) {
|
|
106
|
+
let changedAny = false;
|
|
107
|
+
for (const [relPath, localBeforePull] of snapshots.entries()) {
|
|
108
|
+
const fullPath = path.join(cwd, relPath);
|
|
109
|
+
if (!fs.existsSync(fullPath))
|
|
110
|
+
continue;
|
|
111
|
+
const current = fs.readFileSync(fullPath, "utf8");
|
|
112
|
+
const filename = path.basename(relPath).toLowerCase();
|
|
113
|
+
const merged = filename === "findings.md"
|
|
114
|
+
? mergeFindings(current, localBeforePull)
|
|
115
|
+
: mergeTask(current, localBeforePull);
|
|
116
|
+
if (merged === current)
|
|
117
|
+
continue;
|
|
118
|
+
fs.writeFileSync(fullPath, merged);
|
|
119
|
+
changedAny = true;
|
|
120
|
+
}
|
|
121
|
+
if (!changedAny)
|
|
122
|
+
return false;
|
|
123
|
+
const add = await runBestEffortGit(["add", "--", ...snapshots.keys()], cwd);
|
|
124
|
+
if (!add.ok)
|
|
125
|
+
return false;
|
|
126
|
+
const commit = await runBestEffortGit(["commit", "-m", "auto-merge markdown recovery"], cwd);
|
|
127
|
+
return commit.ok;
|
|
128
|
+
}
|
|
129
|
+
export async function recoverPushConflict(cwd) {
|
|
130
|
+
const localSnapshots = await snapshotLocalMergeableFiles(cwd);
|
|
131
|
+
const pull = await runBestEffortGit(["pull", "--rebase", "--quiet"], cwd);
|
|
132
|
+
if (pull.ok) {
|
|
133
|
+
const reconciled = await reconcileMergeableFiles(cwd, localSnapshots);
|
|
134
|
+
const retryPush = await runBestEffortGit(["push"], cwd);
|
|
135
|
+
return {
|
|
136
|
+
ok: retryPush.ok,
|
|
137
|
+
detail: retryPush.ok
|
|
138
|
+
? (reconciled ? "commit pushed after pull --rebase and markdown reconciliation" : "commit pushed after pull --rebase")
|
|
139
|
+
: (retryPush.error || "push failed after pull --rebase"),
|
|
140
|
+
pullStatus: "ok",
|
|
141
|
+
pullDetail: pull.output || "pull --rebase ok",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const conflicted = await runBestEffortGit(["diff", "--name-only", "--diff-filter=U"], cwd);
|
|
145
|
+
const conflictedOutput = conflicted.output?.trim() || "";
|
|
146
|
+
if (!conflicted.ok || !conflictedOutput) {
|
|
147
|
+
await runBestEffortGit(["rebase", "--abort"], cwd);
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
detail: pull.error || "pull --rebase failed",
|
|
151
|
+
pullStatus: "error",
|
|
152
|
+
pullDetail: pull.error || "pull --rebase failed",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (!autoMergeConflicts(cwd)) {
|
|
156
|
+
await runBestEffortGit(["rebase", "--abort"], cwd);
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
detail: `rebase conflicts require manual resolution: ${conflictedOutput}`,
|
|
160
|
+
pullStatus: "error",
|
|
161
|
+
pullDetail: `rebase conflicts require manual resolution: ${conflictedOutput}`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const continued = await runBestEffortGit(["-c", "core.editor=true", "rebase", "--continue"], cwd);
|
|
165
|
+
if (!continued.ok) {
|
|
166
|
+
await runBestEffortGit(["rebase", "--abort"], cwd);
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
detail: continued.error || "rebase --continue failed",
|
|
170
|
+
pullStatus: "error",
|
|
171
|
+
pullDetail: continued.error || "rebase --continue failed",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const retryPush = await runBestEffortGit(["push"], cwd);
|
|
175
|
+
return {
|
|
176
|
+
ok: retryPush.ok,
|
|
177
|
+
detail: retryPush.ok ? "commit pushed after auto-merge recovery" : (retryPush.error || "push failed after auto-merge recovery"),
|
|
178
|
+
pullStatus: "ok",
|
|
179
|
+
pullDetail: "pull --rebase recovered via auto-merge",
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session metrics tracking.
|
|
3
|
+
* Extracted from hooks-session.ts for modularity.
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import { debugLog, errorMessage, withFileLock, getQualityMultiplier, recordFeedback, } from "./hooks-context.js";
|
|
8
|
+
import { sessionMetricsFile } from "../shared.js";
|
|
9
|
+
function parseSessionMetrics(phrenPathLocal) {
|
|
10
|
+
const file = sessionMetricsFile(phrenPathLocal);
|
|
11
|
+
if (!fs.existsSync(file))
|
|
12
|
+
return {};
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
debugLog(`parseSessionMetrics: failed to read ${file}: ${errorMessage(err)}`);
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function writeSessionMetrics(phrenPathLocal, data) {
|
|
22
|
+
const file = sessionMetricsFile(phrenPathLocal);
|
|
23
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
24
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
|
|
25
|
+
}
|
|
26
|
+
function updateSessionMetrics(phrenPathLocal, updater) {
|
|
27
|
+
const file = sessionMetricsFile(phrenPathLocal);
|
|
28
|
+
withFileLock(file, () => {
|
|
29
|
+
const metrics = parseSessionMetrics(phrenPathLocal);
|
|
30
|
+
updater(metrics);
|
|
31
|
+
writeSessionMetrics(phrenPathLocal, metrics);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
export function trackSessionMetrics(phrenPathLocal, sessionId, selected) {
|
|
35
|
+
updateSessionMetrics(phrenPathLocal, (metrics) => {
|
|
36
|
+
if (!metrics[sessionId])
|
|
37
|
+
metrics[sessionId] = { prompts: 0, keys: {}, lastChangedCount: 0, lastKeys: [] };
|
|
38
|
+
metrics[sessionId].prompts += 1;
|
|
39
|
+
const injectedKeys = [];
|
|
40
|
+
for (const injected of selected) {
|
|
41
|
+
injectedKeys.push(injected.key);
|
|
42
|
+
const key = injected.key;
|
|
43
|
+
const seen = metrics[sessionId].keys[key] || 0;
|
|
44
|
+
metrics[sessionId].keys[key] = seen + 1;
|
|
45
|
+
if (seen >= 1)
|
|
46
|
+
recordFeedback(phrenPathLocal, key, "reprompt");
|
|
47
|
+
}
|
|
48
|
+
const relevantCount = selected.filter((s) => getQualityMultiplier(phrenPathLocal, s.key) > 0.5).length;
|
|
49
|
+
const prevRelevant = metrics[sessionId].lastChangedCount || 0;
|
|
50
|
+
const prevKeys = metrics[sessionId].lastKeys || [];
|
|
51
|
+
if (relevantCount > prevRelevant) {
|
|
52
|
+
for (const prevKey of prevKeys) {
|
|
53
|
+
recordFeedback(phrenPathLocal, prevKey, "helpful");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
metrics[sessionId].lastChangedCount = relevantCount;
|
|
57
|
+
metrics[sessionId].lastKeys = injectedKeys;
|
|
58
|
+
metrics[sessionId].lastSeen = new Date().toISOString();
|
|
59
|
+
const thirtyDaysAgo = Date.now() - 30 * 86400000;
|
|
60
|
+
for (const sid of Object.keys(metrics)) {
|
|
61
|
+
const seen = metrics[sid].lastSeen;
|
|
62
|
+
if (seen && new Date(seen).getTime() < thirtyDaysAgo) {
|
|
63
|
+
delete metrics[sid];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare function getUntrackedProjectNotice(phrenPath: string, cwd: string): string | null;
|
|
2
|
+
export declare function getSessionStartOnboardingNotice(phrenPath: string, cwd: string, activeProject: string | null): string | null;
|
|
3
|
+
export declare function handleHookSessionStart(): Promise<void>;
|