jackherd 1.9.0 → 1.10.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 (204) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +50 -32
  4. package/dist/{chunk-MCP4XJQH.js → chunk-5MQBCUQ5.js} +9 -4
  5. package/dist/config.d.ts +39 -4
  6. package/dist/config.js +67 -26
  7. package/dist/plan-format.d.ts +45 -6
  8. package/dist/plan-format.js +46 -6
  9. package/docs/workflow-dark.svg +67 -63
  10. package/docs/workflow.svg +67 -63
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/ask-rules.json +15 -0
  14. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  15. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  16. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  17. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  18. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  19. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  20. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  21. package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
  22. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  23. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  24. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  25. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  26. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  27. package/kit/hooks/hooks/open-question/index.ts +26 -0
  28. package/kit/hooks/hooks/open-question/record.ts +135 -0
  29. package/kit/hooks/hooks/open-question/run.ts +52 -0
  30. package/kit/hooks/hooks/open-question/store.ts +39 -0
  31. package/kit/hooks/hooks/sent-file/store.ts +3 -12
  32. package/kit/hooks/hooks/shared/config.ts +7 -0
  33. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  34. package/kit/hooks/hooks/shared/files.ts +7 -0
  35. package/kit/hooks/hooks/shared/lane.ts +1 -1
  36. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  37. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  38. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  39. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  40. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  41. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  42. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  43. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  44. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  45. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  46. package/kit/hooks/hooks.json +13 -5
  47. package/kit/review/agents/skill-review.md +34 -0
  48. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  49. package/kit/workflow/skills/follow/SKILL.md +1 -8
  50. package/kit/workflow/skills/implement/SKILL.md +8 -6
  51. package/kit/workflow/skills/orchestrate/SKILL.md +210 -433
  52. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  53. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  54. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  55. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  56. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  57. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  58. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  59. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  60. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  61. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  62. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  63. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  64. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  65. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  66. package/kit/workflow/skills/plan/SKILL.md +6 -5
  67. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  68. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  69. package/package.json +3 -1
  70. package/recipes/README.md +2 -1
  71. package/recipes/sources.md +1 -1
  72. package/schemas/jackherd.schema.json +59 -5
  73. package/src/check/agent-text-reviewers.ts +23 -0
  74. package/src/check/command.ts +2 -0
  75. package/src/check/plan-structure-check.ts +49 -2
  76. package/src/check/plan.ts +8 -1
  77. package/src/check/schema.ts +9 -0
  78. package/src/check/skill-budget-defaults.ts +55 -0
  79. package/src/check/skill-budgets.ts +185 -0
  80. package/src/cli.ts +1 -0
  81. package/src/config/auto-window.ts +8 -0
  82. package/src/config/link-kinds.ts +7 -1
  83. package/src/config/schema.ts +58 -24
  84. package/src/lane/close-board.ts +106 -0
  85. package/src/lane/close-check.ts +183 -0
  86. package/src/lane/close-command-line.ts +11 -0
  87. package/src/lane/close-command.ts +217 -0
  88. package/src/lane/close-options.ts +43 -0
  89. package/src/lane/close-survey.ts +83 -0
  90. package/src/lane/close.ts +141 -0
  91. package/src/lane/command.ts +13 -0
  92. package/src/lane-prompt/build.ts +74 -0
  93. package/src/lane-prompt/command.ts +87 -0
  94. package/src/lane-prompt/input.ts +116 -0
  95. package/src/lane-prompt/options.ts +68 -0
  96. package/src/lane-prompt/texts.ts +101 -0
  97. package/src/plan-format/clock.ts +8 -0
  98. package/src/plan-format/dependencies.ts +7 -4
  99. package/src/plan-format/index.ts +1 -0
  100. package/src/plan-format/short-names.ts +71 -0
  101. package/src/plan-format/waiting.ts +7 -2
  102. package/src/plans/appointment-rows.ts +59 -0
  103. package/src/plans/ask-coordinator.ts +77 -0
  104. package/src/plans/ask-target.ts +25 -0
  105. package/src/plans/child-stop.ts +51 -0
  106. package/src/plans/closable-records.ts +34 -0
  107. package/src/plans/command.ts +3 -2
  108. package/src/plans/fetch.ts +26 -3
  109. package/src/plans/git.ts +19 -5
  110. package/src/plans/herd-claim.ts +173 -0
  111. package/src/plans/herd-memory.ts +36 -25
  112. package/src/plans/lane-plans.ts +44 -13
  113. package/src/plans/lane-summary.ts +253 -45
  114. package/src/plans/layout-phase.ts +29 -7
  115. package/src/plans/layout-summary.ts +89 -18
  116. package/src/plans/layout.ts +262 -68
  117. package/src/plans/link-chips.ts +1 -0
  118. package/src/plans/live.ts +107 -20
  119. package/src/plans/phase-ids.ts +8 -0
  120. package/src/plans/phase-source.ts +15 -0
  121. package/src/plans/plan-cache.ts +28 -0
  122. package/src/plans/plan-heads.ts +54 -0
  123. package/src/plans/pull-requests.ts +17 -2
  124. package/src/plans/question-records.ts +48 -0
  125. package/src/plans/questions-read.ts +13 -0
  126. package/src/plans/read-cache.ts +8 -0
  127. package/src/plans/record-id.ts +4 -0
  128. package/src/plans/restart-decision.ts +13 -4
  129. package/src/plans/selection.ts +98 -18
  130. package/src/plans/sent-files-read.ts +6 -15
  131. package/src/plans/sent-files.ts +7 -14
  132. package/src/plans/settings.ts +2 -1
  133. package/src/plans/signalled-phase.ts +42 -0
  134. package/src/plans/signals-read.ts +12 -4
  135. package/src/plans/snapshot.ts +85 -62
  136. package/src/plans/sprint-export.ts +11 -11
  137. package/src/plans/sprint-page.ts +5 -5
  138. package/src/plans/subagent-phases.ts +95 -0
  139. package/src/plans/subagent-tasks.ts +51 -0
  140. package/src/plans/subagents-read.ts +66 -17
  141. package/src/plans/subagents.ts +64 -6
  142. package/src/plans/supervisor.ts +14 -4
  143. package/src/plans/symbols.ts +3 -0
  144. package/src/plans/task-name.ts +34 -0
  145. package/src/plans/texts.ts +23 -6
  146. package/src/plans/view.ts +17 -9
  147. package/src/plans/waiting-click.ts +192 -37
  148. package/src/plans/waiting-records.ts +39 -0
  149. package/src/plans/waiting-window.ts +8 -0
  150. package/src/preview/command.ts +25 -3
  151. package/src/shared/private-location.ts +89 -0
  152. package/src/shared/stat-cache.ts +48 -0
  153. package/src/sync/kit-source.ts +29 -10
  154. package/src/sync/kit.ts +2 -0
  155. package/src/sync/manifest.ts +2 -0
  156. package/src/sync/plan-ask-rules.ts +94 -0
  157. package/src/sync/plan-hooks.ts +2 -0
  158. package/src/sync/render.ts +3 -3
  159. package/src/sync/settings-hooks.ts +4 -1
  160. package/src/sync/sync-plan.ts +1 -0
  161. package/src/sync/sync.ts +24 -3
  162. package/src/task-signals/claude-read.ts +27 -10
  163. package/src/terminal/text.ts +8 -3
  164. package/src/version/skill-diff.ts +46 -0
  165. package/src/vocabulary/index.ts +16 -6
  166. package/src/waiting/command.ts +22 -7
  167. package/src/watch/appointments.ts +1 -2
  168. package/src/watch/asking-lanes.ts +40 -0
  169. package/src/watch/base-tip.ts +11 -0
  170. package/src/watch/command.ts +330 -105
  171. package/src/watch/heartbeat-progress.ts +6 -18
  172. package/src/watch/kit-update.ts +28 -9
  173. package/src/watch/lane-name.ts +6 -0
  174. package/src/watch/lane-progress.ts +40 -8
  175. package/src/watch/lane-report-file.ts +20 -5
  176. package/src/watch/lane-report.ts +32 -4
  177. package/src/watch/lane-sources.ts +31 -3
  178. package/src/watch/main-checkout.ts +34 -0
  179. package/src/watch/plan-badges-read.ts +13 -7
  180. package/src/watch/pull-requests.ts +6 -2
  181. package/src/watch/reference-paths.ts +87 -0
  182. package/src/watch/running-subagents.ts +43 -0
  183. package/src/watch/skill-change.ts +124 -32
  184. package/src/watch/source-change.ts +48 -0
  185. package/src/watch/sources.ts +6 -3
  186. package/src/watch/stream-output.ts +4 -3
  187. package/src/watch/task-parallel-auto.ts +208 -0
  188. package/src/watch/task-parallel-data-paths.ts +46 -0
  189. package/src/watch/task-parallel-input.ts +5 -15
  190. package/src/watch/task-parallel-read.ts +3 -1
  191. package/src/watch/task-parallel.ts +36 -7
  192. package/src/watch/task-signal-missing.ts +69 -0
  193. package/src/watch/task-signal-read.ts +29 -0
  194. package/src/watch/texts.ts +10 -0
  195. package/src/watch/update-available.ts +6 -3
  196. package/src/watch/watch-events.ts +23 -0
  197. package/src/watch/watcher-lock.ts +25 -29
  198. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  199. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  200. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  201. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  202. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  203. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  204. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -1,13 +1,40 @@
1
- import { mkdirSync, readFileSync } from 'node:fs'
1
+ import { readdirSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
+ import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
3
4
  import { errorText } from '../plans/error-text'
4
- import { changedSections, SKILL_CHANGE, type SkillChange, skillChangeLine } from '../version/skill-diff'
5
+ import {
6
+ changedSections,
7
+ orchestrateChangeLines,
8
+ REFERENCE_CHANGE,
9
+ type ReferenceChange,
10
+ type ReferenceDelta,
11
+ referenceChanges,
12
+ SKILL_CHANGE,
13
+ type SkillChange,
14
+ } from '../version/skill-diff'
5
15
  import type { StartedKit } from '../version/started-kit'
6
- import { writePrivate } from './watcher-lock'
16
+ import { ORCHESTRATE_SKILL_NAME } from './reference-paths'
17
+
18
+ /** The orchestrate skill as the kit ships it: its text and its reference files by name. */
19
+ export type OrchestrateSnapshot = { skill: string; references: Record<string, string> }
20
+
21
+ /** The change since a snapshot: the skill itself, and each reference that changed, is new or is gone. */
22
+ export type OrchestrateChange = { skill: SkillChange; references: ReferenceChange[] }
23
+
24
+ type WrittenDiff = { text: string; path: string }
25
+
26
+ /** A reference name that stays a plain Markdown file name, since it goes into the name of a diff file. */
27
+ const REFERENCE_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.md$/
28
+
29
+ const skillDirectory = (packageRoot: string): string =>
30
+ join(packageRoot, 'kit', 'workflow', 'skills', ORCHESTRATE_SKILL_NAME)
7
31
 
8
32
  /** The kit's own text of the orchestrate skill, with its placeholders, inside the package. */
9
- export const orchestrateSkillPath = (packageRoot: string): string =>
10
- join(packageRoot, 'kit', 'workflow', 'skills', 'orchestrate', 'SKILL.md')
33
+ export const orchestrateSkillPath = (packageRoot: string): string => join(skillDirectory(packageRoot), 'SKILL.md')
34
+
35
+ /** The directory of the skill's reference files in the kit at `packageRoot`. */
36
+ export const orchestrateReferencesPath = (packageRoot: string): string =>
37
+ join(skillDirectory(packageRoot), 'references')
11
38
 
12
39
  /** The orchestrate skill's text in the kit at `packageRoot`; null when it cannot be read. */
13
40
  export const readOrchestrateSkill = (packageRoot: string): string | null => {
@@ -18,10 +45,45 @@ export const readOrchestrateSkill = (packageRoot: string): string | null => {
18
45
  }
19
46
  }
20
47
 
48
+ /** The skill's reference files by name (every `*.md` file below `references/`); none when the directory is missing. */
49
+ export const readOrchestrateReferences = (packageRoot: string): Record<string, string> => {
50
+ const directory = orchestrateReferencesPath(packageRoot)
51
+ try {
52
+ const names = readdirSync(directory, { withFileTypes: true })
53
+ .filter((entry) => entry.isFile() && REFERENCE_FILE.test(entry.name))
54
+ .map((entry) => entry.name)
55
+ return Object.fromEntries(names.map((name) => [name, readFileSync(join(directory, name), 'utf8')]))
56
+ } catch {
57
+ return {}
58
+ }
59
+ }
60
+
61
+ /** The skill and its references in the kit at `packageRoot`; null when the skill cannot be read. */
62
+ export const readOrchestrateSnapshot = (packageRoot: string): OrchestrateSnapshot | null => {
63
+ const skill = readOrchestrateSkill(packageRoot)
64
+ return skill === null ? null : { skill, references: readOrchestrateReferences(packageRoot) }
65
+ }
66
+
67
+ /**
68
+ * The unified diff (`git diff --no-index`) between `before` and the file at `currentPath`, written to `directory` as
69
+ * `<name>.diff`; null when git has none to give.
70
+ */
71
+ const writeDiff = (before: string, currentPath: string, directory: string, name: string): WrittenDiff | null => {
72
+ ensurePrivateDirectory(directory)
73
+ const beforePath = join(directory, `${name}.before.md`)
74
+ writePrivateFile(beforePath, before)
75
+ const diff = Bun.spawnSync(['git', 'diff', '--no-index', '--unified=3', '--', beforePath, currentPath])
76
+ const text = diff.stdout.toString()
77
+ if (diff.exitCode !== 1 || text === '') return null
78
+ const path = join(directory, `${name}.diff`)
79
+ writePrivateFile(path, text)
80
+ return { text, path }
81
+ }
82
+
21
83
  /**
22
84
  * The change of the orchestrate skill between `snapshot` and the kit at `packageRoot` now: the unified diff
23
- * (`git diff --no-index`) written to `directory` and the sections it touches, so the coordinator reads the change
24
- * instead of the whole skill. Unavailable without a snapshot, a readable skill or a diff.
85
+ * written to `directory` and the sections it touches, so the coordinator reads the change instead of the whole
86
+ * skill. Unavailable without a snapshot, a readable skill or a diff.
25
87
  */
26
88
  export const skillChangeSince = (snapshot: string | null, packageRoot: string, directory: string): SkillChange => {
27
89
  if (snapshot === null) return { kind: SKILL_CHANGE.unavailable, reason: 'no snapshot at start' }
@@ -29,33 +91,63 @@ export const skillChangeSince = (snapshot: string | null, packageRoot: string, d
29
91
  if (current === null) return { kind: SKILL_CHANGE.unavailable, reason: 'skill not readable' }
30
92
  if (current === snapshot) return { kind: SKILL_CHANGE.unchanged }
31
93
  try {
32
- mkdirSync(directory, { recursive: true, mode: 0o700 })
33
- const before = join(directory, 'orchestrate-skill.before.md')
34
- writePrivate(before, snapshot)
35
- const diff = Bun.spawnSync([
36
- 'git',
37
- 'diff',
38
- '--no-index',
39
- '--unified=3',
40
- '--',
41
- before,
42
- orchestrateSkillPath(packageRoot),
43
- ])
44
- const text = diff.stdout.toString()
45
- if (diff.exitCode !== 1 || text === '')
46
- return { kind: SKILL_CHANGE.unavailable, reason: `git diff exit ${diff.exitCode}` }
47
- const diffPath = join(directory, 'orchestrate-skill.diff')
48
- writePrivate(diffPath, text)
49
- return { kind: SKILL_CHANGE.changed, sections: changedSections(current, text), diffPath }
94
+ const diff = writeDiff(snapshot, orchestrateSkillPath(packageRoot), directory, `${ORCHESTRATE_SKILL_NAME}-skill`)
95
+ if (diff === null) return { kind: SKILL_CHANGE.unavailable, reason: 'git diff gave none' }
96
+ return { kind: SKILL_CHANGE.changed, sections: changedSections(current, diff.text), diffPath: diff.path }
50
97
  } catch (error) {
51
98
  return { kind: SKILL_CHANGE.unavailable, reason: errorText(error) }
52
99
  }
53
100
  }
54
101
 
55
- /** The line about the orchestrate skill after a kit update, for the kit the watcher started with. */
56
- export const skillLineFor = (kit: StartedKit | null, snapshot: string | null, directory: string): string =>
57
- skillChangeLine(
58
- kit
59
- ? skillChangeSince(snapshot, kit.packageRoot, directory)
60
- : { kind: SKILL_CHANGE.unavailable, reason: 'kit not located' },
61
- )
102
+ const referenceChangeOf = (
103
+ delta: ReferenceDelta,
104
+ snapshot: OrchestrateSnapshot,
105
+ packageRoot: string,
106
+ directory: string,
107
+ ): ReferenceChange => {
108
+ const path = join(orchestrateReferencesPath(packageRoot), delta.name)
109
+ if (delta.kind === REFERENCE_CHANGE.deleted) return { name: delta.name, kind: REFERENCE_CHANGE.deleted }
110
+ if (delta.kind === REFERENCE_CHANGE.added) return { name: delta.name, kind: REFERENCE_CHANGE.added, path }
111
+ try {
112
+ const diff = writeDiff(
113
+ snapshot.references[delta.name] ?? '',
114
+ path,
115
+ directory,
116
+ `${ORCHESTRATE_SKILL_NAME}-reference-${delta.name}`,
117
+ )
118
+ return { name: delta.name, kind: REFERENCE_CHANGE.changed, diffPath: diff?.path ?? null }
119
+ } catch {
120
+ return { name: delta.name, kind: REFERENCE_CHANGE.changed, diffPath: null }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * The change of the skill and of its references since `snapshot`. The references are judged only while the skill
126
+ * can be read, so a package being replaced does not make every reference look deleted.
127
+ */
128
+ export const orchestrateChangeSince = (
129
+ snapshot: OrchestrateSnapshot | null,
130
+ packageRoot: string,
131
+ directory: string,
132
+ ): OrchestrateChange => {
133
+ const skill = skillChangeSince(snapshot?.skill ?? null, packageRoot, directory)
134
+ if (snapshot === null || skill.kind === SKILL_CHANGE.unavailable) return { skill, references: [] }
135
+ const deltas = referenceChanges(snapshot.references, readOrchestrateReferences(packageRoot))
136
+ return { skill, references: deltas.map((delta) => referenceChangeOf(delta, snapshot, packageRoot, directory)) }
137
+ }
138
+
139
+ /**
140
+ * The lines about the orchestrate skill after a kit update, for the kit the watcher started with: the skill's, then one
141
+ * per changed, new or deleted reference, joined by line breaks.
142
+ */
143
+ export const skillLineFor = (
144
+ kit: StartedKit | null,
145
+ snapshot: OrchestrateSnapshot | null,
146
+ directory: string,
147
+ ): string => {
148
+ if (kit === null) {
149
+ return orchestrateChangeLines({ kind: SKILL_CHANGE.unavailable, reason: 'kit not located' }, []).join('\n')
150
+ }
151
+ const { skill, references } = orchestrateChangeSince(snapshot, kit.packageRoot, directory)
152
+ return orchestrateChangeLines(skill, references).join('\n')
153
+ }
@@ -0,0 +1,48 @@
1
+ import { existsSync, type FSWatcher, watch } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { isRestartTrigger, RESTART_DEBOUNCE_MS } from '../plans/restart-decision'
4
+
5
+ /** The changes to a package's `src/` seen so far: when the last one came (null before any), and the watch to close. */
6
+ export type SourceChanges = { lastChangeAt: () => number | null; close: () => void }
7
+
8
+ /**
9
+ * Whether the package at `packageRoot` is a checkout's own source rather than an installed copy, which lies under a
10
+ * `node_modules` directory. Only the source changes under a running process (a pull or a fast-forward of the
11
+ * checkout); an installed copy changes by a reinstall, which its version shows.
12
+ */
13
+ export const runsFromSource = (packageRoot: string): boolean => !packageRoot.split(/[\\/]/).includes('node_modules')
14
+
15
+ /** Whether a change seen at `lastChangeAt` is over: no further change for the window in which the herd collapses a burst into one restart. */
16
+ export const sourceChangeSettled = (lastChangeAt: number | null, now: number): boolean =>
17
+ lastChangeAt !== null && now - lastChangeAt >= RESTART_DEBOUNCE_MS
18
+
19
+ const NO_CHANGES: SourceChanges = { lastChangeAt: () => null, close: () => {} }
20
+
21
+ /** The watch of `directory`, closed on a later error (`EMFILE` while dev servers run), which would else end the process. */
22
+ const watcherOf = (directory: string, onChange: () => void): FSWatcher | null => {
23
+ try {
24
+ const watcher = watch(directory, { recursive: true }, (_event, name) => {
25
+ if (name && isRestartTrigger(name.toString())) onChange()
26
+ })
27
+ watcher.on('error', () => watcher.close())
28
+ return watcher
29
+ } catch {
30
+ return null
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Follows the source files below `<packageRoot>/src` with the trigger the herd restarts on (`isRestartTrigger`: a
36
+ * `.ts` file, tests and declarations left out), stamping each change with `clock`. A package without `src/`, or one
37
+ * the platform cannot watch, is followed for nothing.
38
+ */
39
+ export const watchSourceChanges = (packageRoot: string, clock: () => number = Date.now): SourceChanges => {
40
+ const directory = join(packageRoot, 'src')
41
+ if (!existsSync(directory)) return NO_CHANGES
42
+ let lastChangeAt: number | null = null
43
+ const watcher = watcherOf(directory, () => {
44
+ lastChangeAt = clock()
45
+ })
46
+ if (!watcher) return NO_CHANGES
47
+ return { lastChangeAt: () => lastChangeAt, close: () => watcher.close() }
48
+ }
@@ -94,6 +94,8 @@ export type TicketRecord = { url: string | null; status: string | null; changedA
94
94
  export type SourceRecord = {
95
95
  seen: SeenEntry[] | null
96
96
  failingSince: number | null
97
+ /** When its last run started, failed or not (`isSourceDue`); absent in a state written before it was kept: never. */
98
+ ranAt?: number
97
99
  /** Of a ticket source only: the entries of its last successful run by id. */
98
100
  tickets?: Record<string, TicketRecord>
99
101
  }
@@ -248,7 +250,7 @@ export const parseSourceOutput = (
248
250
  return { ok: true, items: uniqueById(items as SourceItem[]) }
249
251
  }
250
252
 
251
- /** Whether a source last started at `lastRunAt` (undefined: never in this process) is due again at `now`. */
253
+ /** Whether a source last started at `lastRunAt` (`SourceRecord.ranAt`; undefined: never) is due again at `now`. */
252
254
  export const isSourceDue = (lastRunAt: number | undefined, everyMinutes: number, now: number): boolean =>
253
255
  lastRunAt === undefined || now - lastRunAt >= everyMinutes * MINUTE_MS
254
256
 
@@ -342,8 +344,8 @@ const seenAfter = (seen: readonly SeenEntry[], items: readonly SourceItem[]): Se
342
344
 
343
345
  const recordOf = (record: SourceRecord | undefined, result: SourceResult, now: number): SourceRecord =>
344
346
  result.ok
345
- ? { seen: seenAfter(record?.seen ?? [], result.items), failingSince: null }
346
- : { seen: record?.seen ?? null, failingSince: record?.failingSince ?? now }
347
+ ? { seen: seenAfter(record?.seen ?? [], result.items), failingSince: null, ranAt: now }
348
+ : { seen: record?.seen ?? null, failingSince: record?.failingSince ?? now, ranAt: now }
347
349
 
348
350
  /**
349
351
  * The state after `runs` for the sources configured as `names`: a run records its outcome, a source without a run keeps
@@ -385,6 +387,7 @@ const isSourceRecord = (value: unknown): boolean =>
385
387
  isRecord(value) &&
386
388
  (value.seen === null || (Array.isArray(value.seen) && value.seen.every(isSeenEntry))) &&
387
389
  (value.failingSince === null || typeof value.failingSince === 'number') &&
390
+ (value.ranAt === undefined || typeof value.ranAt === 'number') &&
388
391
  (value.tickets === undefined || (isRecord(value.tickets) && Object.values(value.tickets).every(isTicketRecord)))
389
392
 
390
393
  export const isSourceState = (value: unknown): value is SourceState =>
@@ -17,7 +17,8 @@ const laneState = (lane: string, current: LaneSnapshot, notes: LaneNotes, rests:
17
17
 
18
18
  /**
19
19
  * The heartbeat of a stream, as one line, since every line of a stream reaches the coordinator as a
20
- * message of its own: `=== heartbeat <time> · <lane> <status> · …`.
20
+ * message of its own: `=== heartbeat <time> · <lane> <status> · …`. `standing` are the lines that stay until
21
+ * their cause is gone (what waits on the user, the main checkout behind its base), each one entry at the end.
21
22
  */
22
23
  export const streamHeartbeatLine = (
23
24
  time: string,
@@ -25,12 +26,12 @@ export const streamHeartbeatLine = (
25
26
  current: LaneSnapshot,
26
27
  notes: LaneNotes,
27
28
  rests: LaneNotes = {},
28
- waiting: string | null = null,
29
+ standing: readonly string[] = [],
29
30
  ): string => {
30
31
  const states = lanes.map((lane) => laneState(lane, current, notes, rests))
31
32
  return [
32
33
  `=== ${WATCH_EVENT.heartbeat} ${terminalSafe(time)}`,
33
34
  ...(states.length > 0 ? states : ['no lanes']),
34
- ...(waiting === null ? [] : [terminalSafe(waiting).replaceAll(SEPARATOR, ' / ')]),
35
+ ...standing.map((line) => terminalSafe(line).replaceAll(SEPARATOR, ' / ')),
35
36
  ].join(SEPARATOR)
36
37
  }
@@ -0,0 +1,208 @@
1
+ import { ALWAYS_ASK_PATHS, ENV_PATHS, MIGRATION_PATHS } from '../../kit/hooks/hooks/merge-guard/always-ask'
2
+ import { MAX_AUTO_WINDOW_DAYS, windowDays } from '../config/auto-window'
3
+ import type { JackherdConfig } from '../config/schema'
4
+ import { twoDigits } from '../plan-format/clock'
5
+ import {
6
+ AUTO_VERDICT,
7
+ type AutoVerdict,
8
+ GLOB_CHARACTER,
9
+ pathsOfTask,
10
+ type TaskParallelInput,
11
+ type TaskParallelProposal,
12
+ } from './task-parallel'
13
+
14
+ /** The window of `watch.parallelTasks.auto`: two `YYYY-MM-DD` days, both included. */
15
+ export type AutoWindow = NonNullable<JackherdConfig['watch']['parallelTasks']['auto']>
16
+
17
+ /** The switch, unset when the project has none, and every path a task may not name to start without a question. */
18
+ export type AutoSettings = {
19
+ auto?: AutoWindow
20
+ askPaths: readonly string[]
21
+ /** `watch.parallelTasks.maxTokens` of the same config as the switch. */
22
+ maxTokens: number
23
+ /** Whether the repository holds a path of `DATA_PATHS` at or below a directory, relative to its root. */
24
+ holdsDataPath: (directory: string) => boolean
25
+ }
26
+
27
+ export const MAX_AUTO_EXTRA_AGENTS = 2
28
+
29
+ /**
30
+ * The paths that always ask whose work takes effect before the merge: a migration may be pushed and a secret written
31
+ * while the pull request is open, so the merge guard comes too late. A directory of a writes set that holds one asks.
32
+ */
33
+ export const DATA_PATHS: readonly string[] = [...MIGRATION_PATHS, ...ENV_PATHS]
34
+
35
+ /**
36
+ * The paths that always ask (`ALWAYS_ASK_PATHS`, plus every package.json and CHANGELOG.md, which the merge guard
37
+ * reads apart): work on them is tier C and never starts without a question. The project's `guard.alwaysAsk` come on top.
38
+ */
39
+ export const AUTO_ASK_PATHS: readonly string[] = [...ALWAYS_ASK_PATHS, '**/package.json', '**/CHANGELOG.md']
40
+
41
+ const PROBE = 'x'
42
+ /** Shorter literal pieces of a pattern (`**`, `*`) say nothing about a task's text. */
43
+ const MIN_FRAGMENT = 3
44
+ /**
45
+ * A path that may leave the repository or hide where it lies: absolute, from home, a variable, a drive, a backslash, or
46
+ * a `.` or `..` segment.
47
+ */
48
+ const OUTSIDE_REPOSITORY = /^[/~]|^[A-Za-z]:|[\\%$]|(?:^|\/)\.{1,2}(?:\/|$)/
49
+
50
+ /** Whether a path may leave the repository or hide where it lies (`OUTSIDE_REPOSITORY`). */
51
+ export const leavesRepository = (path: string): boolean => OUTSIDE_REPOSITORY.test(path)
52
+
53
+ const hasFileName = (path: string): boolean => /\.[^/]*$/.test(path.slice(path.lastIndexOf('/') + 1))
54
+
55
+ /** A path up to the directory its first wildcard lies in; a path without one is itself. */
56
+ const literalPrefix = (path: string): string => {
57
+ const glob = path.search(GLOB_CHARACTER)
58
+ return glob === -1 ? path : path.slice(0, Math.max(path.lastIndexOf('/', glob), 0))
59
+ }
60
+
61
+ /** Whether one path lies inside the other or both are the same; `''` is the whole repository. */
62
+ const nested = (left: string, right: string): boolean =>
63
+ left === '' || right === '' || left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)
64
+
65
+ const matchesAnyPattern = (path: string, patterns: readonly string[]): boolean => {
66
+ const lower = path.toLowerCase()
67
+ const globs = patterns.map((pattern) => new Bun.Glob(pattern.toLowerCase()))
68
+ return globs.some((glob) => glob.match(lower) || glob.match(`${lower}/${PROBE}`))
69
+ }
70
+
71
+ /**
72
+ * Whether a path a task names may touch a path that always asks. Only a named file inside a directory of the
73
+ * repository is matched (in lower case, as the merge guard does): a path that may leave the repository, a directory,
74
+ * a glob or a bare file name could reach such a path somewhere, so it counts as one. A dotted name is also matched as
75
+ * a directory, so `.claude` counts like `.claude/**`.
76
+ */
77
+ export const touchesAskPath = (path: string, patterns: readonly string[]): boolean => {
78
+ if (OUTSIDE_REPOSITORY.test(path) || !path.includes('/') || GLOB_CHARACTER.test(path) || !hasFileName(path))
79
+ return true
80
+ return matchesAnyPattern(path, patterns)
81
+ }
82
+
83
+ /** Whether a path relative to the repository root is, or lies in, one of `DATA_PATHS`. */
84
+ export const isDataPath = (path: string): boolean => matchesAnyPattern(path, DATA_PATHS)
85
+
86
+ /**
87
+ * Whether an entry of a phase's writes set may touch a path that always asks: the whole repository, a path that may
88
+ * leave it, an entry a pattern matches as written (`packages/*\/package.json`), a directory or glob that overlaps a
89
+ * pattern anchored at the root (`.claude/**`), or one under whose literal part the repository holds a migration or an
90
+ * env file (`holdsDataPath`). Other patterns that hold anywhere (`**\/package.json`) are not looked for below a
91
+ * directory: their work waits for the merge guard.
92
+ */
93
+ export const writesTouchAskPath = (
94
+ entry: string,
95
+ patterns: readonly string[],
96
+ holdsDataPath: (directory: string) => boolean,
97
+ ): boolean => {
98
+ if (entry === '' || OUTSIDE_REPOSITORY.test(entry)) return true
99
+ if (matchesAnyPattern(entry, patterns)) return true
100
+ if (!GLOB_CHARACTER.test(entry) && hasFileName(entry)) return false
101
+ const literal = literalPrefix(entry.toLowerCase())
102
+ const rootAnchored = patterns.filter((pattern) => !pattern.startsWith('**'))
103
+ return (
104
+ rootAnchored.some((pattern) => nested(literal, literalPrefix(pattern.toLowerCase()))) ||
105
+ holdsDataPath(literalPrefix(entry))
106
+ )
107
+ }
108
+
109
+ /** The literal pieces of the patterns (`.claude`, `package.json`, `migrations`), in lower case. */
110
+ const fragmentsOf = (patterns: readonly string[]): string[] => [
111
+ ...new Set(
112
+ patterns.flatMap((pattern) =>
113
+ pattern
114
+ .toLowerCase()
115
+ .split(/[/*?[\]{}]+/)
116
+ .filter((piece) => piece.length >= MIN_FRAGMENT),
117
+ ),
118
+ ),
119
+ ]
120
+
121
+ /** The local calendar day of `at`, as `YYYY-MM-DD`. */
122
+ export const localDay = (at: number): string => {
123
+ const date = new Date(at)
124
+ return `${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())}`
125
+ }
126
+
127
+ const windowRefusal = (auto: AutoWindow, today: string): string | null => {
128
+ if (!(windowDays(auto.since, auto.until) <= MAX_AUTO_WINDOW_DAYS))
129
+ return `window longer than ${MAX_AUTO_WINDOW_DAYS} days`
130
+ if (today < auto.since) return `off until ${auto.since}`
131
+ if (today > auto.until) return `ended ${auto.until}`
132
+ return null
133
+ }
134
+
135
+ const extraAgentsAtOnce = (input: TaskParallelInput, proposal: TaskParallelProposal): number =>
136
+ Math.max(input.active.length - 1, 0) + proposal.extraAgents
137
+
138
+ const taskPathRefusal = (proposal: TaskParallelProposal, patterns: readonly string[]): string | null => {
139
+ const path = proposal.tasks.flatMap((task) => pathsOfTask(task.text)).find((entry) => touchesAskPath(entry, patterns))
140
+ return path === undefined ? null : `a task names ${path}, which always asks`
141
+ }
142
+
143
+ /** A task's text may name such a path without backticks or extension (`.husky/pre-commit`, `CODEOWNERS`). */
144
+ const taskTextRefusal = (proposal: TaskParallelProposal, patterns: readonly string[]): string | null => {
145
+ const fragments = fragmentsOf(patterns)
146
+ const fragment = proposal.tasks
147
+ .map((task) => task.text.toLowerCase())
148
+ .flatMap((text) => fragments.filter((piece) => text.includes(piece)))[0]
149
+ return fragment === undefined ? null : `a task mentions ${fragment}, which always asks`
150
+ }
151
+
152
+ /**
153
+ * The task's text does not bind the subagent; its phase's writes set is what it may write, read from the plan at the
154
+ * base branch, so the lane's own copy widens nothing. A phase the base branch does not have asks.
155
+ */
156
+ const phaseWritesRefusal = (input: TaskParallelInput, proposal: TaskParallelProposal, settings: AutoSettings) => {
157
+ for (const task of proposal.tasks) {
158
+ const writes = input.baseWrites[task.phase]
159
+ if (writes === undefined || writes.length === 0) return `phase ${task.phase} has no writes set on the base branch`
160
+ const entry = writes.find((path) => writesTouchAskPath(path, settings.askPaths, settings.holdsDataPath))
161
+ if (entry !== undefined)
162
+ return `phase ${task.phase} writes ${entry === '' ? 'the whole repository' : entry}, which always asks`
163
+ }
164
+ return null
165
+ }
166
+
167
+ const limitRefusal = (input: TaskParallelInput, proposal: TaskParallelProposal, settings: AutoSettings) => {
168
+ const { askPaths: patterns, maxTokens } = settings
169
+ if (input.active.length === 0) return 'no running work in the lane'
170
+ const atOnce = extraAgentsAtOnce(input, proposal)
171
+ if (atOnce > MAX_AUTO_EXTRA_AGENTS) return `${atOnce} extra agents at once, at most ${MAX_AUTO_EXTRA_AGENTS}`
172
+ if (proposal.estimateTokens >= Math.min(input.thresholdTokens, maxTokens)) return 'over the token limit'
173
+ return (
174
+ taskPathRefusal(proposal, patterns) ??
175
+ taskTextRefusal(proposal, patterns) ??
176
+ phaseWritesRefusal(input, proposal, settings)
177
+ )
178
+ }
179
+
180
+ /**
181
+ * Whether the coordinator may start `proposal` without asking: the switch holds on `today`, the lane has running work
182
+ * (the extra agents are its subagents, never a new lane), at most two extra agents run at once with the subagents
183
+ * already running, the cost stays under the limit, and neither a task's paths, its text nor its phase's writes set
184
+ * reaches a path that always asks. Every doubt asks.
185
+ */
186
+ export const autoVerdict = (
187
+ input: TaskParallelInput,
188
+ proposal: TaskParallelProposal,
189
+ settings: AutoSettings,
190
+ today: string,
191
+ ): AutoVerdict => {
192
+ if (settings.auto === undefined) return { kind: AUTO_VERDICT.off }
193
+ const reason = windowRefusal(settings.auto, today) ?? limitRefusal(input, proposal, settings)
194
+ return reason === null ? { kind: AUTO_VERDICT.auto, until: settings.auto.until } : { kind: AUTO_VERDICT.asks, reason }
195
+ }
196
+
197
+ /**
198
+ * The line that the switch ended, once per last day: `ended` is the last day already reported (null before any), the
199
+ * answer the one to keep. Nothing while the switch holds or is unset.
200
+ */
201
+ export const autoEndedReport = (
202
+ ended: string | null,
203
+ auto: AutoWindow | undefined,
204
+ today: string,
205
+ ): { ended: string | null; line: string | null } =>
206
+ auto === undefined || today <= auto.until || ended === auto.until
207
+ ? { ended, line: null }
208
+ : { ended: auto.until, line: `parallel tasks auto ended ${auto.until}: every proposal asks again` }
@@ -0,0 +1,46 @@
1
+ import { readdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { isDataPath, leavesRepository } from './task-parallel-auto'
4
+
5
+ type Child = { path: string; searched: boolean }
6
+
7
+ /** Directories a search below a writes entry skips: installed packages and the repository's own store. */
8
+ const SKIPPED = new Set(['node_modules', '.git'])
9
+ /** How many entries one search reads before it gives up; a directory larger than this counts as holding one. */
10
+ const MAX_ENTRIES = 20_000
11
+
12
+ /** The children of a directory below the root, none when it cannot be read; `searched` marks a directory to descend. */
13
+ const childrenOf = (root: string, directory: string): Child[] => {
14
+ try {
15
+ return readdirSync(join(root, directory), { withFileTypes: true }).map((entry) => ({
16
+ path: directory === '' ? entry.name : `${directory}/${entry.name}`,
17
+ searched: entry.isDirectory() && !SKIPPED.has(entry.name),
18
+ }))
19
+ } catch {
20
+ return []
21
+ }
22
+ }
23
+
24
+ /** Whether a path below `relative` is a data path; `true` once the search has read more than `limit` entries. */
25
+ const searchBelow = (root: string, relative: string, limit: number): boolean => {
26
+ const pending = [relative]
27
+ let read = 0
28
+ while (pending.length > 0) {
29
+ for (const child of childrenOf(root, pending.pop() ?? '')) {
30
+ read += 1
31
+ if (read > limit || isDataPath(child.path)) return true
32
+ if (child.searched) pending.push(child.path)
33
+ }
34
+ }
35
+ return false
36
+ }
37
+
38
+ /**
39
+ * `holdsDataPath` for the repository at `root`: whether a migration or an env file lies at or below a directory,
40
+ * read from the file system at each call. A path that may leave the repository counts, a directory that does not exist
41
+ * holds nothing, and a search that reads more than `limit` entries counts as a hit, so a doubt asks.
42
+ */
43
+ export const holdsDataPathIn =
44
+ (root: string, limit: number = MAX_ENTRIES) =>
45
+ (directory: string): boolean =>
46
+ leavesRepository(directory) || isDataPath(directory) || searchBelow(root, directory, limit)
@@ -1,5 +1,5 @@
1
1
  import { isPhaseFinished, isPhaseOpen, type PlanLanguage, type PlanPhase, parsePlanPhases } from '../plan-format'
2
- import { type PhaseDependency, parseDependencyTable } from '../plan-format/dependencies'
2
+ import { type PhaseDependency, parseDependencyTable, writeSetOf } from '../plan-format/dependencies'
3
3
  import { matchSignals, type OpenPlanTask } from '../task-signals/matcher'
4
4
  import type { TaskSignal } from '../task-signals/types'
5
5
  import type { ActiveWork, OpenTask, TaskParallelInput } from './task-parallel'
@@ -12,24 +12,11 @@ export type PlanTaskView = {
12
12
  phaseWrites: Record<string, string[]>
13
13
  }
14
14
 
15
- const writeSetOf = (
16
- row: PhaseDependency,
17
- byId: ReadonlyMap<string, PhaseDependency>,
18
- chain: readonly string[],
19
- ): string[] | null => {
20
- if (chain.includes(row.id)) return null
21
- const like = row.writesLike.map((id) => {
22
- const target = byId.get(id)
23
- return target ? writeSetOf(target, byId, [...chain, row.id]) : null
24
- })
25
- return like.some((set) => set === null) ? null : [...row.writes, ...like.flatMap((set) => set ?? [])]
26
- }
27
-
28
15
  const phaseWritesOf = (rows: readonly PhaseDependency[]): Record<string, string[]> => {
29
16
  const byId = new Map(rows.map((row) => [row.id, row]))
30
17
  return Object.fromEntries(
31
18
  rows.flatMap((row) => {
32
- const writes = writeSetOf(row, byId, [])
19
+ const writes = writeSetOf(row, byId)
33
20
  return writes === null ? [] : [[row.id, writes]]
34
21
  }),
35
22
  )
@@ -77,6 +64,8 @@ export type TaskParallelFacts = {
77
64
  touchedFiles: readonly string[]
78
65
  /** The lane's own agent is working, with or without a task report. */
79
66
  laneWorking: boolean
67
+ /** The writes sets of the plan at the base branch; empty when the base branch has no such plan. */
68
+ baseWrites: Record<string, string[]>
80
69
  baselineTokens: number
81
70
  thresholdTokens: number
82
71
  }
@@ -112,6 +101,7 @@ export const taskParallelInputOf = (facts: TaskParallelFacts): TaskParallelInput
112
101
  open: openTasksWithout(facts),
113
102
  active: activeWorkOf(facts),
114
103
  phaseWrites: facts.plan.phaseWrites,
104
+ baseWrites: facts.baseWrites,
115
105
  doneCount: facts.plan.doneCount,
116
106
  baselineTokens: facts.baselineTokens,
117
107
  thresholdTokens: facts.thresholdTokens,
@@ -11,7 +11,7 @@ import { recordSignals } from '../task-signals/record'
11
11
  import { readTaskRecords } from '../task-signals/record-store'
12
12
  import type { TaskSignal } from '../task-signals/types'
13
13
  import { AGENT_STATUS, type LaneAgent } from '../terminal/types'
14
- import { watchedLanePlanText } from './lane-progress'
14
+ import { baseLanePlanText, watchedLanePlanText } from './lane-progress'
15
15
  import type { TaskParallelInput } from './task-parallel'
16
16
  import { planTaskViewOf, taskParallelInputOf } from './task-parallel-input'
17
17
  import { isWithinWorktree, touchedFilesOf } from './task-parallel-touched'
@@ -134,9 +134,11 @@ export const readTaskParallelInputs = (
134
134
  const signals = uniqueSignals([...reads.flatMap((read) => read.signals), ...records]).filter((signal) =>
135
135
  isWithinWorktree(signal.cwd, worktree),
136
136
  )
137
+ const baseText = baseLanePlanText(sources.root, sources.settings, sources.boardFile, lane)
137
138
  const input = taskParallelInputOf({
138
139
  lane: lane.name,
139
140
  plan: planTaskViewOf(text, sources.settings.planLanguage),
141
+ baseWrites: baseText === null ? {} : planTaskViewOf(baseText, sources.settings.planLanguage).phaseWrites,
140
142
  signals,
141
143
  touchedFiles: reads.flatMap((read) => read.tails.flatMap((tail) => touchedFilesOf(tail, worktree, since))),
142
144
  laneWorking: agents.some((agent) => agent.name === lane.name && agent.status === AGENT_STATUS.working),