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.
Files changed (161) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/INSTALL.md +2 -1
  3. package/README.md +26 -22
  4. package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
  5. package/dist/config.d.ts +33 -1
  6. package/dist/config.js +57 -5
  7. package/dist/plan-format.d.ts +18 -6
  8. package/dist/plan-format.js +8 -1
  9. package/docs/plan-contract.md +1 -0
  10. package/herdr-plugin/herdr-plugin.toml +4 -1
  11. package/herdr-plugin/restore-plans.ts +60 -26
  12. package/herdr-plugin/resume.ts +18 -0
  13. package/install.sh +1 -1
  14. package/kit/hooks/ask-rules.json +11 -0
  15. package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
  16. package/kit/hooks/hooks/approval-gate/index.ts +36 -7
  17. package/kit/hooks/hooks/approval-gate/run.ts +72 -16
  18. package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
  19. package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
  20. package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
  21. package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
  22. package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
  23. package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
  24. package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
  25. package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
  26. package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
  27. package/kit/hooks/hooks/merge-guard/decision.ts +438 -35
  28. package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
  29. package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
  30. package/kit/hooks/hooks/merge-guard/run.ts +110 -28
  31. package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
  32. package/kit/hooks/hooks/plain-question/form.ts +89 -0
  33. package/kit/hooks/hooks/plain-question/index.ts +44 -4
  34. package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
  35. package/kit/hooks/hooks/plain-question/run.ts +14 -7
  36. package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
  37. package/kit/hooks/hooks.json +11 -2
  38. package/kit/review/agents/skill-review.md +3 -2
  39. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  40. package/kit/rules/rules/kit/code-style.md +6 -6
  41. package/kit/rules/rules/kit/writing-for-agents.md +3 -0
  42. package/kit/workflow/skills/follow/SKILL.md +18 -11
  43. package/kit/workflow/skills/implement/SKILL.md +1 -1
  44. package/kit/workflow/skills/orchestrate/SKILL.md +124 -300
  45. package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
  46. package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
  47. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
  48. package/kit/workflow/skills/orchestrate/references/events.md +46 -1
  49. package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
  50. package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
  51. package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
  52. package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
  53. package/kit/workflow/skills/orchestrate/references/plan-commit.md +9 -2
  54. package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
  55. package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
  56. package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
  57. package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
  58. package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
  59. package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
  60. package/kit/workflow/skills/plan/SKILL.md +3 -2
  61. package/package.json +3 -1
  62. package/recipes/README.md +4 -2
  63. package/recipes/google-calendar.md +30 -0
  64. package/recipes/jira-cli.md +1 -1
  65. package/recipes/sources/google-calendar-today.ts +38 -0
  66. package/recipes/sources/google-calendar.ts +368 -0
  67. package/recipes/sources/jira.ts +10 -2
  68. package/recipes/sources/linear.ts +9 -3
  69. package/recipes/sources/sentry.ts +8 -2
  70. package/recipes/sources.md +17 -3
  71. package/schemas/jackherd.schema.json +67 -0
  72. package/schemas/sources.schema.json +9 -0
  73. package/src/approval/checked.ts +60 -0
  74. package/src/approval/command.ts +16 -0
  75. package/src/check/command.ts +51 -6
  76. package/src/check/lint-pointers.ts +79 -0
  77. package/src/check/lint-shaped.ts +196 -0
  78. package/src/check/plan-structure-check.ts +116 -4
  79. package/src/check/report.ts +9 -3
  80. package/src/check/schema.ts +37 -0
  81. package/src/check/settings.ts +4 -0
  82. package/src/check/skill-budget-defaults.ts +81 -23
  83. package/src/check/skill-budgets.ts +58 -10
  84. package/src/cli.ts +2 -0
  85. package/src/config/personal-sources.ts +46 -10
  86. package/src/config/repository-identity.ts +40 -0
  87. package/src/config/schema.ts +53 -1
  88. package/src/context/command.ts +33 -2
  89. package/src/context/options.ts +33 -8
  90. package/src/context/usage-logs.ts +53 -0
  91. package/src/context/usage.ts +110 -0
  92. package/src/lane/base-command.ts +35 -0
  93. package/src/lane/base.ts +43 -0
  94. package/src/lane/close-check.ts +49 -15
  95. package/src/lane/close-command.ts +71 -11
  96. package/src/lane/close-options.ts +29 -8
  97. package/src/lane/close-survey.ts +13 -1
  98. package/src/lane/close.ts +51 -12
  99. package/src/lane/command.ts +21 -3
  100. package/src/lane/send-command.ts +32 -0
  101. package/src/lane/send-herdr.ts +77 -0
  102. package/src/lane/send-options.ts +23 -0
  103. package/src/lane/send.ts +90 -0
  104. package/src/lane/skill-hint.ts +27 -0
  105. package/src/lane/space-command.ts +30 -0
  106. package/src/lane/space.ts +49 -0
  107. package/src/lane/start-lane-hint.ts +18 -0
  108. package/src/plan-contract/base-check-git.ts +33 -0
  109. package/src/plan-contract/base-check.ts +17 -0
  110. package/src/plan-contract/command.ts +25 -4
  111. package/src/plan-format/plan-structure.ts +17 -5
  112. package/src/plans/ask-coordinator.ts +23 -13
  113. package/src/plans/board-rows.ts +9 -4
  114. package/src/plans/click-hint.ts +4 -0
  115. package/src/plans/command.ts +6 -5
  116. package/src/plans/ensure-panes.ts +14 -3
  117. package/src/plans/git.ts +14 -0
  118. package/src/plans/github.ts +19 -10
  119. package/src/plans/layout.ts +191 -25
  120. package/src/plans/live.ts +57 -14
  121. package/src/plans/plan-panes.ts +21 -4
  122. package/src/plans/row-actions.ts +17 -10
  123. package/src/plans/selection.ts +29 -0
  124. package/src/plans/settings.ts +4 -0
  125. package/src/plans/snapshot.ts +8 -3
  126. package/src/plans/source-rows.ts +35 -2
  127. package/src/resume/command.ts +213 -0
  128. package/src/resume/decision.ts +102 -0
  129. package/src/resume/keeper.ts +65 -0
  130. package/src/resume/location.ts +22 -0
  131. package/src/resume/log.ts +27 -0
  132. package/src/resume/ports.ts +102 -0
  133. package/src/resume/record.ts +116 -0
  134. package/src/resume/targets.ts +84 -0
  135. package/src/resume/texts.ts +27 -0
  136. package/src/secrets/command.ts +15 -5
  137. package/src/sync/herdr-plugin.ts +2 -11
  138. package/src/sync/plan-ask-rules.ts +11 -0
  139. package/src/sync/render.ts +3 -1
  140. package/src/vocabulary/index.ts +13 -0
  141. package/src/waiting/command.ts +13 -4
  142. package/src/watch/command.ts +177 -78
  143. package/src/watch/config-reload.ts +119 -0
  144. package/src/watch/lane-model.ts +11 -1
  145. package/src/watch/lane-progress.ts +70 -8
  146. package/src/watch/lane-report.ts +68 -12
  147. package/src/watch/main-checkout.ts +5 -1
  148. package/src/watch/merge-duties.ts +33 -0
  149. package/src/watch/merge-overlap-read.ts +69 -0
  150. package/src/watch/plan-views.ts +16 -4
  151. package/src/watch/reference-paths.ts +46 -5
  152. package/src/watch/report-questions.ts +5 -2
  153. package/src/watch/single-report.ts +3 -2
  154. package/src/watch/source-dry-run-command.ts +22 -4
  155. package/src/watch/source-dry-run.ts +1 -1
  156. package/src/watch/source-reason.ts +58 -0
  157. package/src/watch/source-reload.ts +21 -17
  158. package/src/watch/source-run.ts +24 -7
  159. package/src/watch/sources.ts +9 -2
  160. package/src/watch/texts.ts +15 -4
  161. package/src/watch/waiting-line.ts +4 -3
@@ -0,0 +1,38 @@
1
+ // Copy to ~/.jackherd/sources/google-calendar-today.ts together with google-calendar.ts; the recipe
2
+ // recipes/google-calendar.md says what to fill in. Today's appointments of the calendars whose secret
3
+ // iCal addresses sit in GOOGLE_CALENDAR_ICS_URL, GOOGLE_CALENDAR_ICS_URL_2, … (the entry's `secrets`).
4
+ // One entry per occurrence, so a moved or renamed appointment reports as a change and a new day brings
5
+ // its appointments in as new. One calendar that cannot be read fails the whole run.
6
+ import { addressesOf, civilOf, occurrencesOn, parseIcs, toItems } from './google-calendar'
7
+
8
+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone
9
+
10
+ // The watcher shows this line with the exit code; it never names an address, the address is the credential.
11
+ const fail: (reason: string) => never = (reason) => {
12
+ console.log(`jackherd-reason: ${reason}`)
13
+ return process.exit(1)
14
+ }
15
+
16
+ const addresses = addressesOf(process.env)
17
+ if (addresses.length === 0) fail('GOOGLE_CALENDAR_ICS_URL not set')
18
+
19
+ // The address is the credential: nothing from a failed request (error text, URL) may reach stderr.
20
+ const readCalendar = async (address: string): Promise<string | undefined> => {
21
+ try {
22
+ const answer = await fetch(address, { signal: AbortSignal.timeout(20_000) })
23
+ const text = answer.status === 200 ? await answer.text() : ''
24
+ return text.includes('BEGIN:VCALENDAR') ? text : undefined
25
+ } catch {
26
+ return undefined
27
+ }
28
+ }
29
+
30
+ const answers = await Promise.all(addresses.map(readCalendar))
31
+ const texts = answers.filter((text) => text !== undefined)
32
+ if (texts.length < answers.length) fail(`${answers.length - texts.length} of ${answers.length} calendars unreadable`)
33
+
34
+ const today = civilOf(Date.now(), zone)
35
+ const events = texts.flatMap((text) => parseIcs(text, zone))
36
+ console.log(JSON.stringify(toItems(occurrencesOn(events, today, zone), today, zone)))
37
+
38
+ export {}
@@ -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
+ })
@@ -12,14 +12,22 @@ const plainText = (node: DocumentNode): string =>
12
12
  ? (node.text ?? node.attrs?.text ?? '')
13
13
  : (node.content ?? []).map(plainText).join('')
14
14
 
15
+ // The watcher shows this line with the exit code; it never carries acli's output.
16
+ const fail: (reason: string) => never = (reason) => {
17
+ console.log(`jackherd-reason: ${reason}`)
18
+ return process.exit(1)
19
+ }
20
+
15
21
  const acli = async (args: string[]): Promise<unknown> => {
16
22
  const child = Bun.spawn(['acli', ...args], { stdout: 'pipe', stderr: 'ignore', signal: AbortSignal.timeout(20_000) })
17
23
  const output = await new Response(child.stdout).text()
18
- if ((await child.exited) !== 0) process.exit(1)
24
+ const code = await child.exited
25
+ const call = `acli ${args.slice(0, 2).join(' ')}`
26
+ if (code !== 0) fail(`${call} exited ${code}`)
19
27
  try {
20
28
  return JSON.parse(output)
21
29
  } catch {
22
- return process.exit(1)
30
+ return fail(`${call} answered no JSON`)
23
31
  }
24
32
  }
25
33
 
@@ -1,6 +1,12 @@
1
1
  // Copy to scripts/sources/linear.ts in your repository; the recipe recipes/linear.md says what to fill in.
2
+ // The watcher shows this line with the exit code; it never carries the token or a URL.
3
+ const fail: (reason: string) => never = (reason) => {
4
+ console.log(`jackherd-reason: ${reason}`)
5
+ return process.exit(1)
6
+ }
7
+
2
8
  const token = process.env.LINEAR_API_KEY
3
- if (!token) process.exit(1)
9
+ if (!token) fail('LINEAR_API_KEY not set')
4
10
 
5
11
  const query = `{ issues(filter: { team: { key: { eq: "ENG" } }, number: { eq: 123 } }) {
6
12
  nodes { identifier title url updatedAt comments { nodes { body user { name } } } } } }`
@@ -11,9 +17,9 @@ const response = await fetch('https://<host>/graphql', {
11
17
  body: JSON.stringify({ query }),
12
18
  signal: AbortSignal.timeout(20_000),
13
19
  })
14
- if (!response.ok) process.exit(1)
20
+ if (!response.ok) fail(`HTTP ${response.status}`)
15
21
  const answer = (await response.json()) as { errors?: unknown }
16
- if (answer.errors) process.exit(1)
22
+ if (answer.errors) fail('GraphQL errors in the answer')
17
23
  console.log(JSON.stringify(answer))
18
24
 
19
25
  export {}
@@ -1,12 +1,18 @@
1
1
  // Copy to scripts/sources/sentry.ts in your repository; the recipe recipes/sentry.md says what to fill in.
2
+ // The watcher shows this line with the exit code; it never carries the token or a URL.
3
+ const fail: (reason: string) => never = (reason) => {
4
+ console.log(`jackherd-reason: ${reason}`)
5
+ return process.exit(1)
6
+ }
7
+
2
8
  const token = process.env.SENTRY_AUTH_TOKEN
3
- if (!token) process.exit(1)
9
+ if (!token) fail('SENTRY_AUTH_TOKEN not set')
4
10
 
5
11
  const response = await fetch(
6
12
  'https://<host>/api/0/projects/<org>/<project>/issues/?query=is:unresolved&statsPeriod=24h',
7
13
  { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(20_000) },
8
14
  )
9
- if (!response.ok) process.exit(1)
15
+ if (!response.ok) fail(`HTTP ${response.status}`)
10
16
  console.log(JSON.stringify(await response.json()))
11
17
 
12
18
  export {}
@@ -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,23 @@ 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`, unless it carries `"repos": ["owner/name"]` (one to twenty, unique without regard to case): then it runs only where the main checkout's `origin` names one of them, and elsewhere the watcher notes `<name>: not for this repo` (`<name>: no origin` when the checkout has none). `repos` belongs in the personal file only; the repository's config refuses it. `jh watch source <name> --dry-run` of a left-out source prints the note and exits 2 without running anything. 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>`. `deploy`, `feedback`, `previews` and `checks` follow the same way; a change that applies prints `config reloaded: <parts>`, an invalid one keeps every value and prints `config not reloaded: <file>: <reason>`. The board and the language are read when the watcher starts.
95
+
96
+ **Say why a source failed.** A failing command may print one stdout line `jackherd-reason: <reason>` (a status, a missing variable); the watcher shows the last such line as `exit 1: <reason>`, with the values of the source's `secrets` and URL credentials masked, cut to 160 characters. stderr is never read. Name no token in it.
83
97
 
84
98
  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.
@@ -143,6 +143,10 @@
143
143
  "lockFile": {
144
144
  "type": "string",
145
145
  "pattern": "^(?![/-])(?!(?:.*\\/)?\\.\\.(?:\\/|$))[A-Za-z0-9._/-]+$"
146
+ },
147
+ "minFreeGb": {
148
+ "type": "number",
149
+ "exclusiveMinimum": 0
146
150
  }
147
151
  },
148
152
  "additionalProperties": false
@@ -645,6 +649,15 @@
645
649
  "show": {
646
650
  "type": "string",
647
651
  "const": "entries"
652
+ },
653
+ "repos": {
654
+ "minItems": 1,
655
+ "maxItems": 20,
656
+ "type": "array",
657
+ "items": {
658
+ "type": "string",
659
+ "pattern": "^[\\w.-]+\\/[\\w.-]+$"
660
+ }
648
661
  }
649
662
  },
650
663
  "required": [
@@ -699,6 +712,28 @@
699
712
  "type": "integer",
700
713
  "minimum": 1,
701
714
  "maximum": 9
715
+ },
716
+ "standing": {
717
+ "type": "object",
718
+ "properties": {
719
+ "tools": {
720
+ "type": "object",
721
+ "propertyNames": {
722
+ "type": "string",
723
+ "pattern": "^mcp__[A-Za-z0-9_-]+__execute_sql$"
724
+ },
725
+ "additionalProperties": {
726
+ "type": "string",
727
+ "enum": [
728
+ "stage"
729
+ ]
730
+ }
731
+ }
732
+ },
733
+ "required": [
734
+ "tools"
735
+ ],
736
+ "additionalProperties": false
702
737
  }
703
738
  },
704
739
  "additionalProperties": false
@@ -929,6 +964,38 @@
929
964
  "maximum": 9007199254740991
930
965
  }
931
966
  },
967
+ "openBoxes": {
968
+ "default": "fail",
969
+ "type": "string",
970
+ "enum": [
971
+ "fail",
972
+ "warn",
973
+ "off"
974
+ ]
975
+ },
976
+ "lintPointers": {
977
+ "type": "object",
978
+ "properties": {
979
+ "level": {
980
+ "default": "warn",
981
+ "type": "string",
982
+ "enum": [
983
+ "warn",
984
+ "fail",
985
+ "off"
986
+ ]
987
+ },
988
+ "exclude": {
989
+ "default": [],
990
+ "type": "array",
991
+ "items": {
992
+ "type": "string",
993
+ "minLength": 1
994
+ }
995
+ }
996
+ },
997
+ "additionalProperties": false
998
+ },
932
999
  "looseTests": {
933
1000
  "default": false,
934
1001
  "type": "boolean"
@@ -126,6 +126,15 @@
126
126
  "show": {
127
127
  "type": "string",
128
128
  "const": "entries"
129
+ },
130
+ "repos": {
131
+ "minItems": 1,
132
+ "maxItems": 20,
133
+ "type": "array",
134
+ "items": {
135
+ "type": "string",
136
+ "pattern": "^[\\w.-]+\\/[\\w.-]+$"
137
+ }
129
138
  }
130
139
  },
131
140
  "required": [