@markjaquith/agency 2.70.0 → 2.71.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.
package/README.md CHANGED
@@ -21,6 +21,21 @@ bun install -g @markjaquith/agency
21
21
 
22
22
  For development, run `bun link` from this repository.
23
23
 
24
+ ## Local Usage Logging
25
+
26
+ Agency records privacy-safe CLI usage events locally so command journeys,
27
+ failures, and flag adoption can be analyzed. Events are stored in SQLite at
28
+ `$XDG_STATE_HOME/agency/usage.sqlite3` (or
29
+ `~/.local/state/agency/usage.sqlite3`) and retained for 90 days by default.
30
+ Each event contains the normalized command path, flag names, timing, outcome,
31
+ Agency version, and ordered `AGENCY_SESSION_ID` correlation. Raw arguments,
32
+ flag values, free-form input, and the current directory are never recorded.
33
+
34
+ Export events as JSON Lines with `agency usage export`. Set
35
+ `AGENCY_NO_USAGE_LOG=1` to opt out, `AGENCY_USAGE_RETENTION_DAYS` to change
36
+ retention, or `AGENCY_USAGE_DB` to select a different database path. Logging is
37
+ best effort and never changes command output or exit behavior.
38
+
24
39
  ## Core Model
25
40
 
26
41
  - A **workbase** is the root containing durable documents and local repository
package/cli-main.ts CHANGED
@@ -67,6 +67,7 @@ import {
67
67
  successEnvelope,
68
68
  writeEnvelope,
69
69
  } from "./src/protocol"
70
+ import { exportUsageEvents, recordUsageEvent } from "./src/usage-log"
70
71
 
71
72
  // Create CLI layer with all services
72
73
  const CliLayer = Layer.mergeAll(
@@ -176,6 +177,12 @@ const VERSION = packageJson.version
176
177
 
177
178
  // Define commands
178
179
  const commands: Record<string, Command> = {
180
+ usage: {
181
+ run: async () => {
182
+ for (const event of await exportUsageEvents())
183
+ console.log(JSON.stringify(event))
184
+ },
185
+ },
179
186
  act: {
180
187
  run: async (args: string[], options: Record<string, any>) => {
181
188
  if (options.help) return console.log(actHelp)
@@ -785,6 +792,7 @@ Commands:
785
792
  init [path] Initialize an Agency workbase
786
793
  workbase <subcommand> Manage registered workbases
787
794
  integration <command> Inspect or sync managed integration files
795
+ usage export Export local usage events as JSON Lines
788
796
  epic <subcommand> Manage epics
789
797
  phase <subcommand> Manage task phases
790
798
  claim <task> [phase] Claim an execution unit
@@ -830,9 +838,30 @@ const machineMode = process.argv
830
838
  .slice(2)
831
839
  .some((argument) => argument === "--json" || argument === "--jsonl")
832
840
 
841
+ const invocationStartedAt = performance.now()
842
+ const rawArguments = process.argv.slice(2)
843
+ let usageCommandPath = "invalid"
844
+ let usageFlagNames = rawArguments
845
+ .filter((argument) => argument.startsWith("--"))
846
+ .map((argument) => argument.slice(2).split("=", 1)[0]!)
847
+
833
848
  try {
834
- const args = process.argv.slice(2)
835
- const { commandName, args: commandArgs, passthrough, values } = parseCli(args)
849
+ const {
850
+ commandName,
851
+ args: commandArgs,
852
+ passthrough,
853
+ values,
854
+ } = parseCli(rawArguments)
855
+ usageCommandPath =
856
+ [commandName, commandArgs[0]]
857
+ .filter(
858
+ (part): part is string =>
859
+ typeof part === "string" && part.length > 0 && !part.startsWith("-"),
860
+ )
861
+ .join("/") || "root"
862
+ usageFlagNames = Object.entries(values)
863
+ .filter(([, value]) => value !== undefined && value !== false)
864
+ .map(([name]) => name)
836
865
 
837
866
  // Handle global flags
838
867
  if (values.version) {
@@ -841,6 +870,16 @@ try {
841
870
  } else {
842
871
  console.log(`v${VERSION}`)
843
872
  }
873
+ await recordUsageEvent(
874
+ {
875
+ commandPath: "version",
876
+ flagNames: usageFlagNames,
877
+ durationMs: performance.now() - invocationStartedAt,
878
+ outcome: "success",
879
+ exitStatus: 0,
880
+ },
881
+ VERSION,
882
+ )
844
883
  process.exit(0)
845
884
  }
846
885
 
@@ -848,7 +887,18 @@ try {
848
887
  // Show help if no command
849
888
  if (!commandName) {
850
889
  showMainHelp()
851
- process.exit(values.help ? 0 : 1)
890
+ const exitStatus = values.help ? 0 : 1
891
+ await recordUsageEvent(
892
+ {
893
+ commandPath: "help",
894
+ flagNames: usageFlagNames,
895
+ durationMs: performance.now() - invocationStartedAt,
896
+ outcome: exitStatus === 0 ? "success" : "failure",
897
+ exitStatus,
898
+ },
899
+ VERSION,
900
+ )
901
+ process.exit(exitStatus)
852
902
  }
853
903
 
854
904
  const command = commands[commandName]!
@@ -880,7 +930,28 @@ try {
880
930
  passthrough,
881
931
  })
882
932
  }
933
+ const exitStatus = Number(process.exitCode ?? 0)
934
+ await recordUsageEvent(
935
+ {
936
+ commandPath: usageCommandPath,
937
+ flagNames: usageFlagNames,
938
+ durationMs: performance.now() - invocationStartedAt,
939
+ outcome: exitStatus === 0 ? "success" : "failure",
940
+ exitStatus,
941
+ },
942
+ VERSION,
943
+ )
883
944
  } catch (error) {
945
+ await recordUsageEvent(
946
+ {
947
+ commandPath: usageCommandPath,
948
+ flagNames: usageFlagNames,
949
+ durationMs: performance.now() - invocationStartedAt,
950
+ outcome: "failure",
951
+ exitStatus: 1,
952
+ },
953
+ VERSION,
954
+ )
884
955
  if (machineMode) {
885
956
  writeEnvelope(errorEnvelope(error))
886
957
  process.exit(1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.70.0",
3
+ "version": "2.71.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
package/src/cli-parser.ts CHANGED
@@ -136,6 +136,17 @@ const nonPrCompletionOptions = {
136
136
  } satisfies OptionConfig
137
137
 
138
138
  const commands = {
139
+ usage: {
140
+ usage: "agency usage export",
141
+ options: commonOptions,
142
+ subcommands: {
143
+ export: {
144
+ usage: "agency usage export",
145
+ minArgs: 0,
146
+ maxArgs: 0,
147
+ },
148
+ },
149
+ },
139
150
  init: {
140
151
  usage: "agency init [path] [--json]",
141
152
  options: outputOptions,
package/src/cli.test.ts CHANGED
@@ -36,6 +36,8 @@ async function runCli(
36
36
  env: {
37
37
  ...process.env,
38
38
  XDG_CONFIG_HOME: isolatedConfigHome,
39
+ XDG_STATE_HOME: isolatedConfigHome,
40
+ AGENCY_NO_USAGE_LOG: "1",
39
41
  ...env,
40
42
  },
41
43
  stdout: "pipe",
@@ -170,6 +172,44 @@ describe("CLI", () => {
170
172
  expect(taggedError.stderr).not.toContain("An error has occurred")
171
173
  })
172
174
 
175
+ test("records successful and failed invocations without argument values", async () => {
176
+ const state = await createTempDir()
177
+ tempDirs.push(state)
178
+ const env = {
179
+ XDG_STATE_HOME: state,
180
+ AGENCY_SESSION_ID: "cli-session",
181
+ AGENCY_NO_USAGE_LOG: "0",
182
+ }
183
+ expect((await runCli(["--version"], projectRoot, env)).exitCode).toBe(0)
184
+ expect(
185
+ (await runCli(["unknown", "--cwd", "/private/value"], projectRoot, env))
186
+ .exitCode,
187
+ ).toBe(1)
188
+
189
+ const exported = await runCli(["usage", "export"], projectRoot, env)
190
+ expect(exported).toMatchObject({ exitCode: 0, stderr: "" })
191
+ const events = exported.stdout
192
+ .trim()
193
+ .split("\n")
194
+ .map((line) => JSON.parse(line))
195
+ expect(events).toEqual([
196
+ expect.objectContaining({
197
+ sessionId: "cli-session",
198
+ sessionSequence: 1,
199
+ commandPath: "version",
200
+ flagNames: ["version"],
201
+ outcome: "success",
202
+ }),
203
+ expect.objectContaining({
204
+ sessionSequence: 2,
205
+ commandPath: "invalid",
206
+ flagNames: ["cwd"],
207
+ outcome: "failure",
208
+ }),
209
+ ])
210
+ expect(exported.stdout).not.toContain("/private/value")
211
+ })
212
+
173
213
  test("coordinates claims through revision-guarded machine commands", async () => {
174
214
  const root = await createTempDir()
175
215
  tempDirs.push(root)
@@ -0,0 +1,94 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { rm } from "node:fs/promises"
3
+ import { join } from "node:path"
4
+ import { cleanupTempDir, createTempDir } from "./test-utils"
5
+ import { exportUsageEvents, recordUsageEvent } from "./usage-log"
6
+
7
+ describe("usage logging", () => {
8
+ const tempDirs: string[] = []
9
+
10
+ afterEach(() => Promise.all(tempDirs.splice(0).map(cleanupTempDir)))
11
+
12
+ test("stores versioned privacy-safe events in session order", async () => {
13
+ const state = await createTempDir()
14
+ tempDirs.push(state)
15
+ const env = {
16
+ XDG_STATE_HOME: state,
17
+ AGENCY_SESSION_ID: "session-1",
18
+ } as NodeJS.ProcessEnv
19
+ for (const commandPath of ["worktree/prepare", "context"]) {
20
+ await recordUsageEvent(
21
+ {
22
+ commandPath,
23
+ flagNames: ["json", "task", "json"],
24
+ durationMs: 12.4,
25
+ outcome: "success",
26
+ exitStatus: 0,
27
+ },
28
+ "1.2.3",
29
+ env,
30
+ )
31
+ }
32
+
33
+ expect(await Bun.file(join(state, "agency/usage.sqlite3")).exists()).toBe(
34
+ true,
35
+ )
36
+ expect(await exportUsageEvents(env)).toEqual([
37
+ expect.objectContaining({
38
+ version: 1,
39
+ sessionId: "session-1",
40
+ sessionSequence: 1,
41
+ agencyVersion: "1.2.3",
42
+ commandPath: "worktree/prepare",
43
+ flagNames: ["json", "task"],
44
+ durationMs: 12,
45
+ outcome: "success",
46
+ exitStatus: 0,
47
+ }),
48
+ expect.objectContaining({
49
+ sessionSequence: 2,
50
+ commandPath: "context",
51
+ }),
52
+ ])
53
+ })
54
+
55
+ test("supports opt-out and ignores unavailable storage", async () => {
56
+ const state = await createTempDir()
57
+ tempDirs.push(state)
58
+ const disabled = {
59
+ XDG_STATE_HOME: state,
60
+ AGENCY_NO_USAGE_LOG: "true",
61
+ } as NodeJS.ProcessEnv
62
+ await recordUsageEvent(
63
+ {
64
+ commandPath: "status",
65
+ flagNames: [],
66
+ durationMs: 1,
67
+ outcome: "failure",
68
+ exitStatus: 1,
69
+ },
70
+ "1.2.3",
71
+ disabled,
72
+ )
73
+ expect(await Bun.file(join(state, "agency/usage.sqlite3")).exists()).toBe(
74
+ false,
75
+ )
76
+
77
+ const blocked = join(state, "blocked")
78
+ await Bun.write(blocked, "not a directory")
79
+ await expect(
80
+ recordUsageEvent(
81
+ {
82
+ commandPath: "status",
83
+ flagNames: [],
84
+ durationMs: 1,
85
+ outcome: "failure",
86
+ exitStatus: 1,
87
+ },
88
+ "1.2.3",
89
+ { AGENCY_USAGE_DB: join(blocked, "usage.sqlite3") },
90
+ ),
91
+ ).resolves.toBeUndefined()
92
+ await rm(blocked)
93
+ })
94
+ })
@@ -0,0 +1,143 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { mkdir } from "node:fs/promises"
3
+ import { dirname, join } from "node:path"
4
+
5
+ const USAGE_EVENT_VERSION = 1 as const
6
+ const DEFAULT_RETENTION_DAYS = 90
7
+
8
+ export interface UsageEvent {
9
+ readonly commandPath: string
10
+ readonly flagNames: readonly string[]
11
+ readonly durationMs: number
12
+ readonly exitStatus: number
13
+ readonly outcome: "success" | "failure"
14
+ }
15
+
16
+ const stateDirectory = (env: NodeJS.ProcessEnv) =>
17
+ env.XDG_STATE_HOME ?? join(env.HOME ?? ".", ".local", "state")
18
+
19
+ const usageDatabasePath = (env: NodeJS.ProcessEnv = process.env) =>
20
+ env.AGENCY_USAGE_DB ?? join(stateDirectory(env), "agency", "usage.sqlite3")
21
+
22
+ const enabled = (env: NodeJS.ProcessEnv) =>
23
+ !["1", "true", "yes"].includes((env.AGENCY_NO_USAGE_LOG ?? "").toLowerCase())
24
+
25
+ const retentionDays = (env: NodeJS.ProcessEnv) => {
26
+ const value = Number.parseInt(env.AGENCY_USAGE_RETENTION_DAYS ?? "", 10)
27
+ return Number.isSafeInteger(value) && value >= 0
28
+ ? value
29
+ : DEFAULT_RETENTION_DAYS
30
+ }
31
+
32
+ const openDatabase = async (env: NodeJS.ProcessEnv) => {
33
+ const path = usageDatabasePath(env)
34
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 })
35
+ const database = new Database(path, { create: true, strict: true })
36
+ database.run("PRAGMA journal_mode = WAL")
37
+ database.run("PRAGMA busy_timeout = 1000")
38
+ database.run(`
39
+ CREATE TABLE IF NOT EXISTS usage_events (
40
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
41
+ event_version INTEGER NOT NULL,
42
+ session_id TEXT NOT NULL,
43
+ session_sequence INTEGER NOT NULL,
44
+ occurred_at TEXT NOT NULL,
45
+ agency_version TEXT NOT NULL,
46
+ command_path TEXT NOT NULL,
47
+ flag_names TEXT NOT NULL,
48
+ duration_ms INTEGER NOT NULL,
49
+ outcome TEXT NOT NULL,
50
+ exit_status INTEGER NOT NULL
51
+ )
52
+ `)
53
+ database.run(
54
+ "CREATE INDEX IF NOT EXISTS usage_events_session ON usage_events(session_id, session_sequence)",
55
+ )
56
+ database.run(
57
+ "CREATE INDEX IF NOT EXISTS usage_events_command ON usage_events(command_path, occurred_at)",
58
+ )
59
+ return database
60
+ }
61
+
62
+ export async function recordUsageEvent(
63
+ event: UsageEvent,
64
+ agencyVersion: string,
65
+ env: NodeJS.ProcessEnv = process.env,
66
+ ): Promise<void> {
67
+ if (!enabled(env)) return
68
+ let database: Database | undefined
69
+ try {
70
+ database = await openDatabase(env)
71
+ const sessionId = env.AGENCY_SESSION_ID || `process-${process.pid}`
72
+ database
73
+ .query(`
74
+ INSERT INTO usage_events (
75
+ event_version, session_id, session_sequence, occurred_at,
76
+ agency_version, command_path, flag_names, duration_ms,
77
+ outcome, exit_status
78
+ ) VALUES (
79
+ ?, ?,
80
+ (SELECT COALESCE(MAX(session_sequence), 0) + 1 FROM usage_events WHERE session_id = ?),
81
+ ?, ?, ?, ?, ?, ?, ?
82
+ )
83
+ `)
84
+ .run(
85
+ USAGE_EVENT_VERSION,
86
+ sessionId,
87
+ sessionId,
88
+ new Date().toISOString(),
89
+ agencyVersion,
90
+ event.commandPath,
91
+ JSON.stringify([...new Set(event.flagNames)].sort()),
92
+ Math.max(0, Math.round(event.durationMs)),
93
+ event.outcome,
94
+ event.exitStatus,
95
+ )
96
+ if (Math.random() < 0.01) {
97
+ const days = retentionDays(env)
98
+ database
99
+ .query(
100
+ "DELETE FROM usage_events WHERE occurred_at < datetime('now', ?)",
101
+ )
102
+ .run(`-${days} days`)
103
+ }
104
+ } catch {
105
+ // Usage logging must never affect command behavior.
106
+ } finally {
107
+ database?.close()
108
+ }
109
+ }
110
+
111
+ export async function exportUsageEvents(
112
+ env: NodeJS.ProcessEnv = process.env,
113
+ ): Promise<readonly Record<string, unknown>[]> {
114
+ if (!enabled(env)) return []
115
+ let database: Database | undefined
116
+ try {
117
+ database = await openDatabase(env)
118
+ const rows = database
119
+ .query(`
120
+ SELECT event_version, session_id, session_sequence, occurred_at,
121
+ agency_version, command_path, flag_names, duration_ms,
122
+ outcome, exit_status
123
+ FROM usage_events ORDER BY occurred_at, id
124
+ `)
125
+ .all() as Record<string, string | number>[]
126
+ return rows.map((row) => ({
127
+ version: row.event_version,
128
+ sessionId: row.session_id,
129
+ sessionSequence: row.session_sequence,
130
+ occurredAt: row.occurred_at,
131
+ agencyVersion: row.agency_version,
132
+ commandPath: row.command_path,
133
+ flagNames: JSON.parse(String(row.flag_names)),
134
+ durationMs: row.duration_ms,
135
+ outcome: row.outcome,
136
+ exitStatus: row.exit_status,
137
+ }))
138
+ } catch {
139
+ return []
140
+ } finally {
141
+ database?.close()
142
+ }
143
+ }