jackherd 1.15.1 → 1.15.2

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 (39) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +17 -17
  3. package/dist/{chunk-V4HOLGFP.js → chunk-4LUFR7RC.js} +19 -1
  4. package/dist/config.d.ts +47 -2
  5. package/dist/config.js +65 -4
  6. package/dist/plan-format.js +3 -19
  7. package/herdr-plugin/herdr-plugin.toml +1 -1
  8. package/install.sh +49 -4
  9. package/package.json +1 -1
  10. package/schemas/jackherd.schema.json +12 -0
  11. package/schemas/sources.schema.json +141 -0
  12. package/src/cli.ts +1 -0
  13. package/src/config/json-schema.ts +7 -3
  14. package/src/config/location.ts +7 -0
  15. package/src/config/personal-sources.ts +83 -0
  16. package/src/config/schema.ts +29 -0
  17. package/src/plans/herd-claim.ts +9 -31
  18. package/src/plans/settings.ts +20 -7
  19. package/src/plans/snapshot.ts +26 -19
  20. package/src/secrets/command.ts +163 -0
  21. package/src/secrets/environment.ts +45 -0
  22. package/src/secrets/hidden-input.ts +51 -0
  23. package/src/secrets/listing.ts +63 -0
  24. package/src/secrets/options.ts +45 -0
  25. package/src/secrets/store.ts +74 -0
  26. package/src/shared/process-info.ts +89 -0
  27. package/src/sync/command-context.ts +26 -4
  28. package/src/sync/render.ts +9 -4
  29. package/src/watch/command.ts +44 -12
  30. package/src/watch/plan-badges-read.ts +28 -10
  31. package/src/watch/source-dry-run-command.ts +23 -12
  32. package/src/watch/source-dry-run.ts +19 -2
  33. package/src/watch/source-reload.ts +100 -0
  34. package/src/watch/source-run.ts +63 -7
  35. package/src/watch/source-tickets.ts +15 -0
  36. package/src/watch/sources.ts +21 -0
  37. package/src/watch/watch-events.ts +9 -0
  38. package/src/watch/watched-source.ts +39 -0
  39. package/src/watch/watcher-lock.ts +8 -1
@@ -0,0 +1,63 @@
1
+ import { terminalSafe } from '../plan-format/terminal-text'
2
+ import { SOURCE_ORIGIN, type SourceSettings } from '../watch/sources'
3
+ import type { Environment } from './environment'
4
+ import { SECRET_LOOKUP, type SecretLookup } from './store'
5
+
6
+ /**
7
+ * A secret name and the sources that need it, in their order; `keychain` when a personal source needs it, the only
8
+ * kind the watcher looks up in the keychain for.
9
+ */
10
+ export type RequiredSecret = { name: string; sources: string[]; keychain: boolean }
11
+
12
+ /** Where a needed secret stands, as `jh secret list` prints it: never its value. */
13
+ export const SECRET_STATUS = {
14
+ set: 'set',
15
+ environment: 'set (environment)',
16
+ missing: 'missing',
17
+ environmentOnly: 'missing (environment only)',
18
+ unavailable: 'secret store unavailable',
19
+ } as const
20
+
21
+ export type SecretStatus = (typeof SECRET_STATUS)[keyof typeof SECRET_STATUS]
22
+
23
+ export type SecretRow = RequiredSecret & { status: SecretStatus }
24
+
25
+ /** Every name the sources list under `secrets`, sorted, each with the sources that need it and whether a personal one does. */
26
+ export const requiredSecrets = (
27
+ sources: readonly Pick<SourceSettings, 'name' | 'secrets' | 'origin'>[],
28
+ ): RequiredSecret[] => {
29
+ const needs = new Map<string, RequiredSecret>()
30
+ for (const source of sources) {
31
+ for (const name of source.secrets ?? []) {
32
+ const known = needs.get(name) ?? { name, sources: [], keychain: false }
33
+ const personal = source.origin === SOURCE_ORIGIN.personal
34
+ needs.set(name, { name, sources: [...known.sources, source.name], keychain: known.keychain || personal })
35
+ }
36
+ }
37
+ return [...needs.values()].sort((left, right) => left.name.localeCompare(right.name))
38
+ }
39
+
40
+ /** The status of a name only repository sources need: the watcher hands them the environment, never the keychain. */
41
+ export const environmentOnlyStatusOf = (name: string, environment: Environment): SecretStatus =>
42
+ environment[name] ? SECRET_STATUS.environment : SECRET_STATUS.environmentOnly
43
+
44
+ /**
45
+ * The status the watcher would meet: in the keychain, else in the environment (this shell's, which a watcher started
46
+ * elsewhere may not share), else missing; a keychain without an answer and no variable is `unavailable`. Takes the
47
+ * lookup and keeps nothing of its value.
48
+ */
49
+ export const secretStatusOf = (name: string, lookup: SecretLookup, environment: Environment): SecretStatus => {
50
+ if (lookup.kind === SECRET_LOOKUP.found) return SECRET_STATUS.set
51
+ if (environment[name]) return SECRET_STATUS.environment
52
+ return lookup.kind === SECRET_LOOKUP.unavailable ? SECRET_STATUS.unavailable : SECRET_STATUS.missing
53
+ }
54
+
55
+ /** The lines of `jh secret list`: name, status and the sources that need it, in columns; a note when no source names one. */
56
+ export const secretListing = (rows: readonly Pick<SecretRow, 'name' | 'status' | 'sources'>[]): string[] => {
57
+ if (rows.length === 0) return ['no source names a secret']
58
+ const nameWidth = Math.max(...rows.map(({ name }) => name.length))
59
+ const statusWidth = Math.max(...rows.map(({ status }) => status.length))
60
+ return rows.map(({ name, status, sources }) =>
61
+ terminalSafe(`${name.padEnd(nameWidth)} ${status.padEnd(statusWidth)} ${sources.join(', ')}`),
62
+ )
63
+ }
@@ -0,0 +1,45 @@
1
+ import { CLI_NAME } from '../version/release'
2
+
3
+ /** A secret's name, which becomes the name of an environment variable; the same rule as `secrets` in a source. */
4
+ export const SECRET_NAME = /^[A-Z][A-Z0-9_]*$/
5
+
6
+ export const SECRET_ACTION = { set: 'set', list: 'list', delete: 'delete' } as const
7
+
8
+ export const SECRET_USAGE = `Usage: ${CLI_NAME} secret set <NAME> store a value in the keychain, typed hidden in a terminal
9
+ ${CLI_NAME} secret list the names the sources need, set or missing, never a value
10
+ ${CLI_NAME} secret delete <NAME> remove the value from the keychain`
11
+
12
+ export type SecretRequest =
13
+ | { kind: typeof SECRET_ACTION.set; name: string }
14
+ | { kind: typeof SECRET_ACTION.list }
15
+ | { kind: typeof SECRET_ACTION.delete; name: string }
16
+ | { kind: 'help' }
17
+ | { kind: 'error'; message: string }
18
+
19
+ const HELP_FLAGS = new Set(['--help', '-h'])
20
+
21
+ const named = (
22
+ kind: typeof SECRET_ACTION.set | typeof SECRET_ACTION.delete,
23
+ rest: readonly string[],
24
+ ): SecretRequest => {
25
+ const [name, ...extra] = rest
26
+ if (!name || extra.length > 0) return { kind: 'error', message: `${CLI_NAME} secret ${kind} takes one name` }
27
+ if (!SECRET_NAME.test(name)) {
28
+ return { kind: 'error', message: 'a secret name is capital letters, digits and `_`, starting with a letter' }
29
+ }
30
+ return { kind, name }
31
+ }
32
+
33
+ /**
34
+ * The command line of `jh secret`. A value is never an argument: `set` takes the name only and asks for the value in
35
+ * the terminal, so it shows up in no shell history and no process list.
36
+ */
37
+ export const parseSecretArgs = (args: readonly string[]): SecretRequest => {
38
+ const [action, ...rest] = args
39
+ if (action === undefined || HELP_FLAGS.has(action)) return { kind: 'help' }
40
+ if (action === SECRET_ACTION.list) {
41
+ return rest.length === 0 ? { kind: SECRET_ACTION.list } : { kind: 'error', message: 'list takes no arguments' }
42
+ }
43
+ if (action === SECRET_ACTION.set || action === SECRET_ACTION.delete) return named(action, rest)
44
+ return { kind: 'error', message: `unknown action: ${action}` }
45
+ }
@@ -0,0 +1,74 @@
1
+ /** The keychain service every jackherd secret is stored under; the entry's name is the environment variable's name. */
2
+ export const SECRET_SERVICE = 'jackherd'
3
+
4
+ /** How long one keychain call may take before the store counts as unavailable (a locked keychain, a missing D-Bus). */
5
+ export const SECRET_TIMEOUT_MS = 10_000
6
+
7
+ /** What a keychain lookup found: the value, no entry, or no answer from the keychain service at all. */
8
+ export const SECRET_LOOKUP = { found: 'found', absent: 'absent', unavailable: 'unavailable' } as const
9
+
10
+ export type SecretLookup =
11
+ | { kind: typeof SECRET_LOOKUP.found; value: string }
12
+ | { kind: typeof SECRET_LOOKUP.absent }
13
+ | { kind: typeof SECRET_LOOKUP.unavailable }
14
+
15
+ /** What a write or a delete did: done, nothing there to delete, or no answer from the keychain service. */
16
+ export const SECRET_WRITE = { done: 'done', absent: 'absent', unavailable: 'unavailable' } as const
17
+
18
+ export type SecretWrite = (typeof SECRET_WRITE)[keyof typeof SECRET_WRITE]
19
+
20
+ /** Looks up one secret by its name. */
21
+ export type SecretLookupFn = (name: string) => Promise<SecretLookup>
22
+
23
+ export type SecretStore = {
24
+ get: SecretLookupFn
25
+ set: (name: string, value: string) => Promise<SecretWrite>
26
+ delete: (name: string) => Promise<SecretWrite>
27
+ }
28
+
29
+ /** The part of `Bun.secrets` the store uses, so a test hands in a stub and never reaches the real keychain. */
30
+ export type KeychainApi = Pick<typeof Bun.secrets, 'get' | 'set' | 'delete'>
31
+
32
+ const TIMED_OUT = Symbol('timed out')
33
+
34
+ const UNAVAILABLE: SecretLookup = { kind: SECRET_LOOKUP.unavailable }
35
+ const ABSENT: SecretLookup = { kind: SECRET_LOOKUP.absent }
36
+
37
+ /** `call`'s result, or `TIMED_OUT` once `timeoutMs` passed or it threw: the error text is dropped, it may name the entry's value. */
38
+ const bounded = async <T>(call: () => Promise<T>, timeoutMs: number): Promise<T | typeof TIMED_OUT> => {
39
+ let timer: ReturnType<typeof setTimeout> | undefined
40
+ const deadline = new Promise<typeof TIMED_OUT>((resolve) => {
41
+ timer = setTimeout(() => resolve(TIMED_OUT), timeoutMs)
42
+ })
43
+ try {
44
+ return await Promise.race([call(), deadline])
45
+ } catch {
46
+ return TIMED_OUT
47
+ } finally {
48
+ clearTimeout(timer)
49
+ }
50
+ }
51
+
52
+ /** An empty value counts as no entry: `Bun.secrets` treats storing one as a delete. */
53
+ const lookupOf = (value: string | null | typeof TIMED_OUT): SecretLookup => {
54
+ if (value === TIMED_OUT) return UNAVAILABLE
55
+ return value ? { kind: SECRET_LOOKUP.found, value } : ABSENT
56
+ }
57
+
58
+ /**
59
+ * The system keychain behind `get`, `set` and `delete`, every entry under the service `jackherd`. Each call is bounded
60
+ * by `timeoutMs`; a keychain that throws or does not answer in time is `unavailable`, never a rejection, so a locked
61
+ * keychain cannot stop the watcher. Nothing here logs or prints.
62
+ */
63
+ export const secretStore = (api: KeychainApi = Bun.secrets, timeoutMs: number = SECRET_TIMEOUT_MS): SecretStore => ({
64
+ get: async (name) => lookupOf(await bounded(() => api.get({ service: SECRET_SERVICE, name }), timeoutMs)),
65
+ set: async (name, value) => {
66
+ const done = await bounded(() => api.set({ service: SECRET_SERVICE, name, value }), timeoutMs)
67
+ return done === TIMED_OUT ? SECRET_WRITE.unavailable : SECRET_WRITE.done
68
+ },
69
+ delete: async (name) => {
70
+ const deleted = await bounded(() => api.delete({ service: SECRET_SERVICE, name }), timeoutMs)
71
+ if (deleted === TIMED_OUT) return SECRET_WRITE.unavailable
72
+ return deleted ? SECRET_WRITE.done : SECRET_WRITE.absent
73
+ },
74
+ })
@@ -0,0 +1,89 @@
1
+ import { readFileSync, readlinkSync } from 'node:fs'
2
+
3
+ /** Longest a `ps` or `tty` call may take; a call past it counts as no answer. */
4
+ const SPAWN_TIMEOUT_MS = 2000
5
+
6
+ export type ProcessInfoSystem = {
7
+ platform: NodeJS.Platform
8
+ readText: (path: string) => string | null
9
+ readLink: (path: string) => string | null
10
+ /** Runs a program and answers its stdout, null when it failed, timed out or is missing. */
11
+ run: (command: string[], stdin: 'inherit' | 'ignore') => string | null
12
+ }
13
+
14
+ export type ProcessInfo = {
15
+ /** The terminal device of stdin (`/dev/…`); null without one. */
16
+ terminalOfStdin: () => string | null
17
+ /** Environment entries and arguments of a process, blank-separated words; null where they cannot be read. */
18
+ wordsOf: (pid: number) => string[] | null
19
+ /** True when the process does not exist or is a zombie. */
20
+ isGone: (pid: number) => boolean
21
+ }
22
+
23
+ const orNull = <T>(read: () => T): T | null => {
24
+ try {
25
+ return read()
26
+ } catch {
27
+ return null
28
+ }
29
+ }
30
+
31
+ /** `/dev/…` as `tty` or `/proc/self/fd/0` names it, null for anything else (`not a tty`). */
32
+ export const deviceOf = (text: string | null): string | null => {
33
+ const line = text?.trim() ?? ''
34
+ return line.startsWith('/dev/') ? line : null
35
+ }
36
+
37
+ /** The words of `ps -E -ww -o command=` (macOS appends the environment to the command line). */
38
+ export const wordsOfPsOutput = (output: string | null): string[] | null => {
39
+ const words = output?.trim().split(/\s+/).filter(Boolean) ?? []
40
+ return words.length === 0 ? null : words
41
+ }
42
+
43
+ /** The `\0`-separated entries of `/proc/<pid>/environ` and `/proc/<pid>/cmdline` as one list; null unless both read. */
44
+ export const wordsOfProcFiles = (environment: string | null, command: string | null): string[] | null =>
45
+ environment === null || command === null ? null : [...environment.split('\0'), ...command.split('\0')].filter(Boolean)
46
+
47
+ /** True for a state column of `ps -o stat=` or `/proc/<pid>/stat` that means the process is a zombie. */
48
+ const isZombieState = (state: string): boolean => state.startsWith('Z')
49
+
50
+ /** The state letter after the parenthesised name in a line of `/proc/<pid>/stat`. */
51
+ export const stateOfProcStat = (stat: string): string => stat.charAt(stat.lastIndexOf(')') + 2)
52
+
53
+ export const goneFromProcStat = (stat: string | null): boolean => stat === null || isZombieState(stateOfProcStat(stat))
54
+
55
+ /** Gone when `ps` found nothing (empty output) or the state is a zombie. */
56
+ export const goneFromPsState = (output: string | null): boolean => {
57
+ const state = output?.trim() ?? ''
58
+ return state === '' || isZombieState(state)
59
+ }
60
+
61
+ const linuxInfo = (system: ProcessInfoSystem): ProcessInfo => ({
62
+ terminalOfStdin: () => deviceOf(system.readLink('/proc/self/fd/0')),
63
+ wordsOf: (pid) => wordsOfProcFiles(system.readText(`/proc/${pid}/environ`), system.readText(`/proc/${pid}/cmdline`)),
64
+ isGone: (pid) => goneFromProcStat(system.readText(`/proc/${pid}/stat`)),
65
+ })
66
+
67
+ const psInfo = (system: ProcessInfoSystem): ProcessInfo => ({
68
+ terminalOfStdin: () => deviceOf(system.run(['tty'], 'inherit')),
69
+ wordsOf: (pid) => wordsOfPsOutput(system.run(['ps', '-E', '-ww', '-o', 'command=', '-p', String(pid)], 'ignore')),
70
+ isGone: (pid) => goneFromPsState(system.run(['ps', '-o', 'stat=', '-p', String(pid)], 'ignore')),
71
+ })
72
+
73
+ /** Process inspection through `/proc` on Linux and through `ps` and `tty` elsewhere (macOS has no `/proc`). */
74
+ export const processInfoFor = (system: ProcessInfoSystem): ProcessInfo =>
75
+ system.platform === 'linux' ? linuxInfo(system) : psInfo(system)
76
+
77
+ export const systemProcessInfoSystem = (): ProcessInfoSystem => ({
78
+ platform: process.platform,
79
+ readText: (path) => orNull(() => readFileSync(path, 'utf8')),
80
+ readLink: (path) => orNull(() => readlinkSync(path)),
81
+ run: (command, stdin) => {
82
+ const result = orNull(() =>
83
+ Bun.spawnSync(command, { stdin, stdout: 'pipe', stderr: 'ignore', timeout: SPAWN_TIMEOUT_MS }),
84
+ )
85
+ return result !== null && result.exitCode === 0 ? result.stdout.toString() : null
86
+ },
87
+ })
88
+
89
+ export const systemProcessInfo = (): ProcessInfo => processInfoFor(systemProcessInfoSystem())
@@ -40,16 +40,38 @@ const YES = /^y(es)?$/i
40
40
 
41
41
  const hasTerminal = (): boolean => Boolean(process.stdin.isTTY && process.stdout.isTTY)
42
42
 
43
- /** One question on the terminal, the trimmed answer. */
44
- const questionOnTerminal = async (text: string): Promise<string> => {
45
- const terminal = createInterface({ input: process.stdin, output: process.stdout })
43
+ const UNREADABLE_NOTE = 'jackherd: the terminal could not be read, using the default answer'
44
+
45
+ /**
46
+ * One question on a stream, the trimmed answer. A read error (macOS kqueue refusing the device with EINVAL, say)
47
+ * reaches readline as an `error` event while the pending question never settles; it is answered with the empty
48
+ * string, so the caller takes its default, and one note goes to `warn`.
49
+ */
50
+ export const questionOnStream = async (
51
+ text: string,
52
+ input: NodeJS.ReadableStream,
53
+ output: NodeJS.WritableStream,
54
+ warn: (line: string) => void,
55
+ ): Promise<string> => {
56
+ const terminal = createInterface({ input, output })
57
+ const unreadable = new Promise<null>((resolve) => terminal.once('error', () => resolve(null)))
58
+ const asked = terminal.question(text)
59
+ asked.catch(() => undefined)
46
60
  try {
47
- return (await terminal.question(text)).trim()
61
+ const answer = await Promise.race([asked, unreadable])
62
+ if (answer !== null) return answer.trim()
63
+ } catch {
64
+ // a rejected question is the same unreadable terminal
48
65
  } finally {
49
66
  terminal.close()
50
67
  }
68
+ warn(UNREADABLE_NOTE)
69
+ return ''
51
70
  }
52
71
 
72
+ const questionOnTerminal = (text: string): Promise<string> =>
73
+ questionOnStream(text, process.stdin, process.stdout, (line) => console.error(line))
74
+
53
75
  /** Asks on the terminal; without one on both stdin and stdout nobody would see or answer the question. */
54
76
  const askOnTerminal = async (question: string): Promise<boolean | null> =>
55
77
  hasTerminal() ? YES.test(await questionOnTerminal(`${question} [y/N] `)) : null
@@ -11,14 +11,19 @@ import {
11
11
 
12
12
  const PLACEHOLDER = /\{\{\s*(kit\.[A-Za-z0-9_.-]*)\s*\}\}/g
13
13
 
14
- const WORKTREE_INCLUDE_COPY =
15
- 'cd <main-checkout> && git ls-files -z --others --ignored --exclude-from=.worktreeinclude | grep -zv node_modules | xargs -0 -I{} cp -p --parents {} <worktree>/'
14
+ /** Reads the tar archive from stdin into the worktree, keeping file modes; the same flags work on GNU tar and bsdtar. */
15
+ const UNPACK = 'tar -xpf - -C <worktree>'
16
16
 
17
- /** The command that copies a lane's untracked files; the schema keeps quotes out of every path. */
17
+ const WORKTREE_INCLUDE_COPY = `cd <main-checkout> && git ls-files -z --others --ignored --exclude-from=.worktreeinclude -- ':(exclude)*node_modules*' | tar -cf - --null -T - | ${UNPACK}`
18
+
19
+ /**
20
+ * The command that copies a lane's untracked files, relative paths and modes kept; it uses only what GNU and BSD
21
+ * (macOS) userlands share, no `cp --parents` and no `grep -z`. The schema keeps quotes out of every path.
22
+ */
18
23
  const laneCopyCommand = (copy: readonly string[]): string =>
19
24
  copy.length === 0
20
25
  ? WORKTREE_INCLUDE_COPY
21
- : `cd <main-checkout> && cp -p --parents -- ${copy.map((path) => `'${path}'`).join(' ')} <worktree>/`
26
+ : `cd <main-checkout> && tar -cf - -- ${copy.map((path) => `'${path}'`).join(' ')} | ${UNPACK}`
22
27
 
23
28
  /** The patterns a pull request may match to be merged without checks, as prose for a skill; `none` for an empty list. */
24
29
  const checkFreeList = (patterns: readonly string[]): string =>
@@ -32,6 +32,7 @@ import { existsSync, readFileSync } from 'node:fs'
32
32
  import { homedir } from 'node:os'
33
33
  import { basename, dirname, join, relative } from 'node:path'
34
34
  import { writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
35
+ import { sourcesWithPersonal } from '../config/personal-sources'
35
36
  import { readConfig, readResolvedConfig } from '../config/read-config'
36
37
  import type { JackherdConfig } from '../config/schema'
37
38
  import { resolveCoordinatorName } from '../context/coordinator-name'
@@ -182,9 +183,16 @@ import { withSingleReportNotice } from './single-report'
182
183
  import { readOrchestrateSnapshot, skillLineFor } from './skill-change'
183
184
  import { runsFromSource, sourceChangeSettled, watchSourceChanges } from './source-change'
184
185
  import { sourceDryRun } from './source-dry-run-command'
186
+ import {
187
+ type LoadedSources,
188
+ nextLoadedSources,
189
+ readFileStamps,
190
+ readWatchedSources,
191
+ sourceFilePaths,
192
+ } from './source-reload'
185
193
  import { runSource, SOURCE_LIMITS } from './source-run'
186
- import { recordWatchedSources } from './source-tickets'
187
- import { isSourceDue, type SourceRun, type SourceSettings, type SourceState, sourceEvents } from './sources'
194
+ import { recordedSourceState } from './source-tickets'
195
+ import { isSourceDue, type SourceRun, type SourceState, sourceEvents } from './sources'
188
196
  import { heartbeatStateOf, isHeartbeatNews, streamHeartbeatLine } from './stream-output'
189
197
  import {
190
198
  formatTaskParallelLine,
@@ -257,7 +265,10 @@ type Watch = {
257
265
  /** The switch `watch.parallelTasks.auto` and the paths that always ask, both from the main checkout's config. */
258
266
  parallelAuto: AutoSettings
259
267
  previews: PreviewsConfig
260
- sources: SourceSettings[]
268
+ /** The sources as read at the start; the loop takes up later changes of their files (`Watching.sources`). */
269
+ sources: LoadedSources
270
+ /** The checkouts whose config the sources are read from, as `readResolvedConfig` takes them. */
271
+ configRoots: string[]
261
272
  texts: WatchTexts
262
273
  /** Where the orchestrate skill is installed, the project's copy first; the events name a reference inside the one found. */
263
274
  skillCopies: SkillCopy[]
@@ -305,6 +316,8 @@ type Watching = {
305
316
  failureReadings: FailureReading[]
306
317
  /** The last preview read that answered; recorded with every state like the feedback read. */
307
318
  previewRead: PreviewRead | null
319
+ /** The sources run now, read again whenever one of their files changes (`nextLoadedSources`). */
320
+ sources: LoadedSources
308
321
  /** The runs of the sources due at the last GitHub poll; recorded with every state like the feedback read. */
309
322
  sourceRuns: SourceRun[]
310
323
  /** The lanes' fills of the last read; a lane without an entry or with null has no value. */
@@ -381,7 +394,7 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
381
394
  unlinkedArtifacts: watching.unlinkedArtifacts,
382
395
  planMissing: watching.planMissing,
383
396
  approvals: watching.approvalRequests ? recordApprovals(watching.approvalRequests) : withReads.approvals,
384
- sources: recordWatchedSources(withReads.sources, watching.sourceRuns, watch.sources, watching.githubReadAt),
397
+ ...recordedSourceState(withReads.sources, watching.sourceRuns, watching.sources.sources, watching.githubReadAt),
385
398
  appointments: watching.appointments
386
399
  ? recordAppointments(withReads.appointments, watching.appointments.table, watching.appointments.readAt)
387
400
  : withReads.appointments,
@@ -887,15 +900,26 @@ const recordPreviewRead = (watching: Watching, read: PreviewRead | null): WatchE
887
900
  * source stops the watcher. The run's start is recorded with the GitHub read's time, which is `now`.
888
901
  */
889
902
  const runDueSources = (watch: Watch, watching: Watching, now: number): Promise<SourceRun[]> => {
890
- const due = watch.sources.filter((source) =>
903
+ const due = watching.sources.sources.filter((source) =>
891
904
  isSourceDue(watching.state.sources[source.name]?.ranAt, source.every, now),
892
905
  )
893
906
  return Promise.all(due.map((source) => runSource(source, watch.root, SOURCE_LIMITS)))
894
907
  }
895
908
 
896
- const recordSourceRuns = (watch: Watch, watching: Watching, runs: SourceRun[]): WatchEvent[] => {
909
+ /**
910
+ * Takes up a change of the files the sources come from before the due ones run (`nextLoadedSources`): a source read
911
+ * anew has no `ranAt` and runs at once, its first run a baseline; an invalid file keeps the list and is said once.
912
+ */
913
+ const reloadSources = (watch: Watch, watching: Watching) => {
914
+ const stamps = readFileStamps(Object.keys(watching.sources.stamps))
915
+ const next = nextLoadedSources(watching.sources, stamps, () => readWatchedSources(watch.configRoots, homedir()))
916
+ watching.sources = next.loaded
917
+ for (const line of next.lines) console.log(line)
918
+ }
919
+
920
+ const recordSourceRuns = (watching: Watching, runs: SourceRun[]): WatchEvent[] => {
897
921
  watching.sourceRuns = runs
898
- return sourceEvents(watching.state.sources, runs, watch.sources)
922
+ return sourceEvents(watching.state.sources, runs, watching.sources.sources)
899
923
  }
900
924
 
901
925
  /** Starts a fetch of the base branch in the background when none runs; the loop does not wait for it. */
@@ -917,7 +941,8 @@ const syncPlanHeads = (watch: Watch, watching: Watching, pullRequests: readonly
917
941
 
918
942
  /**
919
943
  * Reads the coordinator's fill, GitHub, the feedback, the previews and the due sources, and stores them only once every read is complete: a SIGTERM
920
- * while a read is pending saves the state as it was, so no event is taken as reported.
944
+ * while a read is pending saves the state as it was, so no event is taken as reported. Before the sources run, a
945
+ * change of the files they come from is taken up (`reloadSources`), which prints its notes.
921
946
  */
922
947
  const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: number): Promise<WatchEvent[]> => {
923
948
  syncBase(watch, watching)
@@ -925,6 +950,7 @@ const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: n
925
950
  const fills = fillReadFor(watch, name, watching.agents, watching.lanes)
926
951
  const laneBranches = laneBranchesOf(watch, watching.lanes)
927
952
  const searched = Object.values({ ...watching.state.laneBranches, ...laneBranches })
953
+ reloadSources(watch, watching)
928
954
  const [github, sourceRuns] = await Promise.all([
929
955
  readGithub(watch.root, watch.deploy, watch.settings.baseBranch, searched),
930
956
  runDueSources(watch, watching, now),
@@ -940,7 +966,7 @@ const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: n
940
966
  ...recordGithub(watching, github, laneBranches, now, failureReadings),
941
967
  ...recordFeedbackRead(watching, feedback?.read ?? null),
942
968
  ...recordPreviewRead(watching, previews),
943
- ...recordSourceRuns(watch, watching, sourceRuns),
969
+ ...recordSourceRuns(watching, sourceRuns),
944
970
  ...recordCoordinator(watching, { name, read: fills.coordinator }),
945
971
  ...recordLaneFills(watching, fills.lanes),
946
972
  ...recordLaneModelReads(watching, laneModelReadingsOf(watch, watching, fills.models)),
@@ -1355,6 +1381,7 @@ const initialWatching = (
1355
1381
  feedbackRead: null,
1356
1382
  failureReadings: [],
1357
1383
  previewRead: null,
1384
+ sources: watch.sources,
1358
1385
  sourceRuns: [],
1359
1386
  laneReadings: {},
1360
1387
  laneModelReadings: {},
@@ -1458,8 +1485,12 @@ const autoSettingsOf = (root: string, checkoutRoot: string, config: JackherdConf
1458
1485
  const watchOf = (options: WatchOptions): Watch => {
1459
1486
  const root = mainCheckout(process.cwd())
1460
1487
  const checkoutRoot = worktreeRoot(process.cwd())
1461
- const { config, notes } = readResolvedConfig([checkoutRoot, root])
1462
- for (const note of notes) console.log(note)
1488
+ const configRoots = [checkoutRoot, root]
1489
+ // Stamped before the read: a change in between is read again at the first poll, never missed.
1490
+ const stamps = readFileStamps(sourceFilePaths(configRoots, homedir()))
1491
+ const { config, notes } = readResolvedConfig(configRoots)
1492
+ const merged = sourcesWithPersonal(config.watch.sources)
1493
+ for (const note of [...notes, ...merged.notes]) console.log(note)
1463
1494
  const settings = plansSettingsOf(config)
1464
1495
  const picked = boardFor(options.boardPath, [root, worktreeRoot(process.cwd())], settings, () =>
1465
1496
  foundLanesOf(root, settings, () => agentNamesOf(listAgents())),
@@ -1478,7 +1509,8 @@ const watchOf = (options: WatchOptions): Watch => {
1478
1509
  taskSignalMinutes: config.watch.taskSignalMinutes,
1479
1510
  parallelAuto: autoSettingsOf(root, checkoutRoot, config),
1480
1511
  previews: config.watch.previews,
1481
- sources: config.watch.sources,
1512
+ sources: { sources: merged.sources, stamps },
1513
+ configRoots,
1482
1514
  texts: watchTexts(settings.responseLanguage),
1483
1515
  skillCopies: skillCopiesFor(root, homedir(), config.skillPrefix, DEFAULT_SKILL_PREFIX),
1484
1516
  herdr: insideHerdr(settings.adapter, process.env),
@@ -1,9 +1,10 @@
1
1
  import { dirname, join } from 'node:path'
2
+ import { type HerdSourceSettings, herdSourceSettingsOf } from '../plans/settings'
2
3
  import { isPrivateLevels, type LocationSystem, readPrivateText, systemLocation } from '../shared/private-location'
3
4
  import {
4
5
  sourceTicketsOf,
5
- type TicketSource,
6
6
  WATCH_HEALTH,
7
+ type WatchHealth,
7
8
  type WatchReading,
8
9
  watchBadgesOf,
9
10
  watchHealthOf,
@@ -22,21 +23,35 @@ const readState = (file: string): WatchState | null => {
22
23
  }
23
24
  }
24
25
 
26
+ /** A reading with the sources it was drawn for: those the watcher wrote into its state, else the herd's own. */
27
+ export type SourcedWatchReading = WatchReading & { sourceSettings: HerdSourceSettings }
28
+
29
+ /**
30
+ * The sources a live watcher keeps in its state (`watchedSources`); `fallback`, the herd's config, for a watcher that
31
+ * stopped (its state may predate a config change) or a state written before it kept them.
32
+ */
33
+ export const sourceSettingsOf = (
34
+ state: WatchState | null,
35
+ health: WatchHealth,
36
+ fallback: HerdSourceSettings,
37
+ ): HerdSourceSettings =>
38
+ health.kind === WATCH_HEALTH.live && state?.watchedSources ? herdSourceSettingsOf(state.watchedSources) : fallback
39
+
25
40
  /**
26
41
  * What the watcher of the repository at `root` gives the herd, read from its state file without a GitHub call:
27
- * its badges, the tickets of `ticketSources`, the status of the sources in `statusNames`, and its health, so the view can say why there are none. A missing or
28
- * damaged file shows no badges; the view goes on without them. Nothing is read unless both levels of the watcher
29
- * directory are private (`isPrivateLevels`), and only regular files, so a directory, a file or a FIFO someone else
30
- * planted neither feeds the view nor blocks it.
42
+ * its badges, the tickets of the ticket sources, the status of the other sources, and its health, so the view can say
43
+ * why there are none. The sources are those the state names, so one the watcher took up since the herd started shows
44
+ * too; `fallback` stands in for a state without them. A missing or damaged file shows no badges; the view goes on
45
+ * without them. Nothing is read unless both levels of the watcher directory are private (`isPrivateLevels`), and
46
+ * only regular files, so a directory, a file or a FIFO someone else planted neither feeds the view nor blocks it.
31
47
  */
32
48
  export const readWatchReading = (
33
49
  root: string,
34
50
  now: number,
35
- ticketSources: readonly TicketSource[],
51
+ fallback: HerdSourceSettings,
36
52
  lookUp?: CommandLineOf,
37
53
  system: LocationSystem = systemLocation(),
38
- statusNames: readonly string[] = [],
39
- ): WatchReading => {
54
+ ): SourcedWatchReading => {
40
55
  const directory = watcherDirectoryOf(root, system)
41
56
  const isPrivate = isPrivateLevels(dirname(directory), directory, system)
42
57
  const state = isPrivate ? readState(join(directory, WATCHER_FILES.state)) : null
@@ -45,11 +60,14 @@ export const readWatchReading = (
45
60
  isPrivate && isWatcherRunning(join(directory, WATCHER_FILES.lock), lookUp),
46
61
  now,
47
62
  )
48
- if (!state || health.kind !== WATCH_HEALTH.live) return { badges: {}, tickets: [], health }
63
+ const sourceSettings = sourceSettingsOf(state, health, fallback)
64
+ if (!state || health.kind !== WATCH_HEALTH.live) return { badges: {}, tickets: [], health, sourceSettings }
65
+ const statusNames = sourceSettings.herdSources.map(({ name }) => name)
49
66
  return {
50
67
  badges: watchBadgesOf(state, now),
51
- tickets: sourceTicketsOf(state, ticketSources, now),
68
+ tickets: sourceTicketsOf(state, sourceSettings.ticketSources, now),
52
69
  health,
53
70
  ...(statusNames.length > 0 ? { sources: sourceStatusesOf(state, statusNames, now) } : {}),
71
+ sourceSettings,
54
72
  }
55
73
  }
@@ -1,15 +1,17 @@
1
- import { readResolvedConfig } from '../config/read-config'
2
- import { type JackherdConfig, parseSourceDraft } from '../config/schema'
1
+ import { homedir } from 'node:os'
2
+ import { personalSourcesPath } from '../config/location'
3
+ import { configuredSources, isNoteAbout } from '../config/personal-sources'
4
+ import { parseSourceDraft } from '../config/schema'
3
5
  import { mainCheckout, worktreeRoot } from '../plans/git'
4
6
  import type { SourceDryRun } from './options'
5
7
  import { type DryRunTarget, dryRunExitCode, dryRunLines, dryRunTarget, emptyMapFields } from './source-dry-run'
6
- import { runSourceCommand, SOURCE_LIMITS, sourceRunOf } from './source-run'
8
+ import { SOURCE_LIMITS, sourceAnswer, sourceRunOf } from './source-run'
7
9
  import type { SourceSettings } from './sources'
8
10
 
9
- const settingsOf = async (target: DryRunTarget, config: JackherdConfig): Promise<SourceSettings> => {
11
+ const settingsOf = async (target: DryRunTarget, sources: readonly SourceSettings[]): Promise<SourceSettings> => {
10
12
  if (target.kind === 'name') {
11
- const found = config.watch.sources.find(({ name }) => name === target.name)
12
- if (!found) throw new Error(`no source ${target.name} in watch.sources`)
13
+ const found = sources.find(({ name }) => name === target.name)
14
+ if (!found) throw new Error(`no source ${target.name} in watch.sources or the personal sources file`)
13
15
  return found
14
16
  }
15
17
  const text = target.kind === 'stdin' ? await Bun.stdin.text() : await Bun.file(target.path).text()
@@ -17,19 +19,28 @@ const settingsOf = async (target: DryRunTarget, config: JackherdConfig): Promise
17
19
  }
18
20
 
19
21
  /**
20
- * `jh watch source <name|draft.json|-> --dry-run [--raw]`: runs one source once in the main checkout, as the
21
- * watcher would, and prints what it found and would report; `--raw` adds the command's output. Takes no lock and
22
+ * `jh watch source <name|draft.json|-> --dry-run [--raw]`: runs one source once in the main checkout (a personal one in `~/.jackherd`), as the
23
+ * watcher would, its `secrets` handed to the command alike, and prints what it found and would report; `--raw` adds
24
+ * the command's stdout, which by the recipe rules carries no secret value. Takes no lock and
22
25
  * neither reads nor writes the watcher's state, so a running watcher is not disturbed. Exits 1 when the run failed or a
23
26
  * `map` field is empty in every entry.
24
27
  */
25
28
  export const sourceDryRun = async ({ target, raw }: SourceDryRun): Promise<number> => {
26
29
  const root = mainCheckout(process.cwd())
27
- const { config } = readResolvedConfig([worktreeRoot(process.cwd()), root])
28
- const settings = await settingsOf(dryRunTarget(target), config)
29
- const answer = await runSourceCommand(settings.command, root, SOURCE_LIMITS)
30
+ const home = homedir()
31
+ const merged = configuredSources([worktreeRoot(process.cwd()), root], home)
32
+ const settings = await settingsOf(dryRunTarget(target), merged.sources)
33
+ const answer = await sourceAnswer(settings, root, SOURCE_LIMITS, home)
30
34
  const run = sourceRunOf(settings, answer)
31
35
  const mapCheck = answer.ok ? emptyMapFields(answer.stdout, settings) : null
32
36
  const stdout = raw && answer.ok ? answer.stdout : undefined
33
- console.log(dryRunLines(settings, run, { mapCheck, stdout }).join('\n'))
37
+ console.log(
38
+ dryRunLines(settings, run, {
39
+ mapCheck,
40
+ stdout,
41
+ personalPath: personalSourcesPath(home),
42
+ notes: merged.notes.filter((note) => isNoteAbout(note, settings.name)),
43
+ }).join('\n'),
44
+ )
34
45
  return dryRunExitCode(run, mapCheck)
35
46
  }