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
@@ -9,7 +9,8 @@ export const SINGLE_REPORT_NOTICE =
9
9
  export const NO_MONITOR_NOTICE =
10
10
  'watch ended after this report. This session has no Monitor tool, so a single call is the way it works here: start it again as a background Bash call (run_in_background) after you acted on this report.'
11
11
 
12
- export const withSingleReportNotice = (lines: readonly string[], monitorAvailable: boolean): string[] => [
12
+ /** The report with its closing notice; without a Monitor tool the notice carries `hint`, the reference that explains the restart. */
13
+ export const withSingleReportNotice = (lines: readonly string[], monitorAvailable: boolean, hint = ''): string[] => [
13
14
  ...lines,
14
- monitorAvailable ? SINGLE_REPORT_NOTICE : NO_MONITOR_NOTICE,
15
+ monitorAvailable ? SINGLE_REPORT_NOTICE : `${NO_MONITOR_NOTICE}${hint}`,
15
16
  ]
@@ -1,6 +1,7 @@
1
1
  import { homedir } from 'node:os'
2
2
  import { personalSourcesPath } from '../config/location'
3
- import { configuredSources, isNoteAbout } from '../config/personal-sources'
3
+ import { configuredSources, type ExcludedSource, excludedNote, isNoteAbout } from '../config/personal-sources'
4
+ import { readRepositoryIdentity } from '../config/repository-identity'
4
5
  import { parseSourceDraft } from '../config/schema'
5
6
  import { mainCheckout, worktreeRoot } from '../plans/git'
6
7
  import type { SourceDryRun } from './options'
@@ -18,18 +19,35 @@ const settingsOf = async (target: DryRunTarget, sources: readonly SourceSettings
18
19
  return parseSourceDraft(JSON.parse(text), target.kind === 'stdin' ? 'stdin' : target.path)
19
20
  }
20
21
 
22
+ /** Exit code of a dry run of a source that is not for this repository: nothing ran. */
23
+ export const NOT_FOR_THIS_REPO_EXIT = 2
24
+
25
+ /** The note of the personal source the dry run names when its `repos` leave this repository out; null for any other target. */
26
+ export const excludedNoteFor = (target: DryRunTarget, excluded: readonly ExcludedSource[]): string | null => {
27
+ if (target.kind !== 'name') return null
28
+ const found = excluded.find(({ source }) => source.name === target.name)
29
+ return found ? excludedNote(found) : null
30
+ }
31
+
21
32
  /**
22
33
  * `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
34
  * watcher would, its `secrets` handed to the command alike, and prints what it found and would report; `--raw` adds
24
35
  * the command's stdout, which by the recipe rules carries no secret value. Takes no lock and
25
36
  * neither reads nor writes the watcher's state, so a running watcher is not disturbed. Exits 1 when the run failed or a
26
- * `map` field is empty in every entry.
37
+ * `map` field is empty in every entry, and 2, running nothing, when the named personal source is not for this
38
+ * repository (its `repos`).
27
39
  */
28
40
  export const sourceDryRun = async ({ target, raw }: SourceDryRun): Promise<number> => {
29
41
  const root = mainCheckout(process.cwd())
30
42
  const home = homedir()
31
- const merged = configuredSources([worktreeRoot(process.cwd()), root], home)
32
- const settings = await settingsOf(dryRunTarget(target), merged.sources)
43
+ const merged = configuredSources([worktreeRoot(process.cwd()), root], readRepositoryIdentity(root), home)
44
+ const named = dryRunTarget(target)
45
+ const excluded = excludedNoteFor(named, merged.excluded)
46
+ if (excluded !== null) {
47
+ console.log(excluded)
48
+ return NOT_FOR_THIS_REPO_EXIT
49
+ }
50
+ const settings = await settingsOf(named, merged.sources)
33
51
  const answer = await sourceAnswer(settings, root, SOURCE_LIMITS, home)
34
52
  const run = sourceRunOf(settings, answer)
35
53
  const mapCheck = answer.ok ? emptyMapFields(answer.stdout, settings) : null
@@ -35,7 +35,7 @@ const RAW_LINE_LENGTH = 200
35
35
  const INDENT = ' '
36
36
  const BASELINE = 'first run of this source: baseline, nothing reported'
37
37
  const NOT_CHECKED = '0 entries: map not checked'
38
- const FOOTER = 'watcher state not read or changed; the watcher takes a new source over at its next start'
38
+ const FOOTER = 'watcher state not read or changed; a running watcher takes a new source over at its next cycle'
39
39
 
40
40
  export const dryRunTarget = (value: string): DryRunTarget => {
41
41
  if (value === STDIN) return { kind: 'stdin' }
@@ -0,0 +1,58 @@
1
+ import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
2
+
3
+ /** The start of the one stdout line a failing source's command may give its reason in. */
4
+ export const SOURCE_REASON_PREFIX = 'jackherd-reason:'
5
+
6
+ export const SOURCE_REASON_MAX_LENGTH = 160
7
+
8
+ /**
9
+ * A reason line past this length is dropped before any pattern reads it: a real reason is short, and the masking
10
+ * patterns backtrack on a long line from a command that passes outside text through.
11
+ */
12
+ export const SOURCE_REASON_MAX_INPUT = 1024
13
+
14
+ const MASK = '***'
15
+
16
+ /** The whole authority up to its last `@`, which WHATWG URLs split the credentials at. */
17
+ const URL_CREDENTIALS = /(?<![a-z0-9+.-])([a-z][a-z0-9+.-]*:\/\/)[^\s/?#]*@/gi
18
+
19
+ const QUERY_VALUE = /([?&#][^\s=&#?]+=)[^\s&#]*/g
20
+
21
+ const lastReasonLine = (stdout: string): string | null => {
22
+ const lines = stdout.split('\n').filter((line) => line.trimStart().startsWith(SOURCE_REASON_PREFIX))
23
+ const last = lines.at(-1)
24
+ return last === undefined ? null : last.trimStart().slice(SOURCE_REASON_PREFIX.length)
25
+ }
26
+
27
+ /** A value as stored and as a script may print it: without control characters, without surrounding blanks. */
28
+ const printedFormsOf = (value: string): string[] => [
29
+ value,
30
+ value.trim(),
31
+ terminalSafe(value),
32
+ terminalSafe(value).trim(),
33
+ ]
34
+
35
+ const withoutSecrets = (text: string, secretValues: readonly string[]): string =>
36
+ [...new Set(secretValues.flatMap(printedFormsOf))]
37
+ .filter((value) => value.length > 0)
38
+ .sort((a, b) => b.length - a.length)
39
+ .reduce((masked, value) => masked.replaceAll(value, MASK), text)
40
+
41
+ const withoutUrlAccess = (text: string): string =>
42
+ text.replace(URL_CREDENTIALS, `$1${MASK}@`).replace(QUERY_VALUE, `$1${MASK}`)
43
+
44
+ /**
45
+ * The reason a failing source's command gives on stdout: the last line starting with `SOURCE_REASON_PREFIX`, with
46
+ * every value of `secretValues` (the source's own `secrets`), credentials and query values of URLs masked and
47
+ * control characters dropped, then cut to `SOURCE_REASON_MAX_LENGTH`. Secrets are masked before and after the control
48
+ * characters go, so one split by them is found too. A token the source took from elsewhere is not recognised; the
49
+ * recipes' rule keeps it out of the line. Null without such a line, with nothing in it, or with one longer than
50
+ * `SOURCE_REASON_MAX_INPUT`.
51
+ */
52
+ export const sourceReason = (stdout: string, secretValues: readonly string[]): string | null => {
53
+ const line = lastReasonLine(stdout)
54
+ if (line === null || line.length > SOURCE_REASON_MAX_INPUT) return null
55
+ const cleaned = withoutUrlAccess(withoutSecrets(terminalSafe(withoutSecrets(line, secretValues)), secretValues))
56
+ const reason = cleaned.trim()
57
+ return reason.length > 0 ? ellipsize(reason, SOURCE_REASON_MAX_LENGTH) : null
58
+ }
@@ -1,8 +1,7 @@
1
- import { existsSync, statSync } from 'node:fs'
1
+ import { statSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { locateConfigFile, PROJECT_CONFIG_PATH, personalSourcesPath } from '../config/location'
3
+ import { PROJECT_CONFIG_PATH, personalSourcesPath } from '../config/location'
4
4
  import { mergedSources, readPersonalSources } from '../config/personal-sources'
5
- import { readResolvedConfig } from '../config/read-config'
6
5
  import { errorText } from '../plans/error-text'
7
6
  import type { SourceSettings } from './sources'
8
7
 
@@ -16,7 +15,7 @@ export type LoadedSources = { sources: SourceSettings[]; stamps: FileStamps }
16
15
  export type SourceFileReader = { path: string; read: () => readonly SourceSettings[] }
17
16
 
18
17
  /** A file that could not be read, as the line the watcher prints. */
19
- type ReadFailure = { ok: false; line: string }
18
+ export type ReadFailure = { ok: false; line: string }
20
19
 
21
20
  /** A reading of both files: the merged sources with the notes of the skipped ones, or the line naming the file that failed. */
22
21
  export type SourceRead = { ok: true; sources: SourceSettings[]; notes: string[] } | ReadFailure
@@ -30,7 +29,7 @@ const sameStamps = (previous: FileStamps, current: FileStamps): boolean => {
30
29
  }
31
30
 
32
31
  /** The error of `path` on one line, without the `<path> is invalid:` the config readers put before the issues. */
33
- const reasonOf = (path: string, error: unknown): string =>
32
+ export const reasonOf = (path: string, error: unknown): string =>
34
33
  errorText(error).replace(`${path} is invalid:`, '').replace(/\s+/g, ' ').trim()
35
34
 
36
35
  const readOne = (reader: SourceFileReader): { ok: true; sources: readonly SourceSettings[] } | ReadFailure => {
@@ -41,13 +40,20 @@ const readOne = (reader: SourceFileReader): { ok: true; sources: readonly Source
41
40
  }
42
41
  }
43
42
 
44
- /** Reads the repository's sources, then the personal ones, and merges them (`mergedSources`); the first file that fails is named. */
45
- export const readSourceFiles = (repo: SourceFileReader, personal: SourceFileReader): SourceRead => {
43
+ /**
44
+ * Reads the repository's sources, then the personal ones, and merges them for the repository `identity`
45
+ * (`mergedSources`); the first file that fails is named.
46
+ */
47
+ export const readSourceFiles = (
48
+ repo: SourceFileReader,
49
+ personal: SourceFileReader,
50
+ identity: string | null,
51
+ ): SourceRead => {
46
52
  const repoRead = readOne(repo)
47
53
  if (!repoRead.ok) return repoRead
48
54
  const personalRead = readOne(personal)
49
55
  if (!personalRead.ok) return personalRead
50
- return { ok: true, ...mergedSources(repoRead.sources, personalRead.sources, personal.path) }
56
+ return { ok: true, ...mergedSources(repoRead.sources, personalRead.sources, personal.path, identity) }
51
57
  }
52
58
 
53
59
  /**
@@ -85,16 +91,14 @@ export const readFileStamps = (paths: readonly string[]): FileStamps =>
85
91
  Object.fromEntries(paths.map((path) => [path, changeTimeOf(path)]))
86
92
 
87
93
  /**
88
- * Reads the sources again: `watch.sources` of the config `readResolvedConfig` finds in `roots` (the rest of the config
89
- * is not taken over), and those of the personal file of `home`. Unlike the start (`sourcesWithPersonal`), where an
90
- * invalid personal file leaves the repository's sources alone, here it keeps the list the watcher runs: both keep
91
- * what ran before, which at the start is no personal source.
94
+ * Reads the sources again: the repository's (`repository`, the config as `readWatchedConfig` read it) and those of the
95
+ * personal file of `home` that are for the repository `identity`. Unlike the start (`sourcesWithPersonal`), where an invalid personal file leaves the
96
+ * repository's sources alone, here it keeps the list the watcher runs: both keep what ran before, which at the start
97
+ * is no personal source.
92
98
  */
93
- export const readWatchedSources = (roots: string[], home: string): SourceRead =>
99
+ export const readWatchedSources = (repository: SourceFileReader, home: string, identity: string | null): SourceRead =>
94
100
  readSourceFiles(
95
- {
96
- path: locateConfigFile(roots, home, existsSync) ?? join(roots[0] ?? home, PROJECT_CONFIG_PATH),
97
- read: () => readResolvedConfig(roots, home).config.watch.sources,
98
- },
101
+ repository,
99
102
  { path: personalSourcesPath(home), read: (): SourceSettings[] => readPersonalSources(home) },
103
+ identity,
100
104
  )
@@ -3,6 +3,7 @@ import { personalSourcesDirectory } from '../config/location'
3
3
  import { type Environment, sourceEnvironment } from '../secrets/environment'
4
4
  import { SECRET_LOOKUP, SECRET_TIMEOUT_MS, type SecretLookupFn, secretStore } from '../secrets/store'
5
5
  import { resolveSourceCommand } from './source-command'
6
+ import { sourceReason } from './source-reason'
6
7
  import {
7
8
  exitFailure,
8
9
  parseSourceOutput,
@@ -27,8 +28,10 @@ const TOO_LARGE_ANSWER: SourceAnswer = { ok: false, reason: SOURCE_FAILURE.tooLa
27
28
  const UNREADABLE_ANSWER: SourceAnswer = { ok: false, reason: SOURCE_FAILURE.unreadable }
28
29
  const READ_FAILED = Symbol('read failed')
29
30
 
30
- const answerOf = (stdout: string, exitCode: number): SourceAnswer =>
31
- exitCode === 0 ? { ok: true, stdout } : { ok: false, reason: exitFailure(exitCode) }
31
+ const answerOf = (stdout: string, exitCode: number, secretValues: readonly string[]): SourceAnswer =>
32
+ exitCode === 0
33
+ ? { ok: true, stdout }
34
+ : { ok: false, reason: exitFailure(exitCode, sourceReason(stdout, secretValues)) }
32
35
 
33
36
  /** The run a command's answer makes: its output over `items` and `map`, or the reason it failed. */
34
37
  export const sourceRunOf = (settings: SourceSettings, answer: SourceAnswer): SourceRun => ({
@@ -76,7 +79,8 @@ const killGroup = (child: Child) => {
76
79
  /**
77
80
  * Runs `command` as given, without a shell, in `cwd` with `env` (the user's environment without it); never rejects.
78
81
  * `env` reaches the command only: it is neither an argument nor printed. stderr is not read,
79
- * so nothing a failing tool prints there (a token, a URL with credentials) reaches the report. Past `timeoutMs` or
82
+ * so nothing a failing tool prints there (a token, a URL with credentials) reaches the report. A non-zero exit keeps
83
+ * stdout's `jackherd-reason:` line only, cleaned of `secretValues` (`sourceReason`). Past `timeoutMs` or
80
84
  * `maxBytes` the command's process group is killed, and the answer never waits past `timeoutMs` for a pipe a leftover
81
85
  * process keeps open; the group is also killed after a normal end, so a source leaves no process behind that stays in its group.
82
86
  */
@@ -85,6 +89,7 @@ export const runSourceCommand = async (
85
89
  cwd: string,
86
90
  limits: SourceLimits,
87
91
  env?: Environment,
92
+ secretValues: readonly string[] = [],
88
93
  ): Promise<SourceAnswer> => {
89
94
  const child = spawned(command, cwd, env)
90
95
  if (!child) return { ok: false, reason: SOURCE_FAILURE.notStarted }
@@ -99,7 +104,7 @@ export const runSourceCommand = async (
99
104
  if (stdout === null) return TOO_LARGE_ANSWER
100
105
  if (stdout === READ_FAILED) return UNREADABLE_ANSWER
101
106
  const exitCode = await Promise.race([child.exited, deadline])
102
- return exitCode === TIMED_OUT ? TIMED_OUT_ANSWER : answerOf(stdout, exitCode)
107
+ return exitCode === TIMED_OUT ? TIMED_OUT_ANSWER : answerOf(stdout, exitCode, secretValues)
103
108
  } finally {
104
109
  clearTimeout(timer)
105
110
  killGroup(child)
@@ -126,10 +131,14 @@ const noKeychain: SecretLookupFn = async () => ({ kind: SECRET_LOOKUP.absent })
126
131
  export const secretLookupFor = (settings: Pick<SourceSettings, 'origin'>, keychain: SecretLookupFn): SecretLookupFn =>
127
132
  settings.origin === SOURCE_ORIGIN.personal ? keychain : noKeychain
128
133
 
134
+ /** The values of the run's own `secrets`, which its reason must not show. */
135
+ const secretValuesOf = (names: readonly string[], environment: Environment): string[] =>
136
+ names.flatMap((name) => environment[name] ?? [])
137
+
129
138
  /**
130
139
  * The answer of a source's command in `sourceDirectory`, its `secrets` resolved first (`sourceEnvironment` over
131
140
  * `secretLookupFor`, looked up afresh on each run and dropped after it). A secret that cannot be resolved fails the run
132
- * before the command starts.
141
+ * before the command starts. The values also clean the reason of a failed run.
133
142
  */
134
143
  export const sourceAnswer = async (
135
144
  settings: SourceSettings,
@@ -138,9 +147,17 @@ export const sourceAnswer = async (
138
147
  home: string = homedir(),
139
148
  lookup: SecretLookupFn = keychainLookup(limits),
140
149
  ): Promise<SourceAnswer> => {
141
- const environment = await sourceEnvironment(settings.secrets ?? [], secretLookupFor(settings, lookup), process.env)
150
+ const names = settings.secrets ?? []
151
+ const environment = await sourceEnvironment(names, secretLookupFor(settings, lookup), process.env)
142
152
  if (!environment.ok) return environment
143
- return runSourceCommand(settings.command, sourceDirectory(settings, root, home), limits, environment.environment)
153
+ const directory = sourceDirectory(settings, root, home)
154
+ return runSourceCommand(
155
+ settings.command,
156
+ directory,
157
+ limits,
158
+ environment.environment,
159
+ secretValuesOf(names, environment.environment),
160
+ )
144
161
  }
145
162
 
146
163
  /** One run of a source in `sourceDirectory`: its secrets and command (`sourceAnswer`), then its output over `items` and `map`. */
@@ -12,7 +12,10 @@ export const SOURCE_CHANGE = { new: 'new', updated: 'updated' } as const
12
12
 
13
13
  export type SourceChange = (typeof SOURCE_CHANGE)[keyof typeof SOURCE_CHANGE]
14
14
 
15
- /** Why a run of a source failed, as a class only: never its stderr, which may carry a token. `exit <code>` comes from `exitFailure`. */
15
+ /**
16
+ * Why a run of a source failed, as a class only: never its stderr, which may carry a token. `exit <code>` and its
17
+ * reason come from `exitFailure`.
18
+ */
16
19
  export const SOURCE_FAILURE = {
17
20
  timeout: 'timeout',
18
21
  tooLarge: 'output too large',
@@ -69,6 +72,8 @@ export type SourceSettings = {
69
72
  show?: SourceShow
70
73
  /** Where the source is defined; without it the repository's config. */
71
74
  origin?: SourceOrigin
75
+ /** `owner/name` of the repositories a personal source runs in; without it every repository. Never in the repository's config. */
76
+ repos?: readonly string[]
72
77
  /** Shown before the source's rows in the herd; without it its name. */
73
78
  label?: string
74
79
  /** The tone of its mark in the herd while its newest report is new. */
@@ -177,7 +182,9 @@ const STORED_PREFIX_LENGTH = 180
177
182
  const VALUE_HASH_LENGTH = 8
178
183
  const MINUTE_MS = 60_000
179
184
 
180
- export const exitFailure = (code: number): string => `exit ${code}`
185
+ /** `exit <code>`, with the cleaned reason of the command's `jackherd-reason:` line (`sourceReason`) when it gave one. */
186
+ export const exitFailure = (code: number, reason: string | null = null): string =>
187
+ reason ? `exit ${code}: ${reason}` : `exit ${code}`
181
188
 
182
189
  export const emptySourceState = (): SourceState => ({})
183
190
 
@@ -18,7 +18,12 @@ export type WatchTexts = {
18
18
  /** The prefix of a merge's duty line, with the pull request number. */
19
19
  afterMerge: (number: number) => string
20
20
  mergedLaneDuty: string
21
+ mergedWithOpenBoxes: (phase: string, lines: readonly number[]) => string
21
22
  otherLaneDuty: string
23
+ /** After the rebase of a lane the merge reached: every check, those of the named roots (`a, b`), or none. */
24
+ mergeChecksAll: string
25
+ mergeChecksRoots: (roots: string) => string
26
+ mergeChecksNone: string
22
27
  blockedDuty: string
23
28
  /** What a lane whose context reached `alert` asks of the coordinator. */
24
29
  laneAlertDuty: string
@@ -46,6 +51,7 @@ export type WatchTexts = {
46
51
 
47
52
  /** The mark the approval gate hook reads from the question, as its `requestMark` writes it (contract test). */
48
53
  const approvalMark = (id: string, hash: string): string => `\`approval ${id} ${hash}\``
54
+ const renameCommand = (name: string): string => `herdr agent rename "$HERDR_PANE_ID" ${name}`
49
55
 
50
56
  /**
51
57
  * The watcher's words: English whatever the project's languages. Only the two option labels in the approval duty follow
@@ -63,11 +69,16 @@ export const watchTexts = (dialogLanguage: PlanLanguage): WatchTexts => ({
63
69
  noPlan: 'no plan',
64
70
  noName: 'no name',
65
71
  noValue: 'no value',
66
- unnamedPane: (name) => `pane without a name, herdr agent rename "$HERDR_PANE_ID" ${name}`,
67
- noAgentNamed: (name) => `no agent named ${name}`,
72
+ unnamedPane: (name) => `pane without a name, ${renameCommand(name)}`,
73
+ noAgentNamed: (name) => `no agent named ${name}, ${renameCommand(name)}`,
68
74
  afterMerge: (number) => `after merge #${number}`,
69
75
  mergedLaneDuty: 'start the next phase or close the lane',
76
+ mergedWithOpenBoxes: (phase, lines) =>
77
+ `${phase} merged with ${lines.length} open ${lines.length === 1 ? 'box' : 'boxes'} at ${lines.join(', ')}`,
70
78
  otherLaneDuty: 'rebase, then start the next phase or name the reason to wait',
79
+ mergeChecksAll: 'checks: all',
80
+ mergeChecksRoots: (roots) => `checks: ${roots}`,
81
+ mergeChecksNone: 'checks: none (no overlap)',
71
82
  blockedDuty:
72
83
  'vet the question against the plan and the rules, answer it in the lane or ask the user in a dialog; never hand the pane over',
73
84
  laneAlertDuty: 'request the hand-over at the next phase boundary (§ 8)',
@@ -79,12 +90,12 @@ export const watchTexts = (dialogLanguage: PlanLanguage): WatchTexts => ({
79
90
  laneModelDuty:
80
91
  'switch it while idle: /exit, then claude --resume <session> --model <id> in the same pane (§ 2 step 6)',
81
92
  readyPhaseDuty: (count) =>
82
- `start ${count === 1 ? 'it' : 'them'} now (§ 2); a waiting PR approval holds only the commit`,
93
+ `start ${count === 1 ? 'it' : 'them'} now (§ 2), on a branch over the open PR of the lane if it waits for approval or merge: that holds only the commit`,
83
94
  mainCheckoutBehind: (remoteBase, count) =>
84
95
  `main checkout behind ${remoteBase} by ${count} (git merge --ff-only ${remoteBase})`,
85
96
  mainCheckoutOffBase: (branch, baseBranch) => `main checkout on ${branch ?? 'a detached head'}, not ${baseBranch}`,
86
97
  checkoutRows: (term, count) => `${term}: ${count} ${count === 1 ? 'row' : 'rows'} only in the checkout`,
87
98
  otherPullRequests: 'other PRs',
88
99
  approvalDuty: (id, hash) =>
89
- `ask through AskUserQuestion, the question contains ${approvalMark(id, hash)} (hash as in the file shown), options "${APPROVAL_TEXTS[dialogLanguage].allow}" and "${APPROVAL_TEXTS[dialogLanguage].deny}" (§ 4)`,
100
+ `show the request file in a code block, then ask through AskUserQuestion, the question contains ${approvalMark(id, hash)} (hash as in the file shown), options "${APPROVAL_TEXTS[dialogLanguage].allow}" and "${APPROVAL_TEXTS[dialogLanguage].deny}" (§ 4)`,
90
101
  })
@@ -9,11 +9,12 @@ const itemText = ({ item }: WaitingItem): string => ellipsize(terminalSafe(item)
9
9
 
10
10
  /**
11
11
  * The line that brings back what waits on the user's answer, in every heartbeat and status until the item is closed:
12
- * `for you (<n>): <item>; …`, three items and `+<n>` for the rest. Null when no item is open.
12
+ * `for you (<n>): <item>; …`, three items and `+<n>` for the rest. Null when no item is open. The heartbeat passes no
13
+ * `hint`, so it stays short; the status passes the one of the reference that holds the handling.
13
14
  */
14
- export const waitingLine = (items: readonly WaitingItem[]): string | null => {
15
+ export const waitingLine = (items: readonly WaitingItem[], hint = ''): string | null => {
15
16
  const open = items.filter(({ open: isOpen }) => isOpen)
16
17
  if (open.length === 0) return null
17
18
  const { shown, more } = shownWithMore(open, SHOWN_ITEMS)
18
- return `for you (${open.length}): ${[...shown.map(itemText), ...(more > 0 ? [`+${more}`] : [])].join('; ')}`
19
+ return `for you (${open.length}): ${[...shown.map(itemText), ...(more > 0 ? [`+${more}`] : [])].join('; ')}${hint}`
19
20
  }