jackherd 1.8.1 → 1.9.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 +30 -0
- package/INSTALL.md +2 -0
- package/README.md +37 -44
- package/dist/{chunk-OU2KALXP.js → chunk-MCP4XJQH.js} +15 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +19 -3
- package/dist/plan-format.d.ts +19 -2
- package/dist/plan-format.js +21 -7
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/index.ts +22 -4
- package/kit/hooks/hooks/approval-gate/run.ts +29 -6
- package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +67 -20
- package/kit/hooks/hooks/sent-file/index.ts +19 -0
- package/kit/hooks/hooks/sent-file/record.ts +128 -0
- package/kit/hooks/hooks/sent-file/run.ts +48 -0
- package/kit/hooks/hooks/sent-file/store.ts +18 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
- package/kit/hooks/hooks.json +9 -0
- package/kit/hooks/statusline.sh +1 -1
- package/kit/workflow/skills/implement/SKILL.md +2 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +30 -6
- package/package.json +2 -1
- package/schemas/jackherd.schema.json +26 -2
- package/src/cli.ts +3 -0
- package/src/config/schema.ts +22 -2
- package/src/context/fill.ts +12 -0
- package/src/context/session-log.ts +43 -1
- package/src/plan-format/clock.ts +4 -0
- package/src/plan-format/dependencies.ts +2 -5
- package/src/plan-format/lanes.ts +30 -5
- package/src/plan-format/terminal-text.ts +4 -0
- package/src/plan-format/waiting.ts +48 -0
- package/src/plans/active-tasks.ts +60 -0
- package/src/plans/current-phase.ts +17 -7
- package/src/plans/current-task.ts +63 -5
- package/src/plans/lane-standing.ts +9 -4
- package/src/plans/lane-summary.ts +141 -74
- package/src/plans/lane-tickets.ts +15 -0
- package/src/plans/layout-phase.ts +57 -18
- package/src/plans/layout-summary.ts +85 -67
- package/src/plans/layout.ts +253 -89
- package/src/plans/live.ts +22 -0
- package/src/plans/phase-models.ts +1 -1
- package/src/plans/phase-ranges.ts +45 -0
- package/src/plans/pull-requests.ts +6 -0
- package/src/plans/selection.ts +19 -16
- package/src/plans/sent-files-read.ts +22 -0
- package/src/plans/sent-files.ts +121 -0
- package/src/plans/signals-read.ts +54 -0
- package/src/plans/snapshot.ts +251 -55
- package/src/plans/subagents-read.ts +82 -0
- package/src/plans/subagents.ts +132 -0
- package/src/plans/texts.ts +16 -11
- package/src/plans/unlisted-lanes.ts +10 -5
- package/src/plans/view.ts +3 -3
- package/src/plans/waiting-click.ts +76 -0
- package/src/preview/command.ts +83 -0
- package/src/preview/embed.ts +209 -0
- package/src/preview/images.ts +89 -0
- package/src/preview/open.ts +12 -0
- package/src/preview/options.ts +32 -0
- package/src/preview/page.ts +89 -0
- package/src/preview/render.ts +41 -0
- package/src/settled/board-row.ts +1 -2
- package/src/shared/is-record.ts +2 -0
- package/src/shared/private-records.ts +48 -0
- package/src/task-signals/agent.ts +15 -0
- package/src/task-signals/claude-read.ts +72 -0
- package/src/task-signals/claude-signals.ts +112 -0
- package/src/task-signals/command.ts +71 -0
- package/src/task-signals/fixtures/session.jsonl +7 -0
- package/src/task-signals/matcher.ts +96 -0
- package/src/task-signals/record-store.ts +40 -0
- package/src/task-signals/record.ts +68 -0
- package/src/task-signals/text.ts +13 -0
- package/src/task-signals/types.ts +14 -0
- package/src/terminal/open.ts +13 -6
- package/src/terminal/text.ts +8 -1
- package/src/terminal/width.ts +2 -1
- package/src/vocabulary/index.ts +28 -0
- package/src/waiting/command.ts +35 -0
- package/src/watch/appointments.ts +2 -3
- package/src/watch/approvals.ts +2 -2
- package/src/watch/command.ts +91 -5
- package/src/watch/failed-tests.ts +56 -10
- package/src/watch/failure-read.ts +18 -6
- package/src/watch/failures.ts +29 -5
- package/src/watch/feedback.ts +1 -1
- package/src/watch/gh-process.ts +9 -3
- package/src/watch/json-guards.ts +0 -3
- package/src/watch/lane-fill.ts +1 -1
- package/src/watch/lane-model.ts +1 -1
- package/src/watch/lane-parallel.ts +1 -1
- package/src/watch/lane-progress.ts +19 -7
- package/src/watch/lane-report.ts +5 -1
- package/src/watch/lane-rest.ts +28 -0
- package/src/watch/plan-badges.ts +9 -2
- package/src/watch/previews.ts +1 -1
- package/src/watch/pull-requests.ts +2 -1
- package/src/watch/source-path.ts +1 -1
- package/src/watch/sources.ts +1 -1
- package/src/watch/stream-output.ts +12 -7
- package/src/watch/task-parallel-input.ts +118 -0
- package/src/watch/task-parallel-read.ts +149 -0
- package/src/watch/task-parallel-touched.ts +39 -0
- package/src/watch/task-parallel.ts +206 -0
- package/src/watch/waiting-line.ts +19 -0
- package/src/watch/watch-events.ts +13 -1
- package/src/watch/workflow-runs.ts +2 -1
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import type { DialogAnswer } from '../shared/dialog-answer'
|
|
2
2
|
import { ASK_USER_QUESTION_TOOL } from '../shared/dialog-answers'
|
|
3
3
|
import { isFresh } from '../shared/fresh'
|
|
4
|
-
import { laneOfAgent } from '../shared/lane'
|
|
4
|
+
import { isCoordinatorAgent, laneOfAgent } from '../shared/lane'
|
|
5
5
|
import type { PaneAgent } from '../shared/pane-agent'
|
|
6
6
|
import { gateDecision, type GateLabels } from './decision'
|
|
7
7
|
import { type ApprovalRequest, callHash, type GateCall, isStorableInput, newRequest, requestId } from './request'
|
|
8
8
|
import { gateSettingsOf } from './settings'
|
|
9
|
+
import { type SubagentCall, subagentLabel } from './subagent'
|
|
9
10
|
|
|
10
|
-
/** A call as the client's hook hands it over, before it is known to be a lane's. */
|
|
11
|
-
export type ClientCall = { cwd: string; session: string; tool: string; input: unknown }
|
|
11
|
+
/** A call as the client's hook hands it over, before it is known to be a lane's; `subagent` when a subagent makes it. */
|
|
12
|
+
export type ClientCall = { cwd: string; session: string; tool: string; input: unknown; subagent?: SubagentCall }
|
|
12
13
|
|
|
13
14
|
/** What the gate decided; null decides nothing, so the client's own dialog stays. */
|
|
14
15
|
export type GateOutcome = { kind: 'allow' } | { kind: 'denied' } | { kind: 'pending' } | null
|
|
@@ -18,6 +19,8 @@ export type GateDependencies = {
|
|
|
18
19
|
/** The agent of the multiplexer pane this session runs in; null outside the multiplexer. */
|
|
19
20
|
paneAgent: () => PaneAgent | null
|
|
20
21
|
mainCheckoutOf: (cwd: string) => string
|
|
22
|
+
/** Whether a worktree of this name exists below the main checkout's worktree directory. */
|
|
23
|
+
isLaneName: (main: string, name: string) => boolean
|
|
21
24
|
config: (main: string) => Record<string, unknown>
|
|
22
25
|
readRequests: (main: string) => ApprovalRequest[]
|
|
23
26
|
writeRequest: (main: string, request: ApprovalRequest) => void
|
|
@@ -75,11 +78,31 @@ const awaitDecision = async (
|
|
|
75
78
|
/** A question to the user asks for an answer, not for a permission: the lane asks the coordinator in its text instead. */
|
|
76
79
|
const isHeld = ({ tool, input }: ClientCall): boolean => tool !== ASK_USER_QUESTION_TOOL && isStorableInput(input)
|
|
77
80
|
|
|
81
|
+
/**
|
|
82
|
+
* What holds the call: the lane of the pane's agent, else, for a subagent of the coordinator, its own name. The
|
|
83
|
+
* coordinator's session is the one the hook was called for, and the agent of the pane started in the main checkout
|
|
84
|
+
* under no lane's name; the subagent's permission dialog would open in that pane.
|
|
85
|
+
*/
|
|
86
|
+
const holderOf = (
|
|
87
|
+
clientCall: ClientCall,
|
|
88
|
+
agent: PaneAgent,
|
|
89
|
+
main: string,
|
|
90
|
+
worktreeDirectory: string,
|
|
91
|
+
dependencies: GateDependencies,
|
|
92
|
+
): string | null => {
|
|
93
|
+
const lane = laneOfAgent({ agent, session: clientCall.session, main, worktreeDirectory })
|
|
94
|
+
if (lane || !clientCall.subagent) return lane
|
|
95
|
+
const isLane = (name: string) => dependencies.isLaneName(main, name)
|
|
96
|
+
const coordinator = isCoordinatorAgent({ agent, session: clientCall.session, main, isLane })
|
|
97
|
+
return coordinator ? subagentLabel(clientCall.subagent) : null
|
|
98
|
+
}
|
|
99
|
+
|
|
78
100
|
/**
|
|
79
101
|
* The gate for a call that would open a permission dialog in a lane: it records the call as a request the watcher
|
|
80
102
|
* reports, waits for the user's answer to the coordinator's dialog, and allows or denies. The lane is the agent of the
|
|
81
|
-
* pane, never the directory of the call
|
|
82
|
-
* (the multiplexer or git cannot be asked), it
|
|
103
|
+
* pane, never the directory of the call; a subagent of the coordinator is held like one, under its own name. Outside a
|
|
104
|
+
* lane, for a question or a call too large to store, and on any failure (the multiplexer or git cannot be asked), it
|
|
105
|
+
* decides nothing, so the dialog stays.
|
|
83
106
|
*/
|
|
84
107
|
export const runGate = async (
|
|
85
108
|
clientCall: ClientCall,
|
|
@@ -92,7 +115,7 @@ export const runGate = async (
|
|
|
92
115
|
if (agent === null || agent.cwd === null) return null
|
|
93
116
|
const main = dependencies.mainCheckoutOf(agent.cwd)
|
|
94
117
|
const { worktreeDirectory, waitMinutes } = gateSettingsOf(dependencies.config(main))
|
|
95
|
-
const lane =
|
|
118
|
+
const lane = holderOf(clientCall, agent, main, worktreeDirectory, dependencies)
|
|
96
119
|
if (!lane) return null
|
|
97
120
|
const { cwd, tool, input } = clientCall
|
|
98
121
|
const request = openRequest(main, { lane, cwd, tool, input }, dependencies)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** The subagent a hook call comes from: Claude Code's `agent_id` and `agent_type`. */
|
|
2
|
+
export type SubagentCall = { id: string; type: string | null }
|
|
3
|
+
|
|
4
|
+
/** `agent_id` as Claude Code writes it into the subagent's log (`agent-<id>.jsonl`): lower-case hexadecimal. */
|
|
5
|
+
const AGENT_ID = /^[0-9a-f]{8,64}$/
|
|
6
|
+
const AGENT_TYPE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
|
7
|
+
const ID_LENGTH = 8
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The name a subagent of the coordinator is held under, in the place of a lane's name: `subagent-<type>-<id start>`.
|
|
11
|
+
* The type is a plain word or `agent`, so nothing the subagent could choose freely reaches the coordinator's question;
|
|
12
|
+
* null for an id of another shape. The herd derives the same name from the subagent's log (contract test).
|
|
13
|
+
*/
|
|
14
|
+
export const subagentLabel = ({ id, type }: SubagentCall): string | null => {
|
|
15
|
+
if (!AGENT_ID.test(id)) return null
|
|
16
|
+
const word = type !== null && AGENT_TYPE.test(type) ? type : 'agent'
|
|
17
|
+
return `subagent-${word}-${id.slice(0, ID_LENGTH)}`
|
|
18
|
+
}
|
|
@@ -62,6 +62,9 @@ type Finding = { command: string; merge: PlainMerge | undefined; definition?: Sh
|
|
|
62
62
|
/** What the search looks for: the guarded commands, and the shell definitions that may stand for one of them. */
|
|
63
63
|
type Search = { commands: readonly string[]; definitions: () => ShellDefinitions }
|
|
64
64
|
|
|
65
|
+
/** A whole token of a script's text; `joined` when only blanks, quotes, commas and brackets stand before it. */
|
|
66
|
+
type TextToken = { value: string; end: number; joined: boolean }
|
|
67
|
+
|
|
65
68
|
/** The parts of a `gh api` call that decide whether it writes, and where. */
|
|
66
69
|
type ApiCall = { method: Word | undefined; fields: boolean; override: boolean; endpoints: Word[] }
|
|
67
70
|
|
|
@@ -130,6 +133,16 @@ const DANGEROUS_MENTION = /\b(merges?|contents|dispatches|rerun|refs)\b/
|
|
|
130
133
|
const EXPANDED_FUNCTION_NOTE =
|
|
131
134
|
'The program is expanded from a variable or a substitution and may name a shell function that runs one of guard.commands in .jackherd/config.json, so the user confirms it.'
|
|
132
135
|
|
|
136
|
+
/** A run of characters that no quote, comma, bracket, backslash, `+`, blank or shell operator ends. */
|
|
137
|
+
const TEXT_TOKEN = /[^\s'"`,()[\]{}\\+;&|<>]+/g
|
|
138
|
+
/** What may stand between two command words in program text: `"gh", "workflow"`, `"gh workflow " + "run"`, `\"gh`. */
|
|
139
|
+
const WORD_GAP = /^[\s'"`,()[\]{}\\+]*$/
|
|
140
|
+
/**
|
|
141
|
+
* Options and their values a command word may be separated by (`workflow -R o/r run`; gh takes flags before the
|
|
142
|
+
* subcommand, too); more ends the search, so a long run of options cannot stall it.
|
|
143
|
+
*/
|
|
144
|
+
const MAX_TOKENS_BETWEEN_WORDS = 16
|
|
145
|
+
|
|
133
146
|
const PASS: MergeGuardDecision = { kind: 'pass' }
|
|
134
147
|
|
|
135
148
|
/**
|
|
@@ -276,10 +289,10 @@ const apiArguments = (words: SimpleCommand, index: number): Word[] => {
|
|
|
276
289
|
const isGuardedUse = (command: string, words: SimpleCommand, programIndex: number): boolean =>
|
|
277
290
|
command !== API_COMMAND || writesDangerously(apiArguments(words, programIndex))
|
|
278
291
|
|
|
279
|
-
/** In text the parse cannot see, `api` followed by the end of a dangerous endpoint is the sign; the method is unknown. */
|
|
280
|
-
const mentionsDangerousApiCall = (script: string): boolean => {
|
|
281
|
-
const
|
|
282
|
-
return
|
|
292
|
+
/** In text the parse cannot see, the token `api` followed by the end of a dangerous endpoint is the sign; the method is unknown. */
|
|
293
|
+
const mentionsDangerousApiCall = (script: string, tokens: TextToken[]): boolean => {
|
|
294
|
+
const api = tokens.find(({ value }) => value === API_WORD)
|
|
295
|
+
return api !== undefined && DANGEROUS_MENTION.test(script.slice(api.end))
|
|
283
296
|
}
|
|
284
297
|
|
|
285
298
|
/** `sole`: the script is this one simple command, written plainly, the only shape an approval can lift. */
|
|
@@ -314,33 +327,67 @@ const callsOfWord = (words: SimpleCommand, index: number, search: Search, sole:
|
|
|
314
327
|
return guarded.flatMap((command) => findingOf(command, words, index, sole && index === 0) ?? [])
|
|
315
328
|
}
|
|
316
329
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
330
|
+
/**
|
|
331
|
+
* The script cut into whole tokens: a path (`kit/workflow/skills`) or a dotted name (`subprocess.run`) is one token,
|
|
332
|
+
* while quotes, commas, brackets and shell operators end one, so `["gh", "workflow", "run"]` gives three.
|
|
333
|
+
*/
|
|
334
|
+
const textTokens = (script: string): TextToken[] => {
|
|
335
|
+
const tokens: TextToken[] = []
|
|
336
|
+
let previousEnd = 0
|
|
337
|
+
for (const match of script.matchAll(TEXT_TOKEN)) {
|
|
338
|
+
const end = match.index + match[0].length
|
|
339
|
+
const joined = WORD_GAP.test(script.slice(previousEnd, match.index))
|
|
340
|
+
tokens.push({ value: match[0], end, joined })
|
|
341
|
+
previousEnd = end
|
|
322
342
|
}
|
|
323
|
-
return
|
|
343
|
+
return tokens
|
|
324
344
|
}
|
|
325
345
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
346
|
+
const isOptionToken = (value: string): boolean => value.length > 1 && value.startsWith('-')
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Whether the tokens from `start` on spell the words, each joined to the one before: only quotes, commas, brackets and
|
|
350
|
+
* blanks between them, and options such as `-R o/r` that `gh` takes between its command words. From the first option
|
|
351
|
+
* on, any joined token may follow, since brackets and braces cut a value such as `"${REPO}"` or `REPOS[0]` into
|
|
352
|
+
* several tokens; without an option before it, a word between the command words ends the match. A bounded walk, so a
|
|
353
|
+
* long script cannot stall it.
|
|
354
|
+
*/
|
|
355
|
+
const spellsFrom = (tokens: TextToken[], start: number, words: string[]): boolean => {
|
|
356
|
+
let index = start
|
|
329
357
|
for (const word of words) {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
358
|
+
let skipped = 0
|
|
359
|
+
let optionSeen = false
|
|
360
|
+
while (tokens[index]?.joined && tokens[index].value !== word && skipped < MAX_TOKENS_BETWEEN_WORDS) {
|
|
361
|
+
optionSeen ||= isOptionToken(tokens[index].value)
|
|
362
|
+
if (!optionSeen) return false
|
|
363
|
+
skipped++
|
|
364
|
+
index++
|
|
365
|
+
}
|
|
366
|
+
if (!tokens[index]?.joined || tokens[index].value !== word) return false
|
|
367
|
+
index++
|
|
333
368
|
}
|
|
334
369
|
return true
|
|
335
370
|
}
|
|
336
371
|
|
|
337
|
-
/**
|
|
338
|
-
const
|
|
339
|
-
|
|
372
|
+
/** Whether the words stand adjacent somewhere in the tokens, in this order. */
|
|
373
|
+
const spellsWords = (tokens: TextToken[], [first, ...rest]: string[]): boolean =>
|
|
374
|
+
first === undefined || tokens.some(({ value }, index) => value === first && spellsFrom(tokens, index + 1, rest))
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Where the program is hidden or the words are handed over as text, the command words after the program, standing
|
|
378
|
+
* adjacent as whole tokens, are the sign: `$g workflow run` and `["gh", "workflow", "run"]` count, a path segment
|
|
379
|
+
* `workflow` and a `run` elsewhere in the script do not.
|
|
380
|
+
*/
|
|
381
|
+
const mentionedCommandWords = (script: string, commands: readonly string[]): Finding[] => {
|
|
382
|
+
const tokens = textTokens(script)
|
|
383
|
+
return commands
|
|
340
384
|
.filter((command) =>
|
|
341
|
-
command === API_COMMAND
|
|
385
|
+
command === API_COMMAND
|
|
386
|
+
? mentionsDangerousApiCall(script, tokens)
|
|
387
|
+
: spellsWords(tokens, command.split(' ').slice(1)),
|
|
342
388
|
)
|
|
343
389
|
.map((command) => ({ command, merge: undefined }))
|
|
390
|
+
}
|
|
344
391
|
|
|
345
392
|
/** A program expanded from a variable or a substitution could be the guarded one. */
|
|
346
393
|
const runsExpandedProgram = (words: SimpleCommand): boolean =>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// PostToolUse hook of Claude Code for `SendUserFile` and `Artifact`, shipped by jackherd: records each file a session
|
|
2
|
+
// sends to the user and each artifact it publishes for them, with time and caption, in `.jackherd/sent/` of the main
|
|
3
|
+
// checkout. The herd shows the files as badges on the lane they belong to and as something that waits on the user's
|
|
4
|
+
// answer, until the coordinator closes the record. It prints nothing and never blocks.
|
|
5
|
+
import { mainCheckoutOf } from '../shared/checkout'
|
|
6
|
+
import { runSentFile } from './run'
|
|
7
|
+
import { writeSentRecord } from './store'
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
runSentFile(JSON.parse(await Bun.stdin.text()), {
|
|
11
|
+
mainCheckoutOf,
|
|
12
|
+
writeRecord: writeSentRecord,
|
|
13
|
+
now: () => Date.now(),
|
|
14
|
+
randomBytes: () => crypto.getRandomValues(new Uint8Array(6)),
|
|
15
|
+
})
|
|
16
|
+
} catch {
|
|
17
|
+
// Unreadable input records nothing.
|
|
18
|
+
}
|
|
19
|
+
process.exit(0)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from 'node:path'
|
|
2
|
+
import { isRecord } from '../shared/json'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A send of files to the user as the hook records it: the files (absolute), the caption, when it happened, from where
|
|
6
|
+
* (the directory and session of the call, the subagent when one sent it) and when the coordinator closed it, null
|
|
7
|
+
* while the user's answer is awaited. Written by the hook, read by the herd and the watcher.
|
|
8
|
+
*/
|
|
9
|
+
export type SentRecord = {
|
|
10
|
+
id: string
|
|
11
|
+
files: string[]
|
|
12
|
+
caption: string | null
|
|
13
|
+
sentAt: number
|
|
14
|
+
cwd: string
|
|
15
|
+
session: string
|
|
16
|
+
agentId: string | null
|
|
17
|
+
closedAt: number | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type SentCall = {
|
|
21
|
+
/** The tool input of `SendUserFile`: `files`, `caption`, `status`, `display`. */
|
|
22
|
+
toolInput: unknown
|
|
23
|
+
cwd: string
|
|
24
|
+
session: string
|
|
25
|
+
agentId: string | null
|
|
26
|
+
now: number
|
|
27
|
+
id: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ArtifactCall = SentCall & { toolResponse: unknown }
|
|
31
|
+
|
|
32
|
+
const ID = /^[0-9a-f]{12}$/
|
|
33
|
+
const MAX_FILES = 20
|
|
34
|
+
const MAX_CAPTION = 200
|
|
35
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/
|
|
36
|
+
const CONTROLS = new RegExp(CONTROL.source, 'g')
|
|
37
|
+
/** The address of a published artifact: https on claude.ai, the host a person opens. */
|
|
38
|
+
const ARTIFACT_ADDRESS = /https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9_-]+/
|
|
39
|
+
const WHOLE_ARTIFACT_ADDRESS = new RegExp(`^${ARTIFACT_ADDRESS.source}$`)
|
|
40
|
+
/** The actions of the Artifact tool that publish; no action means publish. */
|
|
41
|
+
const READING_ACTIONS = new Set(['list', 'read', 'delete', 'open', 'pin', 'unpin', 'quickstart'])
|
|
42
|
+
const MAX_DEPTH = 4
|
|
43
|
+
|
|
44
|
+
const captionOf = (value: unknown): string | null => {
|
|
45
|
+
if (typeof value !== 'string') return null
|
|
46
|
+
const text = value.replace(CONTROLS, ' ').replace(/\s+/g, ' ').trim()
|
|
47
|
+
return text === '' ? null : text.slice(0, MAX_CAPTION)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A file entry the herd can show and open: a non-empty string without a control character. */
|
|
51
|
+
const isFileEntry = (file: unknown): file is string => typeof file === 'string' && file !== '' && !CONTROL.test(file)
|
|
52
|
+
|
|
53
|
+
/** The fields every record shares: who sent it, when, and still open. */
|
|
54
|
+
const recordOf = (
|
|
55
|
+
{ cwd, session, agentId, now, id }: Omit<SentCall, 'toolInput'>,
|
|
56
|
+
files: string[],
|
|
57
|
+
caption: string | null,
|
|
58
|
+
): SentRecord => ({ id, files, caption, sentAt: now, cwd, session, agentId, closedAt: null })
|
|
59
|
+
|
|
60
|
+
/** The record of a `SendUserFile` call; null when its input names no file or a file with a control character. */
|
|
61
|
+
export const sentRecordOf = ({ toolInput, ...call }: SentCall): SentRecord | null => {
|
|
62
|
+
const files: unknown[] = isRecord(toolInput) && Array.isArray(toolInput.files) ? toolInput.files : []
|
|
63
|
+
if (files.length === 0 || !files.every(isFileEntry)) return null
|
|
64
|
+
const paths = (files as string[])
|
|
65
|
+
.slice(0, MAX_FILES)
|
|
66
|
+
.map((file) => (isAbsolute(file) ? file : resolve(call.cwd, file)))
|
|
67
|
+
return recordOf(call, paths, isRecord(toolInput) ? captionOf(toolInput.caption) : null)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The strings of an answer, wherever it carries them: the answer itself, an object's `url`, `text` and `content`, a
|
|
72
|
+
* list's entries.
|
|
73
|
+
*/
|
|
74
|
+
const stringsOf = (value: unknown, depth = 0): string[] => {
|
|
75
|
+
if (typeof value === 'string') return [value]
|
|
76
|
+
if (depth >= MAX_DEPTH) return []
|
|
77
|
+
if (Array.isArray(value)) return value.flatMap((entry) => stringsOf(entry, depth + 1))
|
|
78
|
+
if (!isRecord(value)) return []
|
|
79
|
+
return [value.url, value.text, value.content].flatMap((entry) => stringsOf(entry, depth + 1))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The first artifact address an answer names, whether it is an object with `url` or text. */
|
|
83
|
+
const artifactAddressIn = (toolResponse: unknown): string | null => {
|
|
84
|
+
for (const text of stringsOf(toolResponse)) {
|
|
85
|
+
const address = ARTIFACT_ADDRESS.exec(text)?.[0]
|
|
86
|
+
if (address) return address
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The title the answer names, else the input's `title` or `label`. */
|
|
92
|
+
const artifactCaptionOf = (toolInput: Record<string, unknown>, toolResponse: unknown): string | null =>
|
|
93
|
+
captionOf(isRecord(toolResponse) ? toolResponse.title : null) ??
|
|
94
|
+
captionOf(toolInput.title) ??
|
|
95
|
+
captionOf(toolInput.label)
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The record of an Artifact publish (or update) as a send: its file is the artifact's address, read from the tool's
|
|
99
|
+
* answer (an object with `url`, or text such as `Published … at <address> (Version 3 …)`), and its caption the
|
|
100
|
+
* artifact's title. Null for a listing, a read, an asset upload and an answer without such an address.
|
|
101
|
+
*/
|
|
102
|
+
export const artifactRecordOf = ({ toolInput, toolResponse, ...call }: ArtifactCall): SentRecord | null => {
|
|
103
|
+
if (!isRecord(toolInput)) return null
|
|
104
|
+
const action = toolInput.action
|
|
105
|
+
if ((typeof action === 'string' && READING_ACTIONS.has(action)) || toolInput.asset === true) return null
|
|
106
|
+
const address = artifactAddressIn(toolResponse)
|
|
107
|
+
if (address === null) return null
|
|
108
|
+
return recordOf(call, [address], artifactCaptionOf(toolInput, toolResponse))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A file entry of a record: an artifact address as a whole, or a path. */
|
|
112
|
+
const isRecordedFile = (file: unknown): boolean =>
|
|
113
|
+
typeof file === 'string' && (!file.startsWith('https://') || WHOLE_ARTIFACT_ADDRESS.test(file))
|
|
114
|
+
|
|
115
|
+
/** A record of the shape the hook writes; the herd reads files only through this. */
|
|
116
|
+
export const isSentRecord = (value: unknown): value is SentRecord =>
|
|
117
|
+
isRecord(value) &&
|
|
118
|
+
typeof value.id === 'string' &&
|
|
119
|
+
ID.test(value.id) &&
|
|
120
|
+
Array.isArray(value.files) &&
|
|
121
|
+
value.files.length > 0 &&
|
|
122
|
+
value.files.every(isRecordedFile) &&
|
|
123
|
+
(value.caption === null || typeof value.caption === 'string') &&
|
|
124
|
+
typeof value.sentAt === 'number' &&
|
|
125
|
+
typeof value.cwd === 'string' &&
|
|
126
|
+
typeof value.session === 'string' &&
|
|
127
|
+
(value.agentId === null || typeof value.agentId === 'string') &&
|
|
128
|
+
(value.closedAt === null || typeof value.closedAt === 'number')
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isRecord } from '../shared/json'
|
|
2
|
+
import { artifactRecordOf, type SentRecord, sentRecordOf } from './record'
|
|
3
|
+
|
|
4
|
+
/** Everything with a side effect, passed in so a test reaches the flow without git, files or a clock. */
|
|
5
|
+
export type SentFileDependencies = {
|
|
6
|
+
mainCheckoutOf: (cwd: string) => string
|
|
7
|
+
writeRecord: (main: string, record: SentRecord) => void
|
|
8
|
+
now: () => number
|
|
9
|
+
randomBytes: () => Uint8Array
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The tools whose calls are recorded: files sent to the user and artifacts published for them. */
|
|
13
|
+
const SEND_USER_FILE_TOOL = 'SendUserFile'
|
|
14
|
+
const ARTIFACT_TOOL = 'Artifact'
|
|
15
|
+
const ID_BYTES = 6
|
|
16
|
+
|
|
17
|
+
/** The id from random bytes the caller draws: 12 hex characters. */
|
|
18
|
+
const idFrom = (bytes: Uint8Array): string =>
|
|
19
|
+
[...bytes.slice(0, ID_BYTES)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Records the files a session sent to the user and the artifacts it published for them, for the herd and the
|
|
23
|
+
* watcher, in the main checkout of the call's directory. Claude Code's hook input: `cwd`, `session_id`,
|
|
24
|
+
* `tool_name`, `tool_input`, and `agent_id` inside a subagent. Records nothing for another tool or a call without
|
|
25
|
+
* files, and nothing on any failure: the record never stands in the agent's way.
|
|
26
|
+
*/
|
|
27
|
+
export const runSentFile = (input: unknown, dependencies: SentFileDependencies): void => {
|
|
28
|
+
try {
|
|
29
|
+
if (!isRecord(input) || (input.tool_name !== SEND_USER_FILE_TOOL && input.tool_name !== ARTIFACT_TOOL)) return
|
|
30
|
+
const { cwd, session_id: session, agent_id: agentId } = input
|
|
31
|
+
if (typeof cwd !== 'string' || typeof session !== 'string') return
|
|
32
|
+
const call = {
|
|
33
|
+
toolInput: input.tool_input,
|
|
34
|
+
cwd,
|
|
35
|
+
session,
|
|
36
|
+
agentId: typeof agentId === 'string' ? agentId : null,
|
|
37
|
+
now: dependencies.now(),
|
|
38
|
+
id: idFrom(dependencies.randomBytes()),
|
|
39
|
+
}
|
|
40
|
+
const record =
|
|
41
|
+
input.tool_name === ARTIFACT_TOOL
|
|
42
|
+
? artifactRecordOf({ ...call, toolResponse: input.tool_response })
|
|
43
|
+
: sentRecordOf(call)
|
|
44
|
+
if (record) dependencies.writeRecord(dependencies.mainCheckoutOf(cwd), record)
|
|
45
|
+
} catch {
|
|
46
|
+
// A failed record is no record; the herd shows no badge.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { chmodSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { ensurePrivateDirectory, PRIVATE_FILE } from '../shared/private-directory'
|
|
4
|
+
import type { SentRecord } from './record'
|
|
5
|
+
|
|
6
|
+
/** Where the records lie: in the main checkout beside `.jackherd/approvals`, which the herd reads. Ignored by git. */
|
|
7
|
+
export const sentDirectory = (main: string): string => join(main, '.jackherd', 'sent')
|
|
8
|
+
|
|
9
|
+
/** Writes the record through a temporary file, so a reader never sees half of it. */
|
|
10
|
+
export const writeSentRecord = (main: string, record: SentRecord): void => {
|
|
11
|
+
const directory = sentDirectory(main)
|
|
12
|
+
ensurePrivateDirectory(directory)
|
|
13
|
+
const path = join(directory, `${record.id}.json`)
|
|
14
|
+
const temporary = `${path}.${process.pid}.tmp`
|
|
15
|
+
writeFileSync(temporary, `${JSON.stringify(record)}\n`, { mode: PRIVATE_FILE })
|
|
16
|
+
chmodSync(temporary, PRIVATE_FILE)
|
|
17
|
+
renameSync(temporary, path)
|
|
18
|
+
}
|
|
@@ -72,6 +72,18 @@ const planPathOf = (boardDirectory: string, cell: string): string[] => {
|
|
|
72
72
|
return href === undefined ? [] : [posix.normalize(posix.join(boardDirectory, href.split('#')[0]))]
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** The rows of one lane since it was last cleaned up: a named row without the mark after a closed one starts it anew. */
|
|
76
|
+
const currentRows = <Row extends { cells: string[] }>(rows: Row[], closedWord: string): Row[] =>
|
|
77
|
+
rows.reduce<{ closed: boolean; kept: Row[] }>(
|
|
78
|
+
({ closed, kept }, row) => {
|
|
79
|
+
const first = row.cells[0] ?? ''
|
|
80
|
+
const marked = first.includes(closedWord)
|
|
81
|
+
const startsAnew = closed && !marked && !first.startsWith(CONTINUATION)
|
|
82
|
+
return { closed: marked || (closed && !startsAnew), kept: startsAnew ? [row] : [...kept, row] }
|
|
83
|
+
},
|
|
84
|
+
{ closed: false, kept: [] },
|
|
85
|
+
).kept
|
|
86
|
+
|
|
75
87
|
/** Every lane the lanes table names, in table order. */
|
|
76
88
|
export const laneNamesOf = (markdown: string, language: BoardLanguage): string[] => [
|
|
77
89
|
...new Set(laneRows(markdown, language).map(({ lane }) => lane)),
|
|
@@ -84,8 +96,9 @@ export const lanePlansOf = (
|
|
|
84
96
|
lane: string,
|
|
85
97
|
language: BoardLanguage,
|
|
86
98
|
): LanePlans | undefined => {
|
|
87
|
-
const
|
|
88
|
-
if (
|
|
99
|
+
const all = laneRows(markdown, language).filter((row) => row.lane === lane)
|
|
100
|
+
if (all.length === 0) return undefined
|
|
101
|
+
const rows = currentRows(all, BOARD_WORDS[language].laneClosed)
|
|
89
102
|
return {
|
|
90
103
|
closed: rows.some(({ cells }) => (cells[0] ?? '').includes(BOARD_WORDS[language].laneClosed)),
|
|
91
104
|
plans: [...new Set(rows.flatMap(({ cells }) => planPathOf(boardDirectory, cells[PLAN_COLUMN] ?? '')))],
|
package/kit/hooks/hooks.json
CHANGED
|
@@ -70,6 +70,15 @@
|
|
|
70
70
|
"timeout": 10
|
|
71
71
|
}
|
|
72
72
|
},
|
|
73
|
+
"sent-file": {
|
|
74
|
+
"event": "PostToolUse",
|
|
75
|
+
"matcher": "SendUserFile|Artifact",
|
|
76
|
+
"handler": {
|
|
77
|
+
"type": "command",
|
|
78
|
+
"command": "cd \"$CLAUDE_PROJECT_DIR\" && bun .claude/hooks/sent-file/index.ts",
|
|
79
|
+
"timeout": 10
|
|
80
|
+
}
|
|
81
|
+
},
|
|
73
82
|
"watch-guard": {
|
|
74
83
|
"event": "PreToolUse",
|
|
75
84
|
"matcher": "Bash",
|
package/kit/hooks/statusline.sh
CHANGED
|
@@ -52,7 +52,7 @@ worktrees='.claude/worktrees'
|
|
|
52
52
|
[ -n "$config" ] && worktrees=$(jq -r '.worktreeDirectory // ".claude/worktrees"' "$config" 2>/dev/null)
|
|
53
53
|
case "${cwd:-$PWD}/" in
|
|
54
54
|
*/"$worktrees"/*) block='.plans.context'; warn_at=250000; alert_at=350000 ;;
|
|
55
|
-
*) block='.plans.context.coordinator'; warn_at=
|
|
55
|
+
*) block='.plans.context.coordinator'; warn_at=350000; alert_at=500000 ;;
|
|
56
56
|
esac
|
|
57
57
|
if [ -n "$config" ]; then
|
|
58
58
|
warn=$(jq -r "$block.warnTokens // empty | floor" "$config" 2>/dev/null)
|
|
@@ -48,6 +48,8 @@ Before you write a single line:
|
|
|
48
48
|
|
|
49
49
|
## Step 2: Implement
|
|
50
50
|
|
|
51
|
+
**Report each task before you start it.** Mark it in progress in your own task list; without one, run `jh task start "<task text>"`, and `jh task done` when it is finished. The herd shows the task you work on from that signal.
|
|
52
|
+
|
|
51
53
|
**Money, identifiers, state transitions: the test comes first** — write it, run it, see it red, then implement. `.claude/rules/kit/testing.md` → Test-first carries the reason.
|
|
52
54
|
|
|
53
55
|
Follow `.claude/rules/kit/code-style.md`: types → pure functions → higher-order functions, names that reveal intent, no section-label comments.
|
|
@@ -89,6 +89,9 @@ Always contains:
|
|
|
89
89
|
- what the lane must not do: push a database migration and any write against a shared environment
|
|
90
90
|
(stage, production) without the user's approval, `git reset` or `git stash` on shared refs
|
|
91
91
|
- "ask before every commit (`/pre-commit`); no push, PR or merge without approval"
|
|
92
|
+
- "before you start a task, mark it in progress in your own task list; without one, run `jh task start
|
|
93
|
+
"<task text>"`, and `jh task done` when it is finished": the herd shows the task you work on from that
|
|
94
|
+
signal
|
|
92
95
|
- "a new phase starts with its skeleton in the plan (heading, origin, tasks, review gate) before any
|
|
93
96
|
research, so the herd never shows it as not in the plan"
|
|
94
97
|
- "the report at the end of each step goes to `.claude/handover/<lane>-report.md` in the worktree,
|
|
@@ -411,13 +414,14 @@ On wake-up:
|
|
|
411
414
|
| `deploy …` | Result and target as the run states them, to the user; a target `unknown (…)` is reported as unknown, never guessed. A failed run goes to the user as an `AskUserQuestion`: read the logs now or later |
|
|
412
415
|
| `due <when> · <action>` | An open appointment fell due (**An agreed time**, below); the action is cut to 160 characters, the board row holds it whole. Carry it out as agreed, under the approvals it needs (§ 5), then write the outcome into its status cell in the same turn |
|
|
413
416
|
| `appointment unreadable <when> · <action>` | An open appointment row whose time cannot be read, or a table without a column the watcher needs (`missing columns: …`). Correct the row or the header in the board; until then the appointment never falls due |
|
|
414
|
-
| `heartbeat` | One line of status to the user, also when nothing changed |
|
|
417
|
+
| `heartbeat` | One line of status to the user, also when nothing changed; a resting lane carries `waits on: <question>, <n> open questions` from its report, and `for you (<n>): …` names what still waits on the user (**Anything that waits on the user's answer**, below): remind the user of those items in that line |
|
|
415
418
|
| `gh unavailable` | Tell the user once; herdr watching continues |
|
|
416
419
|
| `lane <name> warn` | One line to the user: the lane's fill and that it hands over at a phase boundary if it rises further; no step in the lane |
|
|
417
420
|
| `lane <name> alert` | § 8: send the lane once the request for its hand-over at the end of its current phase |
|
|
418
421
|
| `approval <name> <id> <tool> · <call>` | A call of the lane waits for the user's approval (§ 4, Approvals); the duty line names the question's mark and labels |
|
|
419
422
|
| `model <name> <id> · board <column>` | The lane's last answer came from a model its board row's model column does not name (every model the column names fits). Switch it while it is idle (§ 2 step 6). A column that is wrong goes to the user instead. The watcher reports a deviation once and again only when the model changes |
|
|
420
423
|
| `parallel <name> P<a> ∥ P<b>` | The lane's plan has ready phases whose `{{kit.heading.writes}}` sets do not overlap and that share no `{{kit.heading.serialMarker}}`, read from the plan's `{{kit.heading.dependencies}}` table and its ticks. Propose them to the user in one line, as parallel lanes (§ 2) or as parallel subagents in one lane (`/implement` Step 0b); nothing starts on the event alone. Before proposing, check what the table cannot see: two phases that collide in content stay your judgement. The watcher reports a set once and again only when the set changes |
|
|
424
|
+
| `parallel tasks <name>: "<a>" ∥ "<b>" · ~<n>k tokens for <m> extra agents` | Open tasks that name paths overlapping neither each other nor what the running agents wrote lately or report (your own subagents in the lane's worktree count as running), at a cost under `watch.parallelTasks.maxTokens`; one free task beside a running agent counts. Once per changed situation, at a heartbeat. Nothing starts on the line. Put it to the user at once, never into an open dialog (right after it), the user's note read first: at most three plain lines (which tasks, why they do not collide, the cost: model and token baseline of a new agent, and what they wait for otherwise; no invented time saving), then one `AskUserQuestion` of at most 15 words, "start in parallel" (recommended only under the threshold; `Parallel starten` / `Nacheinander lassen` for a German user) or "keep in sequence". Until the answer it is a `?` row in the user's answers section (**Anything that waits on the user's answer**). No second proposal for the same situation after "keep in sequence". Two tasks that collide in content stay your judgement |
|
|
421
425
|
| `coordinator warn` / `coordinator alert` | § 8, for yourself |
|
|
422
426
|
| `lane added: <name>` | Nothing: the lane is watched. Check that it has its row (§ 2 step 5); a name `<lane>-<purpose>` without a worktree is a helper agent of that lane (§ 1) and needs no row. One the lane did not announce goes to the user |
|
|
423
427
|
| `lane removed: <name>` | Nothing after you closed it (§ 7). Otherwise its agent exited or its worktree is gone: read why, then tell the user |
|
|
@@ -454,6 +458,20 @@ watcher reports an open row once when it falls due (`due`, below); an empty stat
|
|
|
454
458
|
and a reworded or moved row is a new appointment and reports again. Close a reported row through its
|
|
455
459
|
status cell.
|
|
456
460
|
|
|
461
|
+
**Anything that waits on the user's answer** (a preview or a file sent to them, a question put to them, a
|
|
462
|
+
report handed over for acceptance) goes into the board at once, in the section `## Wartet auf dich` (German
|
|
463
|
+
plans) or `## Waiting for you` (English), one row `Since | Item | Origin | Status` (`Seit | Punkt | Herkunft |
|
|
464
|
+
Stand`) with the time as in **An agreed time**. The section survives a hand-over, which a message in this chat
|
|
465
|
+
does not. Enter the row in the same turn you send the thing, not when you notice it was missed. The herd shows
|
|
466
|
+
every open row on top as `for you`, and the watcher repeats them in each heartbeat and status until the
|
|
467
|
+
row is closed. Close a row in its status cell in the turn the user's answer arrives (also when the answer is "no"),
|
|
468
|
+
or when the thing is withdrawn; an empty status counts as open. A file sent with `SendUserFile` and an Artifact you publish need no row of your own: the kit hook
|
|
469
|
+
`sent-file` records each in `.jackherd/sent/`, and the herd, the heartbeat and the status line list the open
|
|
470
|
+
ones next to the board's rows (by file name, without an id). The herd also shows them as 📎 badges on the lane
|
|
471
|
+
they belong to. Close them after the answer with `jh waiting close <id>` (or `--all`); `jh waiting` lists
|
|
472
|
+
what is open, with the ids. A question that is answered in a dialog needs no row, since the dialog is the wait. A watcher event or a lane's report is no row either, unless the answer to it is
|
|
473
|
+
the user's.
|
|
474
|
+
|
|
457
475
|
**A new idea during a release** (the user's or your own, while a plan's phases run toward one
|
|
458
476
|
release) is a line in the plan's `## Follow-ups` section, with its origin, and one line to the user:
|
|
459
477
|
no dialog, no new phase. It enters the release at once only when the release would otherwise be
|
|
@@ -522,15 +540,21 @@ Question: Retry the failed check, or leave it?
|
|
|
522
540
|
```
|
|
523
541
|
|
|
524
542
|
**A draft for review.** A lane's draft that the user reviews (a README, a page, a plan) goes in
|
|
525
|
-
rounds. Show it where the user can read it
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
543
|
+
rounds. Show it where the user can read it. When this session has the `Artifact` tool and `preview.publish`
|
|
544
|
+
in `.jackherd/config.json` is not `false` (default `true`), render the draft with `jh preview <file>`
|
|
545
|
+
(GitHub and npm side by side, external images embedded) and publish that page as a private Artifact; every
|
|
546
|
+
later round updates the same URL (`url` on the publish call), so the user keeps one link. Without the tool
|
|
547
|
+
(another provider, Codex) or with `preview.publish` off, send the page as a file card (`SendUserFile`) or
|
|
548
|
+
tell the user to run `jh preview --open`; a bare file path is the last resort. Ask through
|
|
549
|
+
`AskUserQuestion` with `multiSelect`, one option per remark, send the chosen remarks to the lane, and
|
|
550
|
+
show the same place again once the lane has changed it. The push waits for the approval of the last round.
|
|
529
551
|
|
|
530
552
|
**Approvals.** In a lane, a call that would open a permission dialog (an ask rule) is held by the
|
|
531
553
|
approval gate hook (a project install ships it; a global install has none, and the dialog stays in
|
|
532
554
|
the lane). The hook records the call, the watcher reports `approval <lane> <id> <tool> · <call>`, and
|
|
533
|
-
it waits up to `guard.approvals.waitMinutes` (default 8) for the user's answer.
|
|
555
|
+
it waits up to `guard.approvals.waitMinutes` (default 8) for the user's answer. A subagent of your own
|
|
556
|
+
session (an `implement` run you started) is held the same way, under the name `subagent-<type>-<id>`
|
|
557
|
+
instead of a lane's; its dialog would otherwise open in your pane and go unnoticed. Ask at once, one
|
|
534
558
|
question per request, through `AskUserQuestion`:
|
|
535
559
|
|
|
536
560
|
- First read the request, `.jackherd/approvals/<id>.json` in the main checkout, and show its `tool`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jackherd",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"keywords": [
|
|
5
5
|
"claude-code",
|
|
6
6
|
"coding-agents",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"!docs/gen",
|
|
49
49
|
"assets",
|
|
50
50
|
"!assets/icons/build.ts",
|
|
51
|
+
"!assets/icons/version.ts",
|
|
51
52
|
"!assets/branding/outline.ts",
|
|
52
53
|
"recipes",
|
|
53
54
|
"README.md",
|