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,9 +1,11 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { projectDirectoryOf, readTailLines } from '../context/session-log'
3
+ import { defaultProjectsDirectory, projectDirectoryOf, readTailLines } from '../context/session-log'
4
4
  import { MS_PER_MINUTE } from '../plan-format/clock'
5
+ import { DEFAULT_SKILL_PREFIX } from '../sync/init'
5
6
  import { readApprovalRequests } from '../watch/approvals'
6
7
  import {
8
+ type RunningSubagent,
7
9
  runningSubagents,
8
10
  SUBAGENT_STALE_MINUTES,
9
11
  type SubagentActivity,
@@ -19,8 +21,19 @@ export type SubagentSource = {
19
21
  approvalsRoot: string
20
22
  /** The absolute worktree directory. */
21
23
  worktreeDirectory: string
24
+ /** The project's `skillPrefix`, which its copies of the review agents carry; without it only the default one counts. */
25
+ skillPrefix?: string
22
26
  }
23
27
 
28
+ /** The source of the main checkout `root`: Claude Code's own session logs, the gate's requests in the checkout itself. */
29
+ export const subagentSourceOf = (root: string, worktreeDirectory: string, skillPrefix?: string): SubagentSource => ({
30
+ projects: defaultProjectsDirectory(),
31
+ root,
32
+ approvalsRoot: root,
33
+ worktreeDirectory,
34
+ skillPrefix,
35
+ })
36
+
24
37
  /** The end of a log that holds the directory of the last entries. */
25
38
  const TAIL_BYTES = 64 * 1024
26
39
  const META_FILE = /^agent-([0-9a-f]+)\.meta\.json$/
@@ -35,6 +48,15 @@ const directoriesOf = (path: string): string[] => {
35
48
  }
36
49
  }
37
50
 
51
+ /** The entry names of `path`; none when it is missing or cannot be read. */
52
+ const namesIn = (path: string): string[] => {
53
+ try {
54
+ return readdirSync(path)
55
+ } catch {
56
+ return []
57
+ }
58
+ }
59
+
38
60
  const logOf = (directory: string, name: string, id: string, now: number): SubagentLog[] => {
39
61
  try {
40
62
  const logPath = join(directory, `agent-${id}.jsonl`)
@@ -47,22 +69,39 @@ const logOf = (directory: string, name: string, id: string, now: number): Subage
47
69
  }
48
70
  }
49
71
 
50
- /**
51
- * The subagents with a recently written log in the sessions started in the main checkout `root`, from Claude Code's
52
- * `<projects>/<project>/<session>/subagents/agent-<id>.{meta.json,jsonl}`; whatever cannot be read is left out.
53
- */
54
- export const readSubagentLogs = (projects: string, root: string, now: number): SubagentLog[] => {
55
- const project = join(projects, projectDirectoryOf(root))
56
- return directoriesOf(project).flatMap((session) => {
72
+ /** The subagent logs of every session in one project directory. */
73
+ const sessionLogsOf = (project: string, now: number): SubagentLog[] =>
74
+ directoriesOf(project).flatMap((session) => {
57
75
  const directory = join(project, session, 'subagents')
58
- if (!existsSync(directory)) return []
59
- return readdirSync(directory).flatMap((name) => {
76
+ return namesIn(directory).flatMap((name) => {
60
77
  const id = META_FILE.exec(name)?.[1]
61
78
  return id ? logOf(directory, name, id, now) : []
62
79
  })
63
80
  })
81
+
82
+ /**
83
+ * The project directories Claude Code keeps for sessions started in the main checkout `root` and, when
84
+ * `worktreeDirectory` is given, in a worktree below it: a lane's own subagents, its reviews among them, live there.
85
+ */
86
+ const projectDirectoriesOf = (projects: string, root: string, worktreeDirectory?: string): string[] => {
87
+ const main = projectDirectoryOf(root)
88
+ const lanes = worktreeDirectory === undefined ? null : `${projectDirectoryOf(worktreeDirectory)}-`
89
+ return directoriesOf(projects).filter((name) => name === main || (lanes !== null && name.startsWith(lanes)))
64
90
  }
65
91
 
92
+ /**
93
+ * The subagents with a recently written log in the sessions started in the main checkout `root` and its worktrees,
94
+ * from Claude Code's `<projects>/<project>/<session>/subagents/agent-<id>.{meta.json,jsonl}`; whatever cannot be read
95
+ * is left out.
96
+ */
97
+ export const readSubagentLogs = (
98
+ projects: string,
99
+ root: string,
100
+ now: number,
101
+ worktreeDirectory?: string,
102
+ ): SubagentLog[] =>
103
+ projectDirectoriesOf(projects, root, worktreeDirectory).flatMap((name) => sessionLogsOf(join(projects, name), now))
104
+
66
105
  /** The names the gate holds an open request under, as the watcher reads them. */
67
106
  const heldNames = (root: string, now: number): Set<string> =>
68
107
  new Set(
@@ -71,12 +110,22 @@ const heldNames = (root: string, now: number): Set<string> =>
71
110
  .map((request) => request.lane),
72
111
  )
73
112
 
74
- /** The state of every worktree a subagent of the main checkout's sessions works in. */
75
- export const readSubagentActivity = (
76
- { projects, root, approvalsRoot, worktreeDirectory }: SubagentSource,
113
+ /**
114
+ * The subagents at work in the sessions of the main checkout and its worktrees: their logs read, the finished and the
115
+ * stale left out. The one chain the herd (`readSubagentActivity`) and the watcher's heartbeat share.
116
+ */
117
+ export const readRunningSubagents = (
118
+ { projects, root, approvalsRoot, worktreeDirectory, skillPrefix }: SubagentSource,
77
119
  now: number = Date.now(),
78
- ): Map<string, SubagentActivity> =>
79
- subagentsByLane(
80
- runningSubagents(readSubagentLogs(projects, root, now), heldNames(approvalsRoot, now), now, worktreeDirectory),
120
+ ): RunningSubagent[] =>
121
+ runningSubagents(
122
+ readSubagentLogs(projects, root, now, worktreeDirectory),
123
+ heldNames(approvalsRoot, now),
124
+ now,
81
125
  worktreeDirectory,
126
+ skillPrefix === undefined ? [DEFAULT_SKILL_PREFIX] : [DEFAULT_SKILL_PREFIX, skillPrefix],
82
127
  )
128
+
129
+ /** The state of every worktree a subagent of the main checkout's sessions works in. */
130
+ export const readSubagentActivity = (source: SubagentSource, now: number = Date.now()): Map<string, SubagentActivity> =>
131
+ subagentsByLane(readRunningSubagents(source, now), source.worktreeDirectory)
@@ -4,6 +4,8 @@ import { terminalSafe } from '../plan-format/terminal-text'
4
4
  import { isRecord } from '../shared/is-record'
5
5
  import { AGENT_STATUS, type AgentStatus } from '../terminal/types'
6
6
  import { activityTextOf } from './current-task'
7
+ import { type PhaselessReview, type SubagentPhaseRun, subagentPhaseRunOf } from './subagent-phases'
8
+ import type { DescribedSubagent } from './subagent-tasks'
7
9
  import { laneDirectoryOf } from './unlisted-lanes'
8
10
 
9
11
  /** What the log files of one subagent say: its id, its `meta.json` as parsed, when the log was written, its last lines. */
@@ -17,10 +19,25 @@ export type RunningSubagent = {
17
19
  status: Extract<AgentStatus, 'working' | 'blocked'>
18
20
  /** The text of its last log entries, from which the herd tells the task it works on (`taskSignalsOf`). */
19
21
  activity: string
22
+ /** The phases its full description names (`P21`, `P17-P20`), none for a review that names none; null for any other. */
23
+ phaseRun: SubagentPhaseRun | null
24
+ /** When its log was last written. */
25
+ writtenAt: number
20
26
  }
21
27
 
22
- /** What a worktree shows of the subagents working in it. */
23
- export type SubagentActivity = Pick<RunningSubagent, 'status' | 'description' | 'activity'>
28
+ /** What a worktree shows of the subagents working in it; `phaseRuns` are those of all its subagents that name a phase. */
29
+ export type SubagentActivity = Pick<RunningSubagent, 'status' | 'description' | 'activity'> & {
30
+ phaseRuns: SubagentPhaseRun[]
31
+ /** Its reviews that name no phase; the lane places them on its signalled phase (`laneSummary`). */
32
+ phaselessReviews: PhaselessReview[]
33
+ /**
34
+ * The descriptions of its other subagents that name no phase: what they work on, a signal of the lane like a
35
+ * reported task.
36
+ */
37
+ taskDescriptions: string[]
38
+ /** Its subagents that are no review, phase or not; one whose description starts with a task's short name sits on it. */
39
+ describedSubagents: DescribedSubagent[]
40
+ }
24
41
 
25
42
  /**
26
43
  * A log untouched this long is not at work: Claude Code marks a finished subagent with `finished: true` in its
@@ -85,18 +102,21 @@ const workingDirectory = (tail: readonly string[], worktreeDirectory: string): s
85
102
  /**
86
103
  * The subagents at work: no `finished` mark in the meta, and the log written within `SUBAGENT_STALE_MINUTES`. One
87
104
  * waits for an answer while the approval gate holds a request under its name (`openRequests` are the names of the open
88
- * requests); the transcript alone cannot tell a permission dialog from a long tool call.
105
+ * requests); the transcript alone cannot tell a permission dialog from a long tool call. `reviewPrefixes` are the skill
106
+ * prefixes a copy of the review agents may carry.
89
107
  */
90
108
  export const runningSubagents = (
91
109
  logs: readonly SubagentLog[],
92
110
  openRequests: ReadonlySet<string>,
93
111
  now: number,
94
112
  worktreeDirectory: string,
113
+ reviewPrefixes: readonly string[],
95
114
  ): RunningSubagent[] =>
96
115
  logs.flatMap(({ id, meta, modifiedAt, tail }) => {
97
116
  if (!isRecord(meta) || meta.finished === true || now - modifiedAt > SUBAGENT_STALE_MINUTES * MS_PER_MINUTE)
98
117
  return []
99
- const label = subagentLabelOf(id, textOrNull(meta.agentType))
118
+ const agentType = textOrNull(meta.agentType)
119
+ const label = subagentLabelOf(id, agentType)
100
120
  if (label === null) return []
101
121
  const description = textOrNull(typeof meta.description === 'string' ? terminalSafe(meta.description) : null)
102
122
  const shortDescription = description?.slice(0, DESCRIPTION_LENGTH) ?? null
@@ -107,25 +127,63 @@ export const runningSubagents = (
107
127
  description: shortDescription,
108
128
  status: openRequests.has(label) ? AGENT_STATUS.blocked : AGENT_STATUS.working,
109
129
  activity: activityTextOf(tail, ACTIVITY_ENTRIES),
130
+ phaseRun: subagentPhaseRunOf(description, agentType, modifiedAt, reviewPrefixes),
131
+ writtenAt: modifiedAt,
110
132
  },
111
133
  ]
112
134
  })
113
135
 
136
+ /** The description of a subagent that names no phase, as a list of at most one; empty for a review or one that names a phase. */
137
+ const taskDescriptionOf = ({ phaseRun, description }: RunningSubagent): string[] =>
138
+ phaseRun === null && description !== null ? [description] : []
139
+
140
+ /** A subagent that is no review, with its description, as a list of at most one. */
141
+ const describedSubagentOf = ({ phaseRun, description, writtenAt }: RunningSubagent): DescribedSubagent[] =>
142
+ description !== null && (phaseRun === null || phaseRun.review === null) ? [{ description, at: writtenAt }] : []
143
+
144
+ /** The run of a subagent that names a phase, as a list of at most one. */
145
+ const namedRunOf = ({ phaseRun }: RunningSubagent): SubagentPhaseRun[] =>
146
+ phaseRun !== null && phaseRun.ids.length > 0 ? [phaseRun] : []
147
+
148
+ /** A review that names no phase, as a list of at most one. */
149
+ const phaselessReviewOf = ({ phaseRun, description }: RunningSubagent): PhaselessReview[] => {
150
+ if (phaseRun === null || phaseRun.review === null || phaseRun.ids.length > 0) return []
151
+ return [{ review: phaseRun.review, at: phaseRun.at, description }]
152
+ }
153
+
114
154
  /** The worktrees below `worktreeDirectory` the subagents work in; one that waits for an answer decides the state. */
115
155
  export const subagentsByLane = (
116
156
  running: readonly RunningSubagent[],
117
157
  worktreeDirectory: string,
118
158
  ): Map<string, SubagentActivity> => {
119
159
  const lanes = new Map<string, SubagentActivity>()
120
- for (const { cwd, status, description, activity } of running) {
160
+ for (const subagent of running) {
161
+ const { cwd, status, description, activity } = subagent
121
162
  const lane = cwd === null ? null : laneDirectoryOf(cwd, worktreeDirectory)
122
163
  if (lane === null) continue
123
164
  const known = lanes.get(lane)
124
- if (!known) lanes.set(lane, { status, description, activity })
165
+ const runs = namedRunOf(subagent)
166
+ const reviews = phaselessReviewOf(subagent)
167
+ const tasks = taskDescriptionOf(subagent)
168
+ const described = describedSubagentOf(subagent)
169
+ if (!known)
170
+ lanes.set(lane, {
171
+ status,
172
+ description,
173
+ activity,
174
+ phaseRuns: runs,
175
+ phaselessReviews: reviews,
176
+ taskDescriptions: tasks,
177
+ describedSubagents: described,
178
+ })
125
179
  else
126
180
  lanes.set(lane, {
127
181
  ...(status === AGENT_STATUS.blocked && known.status !== AGENT_STATUS.blocked ? { status, description } : known),
128
182
  activity: `${known.activity} ${activity}`,
183
+ phaseRuns: [...known.phaseRuns, ...runs],
184
+ phaselessReviews: [...known.phaselessReviews, ...reviews],
185
+ taskDescriptions: [...known.taskDescriptions, ...tasks],
186
+ describedSubagents: [...known.describedSubagents, ...described],
129
187
  })
130
188
  }
131
189
  return lanes
@@ -4,6 +4,7 @@ import { dirname } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { pendingKitUpdate, VERSION_POLL_MS } from '../version/installed-version'
6
6
  import type { StartedKit } from '../version/started-kit'
7
+ import { STOP_REQUEST, type Stopper, stopperOf } from './child-stop'
7
8
  import { HERD_COMMAND } from './herd-command'
8
9
  import { CHILD_MARKER, exitCodeOf, isRestartTrigger, nextStep, RESTART_DEBOUNCE_MS } from './restart-decision'
9
10
  import { TERMINAL_OFF } from './view'
@@ -30,7 +31,8 @@ const spawnView = (scriptPath: string, args: string[]): ChildProcess =>
30
31
  /**
31
32
  * Runs the live view as a child process and restarts it when the package's own source changes,
32
33
  * so a long-lived `plans` never shows the behaviour of code that has since been edited. The child
33
- * stops on SIGTERM through the handler that restores the terminal. Resolves with the exit code
34
+ * stops on SIGTERM through the handler that restores the terminal, and is killed when it does not end within
35
+ * `GRACE_MS` (a restart included); a second signal while stopping kills it at once. Resolves with the exit code
34
36
  * of a wanted end only: the child's clean exit (`q`) or a signal forwarded to it. A child that
35
37
  * dies otherwise leaves the supervisor waiting, with the terminal restored, for the next change
36
38
  * to `src/`, which spawns a fresh child. `kit` is the package this process loaded (null when it cannot be
@@ -45,6 +47,7 @@ export const superviseView = (
45
47
  ): Promise<number> =>
46
48
  new Promise((resolveExit) => {
47
49
  let child: ChildProcess | undefined
50
+ let stopper: Stopper | undefined
48
51
  let restarting = false
49
52
  let stopping = false
50
53
  let timer: ReturnType<typeof setTimeout> | undefined
@@ -66,6 +69,12 @@ export const superviseView = (
66
69
  const start = (isFirst: boolean): void => {
67
70
  const current = spawnView(scriptPath, args)
68
71
  child = current
72
+ const currentStopper = stopperOf({
73
+ kill: (signal) => current.kill(signal),
74
+ setTimer: (run, ms) => setTimeout(run, ms),
75
+ clearTimer: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
76
+ })
77
+ stopper = currentStopper
69
78
 
70
79
  current.on('error', (error) => {
71
80
  if (child !== current) return
@@ -79,6 +88,7 @@ export const superviseView = (
79
88
  })
80
89
 
81
90
  current.on('exit', (code, signal) => {
91
+ currentStopper.ended()
82
92
  if (child !== current) return
83
93
  const context = { restarting, stopping, code, signal }
84
94
  restarting = false
@@ -94,7 +104,7 @@ export const superviseView = (
94
104
  const restartView = (): void => {
95
105
  if (child) {
96
106
  restarting = true
97
- child.kill('SIGTERM')
107
+ stopper?.request('SIGTERM')
98
108
  } else start(false)
99
109
  }
100
110
 
@@ -118,8 +128,8 @@ export const superviseView = (
118
128
  stopping = true
119
129
  clearTimeout(timer)
120
130
  clearInterval(versionTimer)
121
- if (child) child.kill(signal)
122
- else finish(exitCodeOf(null, signal))
131
+ if (!child || !stopper) finish(exitCodeOf(null, signal))
132
+ else if (stopper.request(signal) === STOP_REQUEST.killed) finish(exitCodeOf(null, signal))
123
133
  })
124
134
  }
125
135
  })
@@ -21,6 +21,9 @@ export const statusWord = (agent: string | null): string => agent ?? NO_AGENT_WO
21
21
  /** Before a row that opens and closes: lanes, the planned phases, the ticked items of a phase. */
22
22
  export const CHEVRON = { closed: '▸', open: '▾' } as const
23
23
 
24
+ /** Before the count under a block that shows all its rows; a click folds it back to five. */
25
+ export const LESS_MARK = '▴'
26
+
24
27
  export const chevronOf = (open: boolean): string => (open ? CHEVRON.open : CHEVRON.closed)
25
28
 
26
29
  export const PHASE_STATE = { missing: 'missing', empty: 'empty', all: 'all', open: 'open' } as const
@@ -0,0 +1,34 @@
1
+ const TASK_NAME = /^\*\*([^*]+)\*\*:/
2
+
3
+ const TITLE_END = ': '
4
+
5
+ const titleEndOf = (text: string): number => {
6
+ let inCode = false
7
+ for (let index = 0; index < text.length; index++) {
8
+ if (text[index] === '`') inCode = !inCode
9
+ else if (!inCode && text.startsWith(TITLE_END, index)) return index
10
+ }
11
+ return -1
12
+ }
13
+
14
+ /**
15
+ * The text up to its first colon followed by a space outside backticks, as a folded row shows it; the text as it is
16
+ * without such a colon or with nothing before it. A time like `18:55` has no space after its colon and does not split.
17
+ */
18
+ export const shortTitle = (text: string): string => {
19
+ const end = titleEndOf(text)
20
+ return end > 0 ? text.slice(0, end) : text
21
+ }
22
+
23
+ /**
24
+ * The short name a task carries in bold at its start, `**Name**: content`, as written; null for a line without one.
25
+ * Nothing is guessed from the text up to its first colon: a path at the start of a line would read as a name.
26
+ */
27
+ export const taskNameOf = (text: string): string | null => {
28
+ const name = text.match(TASK_NAME)?.[1]?.trim()
29
+ return name ? name : null
30
+ }
31
+
32
+ /** The text after a task's `**Name**:`, as an opened task shows it under its name; the text as it is without a name. */
33
+ export const taskBodyOf = (text: string): string =>
34
+ taskNameOf(text) === null ? text : text.replace(TASK_NAME, '').trimStart()
@@ -3,11 +3,11 @@ import { CHECK_NAME_LENGTH, CHECKS, type ChecksState, type PullRequest } from '.
3
3
  import { shownWithMore } from './shown-with-more'
4
4
  import { MODEL_MARK, NO_AGENT_WORD, PHASE_COUNT } from './symbols'
5
5
 
6
- /** The words of an open lane: the age of a running phase, the word before the open phases that wait, the finished phases not merged yet, and the pointer of a phase with only its review gate open. */
6
+ /** The words of an open lane: the age of a running phase, the words before the open phases that wait and the finished ones, and the pointer of a phase with only its review gate open. */
7
7
  export type HerdTexts = {
8
8
  minutesAgo: (minutes: number) => string
9
9
  next: string
10
- notMerged: string
10
+ done: string
11
11
  reviewGate: string
12
12
  gate: string
13
13
  }
@@ -23,9 +23,14 @@ export type PlanTexts = {
23
23
  noPlan: string
24
24
  noWorktree: string
25
25
  noRow: string
26
+ /** The heads of the blocks on top of the herd. */
27
+ waitingHeading: string
28
+ appointmentHeading: string
26
29
  notInPlan: string
27
30
  /** Before a task an agent reports that no task of the plan names. */
28
31
  now: string
32
+ /** Before the description of a running subagent that names no phase and sits on none. */
33
+ subagent: string
29
34
  /** In place of the count of a phase whose only open box is the documentation box of its review gate. */
30
35
  allButDocs: string
31
36
  tickedSummary: (count: number) => string
@@ -36,7 +41,13 @@ export type PlanTexts = {
36
41
  herd: HerdTexts
37
42
  checks: Record<ChecksState, string>
38
43
  hint: string
44
+ /** The notice when `a` is pressed with no phase open, where the key changes nothing on screen. */
45
+ showDoneNeedsPhase: string
39
46
  legend: string[]
47
+ /** The footer line when the coordinator could not be asked to put a question, with the reason. */
48
+ notAsked: (reason: string) => string
49
+ /** The footer line when the coordinator `name` was asked to put `subject`, `question <id>` or `board row <key>`. */
50
+ asked: (name: string, subject: string) => string
40
51
  }
41
52
 
42
53
  /** The words of the watcher's badges and of the header notice when they are missing. */
@@ -78,8 +89,11 @@ export const TEXTS: PlanTexts = {
78
89
  noPlan: 'no plan',
79
90
  noWorktree: 'no worktree',
80
91
  noRow: 'no row in lanes',
92
+ waitingHeading: 'Waiting for you',
93
+ appointmentHeading: 'Appointments',
81
94
  notInPlan: 'not in the plan',
82
95
  now: 'now:',
96
+ subagent: 'Subagent:',
83
97
  allButDocs: ALL_BUT_DOCS,
84
98
  tickedSummary: (count) => `${count} ticked · click or a lists them`,
85
99
  draft: 'draft · ',
@@ -88,7 +102,7 @@ export const TEXTS: PlanTexts = {
88
102
  herd: {
89
103
  minutesAgo: (minutes) => (minutes === 0 ? '· just now' : `· ${minutes} min ago`),
90
104
  next: 'next',
91
- notMerged: 'not merged',
105
+ done: 'done',
92
106
  reviewGate: 'Review gate',
93
107
  gate: '· gate',
94
108
  },
@@ -100,16 +114,19 @@ export const TEXTS: PlanTexts = {
100
114
  [CHECKS.pending]: '… checks',
101
115
  [CHECKS.none]: 'no checks',
102
116
  },
103
- hint: '↑↓ select →← open a show ticked s html q quit ? help',
117
+ hint: '↑↓ select →← open a ticked tasks s html q quit ? help',
118
+ showDoneNeedsPhase: 'a lists the ticked tasks inside an open phase: open one with → or a click',
104
119
  legend: [
105
120
  'enter/space or click toggles a lane or a phase, click or enter on a task opens its whole text, and opens a ↗ pull request · wheel PgUp PgDn scroll · j k h l like the arrows · click or ctrl+click on a link chip opens its link · ✦ artifact · ⑂ pull request or issue · ◈ Figma · private = the plan says so',
106
121
  '● working · ◆ blocked, waits for you · ◉ done = the agent finished its answer, not the lane · ○ idle · (·) no agent = a worktree nobody works in · • state unknown · only blocked wears its word in the lane line, the symbol says the rest · waiting = not started: no worktree yet, something left to do, and what it starts after; finished lanes (cleaned up, or every named phase ticked) are not shown · a working or blocked lane opens by itself only when it first appears, after that → ← and clicks are yours alone',
107
- `▸ ▾ open and close · an open lane's branches, top down: the agent's title and ⎇ its branch, ↗ its pull requests, its link chips, the phases it works on, next, ✓ · a lane shows only the phases its board rows name · a phase row = some of its tasks are ticked, one of its boxes was ticked in the last ${STALE_ACTIVITY_MINUTES} min, or an agent reports working on it, by number · n min ago since the last tick or report, · gate = only the review gate is open · ▶ teal = a task an agent reported (its task list or jh task; its name dim at the right edge when several agents work in the lane), ▷ dim = only guessed from the words of its log · now: <text> = a reported task no plan task names · next = the other open phases, as ranges · ✓ the finished ones, not merged = ticked in the worktree, not on the base branch yet · ${ALL_BUT_DOCS} = only the documentation box is open · a folded lane line names the phase moved last, +n more active ones, and its count · ${PHASE_COUNT.empty} no checkboxes · ${PHASE_COUNT.missing} not in the plan · a lists the ticked checkboxes one by one · s opens every lane and plan as one HTML page`,
122
+ `▸ ▾ open and close · an open lane's branches, top down: the agent's title and ⎇ its branch, ↗ its pull requests, its link chips, the phases it works on, next, done · a lane shows only the phases its board rows name · a phase row = some of its tasks are ticked, one of its boxes was ticked in the last ${STALE_ACTIVITY_MINUTES} min, or an agent reports working on it, by number · n min ago since the last tick or report, · gate = only the review gate is open · ▶ teal = a task an agent reported (its task list or jh task; its name dim at the right edge when several agents work in the lane), ▷ dim = only guessed from the words of its log · now: <text> = a reported task no plan task names · a running review sits on the gate box that names it, in the phase it names or else the lane's current one · Subagent: <text> = a running subagent that names no phase and sits on none · next = the other open phases, as ranges · done = the finished ones · ${ALL_BUT_DOCS} = only the documentation box is open · a folded lane line names the phase moved last, +n more active ones, and its count · ${PHASE_COUNT.empty} no checkboxes · ${PHASE_COUNT.missing} not in the plan · a lists the ticked checkboxes of an open phase one by one · s opens every lane and plan as one HTML page`,
108
123
  `the bar = progress over the lane's plans · n/m = m checkboxes, n ticked · n/m ph = n of m phases finished · n/n in green = all ticked · 🥣 share of the context window used, on a folded lane only once it warns · Sonnet ${MODEL_MARK.differs} Opus in amber = the phase's subagent ran on another model than the plan's`,
109
124
  '↗ pull request · 💬 new feedback · ✋ changes requested · 🌐 preview · 📡 ticket: chip 📡 T-1 · status among the link chips, 📡 <n> in amber on a folded lane when tickets are new',
110
- '▌ on top = waits for your answer: 📎 a file or an Artifact that was sent (click: a cut-off row expands, then it opens), ? a question of the board · it goes when the board row is closed or `jh waiting close <id>` · woof closes the block',
125
+ '▌ on top = waits for your answer: 📎 a file or an Artifact that was sent (click: a cut-off row expands, then it opens), ? a question of the board or one a session left in its dialog (a click on its text only expands and folds it; [ask] at the right edge of a question asks the coordinator to put it) · it goes when the board row is closed or `jh waiting close <id>` · `Waiting for you` heads the block (click: fold; click on +n: show all, on ▴ n: five) · ◷ the appointments of the board in a grey bar below it (click: fold)',
111
126
  '⎇ branch · ↑ commits ahead · ± uncommitted · no worktree: plan read from main',
112
127
  ],
128
+ notAsked: (reason) => `no coordinator to ask: ${reason}`,
129
+ asked: (name, subject) => `asked ${name} for ${subject}`,
113
130
  }
114
131
 
115
132
  /** The mark of a running lane the board has no row for; null for a lane it lists. */
package/src/plans/view.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { ARTIFACT_LINK_KIND } from '../config/link-kinds'
1
2
  import { fit, plainText } from '../terminal/text'
2
3
  import { renderSelected, type Screen } from './layout'
3
4
  import type { Options } from './options'
@@ -20,7 +21,9 @@ const paneRows = (): number => process.stdout.rows ?? Number.MAX_SAFE_INTEGER
20
21
  export const bodyRows = (screen: Screen): number =>
21
22
  Math.max(paneRows() - screen.header.length - screen.footer.length, 1)
22
23
 
23
- export const clockOf = (locale: string): string => new Date().toLocaleTimeString(locale)
24
+ /** The time as `HH:MM`, in the header and in the refresh error alike: a frame differs only once a minute. */
25
+ export const clockOf = (locale: string, now: Date = new Date()): string =>
26
+ now.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })
24
27
 
25
28
  export const renderSnapshot = (
26
29
  snapshot: Snapshot,
@@ -38,6 +41,7 @@ export const renderSnapshot = (
38
41
  watch: snapshot.watch,
39
42
  watchesTickets: snapshot.watchesTickets,
40
43
  awaiting: snapshot.waiting,
44
+ appointments: snapshot.appointments,
41
45
  unplaced: snapshot.unplaced,
42
46
  },
43
47
  selection,
@@ -50,19 +54,23 @@ export const startSelection = (snapshot: Snapshot, options: Options): Selection
50
54
  options.lane ? laneSelection(snapshot.views[0]?.progress) : overviewSelection()
51
55
 
52
56
  /** Header and footer stay put; the body shows `bodyRows` lines from `top`, padded so the footer sits at the bottom. */
53
- export const draw = (screen: Screen, top: number): void => {
54
- const width = paneWidth()
55
- const rows = bodyRows(screen)
57
+ export const frameOutput = (screen: Screen, top: number, width: number, rows: number): string => {
56
58
  const body = screen.body.lines.slice(top, top + rows)
57
59
  const padding = Array.from({ length: Math.max(rows - body.length, 0) }, () => '')
58
60
  const lines = [...screen.header, ...body, ...padding, ...screen.footer]
59
- process.stdout.write(
60
- `${CURSOR_HOME}${lines.map((line) => `${fit(line, width)}${CLEAR_LINE_END}`).join('\r\n')}${CLEAR_BELOW}`,
61
- )
61
+ return `${CURSOR_HOME}${lines.map((line) => `${fit(line, width)}${CLEAR_LINE_END}`).join('\r\n')}${CLEAR_BELOW}`
62
62
  }
63
63
 
64
- /** One frame as text; ANSI styling is dropped when stdout is not a terminal. */
64
+ /** The frame to write, or null when the terminal already shows it; a null `previous` means the terminal content is unknown. */
65
+ export const frameToWrite = (previous: string | null, next: string): string | null => (previous === next ? null : next)
66
+
67
+ const ARTIFACT_ADDRESS = new RegExp(ARTIFACT_LINK_KIND.pattern)
68
+
69
+ /**
70
+ * One frame as text; ANSI styling is dropped when stdout is not a terminal, and links are written out as their
71
+ * address, except an Artifact's, which is long and would push the rest of its line past the pane.
72
+ */
65
73
  export const frameText = (screen: Screen, isTerminal: boolean): string => {
66
74
  const lines = [...screen.header, ...screen.body.lines, ...screen.footer]
67
- return (isTerminal ? lines : lines.map(plainText)).join('\n')
75
+ return (isTerminal ? lines : lines.map((line) => plainText(line, (url) => !ARTIFACT_ADDRESS.test(url)))).join('\n')
68
76
  }