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.
- package/CHANGELOG.md +26 -0
- package/INSTALL.md +176 -0
- package/README.md +226 -310
- package/assets/branding/README.md +7 -3
- package/assets/branding/jackherd-logo-dark-1024.png +0 -0
- package/assets/branding/jackherd-logo-dark-512.png +0 -0
- package/assets/branding/jackherd-logo-dark.svg +1 -1
- package/assets/icons/bark-dark.svg +1 -0
- package/assets/icons/bark.svg +1 -0
- package/assets/icons/bowl-dark.svg +1 -0
- package/assets/icons/bowl.svg +1 -0
- package/assets/icons/decide-dark.svg +1 -0
- package/assets/icons/decide.svg +1 -0
- package/assets/icons/ear-dark.svg +1 -0
- package/assets/icons/ear.svg +1 -0
- package/assets/icons/lanes-dark.svg +1 -0
- package/assets/icons/lanes.svg +1 -0
- package/assets/icons/plan-dark.svg +1 -0
- package/assets/icons/plan.svg +1 -0
- package/assets/icons/space.svg +1 -0
- package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
- package/dist/config.d.ts +14 -3
- package/dist/config.js +111 -10
- package/dist/plan-format.d.ts +180 -77
- package/dist/plan-format.js +292 -93
- package/docs/lanes-dark.svg +50 -0
- package/docs/lanes.svg +50 -1
- package/docs/skills-dark.svg +65 -0
- package/docs/skills.svg +65 -1
- package/docs/workflow-dark.svg +138 -0
- package/docs/workflow.svg +135 -169
- package/herdr-plugin/herdr-plugin.toml +6 -6
- package/herdr-plugin/open-plans.ts +1 -1
- package/herdr-plugin/restore-plans.ts +7 -7
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
- package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
- package/kit/hooks/hooks/approval-gate/index.ts +69 -0
- package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
- package/kit/hooks/hooks/approval-gate/request.ts +111 -0
- package/kit/hooks/hooks/approval-gate/run.ts +94 -0
- package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
- package/kit/hooks/hooks/approval-gate/store.ts +70 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
- package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
- package/kit/hooks/hooks/merge-guard/run.ts +2 -1
- package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
- package/kit/hooks/hooks/model-gate/index.ts +58 -0
- package/kit/hooks/hooks/model-gate/run.ts +63 -0
- package/kit/hooks/hooks/model-log/index.ts +60 -0
- package/kit/hooks/hooks/model-log/run.ts +57 -0
- package/kit/hooks/hooks/model-log/store.ts +40 -0
- package/kit/hooks/hooks/model-log/transcript.ts +37 -0
- package/kit/hooks/hooks/shared/checkout.ts +22 -0
- package/kit/hooks/hooks/shared/command.ts +6 -0
- package/kit/hooks/hooks/shared/config.ts +21 -0
- package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
- package/kit/hooks/hooks/shared/files.ts +19 -0
- package/kit/hooks/hooks/shared/fresh.ts +4 -0
- package/kit/hooks/hooks/shared/json.ts +4 -0
- package/kit/hooks/hooks/shared/model-family.ts +48 -0
- package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
- package/kit/hooks/hooks/shared/private-directory.ts +29 -0
- package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
- package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
- package/kit/hooks/hooks.json +25 -0
- package/kit/workflow/skills/follow/SKILL.md +4 -3
- package/kit/workflow/skills/implement/SKILL.md +6 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +109 -27
- package/kit/workflow/skills/plan/SKILL.md +5 -5
- package/kit/workflow/skills/start/SKILL.md +2 -2
- package/package.json +4 -1
- package/schemas/jackherd.schema.json +43 -5
- package/src/check/command.ts +31 -4
- package/src/check/deferral-arrows.ts +1 -1
- package/src/check/parallel-report.ts +23 -0
- package/src/check/plan-structure-check.ts +120 -53
- package/src/cli.ts +8 -2
- package/src/config/nested-quantifier.ts +73 -0
- package/src/config/repository-directory.ts +11 -0
- package/src/config/schema.ts +63 -8
- package/src/plan-format/board-prose.ts +36 -0
- package/src/plan-format/dependencies.ts +227 -0
- package/src/plan-format/documentation-gate.ts +16 -0
- package/src/plan-format/index.ts +23 -1
- package/src/plan-format/lanes.ts +35 -1
- package/src/plan-format/model-column.ts +52 -0
- package/src/plan-format/phases.ts +16 -1
- package/src/plan-format/plan-structure.ts +10 -28
- package/src/plan-format/progress.ts +7 -29
- package/src/plans/command.ts +3 -3
- package/src/plans/current-phase.ts +71 -0
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/herd-command.ts +4 -0
- package/src/plans/lane-discovery.ts +14 -2
- package/src/plans/lane-tickets.ts +69 -0
- package/src/plans/layout.ts +182 -88
- package/src/plans/live.ts +5 -1
- package/src/plans/phase-models-read.ts +37 -0
- package/src/plans/phase-models.ts +70 -0
- package/src/plans/plan-panes.ts +8 -7
- package/src/plans/pull-requests.ts +78 -4
- package/src/plans/selection.ts +1 -40
- package/src/plans/settings.ts +9 -3
- package/src/plans/shown-with-more.ts +5 -0
- package/src/plans/snapshot.ts +82 -8
- package/src/plans/sprint-html.ts +2 -2
- package/src/plans/supervisor.ts +5 -4
- package/src/plans/symbols.ts +9 -6
- package/src/plans/texts.ts +30 -13
- package/src/plans/view.ts +1 -0
- package/src/sync/herdr-plugin.ts +1 -1
- package/src/sync/next-steps.ts +1 -1
- package/src/sync/render.ts +2 -1
- package/src/sync/setup-questions.ts +2 -2
- package/src/terminal/text.ts +4 -0
- package/src/terminal/types.ts +1 -1
- package/src/vocabulary/index.ts +39 -0
- package/src/watch/approvals.ts +96 -0
- package/src/watch/command.ts +154 -50
- package/src/watch/failed-tests.ts +52 -0
- package/src/watch/failure-read.ts +56 -0
- package/src/watch/failures.ts +107 -0
- package/src/watch/feedback-item.ts +21 -0
- package/src/watch/feedback-read.ts +2 -1
- package/src/watch/feedback.ts +37 -28
- package/src/watch/gh-process.ts +53 -0
- package/src/watch/gh.ts +10 -20
- package/src/watch/heartbeat-progress.ts +1 -1
- package/src/watch/lane-model.ts +54 -0
- package/src/watch/lane-parallel.ts +46 -0
- package/src/watch/lane-progress.ts +28 -4
- package/src/watch/lane-report.ts +62 -9
- package/src/watch/lane-sources.ts +1 -1
- package/src/watch/no-finding.ts +47 -0
- package/src/watch/plan-badges-read.ts +19 -7
- package/src/watch/plan-badges.ts +59 -7
- package/src/watch/plan-views.ts +5 -5
- package/src/watch/previews.ts +3 -9
- package/src/watch/pull-requests.ts +12 -2
- package/src/watch/recorded-reads.ts +25 -0
- package/src/watch/source-fields.ts +4 -0
- package/src/watch/source-tickets.ts +99 -0
- package/src/watch/sources.ts +62 -15
- package/src/watch/texts.ts +21 -1
- package/src/watch/watch-defaults.ts +8 -0
- package/src/watch/watch-events.ts +64 -4
- package/src/watch/watcher-lock.ts +1 -1
- package/src/watch/workflow-runs.ts +5 -7
- package/docs/lanes.mmd +0 -9
- package/docs/skills.mmd +0 -10
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
id = "crossdelta.jackherd"
|
|
2
2
|
name = "jackherd"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.7.0"
|
|
4
4
|
min_herdr_version = "0.8.2"
|
|
5
|
-
description = "Show the
|
|
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 = "
|
|
10
|
+
title = "herd"
|
|
11
11
|
placement = "split"
|
|
12
|
-
command = ["bunx", "jackherd", "
|
|
12
|
+
command = ["bunx", "jackherd", "herd"]
|
|
13
13
|
|
|
14
14
|
[[actions]]
|
|
15
15
|
id = "open-plans"
|
|
16
|
-
title = "Open the
|
|
17
|
-
description = "Split the focused pane to the right and run jh
|
|
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
|
|
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; `
|
|
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 `
|
|
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
|
-
'
|
|
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
|
|
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 `
|
|
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 `
|
|
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:
|
|
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.
|
|
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
|
+
})
|