jackherd 1.15.2 → 1.16.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.
@@ -0,0 +1,368 @@
1
+ // The iCalendar reader of the Google Calendar recipe (recipes/google-calendar.md). Copy it next to
2
+ // google-calendar-today.ts; the zone every function reads a wall-clock time in is an argument.
3
+ export type Civil = { year: number; month: number; day: number; hour: number; minute: number; second: number }
4
+ export type IcsDate = { civil: Civil; allDay: boolean; zone: string }
5
+ export const FREQUENCY = { daily: 'DAILY', weekly: 'WEEKLY', monthly: 'MONTHLY', yearly: 'YEARLY' } as const
6
+ export type Frequency = (typeof FREQUENCY)[keyof typeof FREQUENCY]
7
+ export const isFrequency = (value: string): value is Frequency => (Object.values(FREQUENCY) as string[]).includes(value)
8
+ export const EVENT_STATUS = { cancelled: 'CANCELLED' } as const
9
+
10
+ export type Rule = {
11
+ freq: Frequency | undefined
12
+ interval: number
13
+ until?: IcsDate
14
+ count?: number
15
+ byDay: { ordinal: number; weekday: number }[]
16
+ byMonthDay: number[]
17
+ byMonth: number[]
18
+ unsupported: boolean
19
+ }
20
+ export type IcsEvent = {
21
+ uid: string
22
+ summary: string
23
+ location: string
24
+ status: string
25
+ lastModified?: IcsDate
26
+ start: IcsDate
27
+ end?: IcsDate
28
+ rule?: Rule
29
+ exDates: string[]
30
+ recurrenceId?: IcsDate
31
+ }
32
+ export type Occurrence = { event: IcsEvent; key: string; startMs: number; endMs: number }
33
+ export type CalendarItem = {
34
+ id: string
35
+ title: string
36
+ url: string
37
+ updatedAt: string
38
+ }
39
+
40
+ const DAY_MS = 24 * 60 * 60 * 1000
41
+ const WEEKDAYS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']
42
+ const UNSUPPORTED_PARTS = ['BYSETPOS', 'BYWEEKNO', 'BYYEARDAY', 'BYHOUR', 'BYMINUTE', 'BYSECOND']
43
+ const PROPERTY_LINE = /^([A-Za-z-]+)((?:;[^:;=]+=(?:"[^"]*"|[^:;]*))*):(.*)$/
44
+ const UNFOLD = /\r?\n[ \t]/g
45
+ const UTC = 'UTC'
46
+ const ADDRESS_VARIABLE = /^GOOGLE_CALENDAR_ICS_URL(?:_(\d+))?$/
47
+
48
+ /** The calendar addresses in the environment, GOOGLE_CALENDAR_ICS_URL first, then `_2`, `_3`, … */
49
+ export const addressesOf = (environment: Record<string, string | undefined>): string[] =>
50
+ Object.entries(environment)
51
+ .flatMap(([name, value]) => {
52
+ const match = ADDRESS_VARIABLE.exec(name)
53
+ return match && value ? [{ order: Number(match[1] ?? 1), value }] : []
54
+ })
55
+ .sort((a, b) => a.order - b.order)
56
+ .map(({ value }) => value)
57
+
58
+ const formatters = new Map<string, Intl.DateTimeFormat>()
59
+
60
+ const formatterFor = (zone: string): Intl.DateTimeFormat => {
61
+ const cached = formatters.get(zone)
62
+ if (cached) return cached
63
+ const formatter = new Intl.DateTimeFormat('en-US', {
64
+ timeZone: zone,
65
+ hourCycle: 'h23',
66
+ year: 'numeric',
67
+ month: 'numeric',
68
+ day: 'numeric',
69
+ hour: 'numeric',
70
+ minute: 'numeric',
71
+ second: 'numeric',
72
+ })
73
+ formatters.set(zone, formatter)
74
+ return formatter
75
+ }
76
+
77
+ export const civilOf = (ms: number, zone: string): Civil => {
78
+ const parts = Object.fromEntries(formatterFor(zone).formatToParts(new Date(ms)).map((part) => [part.type, part.value]))
79
+ return {
80
+ year: Number(parts.year),
81
+ month: Number(parts.month),
82
+ day: Number(parts.day),
83
+ hour: Number(parts.hour),
84
+ minute: Number(parts.minute),
85
+ second: Number(parts.second),
86
+ }
87
+ }
88
+
89
+ const civilMs = (civil: Civil): number =>
90
+ Date.UTC(civil.year, civil.month - 1, civil.day, civil.hour, civil.minute, civil.second)
91
+
92
+ const offsetAt = (ms: number, zone: string): number => civilMs(civilOf(ms, zone)) - Math.floor(ms / 1000) * 1000
93
+
94
+ /** The instant a wall-clock time in `zone` names; in a DST gap it lands one hour later. */
95
+ export const instantOf = (civil: Civil, zone: string): number => {
96
+ if (zone === UTC) return civilMs(civil)
97
+ const guess = civilMs(civil)
98
+ const first = guess - offsetAt(guess, zone)
99
+ return guess - offsetAt(first, zone)
100
+ }
101
+
102
+ const addDays = (civil: Civil, days: number): Civil => {
103
+ const shifted = new Date(civilMs(civil) + days * DAY_MS)
104
+ return {
105
+ ...civil,
106
+ year: shifted.getUTCFullYear(),
107
+ month: shifted.getUTCMonth() + 1,
108
+ day: shifted.getUTCDate(),
109
+ }
110
+ }
111
+
112
+ const dayNumber = (civil: Civil): number => Math.floor(Date.UTC(civil.year, civil.month - 1, civil.day) / DAY_MS)
113
+ const weekdayOf = (civil: Civil): number => new Date(Date.UTC(civil.year, civil.month - 1, civil.day)).getUTCDay()
114
+ const daysInMonth = (civil: Civil): number => new Date(Date.UTC(civil.year, civil.month, 0)).getUTCDate()
115
+ const dateKey = (civil: Civil): string =>
116
+ `${civil.year}${String(civil.month).padStart(2, '0')}${String(civil.day).padStart(2, '0')}`
117
+
118
+ const unescapeText = (value: string): string =>
119
+ value.replace(/\\([\\,;nN])/g, (_, character: string) => (character.toLowerCase() === 'n' ? '\n' : character))
120
+
121
+ const parseParameters = (raw: string): Record<string, string> =>
122
+ Object.fromEntries(
123
+ raw
124
+ .split(';')
125
+ .filter(Boolean)
126
+ .map((pair) => {
127
+ const [name, ...value] = pair.split('=')
128
+ return [name.toUpperCase(), value.join('=').replace(/^"|"$/g, '')]
129
+ }),
130
+ )
131
+
132
+ const parseIcsDate =(value: string, parameters: Record<string, string>, floatingZone: string): IcsDate => {
133
+ const match = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/.exec(value.trim())
134
+ if (!match) throw new Error('unreadable date')
135
+ const [, year, month, day, hour, minute, second, utc] = match
136
+ const allDay = hour === undefined
137
+ return {
138
+ civil: {
139
+ year: Number(year),
140
+ month: Number(month),
141
+ day: Number(day),
142
+ hour: Number(hour ?? 0),
143
+ minute: Number(minute ?? 0),
144
+ second: Number(second ?? 0),
145
+ },
146
+ allDay,
147
+ zone: utc ? UTC : (parameters.TZID ?? floatingZone),
148
+ }
149
+ }
150
+
151
+ const parseWeekday = (token: string): { ordinal: number; weekday: number } => {
152
+ const match = /^([+-]?\d+)?([A-Z]{2})$/.exec(token)
153
+ return { ordinal: Number(match?.[1] ?? 0), weekday: WEEKDAYS.indexOf(match?.[2] ?? '') }
154
+ }
155
+
156
+ const numbers = (value?: string): number[] => (value ? value.split(',').map(Number) : [])
157
+
158
+ export const parseRule = (value: string, floatingZone: string): Rule => {
159
+ const parts = Object.fromEntries(value.split(';').map((pair) => pair.split('=') as [string, string]))
160
+ return {
161
+ freq: isFrequency(parts.FREQ ?? '') ? (parts.FREQ as Frequency) : undefined,
162
+ interval: Number(parts.INTERVAL ?? 1),
163
+ until: parts.UNTIL ? parseIcsDate(parts.UNTIL, {}, floatingZone) : undefined,
164
+ count: parts.COUNT ? Number(parts.COUNT) : undefined,
165
+ byDay: parts.BYDAY ? parts.BYDAY.split(',').map(parseWeekday) : [],
166
+ byMonthDay: numbers(parts.BYMONTHDAY),
167
+ byMonth: numbers(parts.BYMONTH),
168
+ unsupported: !isFrequency(parts.FREQ ?? '') || UNSUPPORTED_PARTS.some((part) => part in parts),
169
+ }
170
+ }
171
+
172
+ const occurrenceKey = (date: IcsDate): string =>
173
+ date.allDay ? dateKey(date.civil) : new Date(instantOf(date.civil, date.zone)).toISOString()
174
+
175
+ type Property = { name: string; parameters: Record<string, string>; value: string }
176
+
177
+ const readProperties = (block: string[]): Property[] =>
178
+ block.flatMap((line) => {
179
+ const match = PROPERTY_LINE.exec(line)
180
+ return match ? [{ name: match[1].toUpperCase(), parameters: parseParameters(match[2]), value: match[3] }] : []
181
+ })
182
+
183
+ const buildEvent = (properties: Property[], floatingZone: string): IcsEvent | undefined => {
184
+ const first = (name: string) => properties.find((property) => property.name === name)
185
+ const date = (name: string) => {
186
+ const property = first(name)
187
+ return property ? parseIcsDate(property.value, property.parameters, floatingZone) : undefined
188
+ }
189
+ const start = date('DTSTART')
190
+ const uid = first('UID')?.value
191
+ if (!start || !uid) return undefined
192
+ const rule = first('RRULE')
193
+ return {
194
+ uid,
195
+ summary: unescapeText(first('SUMMARY')?.value ?? ''),
196
+ location: unescapeText(first('LOCATION')?.value ?? ''),
197
+ status: (first('STATUS')?.value ?? '').toUpperCase(),
198
+ lastModified: date('LAST-MODIFIED'),
199
+ start,
200
+ end: date('DTEND'),
201
+ rule: rule ? parseRule(rule.value, floatingZone) : undefined,
202
+ exDates: properties
203
+ .filter((property) => property.name === 'EXDATE')
204
+ .flatMap((property) =>
205
+ property.value.split(',').map((value) => occurrenceKey(parseIcsDate(value, property.parameters, floatingZone))),
206
+ ),
207
+ recurrenceId: date('RECURRENCE-ID'),
208
+ }
209
+ }
210
+
211
+ const toEvent = (properties: Property[], floatingZone: string): IcsEvent | undefined => {
212
+ try {
213
+ return buildEvent(properties, floatingZone)
214
+ } catch {
215
+ return undefined
216
+ }
217
+ }
218
+
219
+ /**
220
+ * The VEVENTs of an iCalendar text. A floating time (no TZID, no Z) is read in `floatingZone`;
221
+ * a block without UID or DTSTART, or with a date it cannot read, is dropped on its own and
222
+ * leaves the other events alone.
223
+ */
224
+ export const parseIcs = (text: string, floatingZone: string): IcsEvent[] => {
225
+ const events: IcsEvent[] = []
226
+ let block: string[] | undefined
227
+ for (const line of text.replace(UNFOLD, '').split(/\r?\n/)) {
228
+ if (line === 'BEGIN:VEVENT') block = []
229
+ else if (line === 'END:VEVENT' && block) {
230
+ const event = toEvent(readProperties(block), floatingZone)
231
+ if (event) events.push(event)
232
+ block = undefined
233
+ } else block?.push(line)
234
+ }
235
+ return events
236
+ }
237
+
238
+ const monthsBetween = (from: Civil, to: Civil): number => (to.year - from.year) * 12 + (to.month - from.month)
239
+
240
+ const matchesByDay = (rule: Rule, candidate: Civil): boolean =>
241
+ rule.byDay.some(({ ordinal, weekday }) => {
242
+ if (weekday !== weekdayOf(candidate)) return false
243
+ if (ordinal === 0) return true
244
+ const fromStart = Math.floor((candidate.day - 1) / 7) + 1
245
+ const fromEnd = -(Math.floor((daysInMonth(candidate) - candidate.day) / 7) + 1)
246
+ return ordinal === fromStart || ordinal === fromEnd
247
+ })
248
+
249
+ const matchesMonthDay = (rule: Rule, candidate: Civil): boolean =>
250
+ rule.byMonthDay.some((day) => (day > 0 ? day : daysInMonth(candidate) + day + 1) === candidate.day)
251
+
252
+ const matchesMonthly = (rule: Rule, start: Civil, candidate: Civil): boolean => {
253
+ if (rule.byMonthDay.length > 0) return matchesMonthDay(rule, candidate)
254
+ if (rule.byDay.length > 0) return matchesByDay(rule, candidate)
255
+ return candidate.day === start.day
256
+ }
257
+
258
+ /** Whether a rule with its DTSTART on `start` falls on the civil day `candidate` (on or after `start`). */
259
+ export const ruleMatches = (rule: Rule, start: Civil, candidate: Civil): boolean => {
260
+ if (rule.byMonth.length > 0 && !rule.byMonth.includes(candidate.month)) return false
261
+ const days = dayNumber(candidate) - dayNumber(start)
262
+ if (rule.freq === FREQUENCY.daily) return days % rule.interval === 0
263
+ if (rule.freq === FREQUENCY.weekly) {
264
+ const mondayOf = (civil: Civil) => dayNumber(civil) - ((weekdayOf(civil) + 6) % 7)
265
+ const weeks = (mondayOf(candidate) - mondayOf(start)) / 7
266
+ const weekdays = rule.byDay.length > 0 ? rule.byDay.map(({ weekday }) => weekday) : [weekdayOf(start)]
267
+ return weeks % rule.interval === 0 && weekdays.includes(weekdayOf(candidate))
268
+ }
269
+ if (rule.freq === FREQUENCY.monthly) {
270
+ return monthsBetween(start, candidate) % rule.interval === 0 && matchesMonthly(rule, start, candidate)
271
+ }
272
+ if (rule.freq === FREQUENCY.yearly) {
273
+ const month = rule.byMonth.length > 0 ? candidate.month : start.month
274
+ return (candidate.year - start.year) % rule.interval === 0 && candidate.month === month && candidate.day === start.day
275
+ }
276
+ return false
277
+ }
278
+
279
+ const durationMs = (event: IcsEvent): number => {
280
+ if (!event.end) return event.start.allDay ? DAY_MS : 0
281
+ if (event.start.allDay) return (dayNumber(event.end.civil) - dayNumber(event.start.civil)) * DAY_MS
282
+ return instantOf(event.end.civil, event.end.zone) - instantOf(event.start.civil, event.start.zone)
283
+ }
284
+
285
+ const startMsOf = (date: IcsDate, viewZone: string): number =>
286
+ date.allDay ? instantOf(date.civil, viewZone) : instantOf(date.civil, date.zone)
287
+
288
+ const isBeyondUntil = (rule: Rule, candidate: IcsDate, viewZone: string): boolean => {
289
+ if (!rule.until) return false
290
+ if (rule.until.allDay || candidate.allDay) return dayNumber(candidate.civil) > dayNumber(rule.until.civil)
291
+ return startMsOf(candidate, viewZone) > startMsOf(rule.until, viewZone)
292
+ }
293
+
294
+ const expandRule = (event: IcsEvent, rule: Rule, lastDay: Civil, viewZone: string): IcsDate[] => {
295
+ const dates: IcsDate[] = []
296
+ let matched = 0
297
+ for (let civil = event.start.civil; dayNumber(civil) <= dayNumber(lastDay); civil = addDays(civil, 1)) {
298
+ if (!ruleMatches(rule, event.start.civil, civil)) continue
299
+ const candidate = { ...event.start, civil }
300
+ if (isBeyondUntil(rule, candidate, viewZone)) break
301
+ matched += 1
302
+ if (rule.count !== undefined && matched > rule.count) break
303
+ dates.push(candidate)
304
+ }
305
+ return dates
306
+ }
307
+
308
+ /**
309
+ * Every occurrence that overlaps the civil day `today` in `viewZone`. Recurring events are expanded
310
+ * day by day from their DTSTART (FREQ DAILY, WEEKLY, MONTHLY and YEARLY with INTERVAL, UNTIL, COUNT,
311
+ * BYDAY, BYMONTHDAY and BYMONTH); a rule beyond that is skipped. EXDATE removes an occurrence, an
312
+ * event with RECURRENCE-ID replaces it, and a cancelled one is dropped.
313
+ */
314
+ export const occurrencesOn = (events: IcsEvent[], today: Civil, viewZone: string): Occurrence[] => {
315
+ const dayStart = instantOf({ ...today, hour: 0, minute: 0, second: 0 }, viewZone)
316
+ const dayEnd = instantOf({ ...addDays(today, 1), hour: 0, minute: 0, second: 0 }, viewZone)
317
+ const overridden = new Set(
318
+ events.flatMap((event) => (event.recurrenceId ? [`${event.uid}@${occurrenceKey(event.recurrenceId)}`] : [])),
319
+ )
320
+ const occurrences: Occurrence[] = []
321
+ for (const event of events) {
322
+ if (event.status === EVENT_STATUS.cancelled) continue
323
+ if (event.rule?.unsupported) continue
324
+ const starts = event.rule && !event.recurrenceId ? expandRule(event, event.rule, today, viewZone) : [event.start]
325
+ for (const start of starts) {
326
+ const key = occurrenceKey(event.recurrenceId ?? start)
327
+ if (!event.recurrenceId && (event.exDates.includes(key) || overridden.has(`${event.uid}@${key}`))) continue
328
+ const startMs = startMsOf(start, viewZone)
329
+ const endMs = startMs + durationMs(event)
330
+ const overlaps = startMs < dayEnd && (endMs > dayStart || (endMs === startMs && startMs >= dayStart))
331
+ if (overlaps) occurrences.push({ event, key, startMs, endMs })
332
+ }
333
+ }
334
+ return occurrences.sort((a, b) => a.startMs - b.startMs)
335
+ }
336
+
337
+ const clock = (ms: number, zone: string): string => {
338
+ const civil = civilOf(ms, zone)
339
+ return `${String(civil.hour).padStart(2, '0')}:${String(civil.minute).padStart(2, '0')}`
340
+ }
341
+
342
+ const whenOf = (occurrence: Occurrence, zone: string): string => {
343
+ if (occurrence.event.start.allDay) return 'all day'
344
+ if (occurrence.endMs === occurrence.startMs) return clock(occurrence.startMs, zone)
345
+ return `${clock(occurrence.startMs, zone)}–${clock(occurrence.endMs, zone)}`
346
+ }
347
+
348
+ /** A short stable id per occurrence: the report line prints it, and a Google UID runs to 60 characters. */
349
+ export const occurrenceId = (uid: string, key: string): string =>
350
+ new Bun.CryptoHasher('sha1').update(`${uid}@${key}`).digest('hex').slice(0, 12)
351
+
352
+ /** Google Calendar's day view of `today`, a short link the watcher keeps. */
353
+ const dayUrl =(today: Civil): string =>
354
+ `https://calendar.google.com/calendar/r/day/${today.year}/${today.month}/${today.day}`
355
+
356
+ export const toItems = (occurrences: Occurrence[], today: Civil, zone: string): CalendarItem[] =>
357
+ occurrences.map((occurrence) => {
358
+ const { event } = occurrence
359
+ const what = [event.summary || '(no title)', event.location].filter(Boolean).join(' · ')
360
+ return {
361
+ id: occurrenceId(event.uid, occurrence.key),
362
+ title: `${whenOf(occurrence, zone)} ${what}`,
363
+ url: dayUrl(today),
364
+ updatedAt: event.lastModified
365
+ ? new Date(instantOf(event.lastModified.civil, event.lastModified.zone)).toISOString()
366
+ : '',
367
+ }
368
+ })
@@ -2,7 +2,7 @@
2
2
 
3
3
  The fields and limits of a `watch.sources` entry, for a source no [recipe](README.md) covers or a field a recipe leaves open.
4
4
 
5
- Source reports are off unless the config lists `watch.sources`. The watcher cannot use the MCP servers of a Claude Code session: their connections and logins (often OAuth in a browser) belong to the session. A source is a command instead, which prints JSON on stdout. It runs without a shell, in the main checkout, with your environment, so its token stays where it is. `/jh-follow` fills one of the [recipes](README.md) for you.
5
+ Source reports are off unless the config lists `watch.sources`. The watcher cannot use the MCP servers of a Claude Code session: their connections and logins (often OAuth in a browser) belong to the session. A source is a command instead, which prints JSON on stdout. It runs without a shell, in the main checkout (a personal source: below), with your environment, so its token stays where it is. `/jh-follow` fills one of the [recipes](README.md) for you.
6
6
 
7
7
  ```json
8
8
  {
@@ -76,9 +76,21 @@ dry run source tickets: ["tickets-cli","search","--query","key in (T-123)","--js
76
76
  first run of this source: baseline, nothing reported; later a new or changed entry reports like this:
77
77
  === source tickets (search) T-123 Decide the layout · new
78
78
  Yes, take variant B.
79
- watcher state not read or changed; the watcher takes a new source over at its next start
79
+ watcher state not read or changed; a running watcher takes a new source over at its next cycle
80
80
  ```
81
81
 
82
- The watcher reads `watch.sources` when it starts: a new or changed source counts from its next start.
82
+ **Keep a personal source.** A source can live in `~/.jackherd/sources.json` instead of the repository: `{ "$schema": "…/schemas/sources.schema.json", "sources": [ … ] }`, entries as above. It runs in every repository where the watcher starts, after the repository's `watch.sources`. On a name clash the repository's source wins and the personal one is skipped with a note. A personal source runs with `~/.jackherd/` as its working directory, so its script is `~/.jackherd/sources/<name>.ts` and its command `["bun", "sources/<name>.ts"]`. `jh watch source <name> --dry-run` finds it too and prints `origin: personal, <path>`; a draft file runs as a repository source, in the main checkout and without the keychain.
83
+
84
+ **Keep a secret for a source.** `"secrets": ["NAME"]` (up to 10 names, `A-Z`, digits and `_`, starting with a letter) puts those variables into the environment of the source's command, and nowhere else. For a personal source each value comes from the keychain (service `jackherd`), else from the watcher's own environment; a repository source gets them from the watcher's environment only, never from the keychain. A value that is found nowhere fails the run with `secret missing: NAME`, an unreachable keychain with `secret store unavailable`; other sources go on. The script reads the value from `process.env` and never prints it.
85
+
86
+ ```text
87
+ jh secret set NAME asks for the value hidden, needs a terminal (exit 2 without), never takes it as argument or from stdin
88
+ jh secret list each name the sources need: set, set (environment), missing, missing (environment only), secret store unavailable
89
+ jh secret delete NAME
90
+ ```
91
+
92
+ **Show entries as content.** `"show": "entries"` opens the source's entries in a herd block of their own under "Your turn", headed by its `label`, instead of the folded row in `Sources`. For a source whose entries are the day's content, such as a calendar; not together with `ticket`.
93
+
94
+ The watcher and the herd read the repository's config and the personal file again each cycle (about a minute): a new, changed or removed source counts without a restart, a new one runs at once. A changed file that is invalid keeps the previous sources and prints `sources not reloaded: <file>: <reason>`. The rest of the config is read when the watcher starts.
83
95
 
84
96
  Three limits stand: the watcher cannot make a command read only, so name only commands that read. A source in `.jackherd/config.json` runs for everyone who starts the watcher in the repository, at the same level of trust as `check.checks` and hooks. The watcher reads the config of the main checkout, never the one of a lane's worktree; a dry run started inside a worktree reads that worktree's config first. And `jh watch source … --dry-run` runs whatever command its draft names, so it never belongs in a permission allowlist.
@@ -16,7 +16,10 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
16
16
  '.claude/skills/devops/SKILL.md': 321,
17
17
  '.claude/skills/diagnose/SKILL.md': 1521,
18
18
  '.claude/skills/diagnose/references/feedback-loops.md': 636,
19
- '.claude/skills/follow/SKILL.md': 1100,
19
+ // Personal sources: where the source lives, the secret step that keeps the value out of the agent, the personal
20
+ // file and its script path, what a reload prints (+295); skill-review: a personal source stores its secret and is
21
+ // dry-run by name after the entry is written, since a draft runs without the keychain (+103).
22
+ '.claude/skills/follow/SKILL.md': 1498,
20
23
  '.claude/skills/grill/SKILL.md': 685,
21
24
  '.claude/skills/implement/SKILL.md': 2190,
22
25
  // P33: the paw in the header chip, panes by tab and title, the lane in the context line of a passed-on question.
@@ -27,7 +30,10 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
27
30
  // Cross-check: measured in a repository whose stack nothing detects, every unset command renders as a note (+7).
28
31
  // Ready phase: the `done` / `idle` row names the watcher's mandatory `ready:` line (+20).
29
32
  // Drafts until the merge: § 5 step 5 opens a draft and readies it, Self-merge step 3 waits for the runs after it (+100).
30
- '.claude/skills/orchestrate/SKILL.md': 8230,
33
+ // Ready round: a PR of the lane waiting for approval or merge holds only the commit; the self-merge command runs
34
+ // alone, so the guard can check it (+132, the longest rendering of the kit's renderings); skill-review: the commit
35
+ // of a PR awaiting approval stages only its files, a branch over a squash-merged PR moves with `--onto` (+51).
36
+ '.claude/skills/orchestrate/SKILL.md': 8413,
31
37
  // skill-review: the copy is taken after a mirror, and rejecting together with a remark is a remark (+36).
32
38
  // Cross-check: an adopted plan also gets short names and a no-review-gate line, which `jh check` asks of a new
33
39
  // plan; the proof became a word diff, since a short name goes into an existing line (+74); skill-review: the
@@ -55,8 +61,9 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
55
61
  // Any language: step 3 points to proposing the missing commands from the CI; the proposal itself is a reference.
56
62
  '.claude/skills/start/SKILL.md': 630,
57
63
  '.claude/skills/uncle-bob/SKILL.md': 466,
58
- // P33 and open up: grows with the skill above.
59
- '.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 8880,
64
+ // P33 and open up: grows with the skill above (+132 for the ready round and the lone merge call); measured with
65
+ // this repository's supplement after the skill-review fixes (+153).
66
+ '.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 9165,
60
67
  '.claude/agents/devops.md': 104,
61
68
  '.claude/agents/explore.md': 132,
62
69
  '.claude/agents/implement.md': 303,