jackherd 1.14.0 → 1.15.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 +15 -0
- package/README.md +27 -19
- package/assets/icons/skills.svg +1 -0
- package/dist/config.d.ts +9 -1
- package/dist/config.js +24 -1
- package/dist/plan-format.d.ts +24 -1
- package/dist/plan-format.js +25 -5
- package/docs/plan-contract.md +84 -0
- package/docs/skills-dark.svg +15 -2
- package/docs/skills.svg +15 -2
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/herdr-plugin/restore-plans.ts +56 -6
- package/install.sh +1 -1
- package/kit/hooks/hooks/merge-guard/board-settings.ts +5 -2
- package/kit/hooks/hooks/merge-guard/pull-request.ts +28 -15
- package/kit/hooks/hooks/merge-guard/run.ts +6 -3
- package/kit/hooks/hooks/merge-guard/settings.ts +12 -1
- package/kit/hooks/hooks/model-log/transcript.ts +4 -7
- package/kit/hooks/hooks/open-question/record.ts +4 -3
- package/kit/hooks/hooks/shared/dialog-answers.ts +7 -3
- package/kit/hooks/hooks/shared/log-vocabulary.ts +13 -0
- package/kit/hooks/hooks/shared/transcript.ts +7 -4
- package/kit/hooks/hooks/task-hint/decision.ts +3 -2
- package/kit/hooks/hooks/task-hint/run.ts +2 -4
- package/kit/rules/rules/kit/pull-requests.md +1 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +22 -9
- package/kit/workflow/skills/orchestrate/handover/index.ts +8 -2
- package/kit/workflow/skills/orchestrate/references/adopt-plan.md +51 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +5 -5
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +19 -0
- package/src/check/command.ts +1 -1
- package/src/check/file-kinds.ts +10 -8
- package/src/check/parallel-report.ts +10 -3
- package/src/check/settings.ts +4 -0
- package/src/check/skill-budget-defaults.ts +17 -5
- package/src/check/skill-budgets.ts +49 -19
- package/src/claude/paths.ts +12 -0
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +27 -0
- package/src/context/fill.ts +2 -1
- package/src/context/log-vocabulary.ts +22 -0
- package/src/context/session-log.ts +2 -1
- package/src/lane-prompt/build.ts +10 -5
- package/src/lane-prompt/command.ts +6 -1
- package/src/lane-prompt/input.ts +4 -4
- package/src/lane-prompt/texts.ts +18 -10
- package/src/plan-contract/command.ts +69 -0
- package/src/plan-contract/contract.ts +144 -0
- package/src/plan-format/dependencies.ts +1 -4
- package/src/plan-format/index.ts +4 -0
- package/src/plan-format/lanes.ts +11 -0
- package/src/plan-format/markdown.ts +6 -5
- package/src/plan-format/model-table.ts +27 -0
- package/src/plans/activity-read.ts +98 -0
- package/src/plans/ask-coordinator.ts +20 -11
- package/src/plans/layout.ts +49 -4
- package/src/plans/live.ts +17 -4
- package/src/plans/plan-panes.ts +25 -5
- package/src/plans/snapshot.ts +18 -8
- package/src/plans/subagents-read.ts +4 -3
- package/src/plans/subagents.ts +2 -1
- package/src/plans/texts.ts +27 -1
- package/src/settled/send.ts +80 -4
- package/src/shared/parse-json.ts +8 -0
- package/src/sync/command.ts +17 -10
- package/src/sync/foreign-skills.ts +40 -0
- package/src/sync/global-kit.ts +31 -21
- package/src/sync/init.ts +2 -2
- package/src/sync/kit-exclude.ts +39 -0
- package/src/sync/plan-file.ts +1 -1
- package/src/sync/render.ts +2 -1
- package/src/sync/report.ts +37 -2
- package/src/sync/sync-result.ts +5 -2
- package/src/sync/sync.ts +13 -5
- package/src/task-signals/claude-signals.ts +6 -5
- package/src/watch/command.ts +56 -17
- package/src/watch/lane-message.ts +1 -1
- package/src/watch/lane-progress.ts +51 -2
- package/src/watch/lane-report.ts +57 -2
- package/src/watch/lane-watch.ts +2 -8
- package/src/watch/task-parallel-touched.ts +9 -2
- package/src/watch/texts.ts +4 -0
|
@@ -7,11 +7,13 @@ type Limits = { attempts: number; intervalMs: number }
|
|
|
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
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
|
-
* goes away between the two reads.
|
|
10
|
+
* goes away between the two reads. The coordinator gets a minute: after a crash its session was still not back after
|
|
11
|
+
* ~8 s, and the herd beside it stayed missing.
|
|
11
12
|
*/
|
|
12
13
|
export const RESTORE_LIMITS = {
|
|
13
14
|
panes: { attempts: 20, intervalMs: 500 },
|
|
14
15
|
ensure: { attempts: 4, intervalMs: 2_000 },
|
|
16
|
+
coordinator: { attempts: 30, intervalMs: 2_000 },
|
|
15
17
|
} as const satisfies Record<string, Limits>
|
|
16
18
|
|
|
17
19
|
/** Runs `attempt` until it works or the attempts are used up, waiting the interval between two of them. */
|
|
@@ -23,6 +25,31 @@ export const retried = (attempt: () => boolean, limits: Limits, sleep: (ms: numb
|
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Waits for every item in one shared window of `limits`, calls `onReady` for each as soon as `isReady` holds, and
|
|
30
|
+
* returns the items that never got ready.
|
|
31
|
+
*/
|
|
32
|
+
export const awaitEach = <T>(
|
|
33
|
+
items: readonly T[],
|
|
34
|
+
isReady: (item: T) => boolean,
|
|
35
|
+
onReady: (item: T) => void,
|
|
36
|
+
limits: Limits,
|
|
37
|
+
sleep: (ms: number) => void,
|
|
38
|
+
): T[] => {
|
|
39
|
+
let pending = [...items]
|
|
40
|
+
retried(
|
|
41
|
+
() => {
|
|
42
|
+
const ready = pending.filter(isReady)
|
|
43
|
+
for (const item of ready) onReady(item)
|
|
44
|
+
pending = pending.filter((item) => !ready.includes(item))
|
|
45
|
+
return pending.length === 0
|
|
46
|
+
},
|
|
47
|
+
limits,
|
|
48
|
+
sleep,
|
|
49
|
+
)
|
|
50
|
+
return pending
|
|
51
|
+
}
|
|
52
|
+
|
|
26
53
|
/**
|
|
27
54
|
* The directories of the panes an agent runs in, each once, from a `herdr pane list` answer; none when unreadable.
|
|
28
55
|
* `agentPanes` are the panes of `herdr agent list`: right after a restart the pane list names no agent there for a
|
|
@@ -43,6 +70,10 @@ export const agentDirectoriesOf = (json: string, agentPanes: readonly string[]):
|
|
|
43
70
|
}
|
|
44
71
|
}
|
|
45
72
|
|
|
73
|
+
/** Whether an agent runs in `root` itself, where the coordinator works, rather than only in its lanes' worktrees. */
|
|
74
|
+
export const hasAgentIn = (root: string, paneList: string, agentPanes: readonly string[]): boolean =>
|
|
75
|
+
agentDirectoriesOf(paneList, agentPanes).includes(root)
|
|
76
|
+
|
|
46
77
|
/** The arguments for `bun` that run `herd --ensure` with the CLI of the package this plugin ships in. */
|
|
47
78
|
export const ensureArguments = (pluginRoot: string): string[] => [
|
|
48
79
|
join(dirname(pluginRoot), 'src', 'cli.ts'),
|
|
@@ -80,13 +111,33 @@ const panesAnswer = (panes: readonly string[]): boolean =>
|
|
|
80
111
|
const ensureIn = (root: string, pluginRoot: string): boolean =>
|
|
81
112
|
spawnSync('bun', ensureArguments(pluginRoot), { cwd: root, stdio: 'inherit' }).status === 0
|
|
82
113
|
|
|
114
|
+
const coordinatorBackIn = (root: string): boolean =>
|
|
115
|
+
hasAgentIn(root, herdrRun(['pane', 'list']).stdout ?? '', idsOf(herdrRun(['agent', 'list']).stdout ?? '', 'agents'))
|
|
116
|
+
|
|
117
|
+
const ensureWithRetries = (root: string, sleep: (ms: number) => void) => {
|
|
118
|
+
if (!retried(() => ensureIn(root, import.meta.dir), RESTORE_LIMITS.ensure, sleep))
|
|
119
|
+
console.error(`restore-plans: herd --ensure failed in ${root}`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The herd beside each coordinator once its session is back, which can take longer than the lanes'. A coordinator
|
|
124
|
+
* already back had its herd from the first `--ensure`; the others share one waiting window.
|
|
125
|
+
*/
|
|
126
|
+
const ensureCoordinators = (roots: readonly string[], sleep: (ms: number) => void) => {
|
|
127
|
+
const missing = roots.filter((root) => !coordinatorBackIn(root))
|
|
128
|
+
const ensure = (root: string) => ensureWithRetries(root, sleep)
|
|
129
|
+
for (const root of awaitEach(missing, coordinatorBackIn, ensure, RESTORE_LIMITS.coordinator, sleep))
|
|
130
|
+
console.error(`restore-plans: no coordinator came back in ${root}`)
|
|
131
|
+
}
|
|
132
|
+
|
|
83
133
|
/**
|
|
84
134
|
* herdr's `[[startup]]` hook: after a server start or a live handoff, brings back the herd panes of every
|
|
85
135
|
* repository an agent runs in. It parses herdr's answers itself and never throws, unlike the strict parsers of
|
|
86
136
|
* `src/plans/plan-panes.ts`: a hook that fails leaves nothing behind but a log line, and `herd --ensure`, which it
|
|
87
137
|
* 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 `herd --ensure` within `RESTORE_LIMITS`. A repository
|
|
89
|
-
*
|
|
138
|
+
* herdr answers for every pane and retries `herd --ensure` within `RESTORE_LIMITS`. A repository whose coordinator
|
|
139
|
+
* is not back yet gets one more `herd --ensure` once an agent runs in its main checkout. A repository without a
|
|
140
|
+
* board or config only prints why and is skipped.
|
|
90
141
|
*/
|
|
91
142
|
const main = (): number => {
|
|
92
143
|
const list = herdrRun(['pane', 'list'])
|
|
@@ -98,9 +149,8 @@ const main = (): number => {
|
|
|
98
149
|
const sleep = (ms: number) => Bun.sleepSync(ms)
|
|
99
150
|
if (!retried(() => panesAnswer(idsOf(listing, 'panes')), RESTORE_LIMITS.panes, sleep))
|
|
100
151
|
console.error('restore-plans: herdr did not answer for every pane in time, trying anyway')
|
|
101
|
-
for (const root of roots)
|
|
102
|
-
|
|
103
|
-
console.error(`restore-plans: herd --ensure failed in ${root}`)
|
|
152
|
+
for (const root of roots) ensureWithRetries(root, sleep)
|
|
153
|
+
ensureCoordinators([...roots], sleep)
|
|
104
154
|
return 0
|
|
105
155
|
}
|
|
106
156
|
|
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.15.0}"
|
|
13
13
|
|
|
14
14
|
JACKHERD_PACKAGE='jackherd'
|
|
15
15
|
# Test seam: the terminal the wizard asks its questions on.
|
|
@@ -39,8 +39,11 @@ const sectionsAreObjects = (config: Record<string, unknown>): boolean =>
|
|
|
39
39
|
isSectionOrAbsent(sectionAt(config, 'guard'), 'planApproval') &&
|
|
40
40
|
isSectionOrAbsent(config, 'board')
|
|
41
41
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
42
|
+
/**
|
|
43
|
+
* `guard.requiredChecks`, else where configs before 1.10.0 kept it, `guard.planApproval.requiredChecks`: `{ value }`,
|
|
44
|
+
* with `value` undefined when neither is set, and `undefined` for a list that does not read.
|
|
45
|
+
*/
|
|
46
|
+
export const requiredChecksOf = (guard: Record<string, unknown>): { value: readonly string[] | undefined } | undefined =>
|
|
44
47
|
'requiredChecks' in guard
|
|
45
48
|
? optionalValue(guard, 'requiredChecks', isCheckList)
|
|
46
49
|
: optionalValue(sectionAt(guard, 'planApproval'), 'requiredChecks', isCheckList)
|
|
@@ -13,6 +13,7 @@ type PullRequestView = {
|
|
|
13
13
|
headRefOid?: unknown
|
|
14
14
|
baseRefName?: unknown
|
|
15
15
|
isCrossRepository?: unknown
|
|
16
|
+
isDraft?: unknown
|
|
16
17
|
state?: unknown
|
|
17
18
|
statusCheckRollup?: unknown
|
|
18
19
|
mergeable?: unknown
|
|
@@ -20,10 +21,17 @@ type PullRequestView = {
|
|
|
20
21
|
changedFiles?: unknown
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* A check of the rollup: `green` when it does not stand in a merge's way, `succeeded` only for a check run that
|
|
26
|
+
* concluded success; a status never succeeds here.
|
|
27
|
+
*/
|
|
28
|
+
type RollupCheck = { name: string; green: boolean; succeeded: boolean }
|
|
29
|
+
|
|
23
30
|
/** Where a pull request lives and how many files it changes, as GitHub's `pr view` says. */
|
|
24
31
|
export type RepositoryFacts = { repository: string; changedFiles: number }
|
|
25
32
|
|
|
26
|
-
const VIEW_FIELDS =
|
|
33
|
+
const VIEW_FIELDS =
|
|
34
|
+
'headRefName,headRefOid,baseRefName,isCrossRepository,isDraft,state,url,changedFiles,statusCheckRollup,mergeable'
|
|
27
35
|
const PULL_REQUEST_URL = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/pull\/[1-9]\d*$/
|
|
28
36
|
/** The conclusions of a finished check run that do not stand in a merge's way. */
|
|
29
37
|
const GREEN_CONCLUSIONS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
|
|
@@ -31,24 +39,27 @@ const GREEN_CONCLUSIONS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
|
|
|
31
39
|
const viewOf = (json: string): PullRequestView | undefined => parseJsonObject(json)
|
|
32
40
|
|
|
33
41
|
/**
|
|
34
|
-
* A check of the rollup by its name, whether it is green, and whether it
|
|
35
|
-
* or an app, while a status anyone with push access can set through the API.
|
|
42
|
+
* A check of the rollup by its name, whether it is green, and whether it succeeded as a check run: a run comes from a
|
|
43
|
+
* workflow or an app, while a status anyone with push access can set through the API.
|
|
36
44
|
*/
|
|
37
|
-
const checkOf = (entry: unknown):
|
|
45
|
+
const checkOf = (entry: unknown): RollupCheck | undefined => {
|
|
38
46
|
if (typeof entry !== 'object' || entry === null) return undefined
|
|
39
47
|
const { __typename, name, status, conclusion, context, state } = entry as Record<string, unknown>
|
|
40
48
|
if (__typename === 'CheckRun' && typeof name === 'string') {
|
|
41
|
-
const
|
|
42
|
-
return { name, green,
|
|
49
|
+
const finished = status === 'COMPLETED' && typeof conclusion === 'string'
|
|
50
|
+
return { name, green: finished && GREEN_CONCLUSIONS.has(conclusion), succeeded: finished && conclusion === 'SUCCESS' }
|
|
43
51
|
}
|
|
44
|
-
if (__typename === 'StatusContext' && typeof context === 'string') return { name: context, green: state === 'SUCCESS',
|
|
52
|
+
if (__typename === 'StatusContext' && typeof context === 'string') return { name: context, green: state === 'SUCCESS', succeeded: false }
|
|
45
53
|
return undefined
|
|
46
54
|
}
|
|
47
55
|
|
|
48
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* The head branch of an open, ready pull request from this repository; a fork's branch name proves nothing, and a
|
|
58
|
+
* draft is never merged, nor one whose draft flag GitHub did not answer.
|
|
59
|
+
*/
|
|
49
60
|
export const parseHeadBranch = (json: string): string | undefined => {
|
|
50
|
-
const { headRefName, isCrossRepository, state } = viewOf(json) ?? {}
|
|
51
|
-
const ours = state === 'OPEN' && isCrossRepository === false
|
|
61
|
+
const { headRefName, isCrossRepository, isDraft, state } = viewOf(json) ?? {}
|
|
62
|
+
const ours = state === 'OPEN' && isCrossRepository === false && isDraft === false
|
|
52
63
|
return ours && typeof headRefName === 'string' ? headRefName : undefined
|
|
53
64
|
}
|
|
54
65
|
|
|
@@ -60,9 +71,11 @@ export const parseBaseBranch = (json: string): string | undefined => {
|
|
|
60
71
|
|
|
61
72
|
/**
|
|
62
73
|
* Whether the head commit's checks let a merge pass without a question: GitHub can merge it, it has at least one check
|
|
63
|
-
* run, every check is green (a run finished as success, neutral or skipped, a status succeeded), and
|
|
64
|
-
* check
|
|
65
|
-
* so it does not pass; neither does a list of statuses alone, which an agent could set itself in
|
|
74
|
+
* run that succeeded, every check is green (a run finished as success, neutral or skipped, a status succeeded), and
|
|
75
|
+
* every required check has a run that succeeded. An empty list is what a pull request shows right after a push, before
|
|
76
|
+
* any workflow started, so it does not pass; neither does a list of statuses alone, which an agent could set itself in
|
|
77
|
+
* that moment, nor skipped runs alone or a required check that was only skipped, as a draft's runs stand right after
|
|
78
|
+
* `gh pr ready`.
|
|
66
79
|
*/
|
|
67
80
|
export const checksPassed = (json: string, requiredChecks: readonly string[] | undefined): boolean => {
|
|
68
81
|
const view = viewOf(json)
|
|
@@ -70,8 +83,8 @@ export const checksPassed = (json: string, requiredChecks: readonly string[] | u
|
|
|
70
83
|
if (view?.mergeable !== 'MERGEABLE' || !Array.isArray(rollup)) return false
|
|
71
84
|
const checks = rollup.map(checkOf)
|
|
72
85
|
if (!checks.every((check) => check?.green === true)) return false
|
|
73
|
-
const
|
|
74
|
-
return
|
|
86
|
+
const succeeded = new Set(checks.flatMap((check) => (check?.succeeded ? [check.name] : [])))
|
|
87
|
+
return succeeded.size > 0 && (requiredChecks ?? []).every((name) => succeeded.has(name))
|
|
75
88
|
}
|
|
76
89
|
|
|
77
90
|
/** The commit the pull request's head points at, as GitHub says now; what a `--match-head-commit` must name. */
|
|
@@ -41,6 +41,8 @@ type StandingApproval = (facts: MergeFacts) => boolean | MergeNote
|
|
|
41
41
|
/** The ways a merge may pass without a question. */
|
|
42
42
|
type MergeApprovals = {
|
|
43
43
|
branches: ReadonlySet<string>
|
|
44
|
+
/** The checks a self-merge of an approved branch waits for a successful run of. */
|
|
45
|
+
requiredChecks: readonly string[] | undefined
|
|
44
46
|
withoutChecks: readonly string[]
|
|
45
47
|
/** The paths that always ask, which a `withoutChecks` pattern never opens. */
|
|
46
48
|
alwaysAsk: readonly string[]
|
|
@@ -64,13 +66,13 @@ const approvedBranchesFrom = (entries: unknown[], settings: GuardSettings, label
|
|
|
64
66
|
* is not merged unchecked. A standing approval that does not cover a file answers with a note for the question.
|
|
65
67
|
*/
|
|
66
68
|
const approvesMerge = (
|
|
67
|
-
{ branches, withoutChecks, alwaysAsk, standing }: MergeApprovals,
|
|
69
|
+
{ branches, requiredChecks, withoutChecks, alwaysAsk, standing }: MergeApprovals,
|
|
68
70
|
merge: PlainMerge,
|
|
69
71
|
facts: MergeFacts,
|
|
70
72
|
): boolean | MergeNote => {
|
|
71
73
|
const { headCommit } = facts
|
|
72
74
|
if (headCommit === undefined || merge.headCommit !== headCommit) return false
|
|
73
|
-
if (branches.has(facts.branch) && checksPassed(facts.view,
|
|
75
|
+
if (branches.has(facts.branch) && checksPassed(facts.view, requiredChecks)) return true
|
|
74
76
|
if (withoutChecks.length > 0 && isCheckFree(facts.files() ?? [], withoutChecks, alwaysAsk)) return true
|
|
75
77
|
return standing === undefined ? false : standing(facts)
|
|
76
78
|
}
|
|
@@ -143,12 +145,13 @@ const policyFor = (input: HookInput, labels: ApprovalLabels, projectDirectory: s
|
|
|
143
145
|
if (!isWithinDirectory(directory, projectDirectory)) return false
|
|
144
146
|
const approvals = {
|
|
145
147
|
branches: branches(),
|
|
148
|
+
requiredChecks: settings.requiredChecks,
|
|
146
149
|
withoutChecks: settings.withoutChecks,
|
|
147
150
|
alwaysAsk: [...ALWAYS_ASK_PATHS, ...settings.alwaysAsk],
|
|
148
151
|
standing: standing(),
|
|
149
152
|
}
|
|
150
153
|
if (approvals.branches.size === 0 && approvals.withoutChecks.length === 0 && approvals.standing === undefined) return false
|
|
151
|
-
// The branch lookup also proves the pull request is open and from this repository; every path needs that.
|
|
154
|
+
// The branch lookup also proves the pull request is open, no draft and from this repository; every path needs that.
|
|
152
155
|
const facts = mergeFactsOf(merge.pullRequest, viewPullRequest(merge.pullRequest, directory) ?? '', directory)
|
|
153
156
|
return facts !== undefined && approvesMerge(approvals, merge, facts)
|
|
154
157
|
},
|
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
2
2
|
import { homedir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { isRecord } from '../shared/json'
|
|
5
|
+
import { requiredChecksOf } from './board-settings'
|
|
5
6
|
|
|
6
7
|
export type GuardSettings = {
|
|
7
8
|
commands: string[]
|
|
@@ -13,6 +14,11 @@ export type GuardSettings = {
|
|
|
13
14
|
* before 1.10.0); they only ever add a question.
|
|
14
15
|
*/
|
|
15
16
|
alwaysAsk: string[]
|
|
17
|
+
/**
|
|
18
|
+
* `guard.requiredChecks`: the checks a self-merge waits for a successful run of; undefined: none named, or a list
|
|
19
|
+
* that does not read, which turns `selfMerge.offer` off.
|
|
20
|
+
*/
|
|
21
|
+
requiredChecks: readonly string[] | undefined
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
export const DEFAULT_SETTINGS: GuardSettings = {
|
|
@@ -20,6 +26,7 @@ export const DEFAULT_SETTINGS: GuardSettings = {
|
|
|
20
26
|
selfMerge: { offer: true, approvalMinutes: 180 },
|
|
21
27
|
withoutChecks: [],
|
|
22
28
|
alwaysAsk: [],
|
|
29
|
+
requiredChecks: undefined,
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
const CONFIG_PATH = join('.jackherd', 'config.json')
|
|
@@ -63,6 +70,7 @@ const pathsOf = (value: unknown): string[] =>
|
|
|
63
70
|
/**
|
|
64
71
|
* The guard settings of the project's `.jackherd/config.json`, else of the home one. Whatever is missing or
|
|
65
72
|
* malformed keeps its default, so a broken config guards more, never less; only an empty list switches the guard off.
|
|
73
|
+
* A `requiredChecks` list that does not read has no default that guards more, so it turns the self-merge answer off.
|
|
66
74
|
*/
|
|
67
75
|
export const readGuardSettings = (projectDirectory: string, home: string = homedir()): GuardSettings => {
|
|
68
76
|
const config = readConfig([projectDirectory, home])
|
|
@@ -70,15 +78,18 @@ export const readGuardSettings = (projectDirectory: string, home: string = homed
|
|
|
70
78
|
const merge = isRecord(config.merge) ? config.merge : {}
|
|
71
79
|
const selfMerge = isRecord(section.selfMerge) ? section.selfMerge : {}
|
|
72
80
|
const legacy = isRecord(section.planApproval) ? section.planApproval : {}
|
|
81
|
+
const readableRequiredChecks = requiredChecksOf(section)
|
|
82
|
+
const offer = typeof selfMerge.offer === 'boolean' ? selfMerge.offer : DEFAULT_SETTINGS.selfMerge.offer
|
|
73
83
|
return {
|
|
74
84
|
commands: isCommandList(section.commands) ? section.commands : DEFAULT_SETTINGS.commands,
|
|
75
85
|
selfMerge: {
|
|
76
|
-
offer:
|
|
86
|
+
offer: offer && readableRequiredChecks !== undefined,
|
|
77
87
|
approvalMinutes: isMinutes(selfMerge.approvalMinutes)
|
|
78
88
|
? selfMerge.approvalMinutes
|
|
79
89
|
: DEFAULT_SETTINGS.selfMerge.approvalMinutes,
|
|
80
90
|
},
|
|
81
91
|
withoutChecks: isPatternList(merge.withoutChecks) ? merge.withoutChecks : DEFAULT_SETTINGS.withoutChecks,
|
|
82
92
|
alwaysAsk: [...pathsOf(section.alwaysAsk), ...pathsOf(legacy.exceptPaths)],
|
|
93
|
+
requiredChecks: readableRequiredChecks?.value,
|
|
83
94
|
}
|
|
84
95
|
}
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { parseTranscript } from '../shared/dialog-answers'
|
|
2
2
|
import { isRecord, type JsonObject } from '../shared/json'
|
|
3
|
+
import { CONTENT_BLOCK_TYPE, LOG_ENTRY_TYPE } from '../shared/log-vocabulary'
|
|
3
4
|
import { familyOf, type ModelFamily } from '../shared/model-family'
|
|
4
5
|
|
|
5
|
-
const USER_ENTRY = 'user'
|
|
6
|
-
const ASSISTANT_ENTRY = 'assistant'
|
|
7
|
-
const TEXT_BLOCK = 'text'
|
|
8
|
-
|
|
9
6
|
const messageOf = (entry: unknown): JsonObject | null =>
|
|
10
7
|
isRecord(entry) && isRecord(entry.message) ? entry.message : null
|
|
11
8
|
|
|
@@ -13,14 +10,14 @@ const textOf = (content: unknown): string | null => {
|
|
|
13
10
|
if (typeof content === 'string') return content
|
|
14
11
|
if (!Array.isArray(content)) return null
|
|
15
12
|
const texts = content.flatMap((block) =>
|
|
16
|
-
isRecord(block) && block.type ===
|
|
13
|
+
isRecord(block) && block.type === CONTENT_BLOCK_TYPE.text && typeof block.text === 'string' ? [block.text] : [],
|
|
17
14
|
)
|
|
18
15
|
return texts.length > 0 ? texts.join('\n') : null
|
|
19
16
|
}
|
|
20
17
|
|
|
21
18
|
/** The subagent's prompt: the text of the first user entry of its transcript; null without one. */
|
|
22
19
|
export const promptOf = (head: string): string | null => {
|
|
23
|
-
const first = parseTranscript(head).find((entry) => isRecord(entry) && entry.type ===
|
|
20
|
+
const first = parseTranscript(head).find((entry) => isRecord(entry) && entry.type === LOG_ENTRY_TYPE.user)
|
|
24
21
|
return textOf(messageOf(first)?.content)
|
|
25
22
|
}
|
|
26
23
|
|
|
@@ -28,7 +25,7 @@ export const promptOf = (head: string): string | null => {
|
|
|
28
25
|
export type AnsweredModel = { model: string; family: ModelFamily }
|
|
29
26
|
|
|
30
27
|
const answeredModel = (entry: unknown): AnsweredModel[] => {
|
|
31
|
-
const model = isRecord(entry) && entry.type ===
|
|
28
|
+
const model = isRecord(entry) && entry.type === LOG_ENTRY_TYPE.assistant ? messageOf(entry)?.model : undefined
|
|
32
29
|
const family = typeof model === 'string' ? familyOf(model) : null
|
|
33
30
|
return typeof model === 'string' && family ? [{ model, family }] : []
|
|
34
31
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ASK_USER_QUESTION_TOOL, contentOf, dialogAnswers, questionsOf } from '../shared/dialog-answers'
|
|
2
2
|
import { isRecord, type JsonObject } from '../shared/json'
|
|
3
|
+
import { CONTENT_BLOCK_TYPE, LOG_ENTRY_TYPE } from '../shared/log-vocabulary'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* A question of a dialog that ended without an answer, as the hook records it: what was asked, when, from where and
|
|
@@ -50,7 +51,7 @@ const asksOf = (entries: readonly JsonObject[]): Ask[] =>
|
|
|
50
51
|
const askedAt = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : Number.NaN
|
|
51
52
|
if (Number.isNaN(askedAt)) return []
|
|
52
53
|
return contentOf(entry)
|
|
53
|
-
.filter((block) => block.type ===
|
|
54
|
+
.filter((block) => block.type === CONTENT_BLOCK_TYPE.toolUse && block.name === ASK_USER_QUESTION_TOOL)
|
|
54
55
|
.flatMap((block) =>
|
|
55
56
|
typeof block.id === 'string'
|
|
56
57
|
? questionsOf(block.input).map(({ question }) => ({
|
|
@@ -70,7 +71,7 @@ const resultsOf = (entries: readonly JsonObject[]): Map<string, JsonObject | nul
|
|
|
70
71
|
for (const entry of entries) {
|
|
71
72
|
const answers = isRecord(entry.toolUseResult) && isRecord(entry.toolUseResult.answers) ? entry.toolUseResult.answers : null
|
|
72
73
|
for (const block of contentOf(entry))
|
|
73
|
-
if (block.type ===
|
|
74
|
+
if (block.type === CONTENT_BLOCK_TYPE.toolResult &&typeof block.tool_use_id === 'string') results.set(block.tool_use_id, answers)
|
|
74
75
|
}
|
|
75
76
|
return results
|
|
76
77
|
}
|
|
@@ -79,7 +80,7 @@ const resultsOf = (entries: readonly JsonObject[]): Map<string, JsonObject | nul
|
|
|
79
80
|
const wentOnAfter = (ask: Ask, entries: readonly JsonObject[]): boolean =>
|
|
80
81
|
entries
|
|
81
82
|
.slice(ask.position + 1)
|
|
82
|
-
.some((entry) => (entry.type ===
|
|
83
|
+
.some((entry) => (entry.type === LOG_ENTRY_TYPE.user || entry.type === LOG_ENTRY_TYPE.assistant) && messageIdOf(entry) !== ask.messageId)
|
|
83
84
|
|
|
84
85
|
/** No answer came back, the answer is not a text for this question, or it only puts the question off. */
|
|
85
86
|
const endedUnanswered = (ask: Ask, results: ReadonlyMap<string, JsonObject | null>, entries: readonly JsonObject[]) => {
|
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { DialogAnswer } from './dialog-answer'
|
|
7
7
|
import { isRecord, type JsonObject } from './json'
|
|
8
|
+
import { CLAUDE_TOOL, CONTENT_BLOCK_TYPE } from './log-vocabulary'
|
|
8
9
|
|
|
9
10
|
export type { DialogAnswer } from './dialog-answer'
|
|
10
11
|
|
|
11
12
|
/** The tool name of the dialog, as hook inputs and transcripts carry it. */
|
|
12
|
-
export const ASK_USER_QUESTION_TOOL =
|
|
13
|
+
export const ASK_USER_QUESTION_TOOL = CLAUDE_TOOL.askUserQuestion
|
|
13
14
|
|
|
14
15
|
/** The content blocks of a transcript entry's message; none when it has no list of blocks. */
|
|
15
16
|
export const contentOf = (entry: JsonObject): JsonObject[] => {
|
|
@@ -52,7 +53,10 @@ const askedQuestions = (entries: JsonObject[]): Map<string, AskedQuestion[]> =>
|
|
|
52
53
|
entries
|
|
53
54
|
.filter(isMainThread)
|
|
54
55
|
.flatMap(contentOf)
|
|
55
|
-
.filter(
|
|
56
|
+
.filter(
|
|
57
|
+
(block) =>
|
|
58
|
+
block.type === CONTENT_BLOCK_TYPE.toolUse && block.name === ASK_USER_QUESTION_TOOL && typeof block.id === 'string',
|
|
59
|
+
)
|
|
56
60
|
.map((block): [string, AskedQuestion[]] => [block.id as string, questionsOf(block.input)]),
|
|
57
61
|
)
|
|
58
62
|
|
|
@@ -61,7 +65,7 @@ const answersIn = (entry: JsonObject, asked: Map<string, AskedQuestion[]>): Dial
|
|
|
61
65
|
const answeredAt = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : Number.NaN
|
|
62
66
|
if (!isRecord(answers) || Number.isNaN(answeredAt)) return []
|
|
63
67
|
return contentOf(entry)
|
|
64
|
-
.filter((block) => block.type ===
|
|
68
|
+
.filter((block) => block.type === CONTENT_BLOCK_TYPE.toolResult)
|
|
65
69
|
.flatMap((block) => asked.get(String(block.tool_use_id)) ?? [])
|
|
66
70
|
.flatMap(({ question, offered }) => {
|
|
67
71
|
const answer = answers[question]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** The entry types of a Claude Code transcript line that a hook tells apart; the transcript delivers them as strings. */
|
|
2
|
+
export const LOG_ENTRY_TYPE = { user: 'user', assistant: 'assistant' } as const
|
|
3
|
+
|
|
4
|
+
/** The block types inside an entry's `message.content`. */
|
|
5
|
+
export const CONTENT_BLOCK_TYPE = { text: 'text', toolUse: 'tool_use', toolResult: 'tool_result' } as const
|
|
6
|
+
|
|
7
|
+
/** The names of Claude Code's tools a hook looks for, in a `tool_use` block or a hook input's `tool_name`. */
|
|
8
|
+
export const CLAUDE_TOOL = {
|
|
9
|
+
askUserQuestion: 'AskUserQuestion',
|
|
10
|
+
agent: 'Agent',
|
|
11
|
+
taskUpdate: 'TaskUpdate',
|
|
12
|
+
todoWrite: 'TodoWrite',
|
|
13
|
+
} as const
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isRecord } from './json'
|
|
2
|
+
import { CONTENT_BLOCK_TYPE, LOG_ENTRY_TYPE } from './log-vocabulary'
|
|
2
3
|
|
|
3
4
|
export type Turn = { firstPrompt: string; tools: string[] }
|
|
4
5
|
|
|
@@ -37,17 +38,19 @@ const blocks = (entry: Entry): Block[] => {
|
|
|
37
38
|
|
|
38
39
|
/** The text a person typed: a string, or text parts. A tool result and the harness's own skill text are no prompt. */
|
|
39
40
|
const promptText = (entry: Entry): string | undefined => {
|
|
40
|
-
if (entry.type !==
|
|
41
|
+
if (entry.type !== LOG_ENTRY_TYPE.user || entry.isMeta === true) return undefined
|
|
41
42
|
const content = entry.message?.content
|
|
42
43
|
if (typeof content === 'string') return content
|
|
43
|
-
const texts = blocks(entry).flatMap((block) =>
|
|
44
|
+
const texts = blocks(entry).flatMap((block) =>
|
|
45
|
+
block.type === CONTENT_BLOCK_TYPE.text && typeof block.text === 'string' ? [block.text] : [],
|
|
46
|
+
)
|
|
44
47
|
return texts.length > 0 ? texts.join('\n') : undefined
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
const toolUses = (entry: Entry): ToolUse[] =>
|
|
48
|
-
entry.type ===
|
|
51
|
+
entry.type === LOG_ENTRY_TYPE.assistant
|
|
49
52
|
? blocks(entry).flatMap((block) =>
|
|
50
|
-
block.type ===
|
|
53
|
+
block.type === CONTENT_BLOCK_TYPE.toolUse && typeof block.name === 'string'
|
|
51
54
|
? [{ name: block.name, input: isRecord(block.input) ? block.input : {} }]
|
|
52
55
|
: [],
|
|
53
56
|
)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isRecord } from '../shared/json'
|
|
2
|
+
import { CLAUDE_TOOL } from '../shared/log-vocabulary'
|
|
2
3
|
import { toolUsesOf } from '../shared/transcript'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -41,8 +42,8 @@ export const hasTaskInProgress = (lines: readonly string[]): boolean => {
|
|
|
41
42
|
let todos = false
|
|
42
43
|
const tasks = new Map<string, boolean>()
|
|
43
44
|
for (const { name, input } of toolUsesOf(lines.join('\n'))) {
|
|
44
|
-
if (name ===
|
|
45
|
-
if (name ===
|
|
45
|
+
if (name === CLAUDE_TOOL.todoWrite) todos = todosInProgress(input)
|
|
46
|
+
if (name === CLAUDE_TOOL.taskUpdate &&input.taskId !== undefined && input.status !== undefined)
|
|
46
47
|
tasks.set(String(input.taskId), input.status === IN_PROGRESS)
|
|
47
48
|
}
|
|
48
49
|
return todos || [...tasks.values()].some(Boolean)
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { isHintOn } from '../shared/config'
|
|
2
2
|
import { isWithinDirectory } from '../shared/files'
|
|
3
|
+
import { CLAUDE_TOOL } from '../shared/log-vocabulary'
|
|
3
4
|
import { firstPromptOf, isLanePrompt } from '../shared/transcript'
|
|
4
5
|
import { decideTaskHint, hasTaskInProgress, TASK_HINT_SUBAGENT_TEXT, TASK_HINT_TEXT } from './decision'
|
|
5
6
|
|
|
6
7
|
/** An Edit, Write or Agent call as the hook receives it: the session, its directory, its transcript and the tool. */
|
|
7
8
|
export type TaskHintCall = { session: string; cwd: string; transcriptPath: string; tool: string }
|
|
8
9
|
|
|
9
|
-
/** The tool that starts a subagent. */
|
|
10
|
-
const SUBAGENT_TOOL = 'Agent'
|
|
11
|
-
|
|
12
10
|
/** Everything with a side effect, passed in so a test reaches the flow without git, files or a home directory. */
|
|
13
11
|
export type TaskHintDependencies = {
|
|
14
12
|
mainCheckoutOf: (cwd: string) => string
|
|
@@ -34,7 +32,7 @@ const hintFor = (call: TaskHintCall, dependencies: TaskHintDependencies): string
|
|
|
34
32
|
const main = dependencies.mainCheckoutOf(call.cwd)
|
|
35
33
|
const worktree = dependencies.repositoryRootOf(call.cwd)
|
|
36
34
|
const hinted = dependencies.readMark(main, call.session)
|
|
37
|
-
const subagent = call.tool ===
|
|
35
|
+
const subagent = call.tool === CLAUDE_TOOL.agent
|
|
38
36
|
const decision = decideTaskHint({
|
|
39
37
|
enabled: isHintOn(dependencies.configs(main), 'taskSignal'),
|
|
40
38
|
lane: isLanePrompt(firstPromptOf(dependencies.readHead(call.transcriptPath).join('\n'))),
|
|
@@ -8,6 +8,7 @@ How an agent opens a pull request so every PR reads consistently and is review-
|
|
|
8
8
|
- **Base = `{{kit.baseBranch}}`** unless the user names another.
|
|
9
9
|
- **Work on a branch.** If the working branch is `{{kit.baseBranch}}`, branch first.
|
|
10
10
|
- **Push before creating, with the user's go-ahead for the push.** The head branch must be on the remote so `gh pr create` has a target.
|
|
11
|
+
- **Drafts:** Opening as a draft is **{{kit.merge.openAsDraft}}** (`merge.openAsDraft`); `on` opens every PR with `gh pr create --draft`; you turn it ready (`gh pr ready <n>`) once it goes to merge.
|
|
11
12
|
- **Check for an existing PR first** (`gh pr view <branch>`). If a prior PR for the same branch is already **merged**, the new one is a continuation — mark it in the title.
|
|
12
13
|
|
|
13
14
|
## Title
|
|
@@ -60,6 +60,9 @@ Each file under `references/` is read when its trigger fires: the step or the ev
|
|
|
60
60
|
`origin/{{kit.baseBranch}}` would not have it: § 2 step 0. New rows of a board that is on the base
|
|
61
61
|
branch need nothing; the herd shows them. Check a plan that joins the board later the same way; one
|
|
62
62
|
only a working lane's worktree has, the watcher reports as `plan missing`.
|
|
63
|
+
- [ ] **Every plan meets the contract.** `jh plan check <plan>` for each plan a starting lane links. Exit 1,
|
|
64
|
+
or a row that links an issue or a `.scratch/` folder instead of a plan file: [`references/adopt-plan.md`](references/adopt-plan.md),
|
|
65
|
+
before § 2 step 0. Exit 2 goes to the user. Done when each check exits 0.
|
|
63
66
|
- [ ] Tell the user which lanes start now and which wait, before starting any, and whether § 2 step 0
|
|
64
67
|
is needed.
|
|
65
68
|
|
|
@@ -84,7 +87,8 @@ stay approval-bound (§ 5, § 6).
|
|
|
84
87
|
When the user wants a plan's lanes merged without a question for a while: § 5, **Standing approval**.
|
|
85
88
|
|
|
86
89
|
0. **Plan commit as the base**, when § 0 found the board or a plan missing on
|
|
87
|
-
`origin/{{kit.baseBranch}}
|
|
90
|
+
`origin/{{kit.baseBranch}}`, or adopted a plan (then the diff round of
|
|
91
|
+
[`references/adopt-plan.md`](references/adopt-plan.md) is this question; ask nothing more). Ask through `AskUserQuestion`: commit the missing files on a
|
|
88
92
|
documentation branch and open a documentation PR, so the lanes start from that commit? The answer
|
|
89
93
|
is the approval of that commit, its push and the PR (§ 5). `<topic>` is the plan's file name
|
|
90
94
|
without date and extension, or the board's directory name when several files are missing. On yes,
|
|
@@ -99,7 +103,7 @@ When the user wants a plan's lanes merged without a question for a while: § 5,
|
|
|
99
103
|
your judgement.
|
|
100
104
|
2. **Worktree workspace** from `<base>`: `origin/{{kit.baseBranch}}` when, after
|
|
101
105
|
`git fetch origin {{kit.baseBranch}}`, `git cat-file -e origin/{{kit.baseBranch}}:<plan>` succeeds
|
|
102
|
-
for the lane's plan, else `origin/docs/plan-<topic>` of step 0. The file decides, not the state of
|
|
106
|
+
for the lane's plan and `git diff --quiet origin/{{kit.baseBranch}} -- <plan>` exits 0, else `origin/docs/plan-<topic>` of step 0. The file decides, not the state of
|
|
103
107
|
the PR, so a later lane gets the right base:
|
|
104
108
|
```bash
|
|
105
109
|
herdr worktree create --cwd <main-checkout> --branch <branch> --base <base> \
|
|
@@ -133,12 +137,16 @@ When the user wants a plan's lanes merged without a question for a while: § 5,
|
|
|
133
137
|
within a poll or two: `bunx jackherd herd --ensure`.
|
|
134
138
|
8. **Prompt** from the file of § 1:
|
|
135
139
|
```bash
|
|
136
|
-
herdr agent prompt <lane> "$(cat <prompt-file>)"
|
|
140
|
+
herdr agent prompt <lane> "$(cat <prompt-file>)" --wait --until working --timeout 30000
|
|
137
141
|
```
|
|
138
142
|
The prompt quotes commands in backticks (`git reset`, `/pre-commit`); typed into the command
|
|
139
143
|
line inside double quotes, your own shell would run them before the lane sees the text. Text
|
|
140
144
|
read through `$(cat …)` is not expanded again. Every `herdr agent prompt` in this skill goes
|
|
141
|
-
this way, from a file you wrote with the `Write` tool or `jh lane prompt` wrote
|
|
145
|
+
this way, from a file you wrote with the `Write` tool or `jh lane prompt` wrote, with the same
|
|
146
|
+
`--wait` options, so the lane confirms it started. `agent_prompt_stalled` or `timeout` does not prove the
|
|
147
|
+
text is lost: read the screen (`herdr agent read <lane> --source visible --lines 12`). Text still in the input
|
|
148
|
+
line: `herdr agent send-keys <lane> enter`; a lane already at work: done; neither: send the file once more,
|
|
149
|
+
same command. A second stall goes to the user.
|
|
142
150
|
|
|
143
151
|
## 3. Dev server
|
|
144
152
|
|
|
@@ -242,7 +250,7 @@ On wake-up:
|
|
|
242
250
|
| `blocked` | Read the screen (`--source detection`, or zoom the pane when cut off). A question is vetted (below, **Settled questions stay in the lane**): one the plan or a rule settles you answer in the lane, an open decision goes to the user via `AskUserQuestion`. You never hand the lane's pane to the user. A question dialog in a lane (not a permission dialog) is a gap in the lane prompt (§ 1): send it the text-block rule and the answer by prompt. A permission dialog for a step on the list in § 5 you confirm, after checking the cursor sits on the one-time option; anything else goes to the user. Dim text in the input line is a suggestion, not input (`--format ansi` shows `\e[2m`); in a multi-select dialog `space` toggles an option, `enter` moves on. The watcher prints a duty line for each blocked lane; it is done when the lane has its answer or the user has the question |
|
|
243
251
|
| `working` with `questions: <n>` | The lane changed the questions of its report while its subagents run. Read `report <path>` and close each new question as the `done` / `idle` row does (`jh settled`, or `AskUserQuestion` with the answer sent on by `herdr agent prompt`). No diff check and no next phase: the lane keeps working |
|
|
244
252
|
| `main checkout on <branch>, not …` | § 0, **The main checkout stays on `{{kit.baseBranch}}`** |
|
|
245
|
-
| `done` / `idle` | Read the report the watcher shows (`report <path>` and its first lines, or the screen); read further only where that is not enough. Check the diff against the `{{kit.heading.writes}}` set (`git -C <worktree> diff --stat <base>...HEAD`, with the ref the lane branched from in § 2 step 2 until it has rebased after the documentation PR's merge) and that checks are quoted with numbers, not claimed. Every blocking question in the report is closed in the same wake-up, also one under open points, one without its mark and also when the `questions: <n>` line is missing: settled by you through `jh settled` (below), or put to the user through `AskUserQuestion` (vetted, below) and the answer sent on to the lane by `herdr agent prompt`. A not blocking one is settled the same way or collected (**Not blocking questions**, below). An answer that stays in your chat with the user is none. Then start the lane's next ready phase (§ 2) or name the reason to wait |
|
|
253
|
+
| `done` / `idle` | Read the report the watcher shows (`report <path>` and its first lines, or the screen); read further only where that is not enough. Check the diff against the `{{kit.heading.writes}}` set (`git -C <worktree> diff --stat <base>...HEAD`, with the ref the lane branched from in § 2 step 2 until it has rebased after the documentation PR's merge) and that checks are quoted with numbers, not claimed. Every blocking question in the report is closed in the same wake-up, also one under open points, one without its mark and also when the `questions: <n>` line is missing: settled by you through `jh settled` (below), or put to the user through `AskUserQuestion` (vetted, below) and the answer sent on to the lane by `herdr agent prompt`. A not blocking one is settled the same way or collected (**Not blocking questions**, below). An answer that stays in your chat with the user is none. Then start the lane's next ready phase (§ 2), at once after a `<lane> ready: …` line, or name the reason to wait; a PR awaiting approval holds only its commit, which stages only its own files |
|
|
246
254
|
| `merged #<n>` | § 6, for every lane |
|
|
247
255
|
| `checks failed #<n> (<lane>): <checks> (<k> tests: …)` | Send the finding to the lane with the named checks and tests (data, above). Without names, the lane reads the run itself |
|
|
248
256
|
| `checks passed #<n>` | One line to the user: ready to merge, with the board's merge order |
|
|
@@ -475,6 +483,9 @@ always goes to the user, whatever the plan settles.
|
|
|
475
483
|
permission mode refuses the push, push its branch yourself under the same approval: run
|
|
476
484
|
`cd <worktree>` as a command of its own, then `git push --force-with-lease origin HEAD:<branch>`,
|
|
477
485
|
then `cd` back (the kit's git guard refuses `git -C`).
|
|
486
|
+
5. **Drafts** (**{{kit.merge.openAsDraft}}**, `pull-requests.md` → Drafts): when you report a draft PR to the
|
|
487
|
+
user to merge, run `gh pr ready <n>` first; for your own merge, **Self-merge** step 1. The merge guard
|
|
488
|
+
never passes a draft.
|
|
478
489
|
|
|
479
490
|
### Self-merge
|
|
480
491
|
|
|
@@ -482,15 +493,17 @@ When the user picked `"{{kit.guard.selfMergeLabel}}"` for a PR:
|
|
|
482
493
|
|
|
483
494
|
1. Commit, push and open the PR as in step 4. Enter in the board's `{{kit.heading.history}}` that the
|
|
484
495
|
user approved this PR with self-merge (time, lane, PR number; the time as § 4 reads it), so the
|
|
485
|
-
approval has a record outside the chat.
|
|
496
|
+
approval has a record outside the chat. A draft turns ready here: `gh pr ready <n>`.
|
|
486
497
|
2. **Merging without checks.** The paths a PR may carry to be merged without waiting for checks are
|
|
487
498
|
`{{kit.merge.withoutChecks}}` (`merge.withoutChecks` in the config; `none` means every merge waits).
|
|
488
499
|
Unless `none`, list the PR's files with `gh pr diff <n> --name-only` and compare each one with the
|
|
489
500
|
patterns yourself. When **every** file matches, go straight to step 4. One file outside, an empty
|
|
490
501
|
list, or a failed `gh` call: go on with step 3. The merge guard reads the file list from GitHub
|
|
491
502
|
itself; if it asks, the dialog is the user's (step 5).
|
|
492
|
-
3. Wait for the checks in the background, never in the foreground
|
|
493
|
-
|
|
503
|
+
3. Wait for the checks in the background, never in the foreground. After a ready (step 1), start only
|
|
504
|
+
once every check that counts (each of `guard.requiredChecks`, else each check the draft skipped) has
|
|
505
|
+
a run that started after the ready, as a watch would end on the draft's skipped runs at once. Then a Bash call
|
|
506
|
+
`gh pr checks <n> --watch` with `run_in_background: true`, and work on until it reports.
|
|
494
507
|
4. Green (or a PR that qualified in step 2): `gh pr merge <n> --squash --delete-branch --match-head-commit <sha>`,
|
|
495
508
|
with the plain PR number, the full `headRefOid` from `gh pr view <n> --json headRefOid` read right before, and
|
|
496
509
|
without `-R`, `--repo` or `--admin`. The merge guard lets exactly this command through for the approved branch
|
|
@@ -531,7 +544,7 @@ For a PR the user approved (step 2) whose lane works on a plan under a standing
|
|
|
531
544
|
|
|
532
545
|
1. A PR behind the base branch is rebased first (§ 6, under the PR's approval), so its checks run
|
|
533
546
|
against the current base.
|
|
534
|
-
2.
|
|
547
|
+
2. Turn a draft ready as in **Self-merge** step 1, then wait for the checks as in its step 3.
|
|
535
548
|
3. Green: the same command as **Self-merge** step 4, with the `<sha>` read right before. The merge
|
|
536
549
|
guard lets it through only before the approval's end, with every check green, GitHub able to merge
|
|
537
550
|
it and no workflow, migration, env file or file of the guards in it; otherwise it asks, and that dialog is the user's
|