jackherd 1.6.1 → 1.7.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.
Files changed (152) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/INSTALL.md +176 -0
  3. package/README.md +226 -310
  4. package/assets/branding/README.md +7 -3
  5. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  6. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  7. package/assets/branding/jackherd-logo-dark.svg +1 -1
  8. package/assets/icons/bark-dark.svg +1 -0
  9. package/assets/icons/bark.svg +1 -0
  10. package/assets/icons/bowl-dark.svg +1 -0
  11. package/assets/icons/bowl.svg +1 -0
  12. package/assets/icons/decide-dark.svg +1 -0
  13. package/assets/icons/decide.svg +1 -0
  14. package/assets/icons/ear-dark.svg +1 -0
  15. package/assets/icons/ear.svg +1 -0
  16. package/assets/icons/lanes-dark.svg +1 -0
  17. package/assets/icons/lanes.svg +1 -0
  18. package/assets/icons/plan-dark.svg +1 -0
  19. package/assets/icons/plan.svg +1 -0
  20. package/assets/icons/space.svg +1 -0
  21. package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
  22. package/dist/config.d.ts +14 -3
  23. package/dist/config.js +111 -10
  24. package/dist/plan-format.d.ts +180 -77
  25. package/dist/plan-format.js +292 -93
  26. package/docs/lanes-dark.svg +50 -0
  27. package/docs/lanes.svg +50 -1
  28. package/docs/skills-dark.svg +65 -0
  29. package/docs/skills.svg +65 -1
  30. package/docs/workflow-dark.svg +138 -0
  31. package/docs/workflow.svg +135 -169
  32. package/herdr-plugin/herdr-plugin.toml +6 -6
  33. package/herdr-plugin/open-plans.ts +1 -1
  34. package/herdr-plugin/restore-plans.ts +7 -7
  35. package/install.sh +1 -1
  36. package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
  37. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
  38. package/kit/hooks/hooks/approval-gate/index.ts +69 -0
  39. package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
  40. package/kit/hooks/hooks/approval-gate/request.ts +111 -0
  41. package/kit/hooks/hooks/approval-gate/run.ts +94 -0
  42. package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
  43. package/kit/hooks/hooks/approval-gate/store.ts +70 -0
  44. package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
  45. package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
  46. package/kit/hooks/hooks/merge-guard/run.ts +2 -1
  47. package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
  48. package/kit/hooks/hooks/model-gate/index.ts +58 -0
  49. package/kit/hooks/hooks/model-gate/run.ts +63 -0
  50. package/kit/hooks/hooks/model-log/index.ts +60 -0
  51. package/kit/hooks/hooks/model-log/run.ts +57 -0
  52. package/kit/hooks/hooks/model-log/store.ts +40 -0
  53. package/kit/hooks/hooks/model-log/transcript.ts +37 -0
  54. package/kit/hooks/hooks/shared/checkout.ts +22 -0
  55. package/kit/hooks/hooks/shared/command.ts +6 -0
  56. package/kit/hooks/hooks/shared/config.ts +21 -0
  57. package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
  58. package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
  59. package/kit/hooks/hooks/shared/files.ts +19 -0
  60. package/kit/hooks/hooks/shared/fresh.ts +4 -0
  61. package/kit/hooks/hooks/shared/json.ts +4 -0
  62. package/kit/hooks/hooks/shared/model-family.ts +48 -0
  63. package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
  64. package/kit/hooks/hooks/shared/private-directory.ts +29 -0
  65. package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
  66. package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
  67. package/kit/hooks/hooks.json +25 -0
  68. package/kit/workflow/skills/follow/SKILL.md +4 -3
  69. package/kit/workflow/skills/implement/SKILL.md +6 -6
  70. package/kit/workflow/skills/orchestrate/SKILL.md +109 -27
  71. package/kit/workflow/skills/plan/SKILL.md +5 -5
  72. package/kit/workflow/skills/start/SKILL.md +2 -2
  73. package/package.json +4 -1
  74. package/schemas/jackherd.schema.json +43 -5
  75. package/src/check/command.ts +31 -4
  76. package/src/check/deferral-arrows.ts +1 -1
  77. package/src/check/parallel-report.ts +23 -0
  78. package/src/check/plan-structure-check.ts +120 -53
  79. package/src/cli.ts +8 -2
  80. package/src/config/nested-quantifier.ts +73 -0
  81. package/src/config/repository-directory.ts +11 -0
  82. package/src/config/schema.ts +63 -8
  83. package/src/plan-format/board-prose.ts +36 -0
  84. package/src/plan-format/dependencies.ts +227 -0
  85. package/src/plan-format/documentation-gate.ts +16 -0
  86. package/src/plan-format/index.ts +23 -1
  87. package/src/plan-format/lanes.ts +35 -1
  88. package/src/plan-format/model-column.ts +52 -0
  89. package/src/plan-format/phases.ts +16 -1
  90. package/src/plan-format/plan-structure.ts +10 -28
  91. package/src/plan-format/progress.ts +7 -29
  92. package/src/plans/command.ts +3 -3
  93. package/src/plans/current-phase.ts +71 -0
  94. package/src/plans/ensure-panes.ts +2 -2
  95. package/src/plans/herd-command.ts +4 -0
  96. package/src/plans/lane-discovery.ts +14 -2
  97. package/src/plans/lane-tickets.ts +69 -0
  98. package/src/plans/layout.ts +182 -88
  99. package/src/plans/live.ts +5 -1
  100. package/src/plans/phase-models-read.ts +37 -0
  101. package/src/plans/phase-models.ts +70 -0
  102. package/src/plans/plan-panes.ts +8 -7
  103. package/src/plans/pull-requests.ts +78 -4
  104. package/src/plans/selection.ts +1 -40
  105. package/src/plans/settings.ts +9 -3
  106. package/src/plans/shown-with-more.ts +5 -0
  107. package/src/plans/snapshot.ts +82 -8
  108. package/src/plans/sprint-html.ts +2 -2
  109. package/src/plans/supervisor.ts +5 -4
  110. package/src/plans/symbols.ts +9 -6
  111. package/src/plans/texts.ts +30 -13
  112. package/src/plans/view.ts +1 -0
  113. package/src/sync/herdr-plugin.ts +1 -1
  114. package/src/sync/next-steps.ts +1 -1
  115. package/src/sync/render.ts +2 -1
  116. package/src/sync/setup-questions.ts +2 -2
  117. package/src/terminal/text.ts +4 -0
  118. package/src/terminal/types.ts +1 -1
  119. package/src/vocabulary/index.ts +39 -0
  120. package/src/watch/approvals.ts +96 -0
  121. package/src/watch/command.ts +154 -50
  122. package/src/watch/failed-tests.ts +52 -0
  123. package/src/watch/failure-read.ts +56 -0
  124. package/src/watch/failures.ts +107 -0
  125. package/src/watch/feedback-item.ts +21 -0
  126. package/src/watch/feedback-read.ts +2 -1
  127. package/src/watch/feedback.ts +37 -28
  128. package/src/watch/gh-process.ts +53 -0
  129. package/src/watch/gh.ts +10 -20
  130. package/src/watch/heartbeat-progress.ts +1 -1
  131. package/src/watch/lane-model.ts +54 -0
  132. package/src/watch/lane-parallel.ts +46 -0
  133. package/src/watch/lane-progress.ts +28 -4
  134. package/src/watch/lane-report.ts +62 -9
  135. package/src/watch/lane-sources.ts +1 -1
  136. package/src/watch/no-finding.ts +47 -0
  137. package/src/watch/plan-badges-read.ts +19 -7
  138. package/src/watch/plan-badges.ts +59 -7
  139. package/src/watch/plan-views.ts +5 -5
  140. package/src/watch/previews.ts +3 -9
  141. package/src/watch/pull-requests.ts +12 -2
  142. package/src/watch/recorded-reads.ts +25 -0
  143. package/src/watch/source-fields.ts +4 -0
  144. package/src/watch/source-tickets.ts +99 -0
  145. package/src/watch/sources.ts +62 -15
  146. package/src/watch/texts.ts +21 -1
  147. package/src/watch/watch-defaults.ts +8 -0
  148. package/src/watch/watch-events.ts +64 -4
  149. package/src/watch/watcher-lock.ts +1 -1
  150. package/src/watch/workflow-runs.ts +5 -7
  151. package/docs/lanes.mmd +0 -9
  152. package/docs/skills.mmd +0 -10
@@ -1,20 +1,20 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.6.1"
3
+ version = "1.7.0"
4
4
  min_herdr_version = "0.8.2"
5
- description = "Show the plans of a board's lanes in a herdr pane"
5
+ description = "Show the herd, the state of a board's lanes, in a herdr pane"
6
6
  platforms = ["linux", "macos"]
7
7
 
8
8
  [[panes]]
9
9
  id = "plans"
10
- title = "jackherd plans"
10
+ title = "herd"
11
11
  placement = "split"
12
- command = ["bunx", "jackherd", "plans"]
12
+ command = ["bunx", "jackherd", "herd"]
13
13
 
14
14
  [[actions]]
15
15
  id = "open-plans"
16
- title = "Open the plans view"
17
- description = "Split the focused pane to the right and run jh plans there, in the focused pane's directory"
16
+ title = "Open the herd"
17
+ description = "Split the focused pane to the right and run jh herd there, in the focused pane's directory"
18
18
  contexts = ["workspace"]
19
19
  command = ["bun", "open-plans.ts"]
20
20
 
@@ -14,7 +14,7 @@ export const paneDirectoryOf = (json: string): string | null => {
14
14
  }
15
15
  }
16
16
 
17
- /** Arguments of `herdr plugin pane open` for the plans pane: to the right of the pane, in its directory when known. */
17
+ /** Arguments of `herdr plugin pane open` for the herd pane: to the right of the pane, in its directory when known. */
18
18
  export const openPlansArguments = (pane: string | null, directory: string | null): string[] => [
19
19
  'plugin',
20
20
  'pane',
@@ -6,7 +6,7 @@ type Limits = { attempts: number; intervalMs: number }
6
6
  /**
7
7
  * Measured on herdr 0.9.0: the hook starts ~85 ms after the server, `pane process-info` answers
8
8
  * `pane_not_found` until the terminals spawn at ~0.9 s, and the agents are back at ~3 s. The panes get 10 s, ten
9
- * times what they took; `plans --ensure` gets four tries 2 s apart, past the ~3 s of the agents, for a pane that
9
+ * times what they took; `herd --ensure` gets four tries 2 s apart, past the ~3 s of the agents, for a pane that
10
10
  * goes away between the two reads.
11
11
  */
12
12
  export const RESTORE_LIMITS = {
@@ -43,10 +43,10 @@ export const agentDirectoriesOf = (json: string, agentPanes: readonly string[]):
43
43
  }
44
44
  }
45
45
 
46
- /** The arguments for `bun` that run `plans --ensure` with the CLI of the package this plugin ships in. */
46
+ /** The arguments for `bun` that run `herd --ensure` with the CLI of the package this plugin ships in. */
47
47
  export const ensureArguments = (pluginRoot: string): string[] => [
48
48
  join(dirname(pluginRoot), 'src', 'cli.ts'),
49
- 'plans',
49
+ 'herd',
50
50
  '--ensure',
51
51
  ]
52
52
 
@@ -81,11 +81,11 @@ const ensureIn = (root: string, pluginRoot: string): boolean =>
81
81
  spawnSync('bun', ensureArguments(pluginRoot), { cwd: root, stdio: 'inherit' }).status === 0
82
82
 
83
83
  /**
84
- * herdr's `[[startup]]` hook: after a server start or a live handoff, brings back the plans views of every
84
+ * herdr's `[[startup]]` hook: after a server start or a live handoff, brings back the herd panes of every
85
85
  * repository an agent runs in. It parses herdr's answers itself and never throws, unlike the strict parsers of
86
- * `src/plans/plan-panes.ts`: a hook that fails leaves nothing behind but a log line, and `plans --ensure`, which it
86
+ * `src/plans/plan-panes.ts`: a hook that fails leaves nothing behind but a log line, and `herd --ensure`, which it
87
87
  * calls, reads the panes again strictly. It reads the panes at once, while herdr still names their agents, then waits until
88
- * herdr answers for every pane and retries `plans --ensure` within `RESTORE_LIMITS`. A repository without a board
88
+ * herdr answers for every pane and retries `herd --ensure` within `RESTORE_LIMITS`. A repository without a board
89
89
  * or config only prints why and is skipped.
90
90
  */
91
91
  const main = (): number => {
@@ -100,7 +100,7 @@ const main = (): number => {
100
100
  console.error('restore-plans: herdr did not answer for every pane in time, trying anyway')
101
101
  for (const root of roots)
102
102
  if (!retried(() => ensureIn(root, import.meta.dir), RESTORE_LIMITS.ensure, sleep))
103
- console.error(`restore-plans: plans --ensure failed in ${root}`)
103
+ console.error(`restore-plans: herd --ensure failed in ${root}`)
104
104
  return 0
105
105
  }
106
106
 
package/install.sh CHANGED
@@ -9,7 +9,7 @@ set -eu
9
9
 
10
10
  # The default is the version this file ships with. The release bump changes it together with
11
11
  # package.json; install.test.ts fails while the two differ. Overridable, but only by a plain release.
12
- JACKHERD_VERSION="${JACKHERD_VERSION:-1.6.1}"
12
+ JACKHERD_VERSION="${JACKHERD_VERSION:-1.7.0}"
13
13
 
14
14
  JACKHERD_PACKAGE='jackherd'
15
15
  # Test seam: the terminal the wizard asks its questions on.
@@ -0,0 +1,61 @@
1
+ import type { DialogAnswer } from '../shared/dialog-answer'
2
+ import { isFresh } from '../shared/fresh'
3
+ import { type ApprovalRequest, namedRequest } from './request'
4
+
5
+ export type GateLabels = { allow: string; deny: string }
6
+
7
+ export type GateDecision = { kind: 'allow'; id: string } | { kind: 'deny'; id: string } | { kind: 'pending' }
8
+
9
+ export type GateQuery = {
10
+ requests: readonly ApprovalRequest[]
11
+ /** The hash of the call waiting now; it covers lane and directory, so no other lane's request matches. */
12
+ hash: string
13
+ answers: readonly DialogAnswer[]
14
+ labels: GateLabels
15
+ now: number
16
+ /** How long an answer holds, from the moment it was given. */
17
+ validMinutes: number
18
+ }
19
+
20
+ const PENDING: GateDecision = { kind: 'pending' }
21
+
22
+ /**
23
+ * The open request of this call an answer names by id and hash, if the answer is one of the offered labels and was
24
+ * given after it. The hash in the question is the one the coordinator showed; a request rewritten meanwhile has another.
25
+ */
26
+ const answeredRequest = (
27
+ answer: DialogAnswer,
28
+ open: ReadonlyMap<string, ApprovalRequest>,
29
+ labels: GateLabels,
30
+ ): ApprovalRequest | undefined => {
31
+ const named = namedRequest(answer.question)
32
+ const candidate = named === null ? undefined : open.get(named.id)
33
+ const request = candidate?.hash === named?.hash ? candidate : undefined
34
+ const isLabel = answer.answer === labels.allow || answer.answer === labels.deny
35
+ return request && isLabel && answer.offered.includes(answer.answer) && answer.answeredAt >= request.createdAt
36
+ ? request
37
+ : undefined
38
+ }
39
+
40
+ /**
41
+ * What the user decided about this call: the newest fresh answer, given in the dialog with one of the two labels, to a
42
+ * request of the same call that no answer was spent on yet. An earlier request of the same call counts, so a retry
43
+ * after the approval passes at once. Anything else leaves the call pending.
44
+ */
45
+ export const gateDecision = ({ requests, hash, answers, labels, now, validMinutes }: GateQuery): GateDecision => {
46
+ const open = new Map(
47
+ requests.filter((request) => request.hash === hash && request.usedAt === null).map((request) => [request.id, request]),
48
+ )
49
+ const decided = answers
50
+ .filter((answer) => isFresh(answer.answeredAt, now, validMinutes))
51
+ .sort((first, second) => second.answeredAt - first.answeredAt)
52
+ .flatMap((answer) => {
53
+ const request = answeredRequest(answer, open, labels)
54
+ return request ? [{ answer, request }] : []
55
+ })
56
+ const [newest] = decided
57
+ if (!newest) return PENDING
58
+ return newest.answer.answer === labels.allow
59
+ ? { kind: 'allow', id: newest.request.id }
60
+ : { kind: 'deny', id: newest.request.id }
61
+ }
@@ -0,0 +1,24 @@
1
+ import { resolve } from 'node:path'
2
+ import { isRecord, type JsonObject } from '../shared/json'
3
+
4
+ /** A client's session id as a UUID; nothing else may reach a path. */
5
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
6
+
7
+ const sessionOf = (pane: JsonObject): string | null => {
8
+ const session = isRecord(pane.agent_session) ? pane.agent_session.value : undefined
9
+ return typeof pane.agent === 'string' && pane.agent !== '' && typeof session === 'string' && SESSION_ID.test(session)
10
+ ? session
11
+ : null
12
+ }
13
+
14
+ /**
15
+ * The sessions of the agents that run in the main checkout, from the `result` of `herdr pane list`: the coordinator
16
+ * runs there, every lane in a worktree of its own. Its herdr name is no help, since it differs per repository.
17
+ */
18
+ export const coordinatorSessions = (listing: unknown, main: string): string[] => {
19
+ const panes = isRecord(listing) && Array.isArray(listing.panes) ? listing.panes.filter(isRecord) : []
20
+ return panes.flatMap((pane) =>
21
+ typeof pane.cwd === 'string' && resolve(pane.cwd) === resolve(main) ? (sessionOf(pane) ?? []) : [],
22
+ )
23
+ }
24
+
@@ -0,0 +1,69 @@
1
+ // PermissionRequest hook of Claude Code, shipped by jackherd: the client adapter of the approval gate. In a lane it
2
+ // holds a call that would open a permission dialog, records it for the coordinator and answers with the user's decision
3
+ // from the coordinator's dialog; elsewhere, and on any failure, it answers nothing and the dialog stays. argv[2..5]: the
4
+ // allow label, the deny label, the text for a call still pending after the wait, the text for a denied call, rendered
5
+ // into the hook entry by `sync`.
6
+ import { homedir } from 'node:os'
7
+ import { mainCheckoutOf } from '../shared/checkout'
8
+ import { commandOutput } from '../shared/command'
9
+ import { readConfig } from '../shared/config'
10
+ import { isRecord } from '../shared/json'
11
+ import { coordinatorSessions } from './herdr-panes'
12
+ import { type ClientCall, type GateDependencies, type GateOutcome, runGate } from './run'
13
+ import { approvalsDirectory, claimRequest, pruneRequests, readRequests, writeRequest } from './store'
14
+ import { coordinatorAnswers } from './transcripts'
15
+
16
+ type ClaudeDecision = { behavior: 'allow' } | { behavior: 'deny'; message: string }
17
+
18
+ /** herdr's pane listing; throws when herdr cannot be asked, so the gate gives up and the dialog stays. */
19
+ const herdrPanes = (): unknown => {
20
+ const answer: unknown = JSON.parse(commandOutput(['herdr', 'pane', 'list']))
21
+ if (!isRecord(answer) || !('result' in answer)) throw new Error('herdr answered no pane list')
22
+ return answer.result
23
+ }
24
+
25
+ /** Claude Code's hook input: `cwd`, `tool_name`, `tool_input`. */
26
+ const clientCallOf = (text: string): ClientCall | null => {
27
+ const input: unknown = JSON.parse(text)
28
+ if (!isRecord(input) || typeof input.cwd !== 'string' || typeof input.tool_name !== 'string') return null
29
+ return { cwd: input.cwd, tool: input.tool_name, input: input.tool_input ?? null }
30
+ }
31
+
32
+ const dependencies: GateDependencies = {
33
+ inLaneSession: process.env.HERDR_ENV === '1',
34
+ mainCheckoutOf,
35
+ config: (main) => readConfig([main, homedir()]),
36
+ readRequests: (main) => readRequests(approvalsDirectory(main)),
37
+ writeRequest: (main, request) => writeRequest(approvalsDirectory(main), request),
38
+ claimRequest: (main, id, now) => claimRequest(approvalsDirectory(main), id, now),
39
+ pruneRequests: (main, now, minutes) => pruneRequests(approvalsDirectory(main), now, minutes),
40
+ answers: (main) => coordinatorAnswers(coordinatorSessions(herdrPanes(), main)),
41
+ now: () => Date.now(),
42
+ sleep: (ms) => Bun.sleep(ms),
43
+ randomBytes: () => crypto.getRandomValues(new Uint8Array(6)),
44
+ }
45
+
46
+ const [allow = '', deny = '', pending = '', denied = ''] = process.argv.slice(2)
47
+
48
+ const decide = async (): Promise<GateOutcome> => {
49
+ if (!allow || !deny) return null
50
+ try {
51
+ const call = clientCallOf(await Bun.stdin.text())
52
+ return call ? await runGate(call, { allow, deny }, dependencies) : null
53
+ } catch {
54
+ return null
55
+ }
56
+ }
57
+
58
+ const claudeDecision = (outcome: NonNullable<GateOutcome>): ClaudeDecision => {
59
+ if (outcome.kind === 'allow') return { behavior: 'allow' }
60
+ return { behavior: 'deny', message: outcome.kind === 'denied' ? denied : pending }
61
+ }
62
+
63
+ const outcome = await decide()
64
+ if (outcome) {
65
+ console.log(
66
+ JSON.stringify({ hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: claudeDecision(outcome) } }),
67
+ )
68
+ }
69
+ process.exit(0)
@@ -0,0 +1,14 @@
1
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
2
+
3
+ /** herdr's agent names; a worktree whose directory has another name cannot be a lane. */
4
+ const LANE_NAME = /^[a-z][a-z0-9_-]{0,31}$/
5
+
6
+ export type LaneLocation = { cwd: string; main: string; worktreeDirectory: string }
7
+
8
+ /** The lane whose worktree holds `cwd`: the first directory below the main checkout's worktree directory, else null. */
9
+ export const laneOf = ({ cwd, main, worktreeDirectory }: LaneLocation): string | null => {
10
+ const path = relative(resolve(main, worktreeDirectory), resolve(cwd))
11
+ if (path === '' || path.startsWith('..') || isAbsolute(path)) return null
12
+ const [lane] = path.split(sep)
13
+ return LANE_NAME.test(lane) ? lane : null
14
+ }
@@ -0,0 +1,111 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { isRecord } from '../shared/json'
3
+
4
+ /** One call a lane wants to make: where it runs, and the tool with its whole input. */
5
+ export type GateCall = { lane: string; cwd: string; tool: string; input: unknown }
6
+
7
+ /**
8
+ * A call a lane wants to make and that waits for the coordinator. Written by the hook, read by the watcher; whatever
9
+ * it says, it never approves anything: the approval is the user's dialog answer in the coordinator's transcript.
10
+ */
11
+ export type ApprovalRequest = GateCall & {
12
+ id: string
13
+ /** Over lane, directory, tool and input: an approval covers exactly this call. */
14
+ hash: string
15
+ /** One line of the call for the coordinator's question; the whole call is `input`. */
16
+ summary: string
17
+ createdAt: number
18
+ /** When an answer to it was spent; a request is answered once. */
19
+ usedAt: number | null
20
+ }
21
+
22
+ const ID = /^[0-9a-f]{12}$/
23
+ const MARK = /`approval ([0-9a-f]{12}) ([0-9a-f]{16})`/g
24
+ const SUMMARY_LENGTH = 160
25
+ const MAX_INPUT_LENGTH = 64_000
26
+ const CONTROL = /[\u0000-\u0008\u000e-\u001f\u007f-\u009f]/g
27
+ const WHITESPACE = /\s+/g
28
+ const BACKTICK = /`/g
29
+
30
+ /** JSON with the keys of every object sorted, so the same input always gives the same text. */
31
+ const canonicalJson = (value: unknown): string => {
32
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
33
+ if (isRecord(value))
34
+ return `{${Object.keys(value)
35
+ .sort()
36
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
37
+ .join(',')}}`
38
+ return JSON.stringify(value) ?? 'null'
39
+ }
40
+
41
+ export const callHash = ({ lane, cwd, tool, input }: GateCall): string =>
42
+ createHash('sha256').update(canonicalJson({ lane, cwd, tool, input })).digest('hex').slice(0, 16)
43
+
44
+ /** Whether the input is small enough to be stored whole; a larger call keeps the client's own dialog. */
45
+ export const isStorableInput = (input: unknown): boolean => canonicalJson(input).length <= MAX_INPUT_LENGTH
46
+
47
+ /** The id from random bytes the caller draws, so the drawing stays out of this function. */
48
+ export const requestId = (bytes: Uint8Array): string =>
49
+ [...bytes]
50
+ .slice(0, 6)
51
+ .map((byte) => byte.toString(16).padStart(2, '0'))
52
+ .join('')
53
+
54
+ /** One line: control characters out, every run of whitespace one space, backticks plain quotes (no mark rides along). */
55
+ const oneLine = (text: string): string =>
56
+ text.replace(CONTROL, '').replace(WHITESPACE, ' ').replace(BACKTICK, "'").trim()
57
+
58
+ const cut = (line: string): string => {
59
+ if (line.length <= SUMMARY_LENGTH) return line
60
+ const kept = SUMMARY_LENGTH - ` … (+${line.length} more characters)`.length
61
+ return `${line.slice(0, kept)}… (+${line.length - kept} more characters)`
62
+ }
63
+
64
+ /** A shell call by its command, any other by its input as JSON; one line of at most 160 characters that says what it cut. */
65
+ export const callSummary = (tool: string, input: unknown): string =>
66
+ cut(oneLine(isRecord(input) && typeof input.command === 'string' ? input.command : (JSON.stringify(input) ?? '')))
67
+
68
+ /** How a question names the request it asks about: its id, and the hash of the call the coordinator showed. */
69
+ export type RequestMark = { id: string; hash: string }
70
+
71
+ /**
72
+ * The mark a question carries, as the watcher's duty line writes it (contract test): an answer approves the shown
73
+ * content, not whatever the request file says later.
74
+ */
75
+ export const requestMark = ({ id, hash }: RequestMark): string => `\`approval ${id} ${hash}\``
76
+
77
+ /** The one mark a question carries; none when it carries none, or marks that differ in id or hash. */
78
+ export const namedRequest = (question: string): RequestMark | null => {
79
+ const marks = [...question.matchAll(MARK)].map(([, id, hash]) => ({ id, hash }))
80
+ const [first] = marks
81
+ return first && marks.every(({ id, hash }) => id === first.id && hash === first.hash) ? first : null
82
+ }
83
+
84
+ export const newRequest = ({ id, now, ...call }: GateCall & { id: string; now: number }): ApprovalRequest => ({
85
+ id,
86
+ hash: callHash(call),
87
+ lane: call.lane,
88
+ cwd: call.cwd,
89
+ tool: call.tool,
90
+ input: call.input,
91
+ summary: callSummary(call.tool, call.input),
92
+ createdAt: now,
93
+ usedAt: null,
94
+ })
95
+
96
+ const hasShape = (value: unknown): value is ApprovalRequest =>
97
+ isRecord(value) &&
98
+ typeof value.id === 'string' &&
99
+ ID.test(value.id) &&
100
+ typeof value.hash === 'string' &&
101
+ typeof value.lane === 'string' &&
102
+ typeof value.cwd === 'string' &&
103
+ typeof value.tool === 'string' &&
104
+ 'input' in value &&
105
+ typeof value.summary === 'string' &&
106
+ typeof value.createdAt === 'number' &&
107
+ (value.usedAt === null || typeof value.usedAt === 'number')
108
+
109
+ /** A request whose hash and summary are those of its own call; a file altered by hand is no request. */
110
+ export const isApprovalRequest = (value: unknown): value is ApprovalRequest =>
111
+ hasShape(value) && value.hash === callHash(value) && value.summary === callSummary(value.tool, value.input)
@@ -0,0 +1,94 @@
1
+ import type { DialogAnswer } from '../shared/dialog-answer'
2
+ import { isFresh } from '../shared/fresh'
3
+ import { gateDecision, type GateLabels } from './decision'
4
+ import { laneOf } from './lane'
5
+ import { type ApprovalRequest, callHash, type GateCall, isStorableInput, newRequest, requestId } from './request'
6
+ import { gateSettingsOf } from './settings'
7
+
8
+ /** A call as the client's hook hands it over, before it is known to be a lane's. */
9
+ export type ClientCall = { cwd: string; tool: string; input: unknown }
10
+
11
+ /** What the gate decided; null decides nothing, so the client's own dialog stays. */
12
+ export type GateOutcome = { kind: 'allow' } | { kind: 'denied' } | { kind: 'pending' } | null
13
+
14
+ /** Everything with a side effect, passed in so a test reaches the flow without a multiplexer, git or files. */
15
+ export type GateDependencies = {
16
+ /** Whether this session runs in a pane of the terminal multiplexer. */
17
+ inLaneSession: boolean
18
+ mainCheckoutOf: (cwd: string) => string
19
+ config: (main: string) => Record<string, unknown>
20
+ readRequests: (main: string) => ApprovalRequest[]
21
+ writeRequest: (main: string, request: ApprovalRequest) => void
22
+ /** Creates the request's claim exclusively; true for the one caller that may act on its answer. */
23
+ claimRequest: (main: string, id: string, now: number) => boolean
24
+ pruneRequests: (main: string, now: number, minutes: number) => void
25
+ answers: (main: string) => DialogAnswer[]
26
+ now: () => number
27
+ sleep: (ms: number) => Promise<void>
28
+ randomBytes: () => Uint8Array
29
+ }
30
+
31
+ const POLL_MS = 2000
32
+ const MS_PER_MINUTE = 60_000
33
+ /** An answer holds this long from the moment it was given (User decision, plan P18). */
34
+ const VALID_MINUTES = 30
35
+ /** A request is kept for its wait and the validity of an answer to it, then removed. */
36
+ const KEEP_MINUTES = 60
37
+
38
+ /** The open request of this call, or a new one written for it, so a retry asks the coordinator only once. */
39
+ const openRequest = (main: string, call: GateCall, dependencies: GateDependencies): ApprovalRequest => {
40
+ const hash = callHash(call)
41
+ const now = dependencies.now()
42
+ dependencies.pruneRequests(main, now, KEEP_MINUTES)
43
+ const open = dependencies
44
+ .readRequests(main)
45
+ .find((request) => request.hash === hash && request.usedAt === null && isFresh(request.createdAt, now, VALID_MINUTES))
46
+ if (open) return open
47
+ const request = newRequest({ id: requestId(dependencies.randomBytes()), ...call, now })
48
+ dependencies.writeRequest(main, request)
49
+ return request
50
+ }
51
+
52
+ /** Polls the coordinator's answers until the user decided, the answer was claimed elsewhere, or the wait is over. */
53
+ const awaitDecision = async (
54
+ main: string,
55
+ hash: string,
56
+ labels: GateLabels,
57
+ waitMinutes: number,
58
+ dependencies: GateDependencies,
59
+ ): Promise<GateOutcome> => {
60
+ const deadline = dependencies.now() + waitMinutes * MS_PER_MINUTE
61
+ for (;;) {
62
+ const now = dependencies.now()
63
+ const requests = dependencies.readRequests(main)
64
+ const answers = dependencies.answers(main)
65
+ const decision = gateDecision({ requests, hash, answers, labels, now, validMinutes: VALID_MINUTES })
66
+ if (decision.kind !== 'pending' && dependencies.claimRequest(main, decision.id, now))
67
+ return decision.kind === 'allow' ? { kind: 'allow' } : { kind: 'denied' }
68
+ if (now >= deadline) return { kind: 'pending' }
69
+ await dependencies.sleep(POLL_MS)
70
+ }
71
+ }
72
+
73
+ /**
74
+ * The gate for a call that would open a permission dialog in a lane: it records the call as a request the watcher
75
+ * reports, waits for the user's answer to the coordinator's dialog, and allows or denies. Outside a lane, for a call too
76
+ * large to store, and on any failure (the multiplexer or git cannot be asked), it decides nothing, so the dialog stays.
77
+ */
78
+ export const runGate = async (
79
+ clientCall: ClientCall,
80
+ labels: GateLabels,
81
+ dependencies: GateDependencies,
82
+ ): Promise<GateOutcome> => {
83
+ if (!dependencies.inLaneSession || !isStorableInput(clientCall.input)) return null
84
+ try {
85
+ const main = dependencies.mainCheckoutOf(clientCall.cwd)
86
+ const settings = gateSettingsOf(dependencies.config(main))
87
+ const lane = laneOf({ cwd: clientCall.cwd, main, worktreeDirectory: settings.worktreeDirectory })
88
+ if (!lane) return null
89
+ const request = openRequest(main, { lane, ...clientCall }, dependencies)
90
+ return await awaitDecision(main, request.hash, labels, settings.waitMinutes, dependencies)
91
+ } catch {
92
+ return null
93
+ }
94
+ }
@@ -0,0 +1,26 @@
1
+ import { sectionOf } from '../shared/config'
2
+ import { isRepositoryDirectory } from '../shared/repository-directory'
3
+
4
+ export type GateSettings = {
5
+ /** How long the hook holds a call for the coordinator; below the 10 minutes of the hook entry. */
6
+ waitMinutes: number
7
+ worktreeDirectory: string
8
+ }
9
+
10
+ export const DEFAULT_GATE_SETTINGS: GateSettings = { waitMinutes: 8, worktreeDirectory: '.claude/worktrees' }
11
+
12
+ /** The same bounds as `guard.approvals.waitMinutes` in the config schema, which the hook cannot import. */
13
+ const isWaitMinutes = (value: unknown): value is number =>
14
+ typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 9
15
+
16
+ /** The gate's settings from a parsed config; whatever is missing or malformed keeps its default. */
17
+ export const gateSettingsOf = (config: Record<string, unknown>): GateSettings => {
18
+ const approvals = sectionOf(sectionOf(config, 'guard'), 'approvals')
19
+ const { worktreeDirectory } = config
20
+ return {
21
+ waitMinutes: isWaitMinutes(approvals.waitMinutes) ? approvals.waitMinutes : DEFAULT_GATE_SETTINGS.waitMinutes,
22
+ worktreeDirectory: isRepositoryDirectory(worktreeDirectory)
23
+ ? worktreeDirectory
24
+ : DEFAULT_GATE_SETTINGS.worktreeDirectory,
25
+ }
26
+ }
@@ -0,0 +1,70 @@
1
+ import { chmodSync, existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { isFresh } from '../shared/fresh'
4
+ import { ensurePrivateDirectory, PRIVATE_FILE } from '../shared/private-directory'
5
+ import { type ApprovalRequest, isApprovalRequest } from './request'
6
+
7
+ const REQUEST_FILE = /^([0-9a-f]{12})\.json$/
8
+
9
+ /** Where the requests of all lanes lie: in the main checkout, which the watcher reads. Ignored by git. */
10
+ export const approvalsDirectory = (main: string): string => join(main, '.jackherd', 'approvals')
11
+
12
+ const requestPath = (directory: string, id: string): string => join(directory, `${id}.json`)
13
+ const claimPath = (directory: string, id: string): string => join(directory, `${id}.used`)
14
+
15
+ /** When the request was claimed, from its claim file; null while it is open. */
16
+ const claimedAt = (directory: string, id: string): number | null => {
17
+ try {
18
+ const at = Number(readFileSync(claimPath(directory, id), 'utf8'))
19
+ return Number.isFinite(at) ? at : null
20
+ } catch {
21
+ return null
22
+ }
23
+ }
24
+
25
+ const readRequest = (directory: string, id: string): ApprovalRequest[] => {
26
+ try {
27
+ const request: unknown = JSON.parse(readFileSync(requestPath(directory, id), 'utf8'))
28
+ return isApprovalRequest(request) && request.id === id ? [{ ...request, usedAt: claimedAt(directory, id) }] : []
29
+ } catch {
30
+ return []
31
+ }
32
+ }
33
+
34
+ /** Every request file that parses as one, each with the time of its claim; any other file is skipped. */
35
+ export const readRequests = (directory: string): ApprovalRequest[] =>
36
+ existsSync(directory)
37
+ ? readdirSync(directory).flatMap((name) => {
38
+ const id = REQUEST_FILE.exec(name)?.[1]
39
+ return id ? readRequest(directory, id) : []
40
+ })
41
+ : []
42
+
43
+ export const writeRequest = (directory: string, request: ApprovalRequest): void => {
44
+ ensurePrivateDirectory(directory)
45
+ const path = requestPath(directory, request.id)
46
+ writeFileSync(path, `${JSON.stringify({ ...request, usedAt: null })}\n`, { mode: PRIVATE_FILE })
47
+ chmodSync(path, PRIVATE_FILE)
48
+ }
49
+
50
+ /**
51
+ * Spends the answer to a request: creates its claim file exclusively, so of two hooks that read the same answer only
52
+ * one gets `true` and acts on it.
53
+ */
54
+ export const claimRequest = (directory: string, id: string, now: number): boolean => {
55
+ try {
56
+ writeFileSync(claimPath(directory, id), String(now), { flag: 'wx', mode: PRIVATE_FILE })
57
+ return true
58
+ } catch {
59
+ return false
60
+ }
61
+ }
62
+
63
+ /** Removes the requests created more than `minutes` ago, with their claims, so the directory does not grow. */
64
+ export const pruneRequests = (directory: string, now: number, minutes: number): void => {
65
+ for (const request of readRequests(directory)) {
66
+ if (isFresh(request.createdAt, now, minutes)) continue
67
+ rmSync(claimPath(directory, request.id), { force: true })
68
+ rmSync(requestPath(directory, request.id), { force: true })
69
+ }
70
+ }
@@ -0,0 +1,40 @@
1
+ import { closeSync, existsSync, fstatSync, openSync, readdirSync, readSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { type DialogAnswer, dialogAnswers, parseTranscript } from '../shared/dialog-answers'
5
+
6
+ /** Claude Code's transcripts: `~/.claude/projects/<project>/<session>.jsonl`; this file is the client adapter. */
7
+ const PROJECTS_DIRECTORY = join(homedir(), '.claude', 'projects')
8
+ /** A transcript grows to hundreds of megabytes; answers of the last 30 minutes lie at its end. */
9
+ const TAIL_BYTES = 4 * 1024 * 1024
10
+
11
+ const transcriptPath = (session: string, projectsDirectory: string): string | undefined =>
12
+ existsSync(projectsDirectory)
13
+ ? readdirSync(projectsDirectory)
14
+ .map((directory) => join(projectsDirectory, directory, `${session}.jsonl`))
15
+ .find((path) => existsSync(path))
16
+ : undefined
17
+
18
+ /** The last `TAIL_BYTES` of a file; the first line may be cut, and the transcript parser skips it. */
19
+ const readTail = (path: string): string => {
20
+ const descriptor = openSync(path, 'r')
21
+ try {
22
+ const size = fstatSync(descriptor).size
23
+ const length = Math.min(size, TAIL_BYTES)
24
+ const buffer = Buffer.alloc(length)
25
+ readSync(descriptor, buffer, 0, length, size - length)
26
+ return buffer.toString('utf8')
27
+ } finally {
28
+ closeSync(descriptor)
29
+ }
30
+ }
31
+
32
+ /** The dialog answers in the transcripts of these sessions; a session without a transcript gives none. */
33
+ export const coordinatorAnswers = (
34
+ sessions: readonly string[],
35
+ projectsDirectory: string = PROJECTS_DIRECTORY,
36
+ ): DialogAnswer[] =>
37
+ sessions.flatMap((session) => {
38
+ const path = transcriptPath(session, projectsDirectory)
39
+ return path ? dialogAnswers(parseTranscript(readTail(path))) : []
40
+ })