@markjaquith/agency 2.61.1 → 2.61.3

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
@@ -527,6 +527,12 @@ and reference authority, local checkout and resolved-commit state, recorded PR
527
527
  state, and validation warnings. Only `done` satisfies a dependency; `dropped` is
528
528
  terminal but remains a blocker.
529
529
 
530
+ `authority.writable` identifies the writable repository checkout, while
531
+ `authority.documents.writable` lists the absolute paths of Agency documents the
532
+ target may maintain. A single-phase task lists its `TASK.md`; a phase lists its
533
+ owning `TASK.md` and active `PHASE.md`; orchestration targets list none. Use
534
+ Agency commands rather than direct edits for structural frontmatter mutations.
535
+
530
536
  Complete output is the default for entity targets. Pass `--compact` explicitly
531
537
  to omit document prose and low-level Git details while retaining identity,
532
538
  hashes, authority, paths, graph state, materialization state, and validation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.61.1",
3
+ "version": "2.61.3",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -208,6 +208,10 @@ Phase prose.
208
208
  expect(result.documents.task.body).toContain("Task prose.")
209
209
  expect(result.documents.phase.body).toContain("Phase prose.")
210
210
  expect(result.documents.phase.sha256).toMatch(/^[a-f0-9]{64}$/)
211
+ expect(result.authority.documents.writable).toEqual([
212
+ join(root, "tasks/agent-contract/TASK.md"),
213
+ join(root, "tasks/agent-contract/phases/context-command/PHASE.md"),
214
+ ])
211
215
  expect(result.workspace.writable.branchCommit).toMatch(/^[a-f0-9]{40}$/)
212
216
  expect(result.workspace.writable.baseCommit).toMatch(/^[a-f0-9]{40}$/)
213
217
  expect(result.workspace.references[0].resolvedCommit).toMatch(
@@ -232,6 +236,10 @@ Phase prose.
232
236
  registered: true,
233
237
  })
234
238
  expect(result.authority.writable.checkoutPath).toContain("code/agency")
239
+ expect(result.authority.documents.writable).toEqual([
240
+ join(root, "tasks/agent-contract/TASK.md"),
241
+ join(root, "tasks/agent-contract/phases/context-command/PHASE.md"),
242
+ ])
235
243
  })
236
244
 
237
245
  test("reports dependency and validation blockers deterministically", async () => {
@@ -275,6 +283,12 @@ status: dropped
275
283
  taskId: "agent-contract",
276
284
  })
277
285
  expect(task.graph.parent).toEqual({ kind: "epic", id: "contract" })
286
+ expect(task.authority.documents.writable).toEqual([])
287
+
288
+ const executionTask = await readContext(root, "foundations")
289
+ expect(executionTask.authority.documents.writable).toEqual([
290
+ join(root, "tasks/foundations/TASK.md"),
291
+ ])
278
292
 
279
293
  const workbase = await readContext(root, ".")
280
294
  expect(workbase).toMatchObject({
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdir } from "node:fs/promises"
3
+ import { chmod, mkdir } from "node:fs/promises"
4
4
  import { join } from "node:path"
5
5
  import { TaskService } from "../services/TaskService"
6
6
  import {
@@ -99,6 +99,44 @@ describe("sync command", () => {
99
99
  })
100
100
  })
101
101
 
102
+ test("groups repeated human-readable warnings by affected target", async () => {
103
+ await runTestEffect(
104
+ TaskService.pipe(
105
+ Effect.flatMap((service) =>
106
+ service.create(
107
+ {
108
+ id: "second",
109
+ ticketUrl: null,
110
+ repo: "agency",
111
+ branch: "task/second",
112
+ base: "main",
113
+ },
114
+ root,
115
+ ),
116
+ ),
117
+ ),
118
+ )
119
+ const originalPath = process.env.PATH
120
+ const bin = join(root, "bin")
121
+ await mkdir(bin)
122
+ await Bun.write(
123
+ join(bin, "gh"),
124
+ '#!/bin/sh\necho "provider unavailable" >&2\nexit 1\n',
125
+ )
126
+ await chmod(join(bin, "gh"), 0o755)
127
+ process.env.PATH = `${bin}:${originalPath}`
128
+ try {
129
+ const logs = await captureLogs(() =>
130
+ runTestEffect(sync({ cwd: root, dryRun: true })),
131
+ )
132
+ expect(
133
+ logs.filter((line) => line.includes("provider unavailable")),
134
+ ).toEqual(["Warning 'task:example', 'task:second': provider unavailable"])
135
+ } finally {
136
+ process.env.PATH = originalPath
137
+ }
138
+ })
139
+
102
140
  test("reports human-readable progress without polluting JSON output", async () => {
103
141
  const updates: string[] = []
104
142
  const progress: Progress = {
@@ -8,6 +8,27 @@ interface SyncCommandOptions extends BaseCommandOptions {
8
8
  readonly dryRun?: boolean
9
9
  }
10
10
 
11
+ interface Notice {
12
+ readonly kind: string
13
+ readonly target: string
14
+ readonly message: string
15
+ readonly action?: string
16
+ }
17
+
18
+ const groupedNotices = <T extends Notice>(notices: readonly T[]) => {
19
+ const groups = new Map<string, { notice: T; targets: string[] }>()
20
+ for (const notice of notices) {
21
+ const key = JSON.stringify([notice.kind, notice.message, notice.action])
22
+ const group = groups.get(key)
23
+ if (group) group.targets.push(notice.target)
24
+ else groups.set(key, { notice, targets: [notice.target] })
25
+ }
26
+ return groups.values()
27
+ }
28
+
29
+ const formatTargets = (targets: readonly string[]) =>
30
+ targets.map((target) => `'${target}'`).join(", ")
31
+
11
32
  export const sync = (
12
33
  options: SyncCommandOptions = {},
13
34
  progress: Progress = createProgress({
@@ -62,9 +83,11 @@ export const sync = (
62
83
  `${change.status === "applied" ? "Applied" : "Planned"} ${change.kind} '${change.target}': ${change.message}`,
63
84
  )
64
85
  }
65
- for (const warning of result.warnings) {
86
+ for (const { notice: warning, targets } of groupedNotices(
87
+ result.warnings,
88
+ )) {
66
89
  log(
67
- `Warning '${warning.target}': ${warning.message}${warning.action ? `. ${warning.action}` : ""}`,
90
+ `Warning ${formatTargets(targets)}: ${warning.message}${warning.action ? `. ${warning.action}` : ""}`,
68
91
  )
69
92
  }
70
93
  for (const issue of result.repositories.unresolved) {
@@ -72,9 +95,11 @@ export const sync = (
72
95
  `Unresolved repository '${issue.alias}': ${issue.message}. ${issue.action}`,
73
96
  )
74
97
  }
75
- for (const issue of result.unresolved) {
98
+ for (const { notice: issue, targets } of groupedNotices(
99
+ result.unresolved,
100
+ )) {
76
101
  log(
77
- `Unresolved '${issue.target}': ${issue.message}${issue.action ? `. ${issue.action}` : ""}`,
102
+ `Unresolved ${formatTargets(targets)}: ${issue.message}${issue.action ? `. ${issue.action}` : ""}`,
78
103
  )
79
104
  }
80
105
  if (
@@ -784,6 +784,15 @@ export class ContextService extends Effect.Service<ContextService>()(
784
784
  : null
785
785
  const reviewData =
786
786
  task?.data && "review" in task.data ? task.data.review : null
787
+ const writableDocuments = reviewData
788
+ ? task
789
+ ? [task.path]
790
+ : []
791
+ : executionData && task
792
+ ? phase
793
+ ? [task.path, phase.path]
794
+ : [task.path]
795
+ : []
787
796
  const references: readonly RepositoryReference[] = reviewData
788
797
  ? [{ repo: reviewData.repo, ref: reviewData.commit }]
789
798
  : executionData
@@ -1078,7 +1087,7 @@ export class ContextService extends Effect.Service<ContextService>()(
1078
1087
  checkoutPath: reference.checkoutPath,
1079
1088
  })),
1080
1089
  documents: {
1081
- writable: reviewData && task ? [task.path] : [],
1090
+ writable: writableDocuments,
1082
1091
  },
1083
1092
  },
1084
1093
  workspace: options.compact
@@ -418,6 +418,7 @@ describe("IntegrationService", () => {
418
418
  expect(body).toContain("agency next --json")
419
419
  expect(body).toContain("agency <command> --help")
420
420
  expect(body).toContain("authority.writable.checkoutPath")
421
+ expect(body).toContain("authority.documents.writable")
421
422
  expect(body).toContain("Only `done` satisfies a dependency")
422
423
  expect(body).toContain("Require explicit user intent")
423
424
  expect(body).toContain(
@@ -64,14 +64,17 @@ case "$*" in
64
64
  *mergeable*) ;;
65
65
  *) echo "mergeable field was not requested" >&2; exit 2 ;;
66
66
  esac
67
+ case "$*" in
68
+ *baseRepository*) echo "unsupported baseRepository field was requested" >&2; exit 3 ;;
69
+ esac
67
70
  if [ "$2" = "view" ]; then
68
71
  cat <<'JSON'
69
- {"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","headRepository":{"nameWithOwner":"example/agency"},"baseRepository":{"nameWithOwner":"example/agency"},"url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"},"mergeable":"MERGEABLE"}
72
+ {"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","headRepository":{"nameWithOwner":"example/agency"},"url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"},"mergeable":"MERGEABLE"}
70
73
  JSON
71
74
  exit 0
72
75
  fi
73
76
  cat <<'JSON'
74
- [{"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","headRepository":{"nameWithOwner":"example/agency"},"baseRepository":{"nameWithOwner":"example/agency"},"url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"},"mergeable":"MERGEABLE"}]
77
+ [{"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","headRepository":{"nameWithOwner":"example/agency"},"url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"},"mergeable":"MERGEABLE"}]
75
78
  JSON
76
79
  `,
77
80
  )
@@ -172,6 +175,7 @@ pr: null
172
175
  )
173
176
  const invocation = await Bun.file(capture).text()
174
177
  expect(invocation).toContain("--repo\n")
178
+ expect(invocation).not.toContain("baseRepository")
175
179
  expect(invocation).toContain("GIT_DIR=")
176
180
  expect(invocation).toContain(".jj")
177
181
 
@@ -1013,4 +1017,44 @@ exit 9
1013
1017
  ),
1014
1018
  ).toEqual([])
1015
1019
  })
1020
+
1021
+ test("keeps pull request query failures concise", async () => {
1022
+ await runTestEffect(
1023
+ TaskService.pipe(
1024
+ Effect.flatMap((service) =>
1025
+ service.create(
1026
+ {
1027
+ id: "unavailable",
1028
+ ticketUrl: null,
1029
+ repo: "agency",
1030
+ branch: "feat/unavailable",
1031
+ base: "main",
1032
+ },
1033
+ root,
1034
+ ),
1035
+ ),
1036
+ ),
1037
+ )
1038
+ await Bun.write(
1039
+ join(root, "bin", "gh"),
1040
+ `#!/bin/sh
1041
+ echo "Unknown JSON field: unsupported" >&2
1042
+ echo "Available fields:" >&2
1043
+ echo " additions" >&2
1044
+ exit 1
1045
+ `,
1046
+ )
1047
+ await chmod(join(root, "bin", "gh"), 0o755)
1048
+
1049
+ const result = await runTestEffect(
1050
+ SyncService.pipe(
1051
+ Effect.flatMap((service) => service.reconcile({ cwd: root })),
1052
+ ),
1053
+ )
1054
+ expect(result.warnings).toContainEqual({
1055
+ kind: "pr-discovery-unavailable",
1056
+ target: "task:unavailable",
1057
+ message: "Unknown JSON field: unsupported",
1058
+ })
1059
+ })
1016
1060
  })
@@ -153,6 +153,15 @@ const isCommitId = (ref: string) => /^[0-9a-f]{40,64}$/i.test(ref)
153
153
  const originRef = (ref: string) =>
154
154
  ref.replace(/^refs\/remotes\/origin\//, "").replace(/^origin\//, "")
155
155
 
156
+ const GITHUB_PR_FIELDS =
157
+ "number,state,title,isDraft,headRefName,baseRefName,headRepository,url,mergedAt,mergeCommit,mergeable"
158
+
159
+ const commandErrorSummary = (stderr: string, fallback: string) =>
160
+ stderr
161
+ .split("\n")
162
+ .map((line) => line.trim())
163
+ .find(Boolean) ?? fallback
164
+
156
165
  export class SyncService extends Effect.Service<SyncService>()("SyncService", {
157
166
  sync: () => ({
158
167
  reconcile: (
@@ -349,7 +358,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
349
358
  "view",
350
359
  existing.url,
351
360
  "--json",
352
- "number,state,title,isDraft,headRefName,baseRefName,headRepository,baseRepository,url,mergedAt,mergeCommit,mergeable",
361
+ GITHUB_PR_FIELDS,
353
362
  ],
354
363
  {
355
364
  cwd: repositoryPath,
@@ -372,7 +381,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
372
381
  "--state",
373
382
  "all",
374
383
  "--json",
375
- "number,state,title,isDraft,headRefName,baseRefName,headRepository,baseRepository,url,mergedAt,mergeCommit,mergeable",
384
+ GITHUB_PR_FIELDS,
376
385
  ],
377
386
  {
378
387
  cwd: repositoryPath,
@@ -824,8 +833,10 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
824
833
  warnings.push({
825
834
  kind: existing ? "pr-unavailable" : "pr-discovery-unavailable",
826
835
  target: record.key,
827
- message:
828
- queried.stderr.trim() || "Could not query delivery provider",
836
+ message: commandErrorSummary(
837
+ queried.stderr,
838
+ "Could not query delivery provider",
839
+ ),
829
840
  })
830
841
  }
831
842
  } else if (existing) {
@@ -858,7 +869,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
858
869
  warnings.push({
859
870
  kind: "pr-unavailable",
860
871
  target: record.key,
861
- message: `Could not inspect ${existing.url}: ${viewed.stderr.trim()}`,
872
+ message: `Could not inspect ${existing.url}: ${commandErrorSummary(viewed.stderr, "GitHub query failed")}`,
862
873
  })
863
874
  }
864
875
  } else {
@@ -901,8 +912,10 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
901
912
  warnings.push({
902
913
  kind: "pr-discovery-unavailable",
903
914
  target: record.key,
904
- message:
905
- listed.stderr.trim() || "Could not discover pull requests",
915
+ message: commandErrorSummary(
916
+ listed.stderr,
917
+ "Could not discover pull requests",
918
+ ),
906
919
  })
907
920
  }
908
921
  }
@@ -48,10 +48,11 @@ agency validate --json
48
48
  - A single-phase task or phase is an execution unit with one writable `repo` and
49
49
  optional read-only `repos`. Only `done` satisfies a dependency; `dropped` is
50
50
  terminal but leaves dependents blocked.
51
- - For an execution unit, write code only at
51
+ - For an execution unit, write repository content only at
52
52
  `authority.writable.checkoutPath`. Every `authority.references` checkout is
53
53
  read-only, even if filesystem permissions allow writes.
54
- - Keep task-wide decisions in `TASK.md` and phase-specific delivery context in
54
+ - Maintain only the Agency documents listed in `authority.documents.writable`:
55
+ keep task-wide decisions in `TASK.md` and phase-specific delivery context in
55
56
  `PHASE.md`. Use Agency commands for structural frontmatter mutations.
56
57
 
57
58
  ## Consent Boundaries