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.
- package/CHANGELOG.md +12 -0
- package/README.md +22 -19
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/merge-guard/decision.ts +17 -2
- package/kit/workflow/skills/follow/SKILL.md +18 -10
- package/kit/workflow/skills/orchestrate/SKILL.md +9 -6
- package/package.json +2 -1
- package/recipes/README.md +3 -2
- package/recipes/google-calendar.md +30 -0
- package/recipes/sources/google-calendar-today.ts +32 -0
- package/recipes/sources/google-calendar.ts +368 -0
- package/recipes/sources.md +15 -3
- package/src/check/skill-budget-defaults.ts +11 -4
- package/src/plans/layout.ts +135 -18
- package/src/plans/live.ts +54 -7
- package/src/plans/row-actions.ts +17 -10
- package/src/plans/snapshot.ts +8 -3
- package/src/plans/source-rows.ts +35 -2
- package/src/watch/lane-report.ts +2 -1
- package/src/watch/source-dry-run.ts +1 -1
- package/src/watch/texts.ts +1 -1
|
@@ -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
|
+
})
|
package/recipes/sources.md
CHANGED
|
@@ -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;
|
|
79
|
+
watcher state not read or changed; a running watcher takes a new source over at its next cycle
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
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,
|