jobarbiter 0.3.12 → 0.4.0

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.
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Privacy Pause Management
3
+ *
4
+ * Manages the PAUSED sentinel file at ~/.config/jobarbiter/observer/PAUSED
5
+ * When paused, all observation hooks and pollers exit immediately.
6
+ */
7
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { homedir } from "node:os";
10
+ import { addPauseWindow, loadPollState } from "./poll-state.js";
11
+ // ── Paths ──────────────────────────────────────────────────────────────
12
+ const OBSERVER_DIR = join(homedir(), ".config", "jobarbiter", "observer");
13
+ const PAUSED_FILE = join(OBSERVER_DIR, "PAUSED");
14
+ const OBSERVATIONS_FILE = join(OBSERVER_DIR, "observations.json");
15
+ // ── Functions ──────────────────────────────────────────────────────────
16
+ export function pauseObservation(expiresAt) {
17
+ mkdirSync(OBSERVER_DIR, { recursive: true });
18
+ const data = {
19
+ pausedAt: new Date().toISOString(),
20
+ expiresAt: expiresAt ? expiresAt.toISOString() : null,
21
+ };
22
+ writeFileSync(PAUSED_FILE, JSON.stringify(data, null, 2) + "\n");
23
+ }
24
+ export function resumeObservation() {
25
+ if (!existsSync(PAUSED_FILE))
26
+ return;
27
+ // Read pause data to record pause window
28
+ try {
29
+ const raw = readFileSync(PAUSED_FILE, "utf-8");
30
+ const data = JSON.parse(raw);
31
+ const now = new Date().toISOString();
32
+ addPauseWindow(data.pausedAt, now);
33
+ }
34
+ catch {
35
+ // Best effort
36
+ }
37
+ // Remove PAUSED file
38
+ try {
39
+ unlinkSync(PAUSED_FILE);
40
+ }
41
+ catch {
42
+ // Already removed
43
+ }
44
+ // Sweep observations.json for entries that leaked during pause
45
+ sweepLeakedObservations();
46
+ }
47
+ export function isPaused() {
48
+ if (!existsSync(PAUSED_FILE))
49
+ return false;
50
+ try {
51
+ const raw = readFileSync(PAUSED_FILE, "utf-8");
52
+ const data = JSON.parse(raw);
53
+ // Check if expired
54
+ if (data.expiresAt) {
55
+ const expiresAt = new Date(data.expiresAt).getTime();
56
+ if (Date.now() >= expiresAt) {
57
+ // Auto-resume: expired
58
+ autoResume(data);
59
+ return false;
60
+ }
61
+ }
62
+ return true;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ export function getPauseStatus() {
69
+ const state = loadPollState();
70
+ const result = {
71
+ paused: false,
72
+ pausedAt: null,
73
+ expiresAt: null,
74
+ pauseWindows: state.pauseWindows,
75
+ };
76
+ if (!existsSync(PAUSED_FILE))
77
+ return result;
78
+ try {
79
+ const raw = readFileSync(PAUSED_FILE, "utf-8");
80
+ const data = JSON.parse(raw);
81
+ // Check for expiry
82
+ if (data.expiresAt) {
83
+ const expiresAt = new Date(data.expiresAt).getTime();
84
+ if (Date.now() >= expiresAt) {
85
+ autoResume(data);
86
+ return result;
87
+ }
88
+ }
89
+ result.paused = true;
90
+ result.pausedAt = data.pausedAt;
91
+ result.expiresAt = data.expiresAt;
92
+ }
93
+ catch {
94
+ // Corrupted PAUSED file
95
+ }
96
+ return result;
97
+ }
98
+ // ── Internal Helpers ───────────────────────────────────────────────────
99
+ function autoResume(data) {
100
+ const now = new Date().toISOString();
101
+ addPauseWindow(data.pausedAt, data.expiresAt || now);
102
+ try {
103
+ unlinkSync(PAUSED_FILE);
104
+ }
105
+ catch {
106
+ // Already removed
107
+ }
108
+ sweepLeakedObservations();
109
+ }
110
+ function sweepLeakedObservations() {
111
+ if (!existsSync(OBSERVATIONS_FILE))
112
+ return;
113
+ try {
114
+ const raw = readFileSync(OBSERVATIONS_FILE, "utf-8");
115
+ const data = JSON.parse(raw);
116
+ if (!Array.isArray(data.sessions))
117
+ return;
118
+ const state = loadPollState();
119
+ const originalCount = data.sessions.length;
120
+ data.sessions = data.sessions.filter((session) => {
121
+ if (!session.timestamp)
122
+ return true;
123
+ const ts = new Date(session.timestamp).getTime();
124
+ return !state.pauseWindows.some((w) => {
125
+ const start = new Date(w.start).getTime();
126
+ const end = new Date(w.end).getTime();
127
+ return ts >= start && ts <= end;
128
+ });
129
+ });
130
+ if (data.sessions.length !== originalCount) {
131
+ writeFileSync(OBSERVATIONS_FILE, JSON.stringify(data, null, 2) + "\n");
132
+ }
133
+ }
134
+ catch {
135
+ // Best effort
136
+ }
137
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Transcript Poller
3
+ *
4
+ * Core polling logic that discovers and processes new transcripts
5
+ * from AI tools that don't support hooks (aider, goose, letta, etc.).
6
+ * Uses existing discoverTranscripts() and parseTranscriptFile() from
7
+ * transcript-reader.ts.
8
+ */
9
+ export interface PollResult {
10
+ sourcesPolled: string[];
11
+ newSessions: number;
12
+ skippedPaused: number;
13
+ skippedDuplicate: number;
14
+ errors: string[];
15
+ }
16
+ interface PollOptions {
17
+ dryRun?: boolean;
18
+ source?: string;
19
+ verbose?: boolean;
20
+ since?: Date;
21
+ }
22
+ export declare function pollAllSources(options?: PollOptions): Promise<PollResult>;
23
+ export {};
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Transcript Poller
3
+ *
4
+ * Core polling logic that discovers and processes new transcripts
5
+ * from AI tools that don't support hooks (aider, goose, letta, etc.).
6
+ * Uses existing discoverTranscripts() and parseTranscriptFile() from
7
+ * transcript-reader.ts.
8
+ */
9
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import { discoverTranscripts, parseTranscriptFile } from "./transcript-reader.js";
13
+ import { detectAllTools } from "./detect-tools.js";
14
+ import { isPaused } from "./privacy-pause.js";
15
+ import { loadPollState, savePollState, addKnownSession, isKnownSession, isInPauseWindow, } from "./poll-state.js";
16
+ import { analyzeSession } from "./session-analyzer.js";
17
+ // ── Paths ──────────────────────────────────────────────────────────────
18
+ const OBSERVER_DIR = join(homedir(), ".config", "jobarbiter", "observer");
19
+ const OBSERVATIONS_FILE = join(OBSERVER_DIR, "observations.json");
20
+ // ── Source-to-tool mapping ─────────────────────────────────────────────
21
+ const POLLER_SOURCES = [
22
+ "aider", "goose", "letta", "zed", "amazon-q",
23
+ "claude-code", "cursor", "codex", "gemini", "opencode",
24
+ "continue", "cline", "copilot-chat", "windsurf", "openclaw", "warp", "idx",
25
+ ];
26
+ // ── Main Entry Point ───────────────────────────────────────────────────
27
+ export async function pollAllSources(options) {
28
+ const result = {
29
+ sourcesPolled: [],
30
+ newSessions: 0,
31
+ skippedPaused: 0,
32
+ skippedDuplicate: 0,
33
+ errors: [],
34
+ };
35
+ // Check pause state first
36
+ if (isPaused()) {
37
+ if (options?.verbose) {
38
+ result.errors.push("Observation is paused — skipping poll");
39
+ }
40
+ return result;
41
+ }
42
+ const state = loadPollState();
43
+ // Determine which sources to poll
44
+ let sourcesToPoll;
45
+ if (options?.source) {
46
+ if (!POLLER_SOURCES.includes(options.source)) {
47
+ result.errors.push(`Unknown source: ${options.source}`);
48
+ return result;
49
+ }
50
+ sourcesToPoll = [options.source];
51
+ }
52
+ else {
53
+ // Filter to installed + not disabled
54
+ const allTools = detectAllTools();
55
+ const installedToolIds = new Set(allTools.filter((t) => t.installed).map((t) => t.id));
56
+ const disabledSet = new Set(state.disabledSources);
57
+ sourcesToPoll = POLLER_SOURCES.filter((source) => {
58
+ // Map source names to tool IDs (most are the same)
59
+ const toolId = sourceToToolId(source);
60
+ return installedToolIds.has(toolId) && !disabledSet.has(source);
61
+ });
62
+ }
63
+ if (sourcesToPoll.length === 0) {
64
+ if (options?.verbose) {
65
+ result.errors.push("No sources to poll (none installed or all disabled)");
66
+ }
67
+ return result;
68
+ }
69
+ // Discover all transcripts, then filter to sources we want
70
+ const oldestSince = sourcesToPoll.reduce((oldest, source) => {
71
+ const sourceDate = options?.since || (state.lastPoll[source]
72
+ ? new Date(state.lastPoll[source])
73
+ : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000));
74
+ return sourceDate < oldest ? sourceDate : oldest;
75
+ }, new Date());
76
+ const allDiscovered = discoverTranscripts({
77
+ since: oldestSince,
78
+ maxFiles: 50,
79
+ });
80
+ const sourcesToPollSet = new Set(sourcesToPoll);
81
+ for (const entry of allDiscovered) {
82
+ const source = entry.source;
83
+ if (!sourcesToPollSet.has(source))
84
+ continue;
85
+ result.sourcesPolled.push(source);
86
+ for (const file of entry.files) {
87
+ try {
88
+ const transcript = parseTranscriptFile(file, source);
89
+ if (!transcript || transcript.messages.length < 3)
90
+ continue;
91
+ const sessionId = transcript.sessionId;
92
+ // Dedup check
93
+ if (isKnownSession(sessionId)) {
94
+ result.skippedDuplicate++;
95
+ continue;
96
+ }
97
+ // Pause window check
98
+ if (transcript.startTime && isInPauseWindow(transcript.startTime)) {
99
+ result.skippedPaused++;
100
+ continue;
101
+ }
102
+ if (options?.dryRun) {
103
+ result.newSessions++;
104
+ if (options.verbose) {
105
+ console.log(` [dry-run] Would process: ${source}/${sessionId} (${transcript.messages.length} messages)`);
106
+ }
107
+ continue;
108
+ }
109
+ // Analyze and extract signals
110
+ const report = analyzeSession(transcript);
111
+ const observation = {
112
+ timestamp: new Date().toISOString(),
113
+ agent: source,
114
+ sessionId,
115
+ source: "poller",
116
+ signals: {
117
+ toolsUsed: report.qualitativeAssessment.toolFluency.toolsUsed,
118
+ fileExtensions: report.qualitativeAssessment.domainExpertise.domains,
119
+ messageCount: report.quantitativeMetrics.messageCount,
120
+ thinkingBlocks: report.quantitativeMetrics.thinkingBlocks,
121
+ tokenUsage: report.quantitativeMetrics.tokenCount > 0
122
+ ? { total: report.quantitativeMetrics.tokenCount }
123
+ : null,
124
+ duration: report.quantitativeMetrics.sessionDurationMinutes > 0
125
+ ? report.quantitativeMetrics.sessionDurationMinutes
126
+ : null,
127
+ },
128
+ };
129
+ appendObservation(observation);
130
+ addKnownSession(sessionId);
131
+ result.newSessions++;
132
+ if (options?.verbose) {
133
+ console.log(` Processed: ${source}/${sessionId} (${transcript.messages.length} messages)`);
134
+ }
135
+ }
136
+ catch (err) {
137
+ result.errors.push(`${source}/${file}: ${err instanceof Error ? err.message : String(err)}`);
138
+ }
139
+ }
140
+ // Update last poll time for this source
141
+ state.lastPoll[source] = new Date().toISOString();
142
+ }
143
+ // Save updated poll state
144
+ savePollState(state);
145
+ return result;
146
+ }
147
+ // ── Helpers ────────────────────────────────────────────────────────────
148
+ function sourceToToolId(source) {
149
+ const mapping = {
150
+ "zed": "zed-ai",
151
+ "amazon-q": "amazon-q",
152
+ "copilot-chat": "copilot-chat",
153
+ "warp": "warp-ai",
154
+ };
155
+ return mapping[source] || source;
156
+ }
157
+ function appendObservation(obs) {
158
+ mkdirSync(OBSERVER_DIR, { recursive: true });
159
+ try {
160
+ let data;
161
+ if (existsSync(OBSERVATIONS_FILE)) {
162
+ data = JSON.parse(readFileSync(OBSERVATIONS_FILE, "utf-8"));
163
+ }
164
+ else {
165
+ data = {
166
+ version: 1,
167
+ installedAt: new Date().toISOString(),
168
+ agents: {},
169
+ sessions: [],
170
+ accumulated: {
171
+ totalSessions: 0,
172
+ totalTokens: 0,
173
+ toolCounts: {},
174
+ domainSignals: [],
175
+ lastSubmitted: null,
176
+ },
177
+ };
178
+ }
179
+ const sessions = data.sessions;
180
+ sessions.push(obs);
181
+ const accumulated = data.accumulated;
182
+ accumulated.totalSessions = (accumulated.totalSessions || 0) + 1;
183
+ // Update tool counts
184
+ const signals = obs.signals;
185
+ const toolsUsed = (signals.toolsUsed || []);
186
+ const toolCounts = accumulated.toolCounts;
187
+ for (const tool of toolsUsed) {
188
+ toolCounts[tool] = (toolCounts[tool] || 0) + 1;
189
+ }
190
+ // Update token totals
191
+ const tokenUsage = signals.tokenUsage;
192
+ if (tokenUsage?.total) {
193
+ accumulated.totalTokens = (accumulated.totalTokens || 0) + tokenUsage.total;
194
+ }
195
+ // Rolling window of 500 sessions
196
+ if (sessions.length > 500) {
197
+ data.sessions = sessions.slice(-500);
198
+ }
199
+ writeFileSync(OBSERVATIONS_FILE, JSON.stringify(data, null, 2) + "\n");
200
+ }
201
+ catch {
202
+ // If file is corrupted, start fresh
203
+ const freshData = {
204
+ version: 1,
205
+ installedAt: new Date().toISOString(),
206
+ agents: {},
207
+ sessions: [obs],
208
+ accumulated: {
209
+ totalSessions: 1,
210
+ totalTokens: 0,
211
+ toolCounts: {},
212
+ domainSignals: [],
213
+ lastSubmitted: null,
214
+ },
215
+ };
216
+ writeFileSync(OBSERVATIONS_FILE, JSON.stringify(freshData, null, 2) + "\n");
217
+ }
218
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Uninstall Module
3
+ *
4
+ * Clean removal of all JobArbiter components:
5
+ * observers, daemon, hooks, and optionally data/config.
6
+ */
7
+ export interface UninstallOptions {
8
+ keepData?: boolean;
9
+ keepConfig?: boolean;
10
+ force?: boolean;
11
+ }
12
+ export interface UninstallResult {
13
+ observersRemoved: string[];
14
+ daemonUninstalled: boolean;
15
+ hooksDeleted: boolean;
16
+ dataDeleted: boolean;
17
+ configDeleted: boolean;
18
+ errors: string[];
19
+ }
20
+ export declare function uninstallAll(options?: UninstallOptions): UninstallResult;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Uninstall Module
3
+ *
4
+ * Clean removal of all JobArbiter components:
5
+ * observers, daemon, hooks, and optionally data/config.
6
+ */
7
+ import { existsSync, rmSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { homedir } from "node:os";
10
+ import { detectAgents, removeObservers } from "./observe.js";
11
+ import { uninstallDaemon } from "./launchd.js";
12
+ // ── Paths ──────────────────────────────────────────────────────────────
13
+ const CONFIG_DIR = join(homedir(), ".config", "jobarbiter");
14
+ const OBSERVER_DIR = join(CONFIG_DIR, "observer");
15
+ const HOOKS_DIR = join(OBSERVER_DIR, "hooks");
16
+ // ── Main Function ──────────────────────────────────────────────────────
17
+ export function uninstallAll(options = {}) {
18
+ const result = {
19
+ observersRemoved: [],
20
+ daemonUninstalled: false,
21
+ hooksDeleted: false,
22
+ dataDeleted: false,
23
+ configDeleted: false,
24
+ errors: [],
25
+ };
26
+ // 1. Remove observers from all agents
27
+ try {
28
+ const agents = detectAgents();
29
+ const withHooks = agents.filter((a) => a.hookInstalled);
30
+ if (withHooks.length > 0) {
31
+ const removeResult = removeObservers(withHooks.map((a) => a.id));
32
+ result.observersRemoved = removeResult.removed;
33
+ }
34
+ }
35
+ catch (err) {
36
+ result.errors.push(`Failed to remove observers: ${err instanceof Error ? err.message : String(err)}`);
37
+ }
38
+ // 2. Uninstall launchd daemon
39
+ try {
40
+ uninstallDaemon();
41
+ result.daemonUninstalled = true;
42
+ }
43
+ catch (err) {
44
+ result.errors.push(`Failed to uninstall daemon: ${err instanceof Error ? err.message : String(err)}`);
45
+ }
46
+ // 3. Delete observer hooks directory
47
+ try {
48
+ if (existsSync(HOOKS_DIR)) {
49
+ rmSync(HOOKS_DIR, { recursive: true, force: true });
50
+ result.hooksDeleted = true;
51
+ }
52
+ }
53
+ catch (err) {
54
+ result.errors.push(`Failed to delete hooks: ${err instanceof Error ? err.message : String(err)}`);
55
+ }
56
+ // 4. Optionally delete observation data
57
+ if (!options.keepData) {
58
+ try {
59
+ if (existsSync(OBSERVER_DIR)) {
60
+ rmSync(OBSERVER_DIR, { recursive: true, force: true });
61
+ result.dataDeleted = true;
62
+ }
63
+ }
64
+ catch (err) {
65
+ result.errors.push(`Failed to delete observer data: ${err instanceof Error ? err.message : String(err)}`);
66
+ }
67
+ }
68
+ // 5. Optionally delete config
69
+ if (!options.keepConfig) {
70
+ try {
71
+ if (existsSync(CONFIG_DIR)) {
72
+ rmSync(CONFIG_DIR, { recursive: true, force: true });
73
+ result.configDeleted = true;
74
+ }
75
+ }
76
+ catch (err) {
77
+ result.errors.push(`Failed to delete config: ${err instanceof Error ? err.message : String(err)}`);
78
+ }
79
+ }
80
+ return result;
81
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jobarbiter",
3
- "version": "0.3.12",
3
+ "version": "0.4.0",
4
4
  "description": "CLI for JobArbiter — the first AI Proficiency Marketplace",
5
5
  "type": "module",
6
6
  "bin": {