@markjaquith/agency 3.2.3 → 3.2.5

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
@@ -26,14 +26,20 @@ Agency records privacy-safe CLI usage events locally so command journeys,
26
26
  failures, and flag adoption can be analyzed. Events are stored in SQLite at
27
27
  `$XDG_STATE_HOME/agency/usage.sqlite3` (or
28
28
  `~/.local/state/agency/usage.sqlite3`) and retained for 90 days by default.
29
- Each event contains the normalized command path, flag names, timing, outcome,
30
- Agency version, and ordered `AGENCY_SESSION_ID` correlation. Raw arguments,
29
+ Each event contains the parser-derived command and subcommand path, flag names,
30
+ timing, a bounded outcome code, Agency version, invocation source, explicit test
31
+ attribution, and ordered journey correlation. Journey IDs are one-way hashes of
32
+ `AGENCY_SESSION_ID`; raw session IDs, positional arguments, entity IDs, paths,
31
33
  flag values, free-form input, and the current directory are never recorded.
32
34
 
33
35
  Export events as JSON Lines with `agency usage export`. Set
34
36
  `AGENCY_NO_USAGE_LOG=1` to opt out, `AGENCY_USAGE_RETENTION_DAYS` to change
35
- retention, or `AGENCY_USAGE_DB` to select a different database path. Logging is
36
- best effort and never changes command output or exit behavior.
37
+ retention, or `AGENCY_USAGE_DB` to select a different database path. Expired
38
+ events are pruned on every read and write. Set `AGENCY_INVOCATION_SOURCE` to one
39
+ of `human`, `agent`, or `automation`, and set `AGENCY_USAGE_TEST=1` for explicit
40
+ test attribution. Logging is best effort and never changes command output or
41
+ exit behavior. Databases created by versions before this privacy boundary are
42
+ cleared because their command paths may contain positional values.
37
43
 
38
44
  ## Core Model
39
45
 
package/cli-main.ts CHANGED
@@ -57,7 +57,11 @@ import {
57
57
  successEnvelope,
58
58
  writeEnvelope,
59
59
  } from "./src/protocol"
60
- import { exportUsageEvents, recordUsageEvent } from "./src/usage-log"
60
+ import {
61
+ exportUsageEvents,
62
+ recordUsageEvent,
63
+ usageOutcomeCode,
64
+ } from "./src/usage-log"
61
65
 
62
66
  // Create CLI layer with all services
63
67
  const CliLayer = Layer.mergeAll(
@@ -752,9 +756,7 @@ const machineMode = process.argv
752
756
  const invocationStartedAt = performance.now()
753
757
  const rawArguments = process.argv.slice(2)
754
758
  let usageCommandPath = "invalid"
755
- let usageFlagNames = rawArguments
756
- .filter((argument) => argument.startsWith("--"))
757
- .map((argument) => argument.slice(2).split("=", 1)[0]!)
759
+ let usageFlagNames: string[] = []
758
760
 
759
761
  const pushUsageDetails = (error?: unknown) => {
760
762
  if (usageCommandPath !== "push") return {}
@@ -795,17 +797,12 @@ const pushUsageDetails = (error?: unknown) => {
795
797
  try {
796
798
  const {
797
799
  commandName,
800
+ commandPath,
798
801
  args: commandArgs,
799
802
  passthrough,
800
803
  values,
801
804
  } = parseCli(rawArguments)
802
- usageCommandPath =
803
- [commandName, commandArgs[0]]
804
- .filter(
805
- (part): part is string =>
806
- typeof part === "string" && part.length > 0 && !part.startsWith("-"),
807
- )
808
- .join("/") || "root"
805
+ usageCommandPath = commandPath
809
806
  usageFlagNames = Object.entries(values)
810
807
  .filter(([, value]) => value !== undefined && value !== false)
811
808
  .map(([name]) => name)
@@ -823,6 +820,7 @@ try {
823
820
  flagNames: usageFlagNames,
824
821
  durationMs: performance.now() - invocationStartedAt,
825
822
  outcome: "success",
823
+ outcomeCode: "SUCCESS",
826
824
  exitStatus: 0,
827
825
  },
828
826
  VERSION,
@@ -841,6 +839,7 @@ try {
841
839
  flagNames: usageFlagNames,
842
840
  durationMs: performance.now() - invocationStartedAt,
843
841
  outcome: exitStatus === 0 ? "success" : "failure",
842
+ outcomeCode: exitStatus === 0 ? "SUCCESS" : "NONZERO_EXIT",
844
843
  exitStatus,
845
844
  },
846
845
  VERSION,
@@ -884,18 +883,28 @@ try {
884
883
  flagNames: usageFlagNames,
885
884
  durationMs: performance.now() - invocationStartedAt,
886
885
  outcome: exitStatus === 0 ? "success" : "failure",
886
+ outcomeCode: exitStatus === 0 ? "SUCCESS" : "NONZERO_EXIT",
887
887
  exitStatus,
888
888
  ...pushUsageDetails(),
889
889
  },
890
890
  VERSION,
891
891
  )
892
892
  } catch (error) {
893
+ if (
894
+ typeof error === "object" &&
895
+ error !== null &&
896
+ "commandPath" in error &&
897
+ typeof error.commandPath === "string"
898
+ ) {
899
+ usageCommandPath = error.commandPath
900
+ }
893
901
  await recordUsageEvent(
894
902
  {
895
903
  commandPath: usageCommandPath,
896
904
  flagNames: usageFlagNames,
897
905
  durationMs: performance.now() - invocationStartedAt,
898
906
  outcome: "failure",
907
+ outcomeCode: usageOutcomeCode(error),
899
908
  exitStatus: 1,
900
909
  ...pushUsageDetails(error),
901
910
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.2.3",
3
+ "version": "3.2.5",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -7,6 +7,25 @@ const expectUsageError = (args: string[], usage: string) => {
7
7
  }
8
8
 
9
9
  describe("strict CLI parsing", () => {
10
+ test("returns canonical paths without positional values", () => {
11
+ expect(parseCli(["task", "show", "private-task-id"]).commandPath).toBe(
12
+ "task/show",
13
+ )
14
+ expect(parseCli(["validate", "/private/customer/path"]).commandPath).toBe(
15
+ "validate",
16
+ )
17
+ expect(parseCli(["work", "prepare", "private-task-id"]).commandPath).toBe(
18
+ "work/prepare",
19
+ )
20
+ expect(parseCli(["pr", "view", "private-task-id"]).commandPath).toBe("pr")
21
+ try {
22
+ parseCli(["task", "create", "private-task-id"])
23
+ expect.unreachable()
24
+ } catch (error) {
25
+ expect(error).toMatchObject({ commandPath: "task/create" })
26
+ }
27
+ })
28
+
10
29
  test("parses act selectors, dry-run, and JSON options", () => {
11
30
  expect(
12
31
  parseCli([
@@ -363,6 +382,7 @@ describe("strict CLI parsing", () => {
363
382
  const args = ["create", "--title", "two words", "--", "--literal"]
364
383
  expect(parseCli(["--cwd", "/workbase", "pr", ...args])).toEqual({
365
384
  commandName: "pr",
385
+ commandPath: "pr",
366
386
  args,
367
387
  passthrough: true,
368
388
  values: { cwd: "/workbase" },
@@ -382,6 +402,7 @@ describe("strict CLI parsing", () => {
382
402
  ]),
383
403
  ).toEqual({
384
404
  commandName: "pr",
405
+ commandPath: "pr/create",
385
406
  args: ["create", "ship", "release"],
386
407
  values: { draft: true, force: true, json: true },
387
408
  })
package/src/cli-parser.ts CHANGED
@@ -1032,6 +1032,7 @@ const preCommandValueOptions = new Set(["--workbase", "--cwd"])
1032
1032
 
1033
1033
  export interface ParsedCli {
1034
1034
  readonly commandName?: keyof typeof commands
1035
+ readonly commandPath: string
1035
1036
  readonly args: string[]
1036
1037
  readonly passthrough?: boolean
1037
1038
  readonly values: Record<
@@ -1040,8 +1041,14 @@ export interface ParsedCli {
1040
1041
  >
1041
1042
  }
1042
1043
 
1044
+ const canonicalCommandPath = (
1045
+ commandName: string,
1046
+ subcommand: string | undefined,
1047
+ ) => (subcommand ? `${commandName}/${subcommand}` : commandName)
1048
+
1043
1049
  class CliUsageError extends Error {
1044
1050
  readonly _tag = "CliUsageError"
1051
+ readonly commandPath: string
1045
1052
 
1046
1053
  constructor(
1047
1054
  readonly detail: string,
@@ -1049,6 +1056,15 @@ class CliUsageError extends Error {
1049
1056
  ) {
1050
1057
  super(`${detail}\n\nUsage: ${usage}`)
1051
1058
  this.name = "CliUsageError"
1059
+ const [, commandName, subcommand] = usage.split(/\s+/)
1060
+ const definition: CommandDefinition | undefined =
1061
+ commands[commandName as keyof typeof commands]
1062
+ this.commandPath = !definition
1063
+ ? "invalid"
1064
+ : subcommand && definition.subcommands?.[subcommand]
1065
+ ? `${commandName}/${subcommand}`
1066
+ : commandName!
1067
+ Object.defineProperty(this, "commandPath", { enumerable: false })
1052
1068
  }
1053
1069
  }
1054
1070
 
@@ -1323,7 +1339,7 @@ export function parseCli(args: readonly string[]): ParsedCli {
1323
1339
  "agency <command> [options]",
1324
1340
  )
1325
1341
  }
1326
- return { args: [], values: parsed.values }
1342
+ return { commandPath: "root", args: [], values: parsed.values }
1327
1343
  }
1328
1344
 
1329
1345
  const commandName = args[commandIndex]!
@@ -1368,6 +1384,7 @@ export function parseCli(args: readonly string[]): ParsedCli {
1368
1384
  }
1369
1385
  return {
1370
1386
  commandName,
1387
+ commandPath: "pr",
1371
1388
  args: prArgs,
1372
1389
  passthrough: true,
1373
1390
  values: parsed.values,
@@ -1391,6 +1408,7 @@ export function parseCli(args: readonly string[]): ParsedCli {
1391
1408
  assertNoDuplicateOptions(parsed.tokens, new Set(), definition.usage)
1392
1409
  return {
1393
1410
  commandName: commandName as keyof typeof commands,
1411
+ commandPath: commandName,
1394
1412
  args: parsed.positionals,
1395
1413
  values: parsed.values,
1396
1414
  }
@@ -1441,6 +1459,10 @@ export function parseCli(args: readonly string[]): ParsedCli {
1441
1459
  if (parsed.values.version) {
1442
1460
  return {
1443
1461
  commandName: commandName as keyof typeof commands,
1462
+ commandPath: canonicalCommandPath(
1463
+ commandName,
1464
+ selectedSubcommand ? subcommand : undefined,
1465
+ ),
1444
1466
  args: parsed.positionals,
1445
1467
  values: parsed.values,
1446
1468
  }
@@ -1448,6 +1470,10 @@ export function parseCli(args: readonly string[]): ParsedCli {
1448
1470
  if (parsed.values.help) {
1449
1471
  return {
1450
1472
  commandName: commandName as keyof typeof commands,
1473
+ commandPath: canonicalCommandPath(
1474
+ commandName,
1475
+ selectedSubcommand ? subcommand : undefined,
1476
+ ),
1451
1477
  args: parsed.positionals,
1452
1478
  values: parsed.values,
1453
1479
  }
@@ -1622,6 +1648,14 @@ export function parseCli(args: readonly string[]): ParsedCli {
1622
1648
 
1623
1649
  return {
1624
1650
  commandName: commandName as keyof typeof commands,
1651
+ commandPath: canonicalCommandPath(
1652
+ commandName,
1653
+ selectedSubcommand
1654
+ ? subcommand
1655
+ : commandName === "work" && commandPositionals[0] === "prepare"
1656
+ ? "prepare"
1657
+ : undefined,
1658
+ ),
1625
1659
  args: selectedSubcommand
1626
1660
  ? [subcommand!, ...commandPositionals]
1627
1661
  : commandPositionals,
package/src/cli.test.ts CHANGED
@@ -179,10 +179,31 @@ describe("CLI", () => {
179
179
  XDG_STATE_HOME: state,
180
180
  AGENCY_SESSION_ID: "cli-session",
181
181
  AGENCY_NO_USAGE_LOG: "0",
182
+ AGENCY_INVOCATION_SOURCE: "automation",
183
+ AGENCY_USAGE_TEST: "1",
182
184
  }
183
185
  expect((await runCli(["--version"], projectRoot, env)).exitCode).toBe(0)
184
186
  expect(
185
- (await runCli(["unknown", "--cwd", "/private/value"], projectRoot, env))
187
+ (
188
+ await runCli(
189
+ [
190
+ "task",
191
+ "create",
192
+ "private-customer-id",
193
+ "--repo",
194
+ "private-repository",
195
+ "--description",
196
+ "private free-form input",
197
+ "--cwd",
198
+ "/private/customer/path",
199
+ ],
200
+ projectRoot,
201
+ env,
202
+ )
203
+ ).exitCode,
204
+ ).toBe(1)
205
+ expect(
206
+ (await runCli(["unknown", "--private-flag=value"], projectRoot, env))
186
207
  .exitCode,
187
208
  ).toBe(1)
188
209
 
@@ -194,20 +215,41 @@ describe("CLI", () => {
194
215
  .map((line) => JSON.parse(line))
195
216
  expect(events).toEqual([
196
217
  expect.objectContaining({
197
- sessionId: "cli-session",
198
- sessionSequence: 1,
218
+ journeyId: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
219
+ journeySequence: 1,
220
+ invocationSource: "automation",
221
+ isTest: true,
199
222
  commandPath: "version",
200
223
  flagNames: ["version"],
201
224
  outcome: "success",
225
+ outcomeCode: "SUCCESS",
202
226
  }),
203
227
  expect.objectContaining({
204
- sessionSequence: 2,
228
+ journeySequence: 2,
229
+ commandPath: "task/create",
230
+ flagNames: ["cwd", "description", "repo"],
231
+ outcome: "failure",
232
+ outcomeCode: "WORKBASE_NOT_FOUND",
233
+ }),
234
+ expect.objectContaining({
235
+ journeySequence: 3,
205
236
  commandPath: "invalid",
206
- flagNames: ["cwd"],
237
+ flagNames: [],
207
238
  outcome: "failure",
239
+ outcomeCode: "CLI_USAGE",
208
240
  }),
209
241
  ])
210
- expect(exported.stdout).not.toContain("/private/value")
242
+ for (const value of [
243
+ "cli-session",
244
+ "private-customer-id",
245
+ "private-repository",
246
+ "private free-form input",
247
+ "/private/customer/path",
248
+ "private-flag",
249
+ "value",
250
+ ]) {
251
+ expect(exported.stdout).not.toContain(value)
252
+ }
211
253
  })
212
254
 
213
255
  test("records status-based non-PR completion", async () => {
@@ -260,6 +260,9 @@ describe("IntegrationService", () => {
260
260
  "!result.authority?.writable?.checkoutPath",
261
261
  )
262
262
  expect(managedWorkbaseOpencodePlugin).toContain('status !== "working"')
263
+ expect(managedWorkbaseOpencodePlugin).toContain(
264
+ 'output.env.AGENCY_INVOCATION_SOURCE = "agent"',
265
+ )
263
266
  expect(managedWorkbaseOpencodePlugin).toContain(
264
267
  "output.env.AGENCY_SESSION_ID = sessionID",
265
268
  )
@@ -1,6 +1,6 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test"
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdir, realpath } from "node:fs/promises"
3
+ import { mkdir, realpath, stat } from "node:fs/promises"
4
4
  import { dirname, join } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
6
  import { RepositoryService } from "./RepositoryService"
@@ -136,6 +136,30 @@ describe("RepositoryService", () => {
136
136
  })
137
137
  })
138
138
 
139
+ test("inspects only the requested repository alias", async () => {
140
+ const requested = join(root, "repos/requested")
141
+ const unrelated = join(root, "repos/unrelated")
142
+ await mkdir(requested, { recursive: true })
143
+ await mkdir(unrelated, { recursive: true })
144
+ await runGit(["init", "--initial-branch=main", requested])
145
+ await runGit(["init", "--initial-branch=main", unrelated])
146
+
147
+ const spawn = spyOn(Bun, "spawn")
148
+ try {
149
+ const repository = await runTestEffect(
150
+ RepositoryService.pipe(
151
+ Effect.flatMap((service) => service.show("requested", root)),
152
+ ),
153
+ )
154
+ expect(repository.alias).toBe("requested")
155
+ expect(spawn.mock.calls).toHaveLength(1)
156
+ expect(spawn.mock.calls[0]?.[0]).toContain(requested)
157
+ expect(spawn.mock.calls[0]?.[0]).not.toContain(unrelated)
158
+ } finally {
159
+ spawn.mockRestore()
160
+ }
161
+ })
162
+
139
163
  test("materializes a linked alias without invalidating active worktrees", async () => {
140
164
  const target = join(root, "linked-repository")
141
165
  const checkout = join(root, "tasks/active/code/linked")
@@ -146,6 +170,13 @@ describe("RepositoryService", () => {
146
170
  await Bun.write(join(target, "README.md"), "linked\n")
147
171
  await runGit(["-C", target, "add", "README.md"])
148
172
  await runGit(["-C", target, "commit", "-m", "initial"])
173
+ const commit = await gitOutput(["-C", target, "rev-parse", "HEAD"])
174
+ const sourceObject = join(
175
+ target,
176
+ ".git/objects",
177
+ commit.slice(0, 2),
178
+ commit.slice(2),
179
+ )
149
180
  await setPortableOrigin(target, "materialized")
150
181
  await runTestEffect(
151
182
  RepositoryService.pipe(
@@ -218,6 +249,15 @@ status: working
218
249
  "refs/agency/reviews/active",
219
250
  ]),
220
251
  ).toBe(await gitOutput(["-C", target, "rev-parse", "main"]))
252
+ const materializedObject = join(
253
+ root,
254
+ "repos/linked/objects",
255
+ commit.slice(0, 2),
256
+ commit.slice(2),
257
+ )
258
+ expect((await stat(materializedObject)).ino).not.toBe(
259
+ (await stat(sourceObject)).ino,
260
+ )
221
261
  })
222
262
 
223
263
  test("preserves detached registered worktree commits from a shallow linked repository", async () => {
@@ -186,18 +186,71 @@ const portableRemote = (path: string, backend?: VersionControlBackend) =>
186
186
  return yield* validateRemote(remote)
187
187
  })
188
188
 
189
- const find = (alias: string, startPath: string) =>
189
+ const inspectRepository = (
190
+ alias: string,
191
+ state: Effect.Effect.Success<ReturnType<typeof configState>>,
192
+ backend: VersionControlBackend,
193
+ ) =>
190
194
  Effect.gen(function* () {
191
- const service = yield* RepositoryService
192
- const validAlias = yield* validateAlias(alias)
193
- const repositories = yield* service.list(startPath)
194
- const repository = repositories.find((item) => item.alias === validAlias)
195
- if (!repository) {
195
+ const fs = yield* FileSystemService
196
+ const path = join(state.root, "repos", alias)
197
+ const declaredRemote = state.config.repositories?.[alias]?.remote ?? null
198
+ const entry = yield* fs.inspectFile(path)
199
+ if (entry.kind === "missing") {
200
+ if (declaredRemote) {
201
+ return {
202
+ alias,
203
+ path,
204
+ kind: null,
205
+ remote: null,
206
+ declaredRemote,
207
+ target: null,
208
+ states: ["declared", "missing"],
209
+ } as RepositoryInfo
210
+ }
196
211
  return yield* new RepositoryError({
197
- message: `Unknown repository alias '${validAlias}'`,
212
+ message: `Unknown repository alias '${alias}'`,
198
213
  })
199
214
  }
200
- return repository
215
+ const isSymlink = entry.kind === "symlink"
216
+ if (!isSymlink && !(yield* fs.isDirectory(path))) {
217
+ return {
218
+ alias,
219
+ path,
220
+ kind: null,
221
+ remote: null,
222
+ declaredRemote,
223
+ target: null,
224
+ states: [...(declaredRemote ? (["declared"] as const) : []), "invalid"],
225
+ } as RepositoryInfo
226
+ }
227
+ const target = isSymlink ? yield* fs.readSymlinkTarget(path) : null
228
+ const inspection = yield* backend.inspectRepository(path)
229
+ const remote = inspection?.remote ?? null
230
+ const states: RepositoryState[] = []
231
+ if (declaredRemote) states.push("declared")
232
+ states.push(isSymlink ? "linked" : "materialized")
233
+ if (!inspection) states.push("invalid")
234
+ if (declaredRemote && remote !== declaredRemote)
235
+ states.push("remote-drifted")
236
+ return {
237
+ alias,
238
+ path,
239
+ kind: isSymlink ? "symlink" : (inspection?.kind ?? "repository"),
240
+ remote,
241
+ declaredRemote,
242
+ target,
243
+ states,
244
+ } as RepositoryInfo
245
+ })
246
+
247
+ const find = (alias: string, startPath: string) =>
248
+ Effect.gen(function* () {
249
+ const versionControl = yield* VersionControlService
250
+ const validAlias = yield* validateAlias(alias)
251
+ const state = yield* configState(startPath)
252
+ const backend = yield* versionControl.forWorkbase(state.root)
253
+ return yield* inspectRepository(validAlias, state, backend)
201
254
  })
202
255
 
203
256
  const requireMaterialized = (repository: RepositoryInfo) =>
@@ -450,14 +503,11 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
450
503
  const backend = yield* versionControl.forWorkbase(state.root)
451
504
  const destination = join(state.root, "repos", validAlias)
452
505
  const resolvedTarget = resolve(startPath, target)
453
- const existing = (yield* RepositoryService)
454
- .list(state.root)
455
- .pipe(
456
- Effect.map((items) =>
457
- items.find((item) => item.alias === validAlias),
458
- ),
459
- )
460
- const current = yield* existing
506
+ const current =
507
+ state.config.repositories?.[validAlias] ||
508
+ (yield* fs.exists(destination))
509
+ ? yield* inspectRepository(validAlias, state, backend)
510
+ : undefined
461
511
  const localCurrent =
462
512
  current && !current.states.includes("missing") ? current : undefined
463
513
  if (localCurrent?.kind === "symlink") {
@@ -521,10 +571,17 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
521
571
  Effect.gen(function* () {
522
572
  const fs = yield* FileSystemService
523
573
  const versionControl = yield* VersionControlService
524
- const repository = yield* find(alias, startPath)
574
+ const validAlias = yield* validateAlias(alias)
575
+ const state = yield* configState(startPath)
576
+ const backend = yield* versionControl.forWorkbase(state.root)
577
+ const repository = yield* inspectRepository(
578
+ validAlias,
579
+ state,
580
+ backend,
581
+ )
525
582
  if (repository.kind !== "symlink" || !repository.target) {
526
583
  return yield* new RepositoryError({
527
- message: `Repository alias '${alias}' is not linked`,
584
+ message: `Repository alias '${validAlias}' is not linked`,
528
585
  })
529
586
  }
530
587
  if (repository.states.includes("invalid")) {
@@ -543,9 +600,6 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
543
600
  })
544
601
  }
545
602
 
546
- const state = yield* configState(startPath)
547
- const backend = yield* versionControl.forWorkbase(state.root)
548
-
549
603
  const source = yield* fs.realPath(repository.path)
550
604
  const registered = yield* backend.listWorkspaces(repository.path)
551
605
  const registeredState = registered
@@ -854,78 +908,22 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
854
908
  list: (startPath: string = process.cwd()) =>
855
909
  Effect.gen(function* () {
856
910
  const fs = yield* FileSystemService
857
- const { root, config } = yield* WorkbaseService.pipe(
858
- Effect.flatMap((service) => service.loadConfig(startPath)),
859
- )
860
911
  const versionControl = yield* VersionControlService
861
- const backend = yield* versionControl.forWorkbase(root)
862
- const reposPath = join(root, "repos")
912
+ const state = yield* configState(startPath)
913
+ const backend = yield* versionControl.forWorkbase(state.root)
914
+ const reposPath = join(state.root, "repos")
863
915
  const entries = (yield* fs.isDirectory(reposPath))
864
916
  ? (yield* fs.readDirectory(reposPath)).filter(
865
917
  (entry) => !entry.name.startsWith(".agency-"),
866
918
  )
867
919
  : []
868
- const local = new Map(entries.map((entry) => [entry.name, entry]))
869
920
  const aliases = new Set([
870
- ...Object.keys(config.repositories ?? {}),
871
- ...local.keys(),
921
+ ...Object.keys(state.config.repositories ?? {}),
922
+ ...entries.map((entry) => entry.name),
872
923
  ])
873
924
  return yield* Effect.forEach(
874
925
  [...aliases].sort(),
875
- (alias) =>
876
- Effect.gen(function* () {
877
- const path = join(reposPath, alias)
878
- const entry = local.get(alias)
879
- const declaredRemote =
880
- config.repositories?.[alias]?.remote ?? null
881
- if (!entry) {
882
- return {
883
- alias,
884
- path,
885
- kind: null,
886
- remote: null,
887
- declaredRemote,
888
- target: null,
889
- states: ["declared", "missing"] as RepositoryState[],
890
- } satisfies RepositoryInfo
891
- }
892
- if (!entry.isDirectory && !entry.isSymlink) {
893
- return {
894
- alias,
895
- path,
896
- kind: null,
897
- remote: null,
898
- declaredRemote,
899
- target: null,
900
- states: [
901
- ...(declaredRemote ? (["declared"] as const) : []),
902
- "invalid",
903
- ] as RepositoryState[],
904
- } satisfies RepositoryInfo
905
- }
906
- const target = entry.isSymlink
907
- ? yield* fs.readSymlinkTarget(path)
908
- : null
909
- const inspection = yield* backend.inspectRepository(path)
910
- const remote = inspection?.remote ?? null
911
- const states: RepositoryState[] = []
912
- if (declaredRemote) states.push("declared")
913
- states.push(entry.isSymlink ? "linked" : "materialized")
914
- if (!inspection) states.push("invalid")
915
- if (declaredRemote && remote !== declaredRemote)
916
- states.push("remote-drifted")
917
- return {
918
- alias,
919
- path,
920
- kind: entry.isSymlink
921
- ? "symlink"
922
- : (inspection?.kind ?? "repository"),
923
- remote,
924
- declaredRemote,
925
- target,
926
- states,
927
- } satisfies RepositoryInfo
928
- }),
926
+ (alias) => inspectRepository(alias, state, backend),
929
927
  { concurrency: 8 },
930
928
  )
931
929
  }),
@@ -171,7 +171,15 @@ export class GitVersionControlService extends Effect.Service<GitVersionControlSe
171
171
  yield* requireSuccess(
172
172
  "Failed to clone Git repository",
173
173
  fs.runCommand(
174
- ["git", "clone", "--bare", "--", source, destination],
174
+ [
175
+ "git",
176
+ "clone",
177
+ "--bare",
178
+ "--no-hardlinks",
179
+ "--",
180
+ source,
181
+ destination,
182
+ ],
175
183
  {
176
184
  captureOutput: true,
177
185
  },
@@ -1,20 +1,36 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test"
2
+ import { Database } from "bun:sqlite"
2
3
  import { rm } from "node:fs/promises"
3
4
  import { join } from "node:path"
4
5
  import { cleanupTempDir, createTempDir } from "./test-utils"
5
- import { exportUsageEvents, recordUsageEvent } from "./usage-log"
6
+ import {
7
+ exportUsageEvents,
8
+ recordUsageEvent,
9
+ usageOutcomeCode,
10
+ } from "./usage-log"
6
11
 
7
12
  describe("usage logging", () => {
8
13
  const tempDirs: string[] = []
9
14
 
10
15
  afterEach(() => Promise.all(tempDirs.splice(0).map(cleanupTempDir)))
11
16
 
17
+ test("maps only reviewed failure categories", () => {
18
+ expect(usageOutcomeCode({ _tag: "WorkbaseNotFoundError" })).toBe(
19
+ "WORKBASE_NOT_FOUND",
20
+ )
21
+ expect(usageOutcomeCode({ _tag: "private-customer-id" })).toBe(
22
+ "COMMAND_FAILED",
23
+ )
24
+ })
25
+
12
26
  test("stores versioned privacy-safe events in session order", async () => {
13
27
  const state = await createTempDir()
14
28
  tempDirs.push(state)
15
29
  const env = {
16
30
  XDG_STATE_HOME: state,
17
31
  AGENCY_SESSION_ID: "session-1",
32
+ AGENCY_INVOCATION_SOURCE: "automation",
33
+ AGENCY_USAGE_TEST: "1",
18
34
  } as NodeJS.ProcessEnv
19
35
  for (const commandPath of ["worktree/prepare", "context"]) {
20
36
  await recordUsageEvent(
@@ -23,6 +39,7 @@ describe("usage logging", () => {
23
39
  flagNames: ["json", "task", "json"],
24
40
  durationMs: 12.4,
25
41
  outcome: "success",
42
+ outcomeCode: "SUCCESS",
26
43
  exitStatus: 0,
27
44
  ...(commandPath === "context"
28
45
  ? {
@@ -40,26 +57,31 @@ describe("usage logging", () => {
40
57
  expect(await Bun.file(join(state, "agency/usage.sqlite3")).exists()).toBe(
41
58
  true,
42
59
  )
43
- expect(await exportUsageEvents(env)).toEqual([
60
+ const events = await exportUsageEvents(env)
61
+ expect(events).toEqual([
44
62
  expect.objectContaining({
45
63
  version: 2,
46
- sessionId: "session-1",
47
- sessionSequence: 1,
64
+ journeyId: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
65
+ journeySequence: 1,
66
+ invocationSource: "automation",
67
+ isTest: true,
48
68
  agencyVersion: "1.2.3",
49
69
  commandPath: "worktree/prepare",
50
70
  flagNames: ["json", "task"],
51
71
  durationMs: 12,
52
72
  outcome: "success",
73
+ outcomeCode: "SUCCESS",
53
74
  exitStatus: 0,
54
75
  }),
55
76
  expect.objectContaining({
56
- sessionSequence: 2,
77
+ journeySequence: 2,
57
78
  commandPath: "context",
58
79
  vcs: "git",
59
80
  terminalStage: "publish",
60
81
  category: "success",
61
82
  }),
62
83
  ])
84
+ expect(JSON.stringify(events)).not.toContain("session-1")
63
85
  })
64
86
 
65
87
  test("supports opt-out and ignores unavailable storage", async () => {
@@ -75,6 +97,7 @@ describe("usage logging", () => {
75
97
  flagNames: [],
76
98
  durationMs: 1,
77
99
  outcome: "failure",
100
+ outcomeCode: "COMMAND_FAILED",
78
101
  exitStatus: 1,
79
102
  },
80
103
  "1.2.3",
@@ -93,6 +116,7 @@ describe("usage logging", () => {
93
116
  flagNames: [],
94
117
  durationMs: 1,
95
118
  outcome: "failure",
119
+ outcomeCode: "COMMAND_FAILED",
96
120
  exitStatus: 1,
97
121
  },
98
122
  "1.2.3",
@@ -101,4 +125,64 @@ describe("usage logging", () => {
101
125
  ).resolves.toBeUndefined()
102
126
  await rm(blocked)
103
127
  })
128
+
129
+ test("prunes expired events on every database access", async () => {
130
+ const state = await createTempDir()
131
+ tempDirs.push(state)
132
+ const env = {
133
+ XDG_STATE_HOME: state,
134
+ AGENCY_USAGE_RETENTION_DAYS: "30",
135
+ } as NodeJS.ProcessEnv
136
+ await recordUsageEvent(
137
+ {
138
+ commandPath: "status",
139
+ flagNames: [],
140
+ durationMs: 1,
141
+ outcome: "success",
142
+ outcomeCode: "SUCCESS",
143
+ exitStatus: 0,
144
+ },
145
+ "1.2.3",
146
+ env,
147
+ )
148
+ const database = new Database(join(state, "agency/usage.sqlite3"))
149
+ database.run(
150
+ "UPDATE usage_events SET occurred_at = datetime('now', '-31 days')",
151
+ )
152
+ database.close()
153
+
154
+ expect(await exportUsageEvents(env)).toEqual([])
155
+ })
156
+
157
+ test("removes legacy events that may contain positional values", async () => {
158
+ const state = await createTempDir()
159
+ tempDirs.push(state)
160
+ const path = join(state, "usage.sqlite3")
161
+ const database = new Database(path, { create: true })
162
+ database.run(
163
+ "CREATE TABLE usage_events (id INTEGER PRIMARY KEY, command_path TEXT NOT NULL)",
164
+ )
165
+ database.run("INSERT INTO usage_events (command_path) VALUES (?)", [
166
+ "task/private-customer-id",
167
+ ])
168
+ database.close()
169
+ const env = { AGENCY_USAGE_DB: path } as NodeJS.ProcessEnv
170
+
171
+ await recordUsageEvent(
172
+ {
173
+ commandPath: "task/show",
174
+ flagNames: [],
175
+ durationMs: 1,
176
+ outcome: "success",
177
+ outcomeCode: "SUCCESS",
178
+ exitStatus: 0,
179
+ },
180
+ "1.2.3",
181
+ env,
182
+ )
183
+
184
+ expect(await exportUsageEvents(env)).toEqual([
185
+ expect.objectContaining({ commandPath: "task/show", version: 2 }),
186
+ ])
187
+ })
104
188
  })
package/src/usage-log.ts CHANGED
@@ -1,21 +1,92 @@
1
1
  import { Database } from "bun:sqlite"
2
+ import { createHash } from "node:crypto"
2
3
  import { mkdir } from "node:fs/promises"
3
4
  import { dirname, join } from "node:path"
4
5
 
5
6
  const USAGE_EVENT_VERSION = 2 as const
6
7
  const DEFAULT_RETENTION_DAYS = 90
7
8
 
9
+ const INVOCATION_SOURCES = new Set(["human", "agent", "automation"])
10
+ const USAGE_EVENT_COLUMNS = new Set([
11
+ "id",
12
+ "event_version",
13
+ "journey_id",
14
+ "journey_sequence",
15
+ "invocation_source",
16
+ "is_test",
17
+ "occurred_at",
18
+ "agency_version",
19
+ "command_path",
20
+ "flag_names",
21
+ "duration_ms",
22
+ "outcome",
23
+ "outcome_code",
24
+ "exit_status",
25
+ "vcs",
26
+ "terminal_stage",
27
+ "category",
28
+ ])
29
+
30
+ export type UsageOutcomeCode =
31
+ | "SUCCESS"
32
+ | "NONZERO_EXIT"
33
+ | "CLI_USAGE"
34
+ | "WORKBASE_NOT_FOUND"
35
+ | "WORKBASE_INVALID"
36
+ | "VALIDATION_FAILED"
37
+ | "CONFLICT"
38
+ | "FILESYSTEM_ERROR"
39
+ | "PROCESS_ERROR"
40
+ | "COMMAND_FAILED"
41
+
8
42
  export interface UsageEvent {
9
43
  readonly commandPath: string
10
44
  readonly flagNames: readonly string[]
11
45
  readonly durationMs: number
12
46
  readonly exitStatus: number
13
47
  readonly outcome: "success" | "failure"
48
+ readonly outcomeCode: UsageOutcomeCode
14
49
  readonly vcs?: "git"
15
50
  readonly terminalStage?: string
16
51
  readonly category?: string
17
52
  }
18
53
 
54
+ export const usageOutcomeCode = (error: unknown): UsageOutcomeCode => {
55
+ const tag =
56
+ typeof error === "object" &&
57
+ error !== null &&
58
+ "_tag" in error &&
59
+ typeof error._tag === "string"
60
+ ? error._tag
61
+ : error instanceof Error
62
+ ? error.name
63
+ : undefined
64
+ switch (tag) {
65
+ case "CliUsageError":
66
+ return "CLI_USAGE"
67
+ case "WorkbaseNotFoundError":
68
+ return "WORKBASE_NOT_FOUND"
69
+ case "WorkbaseConfigError":
70
+ case "WorkbaseRegistryError":
71
+ case "FrontmatterParseError":
72
+ return "WORKBASE_INVALID"
73
+ case "ValidationFailedError":
74
+ return "VALIDATION_FAILED"
75
+ case "ClaimConflictError":
76
+ case "ClaimOwnershipError":
77
+ case "RevisionConflictError":
78
+ case "ExecutionGuardError":
79
+ return "CONFLICT"
80
+ case "FileNotFoundError":
81
+ case "FileSystemError":
82
+ return "FILESYSTEM_ERROR"
83
+ case "ProcessError":
84
+ return "PROCESS_ERROR"
85
+ default:
86
+ return "COMMAND_FAILED"
87
+ }
88
+ }
89
+
19
90
  const stateDirectory = (env: NodeJS.ProcessEnv) =>
20
91
  env.XDG_STATE_HOME ?? join(env.HOME ?? ".", ".local", "state")
21
92
 
@@ -32,36 +103,68 @@ const retentionDays = (env: NodeJS.ProcessEnv) => {
32
103
  : DEFAULT_RETENTION_DAYS
33
104
  }
34
105
 
106
+ const invocationSource = (env: NodeJS.ProcessEnv) => {
107
+ const source = env.AGENCY_INVOCATION_SOURCE?.toLowerCase()
108
+ if (source && INVOCATION_SOURCES.has(source)) return source
109
+ return env.AGENCY_SESSION_ID ? "agent" : "human"
110
+ }
111
+
112
+ const isTestInvocation = (env: NodeJS.ProcessEnv) =>
113
+ ["1", "true", "yes"].includes((env.AGENCY_USAGE_TEST ?? "").toLowerCase())
114
+
115
+ const journeyId = (env: NodeJS.ProcessEnv) => {
116
+ if (!env.AGENCY_SESSION_ID) return null
117
+ return `sha256:${createHash("sha256").update(env.AGENCY_SESSION_ID).digest("hex")}`
118
+ }
119
+
120
+ const pruneExpiredEvents = (database: Database, env: NodeJS.ProcessEnv) => {
121
+ database
122
+ .query(
123
+ "DELETE FROM usage_events WHERE datetime(occurred_at) < datetime('now', ?)",
124
+ )
125
+ .run(`-${retentionDays(env)} days`)
126
+ }
127
+
35
128
  const openDatabase = async (env: NodeJS.ProcessEnv) => {
36
129
  const path = usageDatabasePath(env)
37
130
  await mkdir(dirname(path), { recursive: true, mode: 0o700 })
38
131
  const database = new Database(path, { create: true, strict: true })
39
132
  database.run("PRAGMA journal_mode = WAL")
40
133
  database.run("PRAGMA busy_timeout = 1000")
134
+ const existingColumns = database
135
+ .query("PRAGMA table_info(usage_events)")
136
+ .all() as { name: string }[]
137
+ if (
138
+ existingColumns.length > 0 &&
139
+ (existingColumns.length !== USAGE_EVENT_COLUMNS.size ||
140
+ existingColumns.some(({ name }) => !USAGE_EVENT_COLUMNS.has(name)))
141
+ ) {
142
+ // Version 1 could contain positional values. Do not preserve unsafe telemetry.
143
+ database.run("DROP TABLE usage_events")
144
+ }
41
145
  database.run(`
42
146
  CREATE TABLE IF NOT EXISTS usage_events (
43
147
  id INTEGER PRIMARY KEY AUTOINCREMENT,
44
148
  event_version INTEGER NOT NULL,
45
- session_id TEXT NOT NULL,
46
- session_sequence INTEGER NOT NULL,
149
+ journey_id TEXT,
150
+ journey_sequence INTEGER,
151
+ invocation_source TEXT NOT NULL,
152
+ is_test INTEGER NOT NULL,
47
153
  occurred_at TEXT NOT NULL,
48
154
  agency_version TEXT NOT NULL,
49
155
  command_path TEXT NOT NULL,
50
156
  flag_names TEXT NOT NULL,
51
157
  duration_ms INTEGER NOT NULL,
52
158
  outcome TEXT NOT NULL,
53
- exit_status INTEGER NOT NULL
159
+ outcome_code TEXT NOT NULL,
160
+ exit_status INTEGER NOT NULL,
161
+ vcs TEXT,
162
+ terminal_stage TEXT,
163
+ category TEXT
54
164
  )
55
165
  `)
56
- for (const column of ["vcs", "terminal_stage", "category"]) {
57
- try {
58
- database.run(`ALTER TABLE usage_events ADD COLUMN ${column} TEXT`)
59
- } catch {
60
- // Existing databases already have migrated columns.
61
- }
62
- }
63
166
  database.run(
64
- "CREATE INDEX IF NOT EXISTS usage_events_session ON usage_events(session_id, session_sequence)",
167
+ "CREATE INDEX IF NOT EXISTS usage_events_journey ON usage_events(journey_id, journey_sequence)",
65
168
  )
66
169
  database.run(
67
170
  "CREATE INDEX IF NOT EXISTS usage_events_command ON usage_events(command_path, occurred_at)",
@@ -78,42 +181,42 @@ export async function recordUsageEvent(
78
181
  let database: Database | undefined
79
182
  try {
80
183
  database = await openDatabase(env)
81
- const sessionId = env.AGENCY_SESSION_ID || `process-${process.pid}`
184
+ pruneExpiredEvents(database, env)
185
+ const eventJourneyId = journeyId(env)
82
186
  database
83
187
  .query(`
84
188
  INSERT INTO usage_events (
85
- event_version, session_id, session_sequence, occurred_at,
189
+ event_version, journey_id, journey_sequence,
190
+ invocation_source, is_test, occurred_at,
86
191
  agency_version, command_path, flag_names, duration_ms,
87
- outcome, exit_status, vcs, terminal_stage, category
192
+ outcome, outcome_code, exit_status, vcs, terminal_stage, category
88
193
  ) VALUES (
89
194
  ?, ?,
90
- (SELECT COALESCE(MAX(session_sequence), 0) + 1 FROM usage_events WHERE session_id = ?),
91
- ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
195
+ CASE WHEN ? IS NULL THEN NULL ELSE
196
+ (SELECT COALESCE(MAX(journey_sequence), 0) + 1 FROM usage_events WHERE journey_id = ?)
197
+ END,
198
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
92
199
  )
93
200
  `)
94
201
  .run(
95
202
  USAGE_EVENT_VERSION,
96
- sessionId,
97
- sessionId,
203
+ eventJourneyId,
204
+ eventJourneyId,
205
+ eventJourneyId,
206
+ invocationSource(env),
207
+ isTestInvocation(env) ? 1 : 0,
98
208
  new Date().toISOString(),
99
209
  agencyVersion,
100
210
  event.commandPath,
101
211
  JSON.stringify([...new Set(event.flagNames)].sort()),
102
212
  Math.max(0, Math.round(event.durationMs)),
103
213
  event.outcome,
214
+ event.outcomeCode,
104
215
  event.exitStatus,
105
216
  event.vcs ?? null,
106
217
  event.terminalStage ?? null,
107
218
  event.category ?? null,
108
219
  )
109
- if (Math.random() < 0.01) {
110
- const days = retentionDays(env)
111
- database
112
- .query(
113
- "DELETE FROM usage_events WHERE occurred_at < datetime('now', ?)",
114
- )
115
- .run(`-${days} days`)
116
- }
117
220
  } catch {
118
221
  // Usage logging must never affect command behavior.
119
222
  } finally {
@@ -128,24 +231,29 @@ export async function exportUsageEvents(
128
231
  let database: Database | undefined
129
232
  try {
130
233
  database = await openDatabase(env)
234
+ pruneExpiredEvents(database, env)
131
235
  const rows = database
132
236
  .query(`
133
- SELECT event_version, session_id, session_sequence, occurred_at,
237
+ SELECT event_version, journey_id, journey_sequence,
238
+ invocation_source, is_test, occurred_at,
134
239
  agency_version, command_path, flag_names, duration_ms,
135
- outcome, exit_status, vcs, terminal_stage, category
240
+ outcome, outcome_code, exit_status, vcs, terminal_stage, category
136
241
  FROM usage_events ORDER BY occurred_at, id
137
242
  `)
138
- .all() as Record<string, string | number>[]
243
+ .all() as Record<string, string | number | null>[]
139
244
  return rows.map((row) => ({
140
245
  version: row.event_version,
141
- sessionId: row.session_id,
142
- sessionSequence: row.session_sequence,
246
+ journeyId: row.journey_id,
247
+ journeySequence: row.journey_sequence,
248
+ invocationSource: row.invocation_source,
249
+ isTest: row.is_test === 1,
143
250
  occurredAt: row.occurred_at,
144
251
  agencyVersion: row.agency_version,
145
252
  commandPath: row.command_path,
146
253
  flagNames: JSON.parse(String(row.flag_names)),
147
254
  durationMs: row.duration_ms,
148
255
  outcome: row.outcome,
256
+ outcomeCode: row.outcome_code,
149
257
  exitStatus: row.exit_status,
150
258
  ...(row.vcs == null ? {} : { vcs: row.vcs }),
151
259
  ...(row.terminal_stage == null
@@ -114,6 +114,7 @@ describe("agent commands", () => {
114
114
 
115
115
  expect(environment).toMatchObject({
116
116
  AGENCY_AGENT: "custom",
117
+ AGENCY_INVOCATION_SOURCE: "agent",
117
118
  AGENCY_SESSION_ID: "session-1",
118
119
  AGENCY_WORKBASE: "/workbase",
119
120
  AGENCY_TARGET: "execution-unit:phase/task/build",
@@ -118,6 +118,7 @@ export const agentEnvironment = (
118
118
  variables: AgentCommandVariables,
119
119
  ): Record<string, string> => ({
120
120
  AGENCY_AGENT: agent,
121
+ AGENCY_INVOCATION_SOURCE: "agent",
121
122
  AGENCY_SESSION_ID: variables.sessionId,
122
123
  AGENCY_WORKBASE: variables.workbase,
123
124
  AGENCY_TARGET: variables.target,
@@ -195,6 +195,7 @@ const plugin: Plugin = async ({ directory }) => {
195
195
  if (!sessionID) return
196
196
  const context = workerSessions.get(sessionID)
197
197
  if (!context?.target) return
198
+ output.env.AGENCY_INVOCATION_SOURCE = "agent"
198
199
  output.env.AGENCY_SESSION_ID = sessionID
199
200
  output.env.AGENCY_TARGET = context.target
200
201
  if (context.root) output.env.AGENCY_WORKBASE = context.root