jackherd 1.15.2 → 1.17.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 +34 -0
- package/INSTALL.md +2 -1
- package/README.md +26 -22
- package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
- package/dist/config.d.ts +33 -1
- package/dist/config.js +57 -5
- package/dist/plan-format.d.ts +18 -6
- package/dist/plan-format.js +8 -1
- package/docs/plan-contract.md +1 -0
- package/herdr-plugin/herdr-plugin.toml +4 -1
- package/herdr-plugin/restore-plans.ts +60 -26
- package/herdr-plugin/resume.ts +18 -0
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +11 -0
- package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
- package/kit/hooks/hooks/approval-gate/index.ts +36 -7
- package/kit/hooks/hooks/approval-gate/run.ts +72 -16
- package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
- package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
- package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
- package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
- package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
- package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
- package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
- package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +438 -35
- package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
- package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
- package/kit/hooks/hooks/merge-guard/run.ts +110 -28
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
- package/kit/hooks/hooks/plain-question/form.ts +89 -0
- package/kit/hooks/hooks/plain-question/index.ts +44 -4
- package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
- package/kit/hooks/hooks/plain-question/run.ts +14 -7
- package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
- package/kit/hooks/hooks.json +11 -2
- package/kit/review/agents/skill-review.md +3 -2
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/rules/rules/kit/code-style.md +6 -6
- package/kit/rules/rules/kit/writing-for-agents.md +3 -0
- package/kit/workflow/skills/follow/SKILL.md +18 -11
- package/kit/workflow/skills/implement/SKILL.md +1 -1
- package/kit/workflow/skills/orchestrate/SKILL.md +124 -300
- package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
- package/kit/workflow/skills/orchestrate/references/events.md +46 -1
- package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
- package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +9 -2
- package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
- package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
- package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
- package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
- package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
- package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
- package/kit/workflow/skills/plan/SKILL.md +3 -2
- package/package.json +3 -1
- package/recipes/README.md +4 -2
- package/recipes/google-calendar.md +30 -0
- package/recipes/jira-cli.md +1 -1
- package/recipes/sources/google-calendar-today.ts +38 -0
- package/recipes/sources/google-calendar.ts +368 -0
- package/recipes/sources/jira.ts +10 -2
- package/recipes/sources/linear.ts +9 -3
- package/recipes/sources/sentry.ts +8 -2
- package/recipes/sources.md +17 -3
- package/schemas/jackherd.schema.json +67 -0
- package/schemas/sources.schema.json +9 -0
- package/src/approval/checked.ts +60 -0
- package/src/approval/command.ts +16 -0
- package/src/check/command.ts +51 -6
- package/src/check/lint-pointers.ts +79 -0
- package/src/check/lint-shaped.ts +196 -0
- package/src/check/plan-structure-check.ts +116 -4
- package/src/check/report.ts +9 -3
- package/src/check/schema.ts +37 -0
- package/src/check/settings.ts +4 -0
- package/src/check/skill-budget-defaults.ts +81 -23
- package/src/check/skill-budgets.ts +58 -10
- package/src/cli.ts +2 -0
- package/src/config/personal-sources.ts +46 -10
- package/src/config/repository-identity.ts +40 -0
- package/src/config/schema.ts +53 -1
- package/src/context/command.ts +33 -2
- package/src/context/options.ts +33 -8
- package/src/context/usage-logs.ts +53 -0
- package/src/context/usage.ts +110 -0
- package/src/lane/base-command.ts +35 -0
- package/src/lane/base.ts +43 -0
- package/src/lane/close-check.ts +49 -15
- package/src/lane/close-command.ts +71 -11
- package/src/lane/close-options.ts +29 -8
- package/src/lane/close-survey.ts +13 -1
- package/src/lane/close.ts +51 -12
- package/src/lane/command.ts +21 -3
- package/src/lane/send-command.ts +32 -0
- package/src/lane/send-herdr.ts +77 -0
- package/src/lane/send-options.ts +23 -0
- package/src/lane/send.ts +90 -0
- package/src/lane/skill-hint.ts +27 -0
- package/src/lane/space-command.ts +30 -0
- package/src/lane/space.ts +49 -0
- package/src/lane/start-lane-hint.ts +18 -0
- package/src/plan-contract/base-check-git.ts +33 -0
- package/src/plan-contract/base-check.ts +17 -0
- package/src/plan-contract/command.ts +25 -4
- package/src/plan-format/plan-structure.ts +17 -5
- package/src/plans/ask-coordinator.ts +23 -13
- package/src/plans/board-rows.ts +9 -4
- package/src/plans/click-hint.ts +4 -0
- package/src/plans/command.ts +6 -5
- package/src/plans/ensure-panes.ts +14 -3
- package/src/plans/git.ts +14 -0
- package/src/plans/github.ts +19 -10
- package/src/plans/layout.ts +191 -25
- package/src/plans/live.ts +57 -14
- package/src/plans/plan-panes.ts +21 -4
- package/src/plans/row-actions.ts +17 -10
- package/src/plans/selection.ts +29 -0
- package/src/plans/settings.ts +4 -0
- package/src/plans/snapshot.ts +8 -3
- package/src/plans/source-rows.ts +35 -2
- package/src/resume/command.ts +213 -0
- package/src/resume/decision.ts +102 -0
- package/src/resume/keeper.ts +65 -0
- package/src/resume/location.ts +22 -0
- package/src/resume/log.ts +27 -0
- package/src/resume/ports.ts +102 -0
- package/src/resume/record.ts +116 -0
- package/src/resume/targets.ts +84 -0
- package/src/resume/texts.ts +27 -0
- package/src/secrets/command.ts +15 -5
- package/src/sync/herdr-plugin.ts +2 -11
- package/src/sync/plan-ask-rules.ts +11 -0
- package/src/sync/render.ts +3 -1
- package/src/vocabulary/index.ts +13 -0
- package/src/waiting/command.ts +13 -4
- package/src/watch/command.ts +177 -78
- package/src/watch/config-reload.ts +119 -0
- package/src/watch/lane-model.ts +11 -1
- package/src/watch/lane-progress.ts +70 -8
- package/src/watch/lane-report.ts +68 -12
- package/src/watch/main-checkout.ts +5 -1
- package/src/watch/merge-duties.ts +33 -0
- package/src/watch/merge-overlap-read.ts +69 -0
- package/src/watch/plan-views.ts +16 -4
- package/src/watch/reference-paths.ts +46 -5
- package/src/watch/report-questions.ts +5 -2
- package/src/watch/single-report.ts +3 -2
- package/src/watch/source-dry-run-command.ts +22 -4
- package/src/watch/source-dry-run.ts +1 -1
- package/src/watch/source-reason.ts +58 -0
- package/src/watch/source-reload.ts +21 -17
- package/src/watch/source-run.ts +24 -7
- package/src/watch/sources.ts +9 -2
- package/src/watch/texts.ts +15 -4
- package/src/watch/waiting-line.ts +4 -3
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { questionsOf, recordsOf } from './run'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The words the check needs, per response language: the mark a recommended option's label ends with, and the option
|
|
5
|
+
* labels a guard reads word for word (approvals of a lane call, the self-merge and the standing approval). An empty
|
|
6
|
+
* mark, from an install that does not pass one, checks nothing.
|
|
7
|
+
*/
|
|
8
|
+
export type RecommendationWords = { mark: string; guardedLabels: readonly string[] }
|
|
9
|
+
|
|
10
|
+
const QUESTION_LENGTH = 80
|
|
11
|
+
|
|
12
|
+
const shorten = (text: string): string =>
|
|
13
|
+
text.length > QUESTION_LENGTH ? `${text.slice(0, QUESTION_LENGTH - 1)}…` : text
|
|
14
|
+
|
|
15
|
+
const labelsOf = (options: unknown): string[] =>
|
|
16
|
+
recordsOf(options).flatMap((option) => (typeof option.label === 'string' ? [option.label] : []))
|
|
17
|
+
|
|
18
|
+
const isMarked = (label: string, mark: string): boolean => label.trimEnd().toLowerCase().endsWith(mark.toLowerCase())
|
|
19
|
+
|
|
20
|
+
const withoutMark = (label: string, mark: string): string =>
|
|
21
|
+
isMarked(label, mark) ? label.trimEnd().slice(0, -mark.length).trimEnd() : label
|
|
22
|
+
|
|
23
|
+
/** A guarded label that carries the mark: the guard would not know the answer, so the dialog goes back. */
|
|
24
|
+
const markedGuardReason = (labels: readonly string[], { mark, guardedLabels }: RecommendationWords): string | null => {
|
|
25
|
+
const label = labels.find(
|
|
26
|
+
(candidate) => isMarked(candidate, mark) && guardedLabels.includes(withoutMark(candidate, mark)),
|
|
27
|
+
)
|
|
28
|
+
return label === undefined
|
|
29
|
+
? null
|
|
30
|
+
: `Option "${label}": a guard reads "${withoutMark(label, mark)}" word for word; drop "${mark}" from it.`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The first option carries the mark; else the reason names a later marked option to move up, or asks for one. */
|
|
34
|
+
const firstMarkReason = (question: string, labels: readonly string[], mark: string): string | null => {
|
|
35
|
+
const [first, ...rest] = labels
|
|
36
|
+
if (first !== undefined && isMarked(first, mark)) return null
|
|
37
|
+
const later = rest.find((label) => isMarked(label, mark))
|
|
38
|
+
return later === undefined
|
|
39
|
+
? `Question "${shorten(question)}": put your recommendation first and end its label with "${mark}".`
|
|
40
|
+
: `Question "${shorten(question)}": the recommendation goes first; move "${later}" to the top.`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A single choice has one recommendation: the first option carries the mark and no other does. */
|
|
44
|
+
const singleChoiceReason = (question: string, labels: readonly string[], mark: string): string | null => {
|
|
45
|
+
const firstReason = firstMarkReason(question, labels, mark)
|
|
46
|
+
const second = labels.slice(1).find((label) => isMarked(label, mark))
|
|
47
|
+
return firstReason ?? (second === undefined
|
|
48
|
+
? null
|
|
49
|
+
: `Question "${shorten(question)}": one recommendation only; drop "${mark}" from "${second}".`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const questionReason = (question: Record<string, unknown>, words: RecommendationWords): string | null => {
|
|
53
|
+
const labels = labelsOf(question.options)
|
|
54
|
+
if (labels.length === 0) return null
|
|
55
|
+
const guarded = markedGuardReason(labels, words)
|
|
56
|
+
if (guarded !== null) return guarded
|
|
57
|
+
if (labels.some((label) => words.guardedLabels.includes(label))) return null
|
|
58
|
+
const text = typeof question.question === 'string' ? question.question : ''
|
|
59
|
+
// A multiSelect question may recommend several options, the first among them.
|
|
60
|
+
const reasonOf = question.multiSelect === true ? firstMarkReason : singleChoiceReason
|
|
61
|
+
return reasonOf(text, labels, words.mark)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The reason to send a dialog back because a question does not carry its recommendation as the first option, marked
|
|
66
|
+
* at the end of its label; null when every question does. A question that offers a label a guard reads word for word
|
|
67
|
+
* needs no mark, and such a label must not carry one.
|
|
68
|
+
*/
|
|
69
|
+
export const recommendationReasonOf = (toolInput: unknown, words: RecommendationWords): string | null => {
|
|
70
|
+
if (words.mark === '') return null
|
|
71
|
+
const [reason] = questionsOf(toolInput).flatMap((question) => questionReason(question, words) ?? [])
|
|
72
|
+
return reason ?? null
|
|
73
|
+
}
|
|
@@ -9,16 +9,23 @@ const LANE_DIRECTORY = /^(?!\.{1,2}$)[A-Za-z0-9._-]+$/
|
|
|
9
9
|
|
|
10
10
|
const strings = (values: unknown[]): string[] => values.filter((value): value is string => typeof value === 'string')
|
|
11
11
|
|
|
12
|
+
/** The objects of a list, or none when it is no list: the questions of a dialog, the options of a question. */
|
|
13
|
+
export const recordsOf = (values: unknown): Record<string, unknown>[] =>
|
|
14
|
+
Array.isArray(values) ? values.filter(isRecord) : []
|
|
15
|
+
|
|
16
|
+
/** The questions of an `AskUserQuestion` input; none when it is no object or has no list of them. */
|
|
17
|
+
export const questionsOf = (toolInput: unknown): Record<string, unknown>[] =>
|
|
18
|
+
isRecord(toolInput) ? recordsOf(toolInput.questions) : []
|
|
19
|
+
|
|
12
20
|
const optionTexts = (options: unknown): string[] =>
|
|
13
|
-
|
|
21
|
+
recordsOf(options).flatMap((option) => strings([option.label, option.description]))
|
|
14
22
|
|
|
15
23
|
/** The texts a dialog shows the user: each question, its header chip, and every option's label and description. */
|
|
16
|
-
export const dialogTextsOf = (toolInput: unknown): string[] =>
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
.
|
|
20
|
-
|
|
21
|
-
}
|
|
24
|
+
export const dialogTextsOf = (toolInput: unknown): string[] =>
|
|
25
|
+
questionsOf(toolInput).flatMap((question) => [
|
|
26
|
+
...strings([question.question, question.header]),
|
|
27
|
+
...optionTexts(question.options),
|
|
28
|
+
])
|
|
22
29
|
|
|
23
30
|
/** Every plan the boards link, relative to the repository, once; a path that leaves the repository is dropped. */
|
|
24
31
|
export const planPathsOfBoards = (boards: readonly Board[], language: BoardLanguage): string[] => [
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A standing approval: the user's answer to a dialog whose question carries ``<Key> `<subject>` <until> <YYYY-MM-DD HH:MM>``
|
|
3
|
+
* lifts a question about that subject until that moment, at most 24 hours after the answer. The key names what is
|
|
4
|
+
* approved (`Plan` for the merges of a plan's lanes, `Environment` for the calls against an environment), so two kinds
|
|
5
|
+
* never read each other's questions. The newest answer to a question naming the subject decides; any other label ends
|
|
6
|
+
* the approval, also on a question without an end: in doubt it ends.
|
|
7
|
+
*/
|
|
8
|
+
import type { DialogAnswer } from './dialog-answer'
|
|
9
|
+
import { MS_PER_MINUTE } from './fresh'
|
|
10
|
+
import { newestVerdict } from './newest-verdict'
|
|
11
|
+
|
|
12
|
+
/** The exact option label that approves, and the word before the end in the question, both per response language. */
|
|
13
|
+
export type StandingLabels = { approve: string; until: string }
|
|
14
|
+
|
|
15
|
+
/** What a kind of standing approval is about: the word before the subject, and which subjects it accepts. */
|
|
16
|
+
export type StandingKind = { key: string; isSubject: (subject: string) => boolean }
|
|
17
|
+
|
|
18
|
+
/** A subject approved until `until`, in milliseconds. */
|
|
19
|
+
export type StandingMark = { subject: string; until: number }
|
|
20
|
+
|
|
21
|
+
export const MAX_STANDING_MINUTES = 24 * 60
|
|
22
|
+
|
|
23
|
+
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
24
|
+
|
|
25
|
+
/** A local moment from its parts; undefined for one the calendar or the clock does not have (Feb 30, 24:00, a DST gap). */
|
|
26
|
+
const localMoment = ([year, month, day, hour, minute]: number[]): number | undefined => {
|
|
27
|
+
const date = new Date(year, month - 1, day, hour, minute)
|
|
28
|
+
const exact =
|
|
29
|
+
date.getFullYear() === year &&
|
|
30
|
+
date.getMonth() === month - 1 &&
|
|
31
|
+
date.getDate() === day &&
|
|
32
|
+
date.getHours() === hour &&
|
|
33
|
+
date.getMinutes() === minute
|
|
34
|
+
return exact ? date.getTime() : undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The subject and the end a question names as ``<Key> `<subject>` <until> YYYY-MM-DD HH:MM``; none for none or several. */
|
|
38
|
+
export const standingMarkOf = (question: string, kind: StandingKind, until: string): StandingMark | undefined => {
|
|
39
|
+
if (until === '') return undefined
|
|
40
|
+
const pattern = `\\b${escapeRegExp(kind.key)} \`([^\`]*)\` ${escapeRegExp(until)} (\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2})(?!\\d)`
|
|
41
|
+
const marks = [...question.matchAll(new RegExp(pattern, 'g'))]
|
|
42
|
+
if (marks.length !== 1) return undefined
|
|
43
|
+
const [, subject, ...parts] = marks[0]
|
|
44
|
+
const end = localMoment(parts.map(Number))
|
|
45
|
+
return end !== undefined && kind.isSubject(subject) ? { subject, until: end } : undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The one subject a question names as ``<Key> `<subject>` ``, with or without an end; none for none or several. */
|
|
49
|
+
const namedSubjectOf = (question: string, kind: StandingKind): string | undefined => {
|
|
50
|
+
const mention = new RegExp(`\\b${escapeRegExp(kind.key)} \`([^\`]*)\``, 'g')
|
|
51
|
+
const subjects = new Set([...question.matchAll(mention)].map(([, subject]) => subject))
|
|
52
|
+
const [only] = subjects
|
|
53
|
+
return subjects.size === 1 && kind.isSubject(only) ? only : undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The subject an answer speaks about, and the end its question names; `NaN` for a question that names the subject
|
|
58
|
+
* without an end, which never approves but, answered with anything else, ends the approval: in doubt it ends.
|
|
59
|
+
*/
|
|
60
|
+
const markOf = (question: string, kind: StandingKind, until: string): StandingMark | undefined => {
|
|
61
|
+
const mark = standingMarkOf(question, kind, until)
|
|
62
|
+
if (mark !== undefined) return mark
|
|
63
|
+
const subject = namedSubjectOf(question, kind)
|
|
64
|
+
return subject === undefined ? undefined : { subject, until: Number.NaN }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type MarkedAnswer = DialogAnswer & { mark: StandingMark }
|
|
68
|
+
|
|
69
|
+
const approves = ({ answer, offered, answeredAt, mark }: MarkedAnswer, label: string): boolean =>
|
|
70
|
+
label !== '' &&
|
|
71
|
+
answer === label &&
|
|
72
|
+
offered.includes(label) &&
|
|
73
|
+
mark.until > answeredAt &&
|
|
74
|
+
mark.until - answeredAt <= MAX_STANDING_MINUTES * MS_PER_MINUTE
|
|
75
|
+
|
|
76
|
+
/** The approval the newest answers about one subject give: all of them approve, and the earliest end is still ahead. */
|
|
77
|
+
const grantOf = (subject: string, answers: readonly MarkedAnswer[], label: string, now: number): StandingMark[] => {
|
|
78
|
+
const verdict = newestVerdict(answers, now)
|
|
79
|
+
if (verdict.length === 0 || !verdict.every((answer) => approves(answer, label))) return []
|
|
80
|
+
const until = Math.min(...verdict.map(({ mark }) => mark.until))
|
|
81
|
+
return until > now ? [{ subject, until }] : []
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The subjects of this kind the user approved standing in these dialog answers, and until when; only those holding at `now`. */
|
|
85
|
+
export const standingGrants = (
|
|
86
|
+
answers: readonly DialogAnswer[],
|
|
87
|
+
kind: StandingKind,
|
|
88
|
+
labels: StandingLabels,
|
|
89
|
+
now: number,
|
|
90
|
+
): StandingMark[] => {
|
|
91
|
+
const marked = answers.flatMap((answer): MarkedAnswer[] => {
|
|
92
|
+
const mark = markOf(answer.question, kind, labels.until)
|
|
93
|
+
return mark === undefined ? [] : [{ ...answer, mark }]
|
|
94
|
+
})
|
|
95
|
+
const subjects = [...new Set(marked.map(({ mark }) => mark.subject))]
|
|
96
|
+
return subjects.flatMap((subject) =>
|
|
97
|
+
grantOf(
|
|
98
|
+
subject,
|
|
99
|
+
marked.filter(({ mark }) => mark.subject === subject),
|
|
100
|
+
labels.approve,
|
|
101
|
+
now,
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
}
|
package/kit/hooks/hooks.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"event": "PermissionRequest",
|
|
4
4
|
"handler": {
|
|
5
5
|
"type": "command",
|
|
6
|
-
"command": "cd \"$CLAUDE_PROJECT_DIR\" && bun .claude/hooks/approval-gate/index.ts '{{kit.approval.allow}}' '{{kit.approval.deny}}' '{{kit.approval.pending}}' '{{kit.approval.denied}}'",
|
|
6
|
+
"command": "cd \"$CLAUDE_PROJECT_DIR\" && bun .claude/hooks/approval-gate/index.ts '{{kit.approval.allow}}' '{{kit.approval.deny}}' '{{kit.approval.pending}}' '{{kit.approval.denied}}' '{{kit.guard.standingApproval.approve}}' '{{kit.guard.standingApproval.until}}'",
|
|
7
7
|
"timeout": 600
|
|
8
8
|
}
|
|
9
9
|
},
|
|
@@ -34,6 +34,15 @@
|
|
|
34
34
|
"onFailure": "block"
|
|
35
35
|
}
|
|
36
36
|
},
|
|
37
|
+
"lane-start-guard": {
|
|
38
|
+
"event": "PreToolUse",
|
|
39
|
+
"matcher": "Bash",
|
|
40
|
+
"handler": {
|
|
41
|
+
"type": "command",
|
|
42
|
+
"command": "cd \"$CLAUDE_PROJECT_DIR\" && { input=$(cat); case \"$input\" in *herdr*) printf '%s' \"$input\" | bun .claude/hooks/lane-start-guard/index.ts;; esac; }",
|
|
43
|
+
"timeout": 10
|
|
44
|
+
}
|
|
45
|
+
},
|
|
37
46
|
"merge-guard": {
|
|
38
47
|
"event": "PreToolUse",
|
|
39
48
|
"matcher": "Bash",
|
|
@@ -74,7 +83,7 @@
|
|
|
74
83
|
"matcher": "AskUserQuestion",
|
|
75
84
|
"handler": {
|
|
76
85
|
"type": "command",
|
|
77
|
-
"command": "cd \"$CLAUDE_PROJECT_DIR\" && bun .claude/hooks/plain-question/index.ts",
|
|
86
|
+
"command": "cd \"$CLAUDE_PROJECT_DIR\" && bun .claude/hooks/plain-question/index.ts '{{kit.question.recommended}}' '{{kit.approval.allow}}' '{{kit.approval.deny}}' '{{kit.guard.selfMergeLabel}}' '{{kit.guard.standingApproval.approve}}'",
|
|
78
87
|
"timeout": 10
|
|
79
88
|
}
|
|
80
89
|
},
|
|
@@ -8,11 +8,11 @@ user-invocable: false
|
|
|
8
8
|
|
|
9
9
|
Read `.claude/rules/kit/writing-for-agents.md` first, then the project's `.claude/rules/writing-for-agents.md` if it exists, then `.claude/project/skill-review.md` if it exists. They carry the levers this review applies; the project files win where they disagree with the kit.
|
|
10
10
|
|
|
11
|
-
You receive the `git diff` of agent texts: skills, agents, rules, project supplements, `CLAUDE.md`, `AGENTS.md`. Judge only the lines the diff adds, changes or removes, a moved passage where its trigger or rung changed; the rest of a file is context. A budget, a broken link, a required rule and a trigger in the first lines are measured by `jh check`, so leave them to it.
|
|
11
|
+
You receive the `git diff` of agent texts: skills, agents, rules, project supplements, `CLAUDE.md`, `AGENTS.md`. Judge only the lines the diff adds, changes or removes, a moved passage where its trigger or rung changed; the rest of a file is context. A budget, a broken link, a required rule and a trigger in the first lines are measured by `jh check`, so leave them to it. Length: a finding when the diff takes a file over its target or grows one already over, per the `hint:` lines of the `Skill budgets` result /pre-commit passes; that result missing is a finding. `jh check` measures `Lint pointers`; lens 7 judges what its patterns miss.
|
|
12
12
|
|
|
13
13
|
## What is a finding
|
|
14
14
|
|
|
15
|
-
Check every changed passage against each lens; a passage that passes all
|
|
15
|
+
Check every changed passage against each lens; a passage that passes all seven needs no row.
|
|
16
16
|
|
|
17
17
|
1. **No-op:** the sentence changes nothing against what the model does by default. Delete the sentence, never trim it.
|
|
18
18
|
2. **Same meaning twice:** the passage restates, in other words, what the skill, its project supplement, a rule, `CLAUDE.md` or a memory file the caller names already carries. Grep for the key terms before you call it new; name the other place. A passage removed in favour of a pointer is lost when the target lacks part of it.
|
|
@@ -20,6 +20,7 @@ Check every changed passage against each lens; a passage that passes all six nee
|
|
|
20
20
|
4. **Late trigger:** a pointer to a reference or another document names its condition after the content, or fires at a moment the agent has already passed.
|
|
21
21
|
5. **Negation:** the passage steers by prohibition where a target could be stated; a hard guardrail stays, paired with its target.
|
|
22
22
|
6. **Open step:** a step ends without a condition the agent can check, so done and not done look alike.
|
|
23
|
+
7. **Lint-shaped prose:** a rule the project's linter can check, without a `lint:` pointer. Propose the lint rule plus the pointer.
|
|
23
24
|
|
|
24
25
|
## Output
|
|
25
26
|
|
|
@@ -27,7 +27,7 @@ Run `/review` for what the command cannot decide: dead exports, naming, function
|
|
|
27
27
|
|
|
28
28
|
**Step 1 printed the reviewer list** (`Reviewers: …`, from `check.reviewerRoutes`, plus `check.publicContent` and `skill-review` for changed agent texts) — dispatch exactly those, each as a subagent via the `Agent` tool with its `subagent_type`:
|
|
29
29
|
|
|
30
|
-
- Pass in the `prompt`: the `git diff` of the relevant files and the file paths.
|
|
30
|
+
- Pass in the `prompt`: the `git diff` of the relevant files and the file paths; to `skill-review`, also the `Skill budgets` and `Lint pointers` results of the latest `jh check` run.
|
|
31
31
|
- Start all of them **in parallel**, in a single message, and wait for every result before step 4.
|
|
32
32
|
|
|
33
33
|
`security-audit` has a judgement half: add it when the diff *does* something security-relevant in a path no route lists — auth middleware, API key handling, a schema at an HTTP endpoint, a raw database query, an access policy.
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# Code Style
|
|
2
2
|
|
|
3
|
-
The kit's core. Project-specific conventions (UI framework, shared component library, class utilities) live in the project's own rules beside `.claude/rules/kit/`; where they are stricter, they win.
|
|
3
|
+
The kit's core. Project-specific conventions (UI framework, shared component library, class utilities) live in the project's own rules beside `.claude/rules/kit/`; where they are stricter, they win. Where the project's own rule names a `lint:<rule id>` for a rule below, the linter's message is the authority.
|
|
4
4
|
|
|
5
5
|
## Formatting
|
|
6
6
|
|
|
7
7
|
The formatter and linter config is the authority; where it is silent, match the surrounding code.
|
|
8
8
|
|
|
9
9
|
- No unused imports.
|
|
10
|
-
- Blank lines separate
|
|
10
|
+
- Blank lines separate sections; no decorative separator comments.
|
|
11
11
|
|
|
12
12
|
## Language
|
|
13
13
|
|
|
@@ -24,7 +24,7 @@ The formatter and linter config is the authority; where it is silent, match the
|
|
|
24
24
|
## Comments & Documentation
|
|
25
25
|
|
|
26
26
|
- Self-documenting code over comments: clear naming, pure functions, obvious control flow.
|
|
27
|
-
- **No section-label comments** (`// Types`, `//
|
|
27
|
+
- **No section-label comments** (`// Types`, `// Imports`): the code order speaks for itself.
|
|
28
28
|
|
|
29
29
|
### When to add a doc comment
|
|
30
30
|
|
|
@@ -112,10 +112,10 @@ Before editing a file that might be generated:
|
|
|
112
112
|
|
|
113
113
|
Roles, modes, statuses, channels: every closed set of values has **one** constant next to its type (an `as const` object, an enum, a class constant), and every comparison, option id and schema literal reads from it.
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
The constant earns its place at the **seams where a `string` arrives** (a query parameter, a form option id, a database row): there a literal comparison is unchecked, and `mode === 'per_km' ? 'per_km' : 'tiers'` maps every unknown value to a default in silence.
|
|
116
116
|
|
|
117
117
|
```ts
|
|
118
|
-
// ✅
|
|
118
|
+
// ✅ the constant is the vocabulary; a guard narrows the string, an unknown value is refused
|
|
119
119
|
export const FEE_MODE = { tiers: 'tiers', perKm: 'per_km' } as const
|
|
120
120
|
export type FeeMode = (typeof FEE_MODE)[keyof typeof FEE_MODE]
|
|
121
121
|
export const isFeeMode = (value: string): value is FeeMode =>
|
|
@@ -127,7 +127,7 @@ if (isFeeMode(id)) handleChange(switchMode(form, id))
|
|
|
127
127
|
mode: id === 'per_km' ? 'per_km' : 'tiers'
|
|
128
128
|
```
|
|
129
129
|
|
|
130
|
-
Derive the schema from the constant
|
|
130
|
+
Derive the schema from the constant, never the other way round, so constant, type and parser cannot disagree. Test fixtures may spell the literal.
|
|
131
131
|
|
|
132
132
|
## Database Writes
|
|
133
133
|
|
|
@@ -57,6 +57,9 @@ A **context pointer** names material that is out of context and encodes the cond
|
|
|
57
57
|
- **One trigger per branch.** Synonyms renaming one branch are that branch written twice.
|
|
58
58
|
- **Cut what the body already carries.** A pointer says when to come, not what is inside.
|
|
59
59
|
- **Every trigger names something its loader can honour.** A skill with `disable-model-invocation: true` describes what it is for, since the user picks it; a `paths:` list names globs the repository actually has. Check both against reality — a trigger nothing can fire is a line paid for on every load and never earned back.
|
|
60
|
+
- **A mandatory rule behind a pointer needs a trigger that fires without the reader's judgement.** Code raises it (an event line that names the file, a hook's denial, a command's output) or it is a step every run reaches; "read when needed" is no pointer, since a rule reached only when the reader thinks of it is a weakened rule. Test the link: the rule's sentence stands in the target, the target is linked, and the trigger names it.
|
|
61
|
+
- **A rule a hook enforces is one sentence in the text: how a call gets through.** The full rule lives in the hook's denial, which the reader sees at the moment it matters; written out in both places, it is one meaning in two places that drift apart.
|
|
62
|
+
- **A rule the project's linter can check is a lint rule, and the text keeps one sentence naming it as `lint:<rule id>`**, plus the why where it is not obvious. The full rule lives in the linter's message, for the same reason as above.
|
|
60
63
|
|
|
61
64
|
## The two loads
|
|
62
65
|
|
|
@@ -1,49 +1,56 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: follow
|
|
3
|
-
description: 'Have the watcher report changes of something outside GitHub (a ticket, an issue, an error, a status page): pick a recipe, check the tool and its login read only, dry-run the source, write it to
|
|
3
|
+
description: 'Have the watcher report changes of something outside GitHub (a ticket, an issue, an error, a status page): pick a recipe, check the tool and its login read only, dry-run the source, write it to the watcher''s sources (personal or repository) after approval. Use when the user asks to follow or be told about such an item, e.g. "/follow PROJ-123".'
|
|
4
4
|
argument-hint: 'What to follow, e.g. a ticket key, an issue or a query'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
If `.claude/project/follow.md` exists, read it first. It extends this skill for this project and wins where the two disagree.
|
|
8
8
|
|
|
9
|
-
You add one entry to
|
|
9
|
+
You add one entry to the watcher's sources, so that `jh watch` reports a new or changed entry of an external service as `=== source <name> …`. You run only reading commands; the one file you write is the entry's file (step 4: the user's `~/.jackherd/sources.json` or the project's `.jackherd/config.json` in the main checkout), and only after the user approved the entry. Read the recipe index `recipes/README.md` of the installed package first (`"$(dirname "$(readlink -f "$(command -v jh)")")/../recipes/README.md"`), then only the recipe file it names for the chosen service; do not copy a recipe from memory. The index's section **Rules every recipe follows** binds every script and direct command, and decides any field the recipe leaves open, and `recipes/sources.md` beside it describes every field and limit of a source.
|
|
10
10
|
|
|
11
11
|
## 1. Ask
|
|
12
12
|
|
|
13
13
|
`AskUserQuestion` in {{kit.responseLanguage}}, only for what the argument and the conversation leave open, each with the recommended option first; at most four questions per call, so more open points go into a second call:
|
|
14
14
|
|
|
15
|
+
- **Where it lives:** a personal source (`~/.jackherd/sources.json`: runs in every repository of the user, the keychain can hold its secrets; recommended when a personal secret is attached, otherwise second) or a repository source (`watch.sources` of the project: runs for everyone who starts the watcher there, and its `secrets` read only the watcher's environment, never the keychain, so it needs a token from the environment or none).
|
|
15
16
|
- **Service:** the services the recipe index lists, and "another command that prints JSON".
|
|
16
17
|
- **What to follow:** the item the argument names (its comments, recommended) or a query over several items.
|
|
17
18
|
- **Lane:** the lane the reports name, from the board's lanes table, or none.
|
|
18
19
|
- **Ticket:** whether the entries are tickets (`ticket: true`, recommended for an issue tracker or a project board), which the herd shows as a badge beside the plan of their lane: the lane above, or the lane whose name, board row or plan names the ticket's id. A source of comments or messages is no ticket; it shows in the herd's `Sources` block instead (a click opens the entry at `map.url`). The path of the status you find yourself in step 3.
|
|
19
|
-
- **Tone:** for a non-ticket source, the colour of its `Sources` mark while its report is new (`tone`: amber, teal, grey, red, green, blue, magenta); recommended: no `tone
|
|
20
|
+
- **Tone:** for a non-ticket source, the colour of its `Sources` mark while its report is new (`tone`: amber, teal, grey, red, green, blue, magenta); recommended: no `tone`.
|
|
21
|
+
- **Show:** for a non-ticket source whose entries are the day's content (a calendar), `show: "entries"` opens them in a block of their own under "Your turn"; otherwise none.
|
|
22
|
+
- **What reports:** a change of what the user reads (title, body, the latest comment; recommended), or a change of named fields such as a status (`reportOn` with those fields, mapped in `map`).
|
|
20
23
|
- **Own changes:** ignore the user's own account on that service (`ignoreAuthors`, needs an author in the map), or report everything.
|
|
21
24
|
|
|
22
25
|
## 2. Check, read only
|
|
23
26
|
|
|
24
27
|
- The tool: `command -v <tool>` for the recipe's program (`curl`, the service's CLI, and `bun` for a recipe with a script).
|
|
25
|
-
- The login: the tool's own status command where it has one. For a token in an environment variable only whether it is set: `test -n "$<VARIABLE>" && echo set || echo missing`. Never print, echo or pass on a token's value, and never read a file that holds one.
|
|
28
|
+
- The login: the tool's own status command where it has one. For a token in an environment variable only whether it is set: `test -n "$<VARIABLE>" && echo set || echo missing`. Never print, echo or pass on a token's value, never ask for it, and never read a file that holds one.
|
|
26
29
|
|
|
27
|
-
Anything missing goes to the user as the command to run themselves (`! <command
|
|
30
|
+
Anything missing goes to the user as the command to run themselves (`! <command>`; a keychain secret: below, not with `!`); wait until they did, then check again.
|
|
31
|
+
|
|
32
|
+
**A secret** (a token the script needs) is named in the entry's `secrets` (`["NAME"]`) and never passes through you (the rule above). A repository source does not read the keychain: check its environment variable as above. A personal source's secret goes into the keychain in step 4, after the entry is written: `jh secret list` knows only names an entry already lists.
|
|
28
33
|
|
|
29
34
|
## 3. Fill and dry-run
|
|
30
35
|
|
|
31
36
|
Before filling, read the help of the installed CLI version for every command the recipe calls (`acli jira workitem search --help` and `workitem view --help`, `gh issue list --help`) and confirm that the fields and options the entry needs exist there. Read the provider's web documentation only when the help leaves it open or no recipe fits. A recipe is a starting point; the installed version decides.
|
|
32
37
|
|
|
33
|
-
Fill the recipe with the item or query and the answers from step 1: a `name` of letters, digits, `.`, `_` and `-` that says what is followed, `lane`, `ignoreAuthors`, `ticket` and `tone` where chosen, and a short `label` (e.g. `Error log · Checkout`; it prefixes the source's `Waiting for you` rows). For a ticket source, find the path of the status in the output (from the CLI's help, or the dry run with `--raw`) and set it as `map.status`; the entry you show for approval in step 4 carries it, and without a status in the output the entry has none and the badge shows only the key. A recipe that needs a script (a token from the environment, or one call per item) gets TypeScript run by Bun: write it, from the recipe's script under `recipes/sources/`, to the scratchpad first as `<name>.ts`, never into the repository before step 4, and let the entry's `command` be `["bun", "scripts/sources/<name>.ts"]
|
|
34
|
-
|
|
38
|
+
Fill the recipe with the item or query and the answers from step 1: a `name` of letters, digits, `.`, `_` and `-` that says what is followed, `lane`, `ignoreAuthors`, `ticket` and `tone` where chosen, and a short `label` (e.g. `Error log · Checkout`; it prefixes the source's `Waiting for you` rows). For a ticket source, find the path of the status in the output (from the CLI's help, or the dry run with `--raw`) and set it as `map.status`; the entry you show for approval in step 4 carries it, and without a status in the output the entry has none and the badge shows only the key. A recipe that needs a script (a token from the environment, or one call per item) gets TypeScript run by Bun: write it, from the recipe's script under `recipes/sources/`, to the scratchpad first as `<name>.ts`, never into the repository before step 4, and let the entry's `command` be `["bun", "scripts/sources/<name>.ts"]` (a personal source runs in `~/.jackherd/`: `["bun", "sources/<name>.ts"]`).
|
|
35
39
|
Write the entry as one JSON object to a draft file in the scratchpad and run, from the main checkout:
|
|
36
40
|
|
|
37
41
|
```bash
|
|
38
42
|
jh watch source <draft.json> --dry-run
|
|
39
43
|
```
|
|
40
44
|
|
|
41
|
-
It runs the command once and shows the entries and the report the watcher would print, without touching the watcher's state. Exit code 1 means the run failed or a `map` field is empty in every entry (`map.<field>: in … entries empty (<path>)`): never write such an entry, correct the path or drop the field. When the run fails or the entries look wrong, run it again with `--raw` to see the command's output, correct `items` or a path of `map`, and try again; after two failed corrections, show the user the reason and the output's first lines and ask how to go on. Show the user the dry run's list and report, not the raw output.
|
|
45
|
+
It runs the command once and shows the entries and the report the watcher would print, without touching the watcher's state. A draft runs as a repository source: in the main checkout, without the keychain. A personal entry with `secrets` or a script under `sources/` cannot pass there; skip its draft run, show the entry and the recipe's sample output, and dry-run it by name in step 4. Exit code 1 means the run failed or a `map` field is empty in every entry (`map.<field>: in … entries empty (<path>)`): never write such an entry, correct the path or drop the field. When the run fails or the entries look wrong, run it again with `--raw` to see the command's output, correct `items` or a path of `map`, and try again; after two failed corrections, show the user the reason and the output's first lines and ask how to go on. Show the user the dry run's list and report, not the raw output.
|
|
42
46
|
|
|
43
47
|
## 4. Write after approval
|
|
44
48
|
|
|
45
|
-
Ask through `AskUserQuestion`: write the entry (recommended), change it, or drop it. Say in the question
|
|
49
|
+
Ask through `AskUserQuestion`: write the entry (recommended), change it, or drop it. Say in the question which file it goes to and that the watcher cannot keep a command from writing, so the command only reads; for a repository source add that it runs for everyone who starts the watcher in this repository.
|
|
50
|
+
|
|
51
|
+
On approval write exactly one of these files, and a script from step 3 under the path the entry names:
|
|
46
52
|
|
|
47
|
-
|
|
53
|
+
- **Personal:** add the entry to `sources` in `~/.jackherd/sources.json`; create the file as `{ "$schema": "<installed package>/schemas/sources.schema.json", "sources": [] }` if missing (the package directory is the one the recipe path above resolves to, minus `/recipes/README.md`). The script goes to `~/.jackherd/sources/<name>.ts`. A repository source of the same `name` wins over it, so pick a name the project does not use. For each name in `secrets`, ask the user to run `jh secret set <NAME>` in a terminal of their own (a separate pane; it asks hidden and refuses without a terminal), wait, then check only `jh secret list`: `set` is fine, `missing` means they still have to, `set (environment)` means the watcher's environment must carry it too, `secret store unavailable` means it can come only from that environment. Then run `jh watch source <name> --dry-run`; it prints `origin: personal`. A failure here is corrected in the written entry as in step 3.
|
|
54
|
+
- **Repository:** add the entry to `watch.sources` in `.jackherd/config.json` of the main checkout (create the array if missing). Then run `jh config`, which fails on an invalid config, and `jh watch source <name> --dry-run` once more.
|
|
48
55
|
|
|
49
|
-
End with one line:
|
|
56
|
+
End with one line: a running watcher and the herd take the source over at their next cycle (about a minute); a file that is invalid then keeps the previous sources and prints `sources not reloaded`. A repository entry and its script are part of the repository and follow its commit rules; the personal file is not.
|
|
@@ -35,7 +35,7 @@ When the user asks for more than one phase:
|
|
|
35
35
|
|
|
36
36
|
- **Tick off tasks as you go.** As soon as a task is done, set `- [ ]` → `- [x]` in the plan document, right then, so the plan reflects real progress and the herd shows it while the phase runs.
|
|
37
37
|
- **A subagent ticks only its own phase,** and only with `Edit` on the exact task line (enough of it to be unique in the file). Never `Write` the plan or rewrite it with a script: parallel subagents share the file, and `Edit` applies its replacement to the file as it is on disk, so ticks in other phases survive, while a whole-file write from an older read erases them. A line that is not unique, and every review-gate item, stays with the caller.
|
|
38
|
-
- **A task you add
|
|
38
|
+
- **A task you add, or one the user adds later,** takes the form of `/plan` step 2: `- [ ] **Name**: …`.
|
|
39
39
|
- **The review gate is binding.** A phase is finished once its review gate has been walked through completely and ticked off; only then comes the next phase or `/pre-commit`. Gate items that name a skill (`/uncle-bob`, `/security-audit`, …) are ticked after the skill has actually run.
|
|
40
40
|
|
|
41
41
|
## Step 1: Explore
|