jackherd 1.6.1 → 1.7.1

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 (156) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/INSTALL.md +176 -0
  3. package/README.md +228 -312
  4. package/assets/branding/README.md +7 -3
  5. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  6. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  7. package/assets/branding/jackherd-logo-dark.svg +1 -1
  8. package/assets/icons/bark-dark.svg +1 -0
  9. package/assets/icons/bark.svg +1 -0
  10. package/assets/icons/bowl-dark.svg +1 -0
  11. package/assets/icons/bowl.svg +1 -0
  12. package/assets/icons/decide-dark.svg +1 -0
  13. package/assets/icons/decide.svg +1 -0
  14. package/assets/icons/ear-dark.svg +1 -0
  15. package/assets/icons/ear.svg +1 -0
  16. package/assets/icons/lanes-dark.svg +1 -0
  17. package/assets/icons/lanes.svg +1 -0
  18. package/assets/icons/plan-dark.svg +1 -0
  19. package/assets/icons/plan.svg +1 -0
  20. package/assets/icons/space.svg +1 -0
  21. package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
  22. package/dist/config.d.ts +14 -3
  23. package/dist/config.js +111 -10
  24. package/dist/plan-format.d.ts +180 -77
  25. package/dist/plan-format.js +292 -93
  26. package/docs/lanes-dark.svg +50 -0
  27. package/docs/lanes.svg +50 -1
  28. package/docs/skills-dark.svg +65 -0
  29. package/docs/skills.svg +65 -1
  30. package/docs/workflow-dark.svg +138 -0
  31. package/docs/workflow.svg +135 -169
  32. package/herdr-plugin/herdr-plugin.toml +6 -6
  33. package/herdr-plugin/open-plans.ts +1 -1
  34. package/herdr-plugin/restore-plans.ts +7 -7
  35. package/install.sh +1 -1
  36. package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
  37. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
  38. package/kit/hooks/hooks/approval-gate/index.ts +69 -0
  39. package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
  40. package/kit/hooks/hooks/approval-gate/request.ts +111 -0
  41. package/kit/hooks/hooks/approval-gate/run.ts +94 -0
  42. package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
  43. package/kit/hooks/hooks/approval-gate/store.ts +70 -0
  44. package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
  45. package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
  46. package/kit/hooks/hooks/merge-guard/run.ts +2 -1
  47. package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
  48. package/kit/hooks/hooks/model-gate/index.ts +58 -0
  49. package/kit/hooks/hooks/model-gate/run.ts +63 -0
  50. package/kit/hooks/hooks/model-log/index.ts +60 -0
  51. package/kit/hooks/hooks/model-log/run.ts +57 -0
  52. package/kit/hooks/hooks/model-log/store.ts +40 -0
  53. package/kit/hooks/hooks/model-log/transcript.ts +37 -0
  54. package/kit/hooks/hooks/shared/checkout.ts +22 -0
  55. package/kit/hooks/hooks/shared/command.ts +6 -0
  56. package/kit/hooks/hooks/shared/config.ts +21 -0
  57. package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
  58. package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
  59. package/kit/hooks/hooks/shared/files.ts +19 -0
  60. package/kit/hooks/hooks/shared/fresh.ts +4 -0
  61. package/kit/hooks/hooks/shared/json.ts +4 -0
  62. package/kit/hooks/hooks/shared/model-family.ts +48 -0
  63. package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
  64. package/kit/hooks/hooks/shared/private-directory.ts +29 -0
  65. package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
  66. package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
  67. package/kit/hooks/hooks.json +25 -0
  68. package/kit/workflow/skills/follow/SKILL.md +4 -3
  69. package/kit/workflow/skills/implement/SKILL.md +6 -6
  70. package/kit/workflow/skills/orchestrate/SKILL.md +109 -27
  71. package/kit/workflow/skills/plan/SKILL.md +5 -5
  72. package/kit/workflow/skills/start/SKILL.md +2 -2
  73. package/package.json +4 -1
  74. package/schemas/jackherd.schema.json +43 -5
  75. package/src/check/command.ts +31 -4
  76. package/src/check/deferral-arrows.ts +1 -1
  77. package/src/check/parallel-report.ts +23 -0
  78. package/src/check/plan-structure-check.ts +120 -53
  79. package/src/cli.ts +8 -2
  80. package/src/config/legacy-names.ts +8 -0
  81. package/src/config/nested-quantifier.ts +73 -0
  82. package/src/config/repository-directory.ts +11 -0
  83. package/src/config/schema.ts +63 -8
  84. package/src/plan-format/board-prose.ts +36 -0
  85. package/src/plan-format/dependencies.ts +227 -0
  86. package/src/plan-format/documentation-gate.ts +16 -0
  87. package/src/plan-format/index.ts +23 -1
  88. package/src/plan-format/lanes.ts +35 -1
  89. package/src/plan-format/model-column.ts +52 -0
  90. package/src/plan-format/phases.ts +16 -1
  91. package/src/plan-format/plan-structure.ts +10 -28
  92. package/src/plan-format/progress.ts +7 -29
  93. package/src/plans/command.ts +3 -3
  94. package/src/plans/current-phase.ts +71 -0
  95. package/src/plans/ensure-panes.ts +2 -2
  96. package/src/plans/herd-command.ts +4 -0
  97. package/src/plans/lane-discovery.ts +14 -2
  98. package/src/plans/lane-tickets.ts +69 -0
  99. package/src/plans/layout.ts +182 -88
  100. package/src/plans/live.ts +5 -1
  101. package/src/plans/phase-models-read.ts +37 -0
  102. package/src/plans/phase-models.ts +70 -0
  103. package/src/plans/plan-panes.ts +8 -7
  104. package/src/plans/pull-requests.ts +78 -4
  105. package/src/plans/selection.ts +1 -40
  106. package/src/plans/settings.ts +9 -3
  107. package/src/plans/shown-with-more.ts +5 -0
  108. package/src/plans/snapshot.ts +82 -8
  109. package/src/plans/sprint-html.ts +2 -2
  110. package/src/plans/supervisor.ts +5 -4
  111. package/src/plans/symbols.ts +9 -6
  112. package/src/plans/texts.ts +30 -13
  113. package/src/plans/view.ts +1 -0
  114. package/src/sync/command.ts +16 -1
  115. package/src/sync/herdr-plugin.ts +1 -1
  116. package/src/sync/next-steps.ts +1 -1
  117. package/src/sync/render.ts +2 -1
  118. package/src/sync/setup-questions.ts +2 -2
  119. package/src/terminal/text.ts +4 -0
  120. package/src/terminal/types.ts +1 -1
  121. package/src/update/command.ts +4 -2
  122. package/src/update/update.ts +33 -9
  123. package/src/vocabulary/index.ts +39 -0
  124. package/src/watch/approvals.ts +96 -0
  125. package/src/watch/command.ts +154 -50
  126. package/src/watch/failed-tests.ts +52 -0
  127. package/src/watch/failure-read.ts +56 -0
  128. package/src/watch/failures.ts +107 -0
  129. package/src/watch/feedback-item.ts +21 -0
  130. package/src/watch/feedback-read.ts +2 -1
  131. package/src/watch/feedback.ts +37 -28
  132. package/src/watch/gh-process.ts +53 -0
  133. package/src/watch/gh.ts +10 -20
  134. package/src/watch/heartbeat-progress.ts +1 -1
  135. package/src/watch/lane-model.ts +54 -0
  136. package/src/watch/lane-parallel.ts +46 -0
  137. package/src/watch/lane-progress.ts +28 -4
  138. package/src/watch/lane-report.ts +62 -9
  139. package/src/watch/lane-sources.ts +1 -1
  140. package/src/watch/no-finding.ts +47 -0
  141. package/src/watch/plan-badges-read.ts +19 -7
  142. package/src/watch/plan-badges.ts +59 -7
  143. package/src/watch/plan-views.ts +5 -5
  144. package/src/watch/previews.ts +3 -9
  145. package/src/watch/pull-requests.ts +12 -2
  146. package/src/watch/recorded-reads.ts +25 -0
  147. package/src/watch/source-fields.ts +4 -0
  148. package/src/watch/source-tickets.ts +99 -0
  149. package/src/watch/sources.ts +62 -15
  150. package/src/watch/texts.ts +21 -1
  151. package/src/watch/watch-defaults.ts +8 -0
  152. package/src/watch/watch-events.ts +64 -4
  153. package/src/watch/watcher-lock.ts +1 -1
  154. package/src/watch/workflow-runs.ts +5 -7
  155. package/docs/lanes.mmd +0 -9
  156. package/docs/skills.mmd +0 -10
@@ -1,9 +1,10 @@
1
1
  import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
2
- import { CHECKS, type ChecksState } from './pull-requests'
3
- import { NO_AGENT_WORD, PHASE_COUNT } from './symbols'
2
+ import { CHECK_NAME_LENGTH, CHECKS, type ChecksState, type PullRequest } from './pull-requests'
3
+ import { shownWithMore } from './shown-with-more'
4
+ import { MODEL_MARK, NO_AGENT_WORD, PHASE_COUNT } from './symbols'
4
5
 
5
6
  /**
6
- * Everything the plans view says in words but the watcher's badges. The view is English whatever language the
7
+ * Everything the herd says in words but the watcher's badges. The herd is English whatever language the
7
8
  * project answers in; plan and phase titles come from the plans as written, and the status words are herdr's own terms.
8
9
  */
9
10
  export type PlanTexts = {
@@ -15,11 +16,10 @@ export type PlanTexts = {
15
16
  noRow: string
16
17
  notInPlan: string
17
18
  noCheckboxes: string
18
- ticked: string
19
+ /** In place of the count of a phase whose only open box is the documentation box of its review gate. */
20
+ allButDocs: string
19
21
  tickedUnmerged: string
20
22
  tickedSummary: (count: number) => string
21
- notesHidden: (count: number) => string
22
- notesShown: (count: number) => string
23
23
  draft: string
24
24
  privateLink: string
25
25
  planned: (phases: number, plans: number) => string
@@ -59,7 +59,7 @@ export const BADGE_TEXTS: Record<PlanLanguage, BadgeTexts> = {
59
59
  },
60
60
  }
61
61
 
62
- /** What the view says about a board, in the word the project calls it (`board.term`). */
62
+ /** What the herd says about a board, in the word the project calls it (`board.term`). */
63
63
  export const boardTexts = (term: string) => ({
64
64
  missing: (glob: string) => `no ${term} under ${glob}`,
65
65
  laneNotListed: (lane: string) => `lane "${lane}" is not in the ${term}`,
@@ -70,6 +70,8 @@ export const boardTexts = (term: string) => ({
70
70
  notReadable: (reason: string) => `${term} not readable: ${reason}`,
71
71
  })
72
72
 
73
+ const ALL_BUT_DOCS = 'docs open'
74
+
73
75
  export const TEXTS: PlanTexts = {
74
76
  noAgent: NO_AGENT_WORD,
75
77
  waiting: (lanes) => `waiting (${lanes})`,
@@ -79,11 +81,9 @@ export const TEXTS: PlanTexts = {
79
81
  noRow: 'no row in lanes',
80
82
  notInPlan: 'not in the plan',
81
83
  noCheckboxes: 'no checkboxes',
82
- ticked: 'ticked',
84
+ allButDocs: ALL_BUT_DOCS,
83
85
  tickedUnmerged: 'ticked, not merged',
84
86
  tickedSummary: (count) => `${count} ticked · click or a lists them`,
85
- notesHidden: (count) => `${count} ${count === 1 ? 'note' : 'notes'} hidden · click or n shows them`,
86
- notesShown: (count) => `${count} ${count === 1 ? 'note' : 'notes'} shown · click or n hides them`,
87
87
  draft: 'draft · ',
88
88
  privateLink: 'private',
89
89
  following: (groups) => `also open · ${groups}`,
@@ -95,13 +95,30 @@ export const TEXTS: PlanTexts = {
95
95
  [CHECKS.pending]: '… checks',
96
96
  [CHECKS.none]: 'no checks',
97
97
  },
98
- hint: '↑↓ select →← open a all n notes s html q quit ? help',
98
+ hint: '↑↓ select →← open a show ticked s html q quit ? help',
99
99
  legend: [
100
100
  '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 📎 chip opens its link · ✦ artifact · ⑂ pull request or issue · ◈ Figma · private = the plan says so',
101
- '⚡ working · ❓ blocked, waits for you · 📨 done = the agent finished its answer, not the lane · ⏸ idle · · no agent = a worktree nobody works in · 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 · working and blocked lanes open by themselves, → ← overrule until the status changes',
102
- `n/m = m checkboxes of the phase, n ticked (the phase being worked on is yellow; ticked, not merged = all ticked in the worktree, not on the base branch yet) · ${PHASE_COUNT.allTicked} n/n all ticked · ${PHASE_COUNT.empty} no checkboxes · ${PHASE_COUNT.missing} not in the plan · n shows or hides the notes of a lane · s opens every lane and plan as one HTML page · ⎇ branch · ↑ commits ahead · ± uncommitted · no worktree: plan read from main`,
101
+ '● working · ◆ blocked, waits for you · ◉ done = the agent finished its answer, not the lane · ○ idle · (·) no agent = a worktree nobody works in · 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 · working and blocked lanes open by themselves, → ← overrule until the status changes',
102
+ `▸ ▾ open and close · ▶ next open task · ✓ finished phase · ${ALL_BUT_DOCS} = only the documentation box is open · ticked, not merged = all ticked in the worktree, not on the base branch yet · the herd follows the phase whose boxes moved last, in yellow · ${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`,
103
+ `◐ lane progress · n/m = m checkboxes of the phase, n ticked · n/n in green = all ticked · 🥣 share of the context window used · Opus ${MODEL_MARK.fits} = the phase's subagent ran on the plan's model, Sonnet ${MODEL_MARK.differs} Opus in amber = on another`,
104
+ '↗ pull request · 💬 new feedback · ✋ changes requested · 🌐 preview · 📡 ticket: chip 📡 T-1 · status under 📎, 📡 <n> in amber on a folded lane when tickets are new',
105
+ '⎇ branch · ↑ commits ahead · ± uncommitted · no worktree: plan read from main',
103
106
  ],
104
107
  }
105
108
 
106
109
  /** The mark of a running lane the board has no row for; null for a lane it lists. */
107
110
  export const laneMarkText = (lane: { unlisted?: boolean }): string | null => (lane.unlisted ? TEXTS.noRow : null)
111
+
112
+ /** The most red checks the checks column names; the rest is counted. */
113
+ export const NAMED_CHECKS = 3
114
+
115
+ /**
116
+ * The words of a pull request's check state: `✗ test, lint` names the red checks (at most `NAMED_CHECKS`, then
117
+ * `+<n>`), every other state and a red one without names is the fixed word of `PLAN_TEXTS.checks`.
118
+ */
119
+ export const checksText = ({ checks, failedChecks }: Pick<PullRequest, 'checks' | 'failedChecks'>): string => {
120
+ if (checks !== CHECKS.failed || failedChecks.length === 0) return TEXTS.checks[checks]
121
+ const { shown, more } = shownWithMore(failedChecks, NAMED_CHECKS)
122
+ const names = shown.map((name) => name.slice(0, CHECK_NAME_LENGTH)).join(', ')
123
+ return `✗ ${names}${more > 0 ? ` +${more}` : ''}`
124
+ }
package/src/plans/view.ts CHANGED
@@ -36,6 +36,7 @@ export const renderSnapshot = (
36
36
  lanes: snapshot.views,
37
37
  planned: snapshot.planned,
38
38
  watch: snapshot.watch,
39
+ watchesTickets: snapshot.watchesTickets,
39
40
  language: snapshot.language,
40
41
  },
41
42
  selection,
@@ -1,4 +1,7 @@
1
- import { CLI_NAME } from '../version/release'
1
+ import { existsSync } from 'node:fs'
2
+ import { hasProjectInstall } from '../config/legacy-names'
3
+ import { configCandidates } from '../config/location'
4
+ import { CLI_NAME, PACKAGE_NAME } from '../version/release'
2
5
  import {
3
6
  type CommandContext,
4
7
  defaultContext,
@@ -62,6 +65,16 @@ export const parseSyncArgs = (args: string[]): SyncArgs => {
62
65
  return { ok: true, accept, global, ifChanged, check }
63
66
  }
64
67
 
68
+ /**
69
+ * True when `root` has a project config but no project install: the config holds settings for the global install,
70
+ * and only `jh init` may turn it into a project install.
71
+ */
72
+ const isConfigWithoutInstall = (root: string, exists: (path: string) => boolean): boolean =>
73
+ configCandidates(root).some(exists) && !hasProjectInstall(root, exists)
74
+
75
+ const notInstalledLine = (root: string): string =>
76
+ `${CLI_NAME} sync: ${root} has a ${PACKAGE_NAME} config but no project install, so nothing was synced. \`${CLI_NAME} init\` installs the kit here; \`${CLI_NAME} sync ${GLOBAL_FLAG}\` updates the global kit.`
77
+
65
78
  const syncGlobal = (context: CommandContext, accept: string[]): Promise<number> =>
66
79
  runWithKit(context, 'sync', async (kit) => {
67
80
  const result = await runGlobalSync({ home: context.home, kit, accept, configHome: context.configHome })
@@ -98,6 +111,8 @@ export const runSyncCommand = async (
98
111
  const { accept, global, ifChanged, check: dryRun } = parsed
99
112
  if (ifChanged) return syncGlobalIfChanged(context, accept, check)
100
113
  if (global) return syncGlobal(context, accept)
114
+ if (isConfigWithoutInstall(context.projectRoot, existsSync))
115
+ return usageError(context, notInstalledLine(context.projectRoot))
101
116
  return runWithKit(
102
117
  context,
103
118
  'sync',
@@ -142,7 +142,7 @@ export const linkHerdrPlugin = ({
142
142
  return `Could not link the herdr plugin, herdr said: ${result.output || `exit ${result.exitCode}`}`
143
143
  const linkedLine = linked
144
144
  ? `Relinked the herdr plugin from ${linked.root} to ${directory} (${reasonText(reason, linked)}).`
145
- : 'Linked the herdr plugin (the pane and the action for the plans view).'
145
+ : 'Linked the herdr plugin (the pane and the action for the herd).'
146
146
  return joinLines(linkedLine, unlink())
147
147
  }
148
148
 
@@ -36,7 +36,7 @@ export const nextSteps = ({ skillPrefix, adapter, tools, version, cli }: NextSte
36
36
  `${orchestrate}: run a board's phases in parallel lanes.`,
37
37
  herdrLine(orchestrate, adapter, tools),
38
38
  ghLine(orchestrate, tools),
39
- `${cli} plans: see every plan and lane in one view.`,
39
+ `${cli} herd: the whole herd at a glance, the state of every lane.`,
40
40
  `Docs: https://www.npmjs.com/package/${PACKAGE_NAME}/v/${version}#documentation`,
41
41
  ]
42
42
  }
@@ -1,6 +1,6 @@
1
1
  import type { JackherdConfig } from '../config'
2
2
  import { frozenInstallCommand } from '../config/detect'
3
- import { ADDRESS_FORM, LANGUAGE_NAME, PLAN_VOCABULARY, SELF_MERGE_LABEL } from '../vocabulary'
3
+ import { ADDRESS_FORM, APPROVAL_TEXTS, LANGUAGE_NAME, PLAN_VOCABULARY, SELF_MERGE_LABEL } from '../vocabulary'
4
4
 
5
5
  const PLACEHOLDER = /\{\{\s*(kit\.[A-Za-z0-9_.-]*)\s*\}\}/g
6
6
 
@@ -39,6 +39,7 @@ const placeholderValues = (config: JackherdConfig): Map<string, string> => {
39
39
  ...prefixed('board', config.board),
40
40
  ['kit.guard.selfMergeLabel', SELF_MERGE_LABEL[config.responseLanguage]],
41
41
  ['kit.guard.selfMerge', config.guard.selfMerge.offer ? 'offered' : 'not offered'],
42
+ ...prefixed('approval', APPROVAL_TEXTS[config.responseLanguage]),
42
43
  ['kit.merge.withoutChecks', checkFreeList(config.merge.withoutChecks)],
43
44
  ...prefixed('heading', { ...header, ...sections, ...columns, phase }),
44
45
  ])
@@ -11,7 +11,7 @@ const LANGUAGE_CHOICES: readonly Choice<PlanLanguage>[] = [PLAN_LANGUAGE.en, PLA
11
11
  }))
12
12
 
13
13
  const ADAPTER_CHOICES: readonly Choice<TerminalAdapter>[] = [
14
- { value: TERMINAL_ADAPTER.herdr, label: 'Yes, use herdr (agent states and context fill in the plans view)' },
14
+ { value: TERMINAL_ADAPTER.herdr, label: 'Yes, use herdr (agent states and context fill in the herd)' },
15
15
  { value: TERMINAL_ADAPTER.none, label: 'No, not now' },
16
16
  ]
17
17
 
@@ -20,7 +20,7 @@ const RESPONSE_QUESTION = 'Which language should the skills answer you in?'
20
20
  const PLAN_QUESTION = 'Which language should plans and boards be written in? It sets their headings.'
21
21
 
22
22
  const ADAPTER_QUESTION =
23
- 'Use herdr? It runs parallel lanes for /orchestrate and shows each agent in the plans view; /plan and /implement work without it.'
23
+ 'Use herdr? It runs parallel lanes for /orchestrate and shows each agent in the herd; /plan and /implement work without it.'
24
24
 
25
25
  const INSTALL_QUESTION = `herdr is not installed. It is only needed for parallel lanes. Install it now with its official installer, \`${INSTALLERS.herdr}\`?`
26
26
 
@@ -41,6 +41,7 @@ export const paint = {
41
41
  green: style('32'),
42
42
  yellow: style('33'),
43
43
  magenta: style('35'),
44
+ teal: style('38;5;37'),
44
45
  cyan: style('36'),
45
46
  inverse: style('7'),
46
47
  }
@@ -67,6 +68,9 @@ export const link = (url: string, text: string): string =>
67
68
  export const badge = (url: string, text: string, hint = '', tone: BadgeTone = BADGE_TONE.grey): string =>
68
69
  `${ESC}[${tone}m ${link(url, text)}${hint ? `${FAINT_ON} ${hint}${FAINT_OFF}` : ''} ${RESET}`
69
70
 
71
+ /** `text` as a badge without a link, for what no click opens: the background of `tone`, a cell of padding either side. */
72
+ export const box = (text: string, tone: BadgeTone = BADGE_TONE.grey): string => `${ESC}[${tone}m ${text} ${RESET}`
73
+
70
74
  /** A line for output that is not a terminal: styling dropped, each link written out as `text (url)`. */
71
75
  export const plainText = (text: string): string =>
72
76
  stripAnsi(text.replace(LINK, (_, url, inner) => `${stripAnsi(inner)} (${url})`))
@@ -16,7 +16,7 @@ export type AdoptedName = { pane: string; name: string }
16
16
  export type LaneAgent = { name: string; pane: string; status: string; session: string | null; cwd: string | null }
17
17
 
18
18
  /**
19
- * Where lane agents run. The plans view reads agent status and sets a short title per lane through
19
+ * Where lane agents run. The herd reads agent status and sets a short title per lane through
20
20
  * this; the adapter `none` stands for a setup without a terminal multiplexer.
21
21
  */
22
22
  export type LaneStatusSource = {
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * `jh update`: installs the newest release with the package manager that holds the kit globally,
3
- * then syncs `~/.claude` (and the project, when the working directory is inside one) with the new code. It
4
- * runs only when the user or a session that asked the user starts it; no hook runs it.
3
+ * then syncs `~/.claude` (and the project, when the working directory is inside one with its own install) with
4
+ * the new code. It runs only when the user or a session that asked the user starts it; no hook runs it.
5
5
  *
6
6
  * jh update
7
7
  */
8
8
  import { existsSync, realpathSync } from 'node:fs'
9
9
  import { homedir } from 'node:os'
10
10
  import { join } from 'node:path'
11
+ import { hasProjectInstall } from '../config/legacy-names'
11
12
  import { findProjectRoot } from '../config/location'
12
13
  import { defaultUpdateCheck, lockedVersion } from '../sync/if-changed'
13
14
  import { locateKit } from '../sync/kit-source'
@@ -50,6 +51,7 @@ const adaptersFor = async (): Promise<UpdateAdapters> => {
50
51
  jhOnPath: () => Bun.which(CLI_NAME) !== null,
51
52
  home,
52
53
  projectRoot: findProjectRoot(process.cwd(), home, existsSync),
54
+ hasProjectKit: (root) => hasProjectInstall(root, existsSync),
53
55
  log: (line) => console.log(line),
54
56
  }
55
57
  }
@@ -1,4 +1,4 @@
1
- import { CLI_NAME, isNewerVersion, isRelease } from '../version/release'
1
+ import { CLI_NAME, isNewerVersion, isRelease, PACKAGE_NAME } from '../version/release'
2
2
  import { installCommand, LEGACY_PACKAGE_NAME, type PackageManager, removeCommand } from './package-manager'
3
3
 
4
4
  export const UPDATE_EXIT = { ok: 0, error: 1 } as const
@@ -21,8 +21,13 @@ export type UpdateAdapters = {
21
21
  /** Whether a `jh` command is on the path; false when only the `@crossdelta/agent-kit` shell carries this jackherd. Asked before and after the install. */
22
22
  jhOnPath: () => boolean
23
23
  home: string
24
- /** The project the working directory belongs to, or null. */
24
+ /** The project the working directory belongs to (the nearest directory with a project config), or null. */
25
25
  projectRoot: string | null
26
+ /**
27
+ * True when `root` holds a project install (a lock of its own); a config alone is settings for the global kit.
28
+ * The lock's existence counts, not its validity: a broken lock is synced, and the sync names the error.
29
+ */
30
+ hasProjectKit: (root: string) => boolean
26
31
  log: (line: string) => void
27
32
  }
28
33
 
@@ -34,10 +39,20 @@ const failed = (log: UpdateAdapters['log'], step: string, command: string): numb
34
39
  return UPDATE_EXIT.error
35
40
  }
36
41
 
42
+ const notInstalledLine = (projectRoot: string): string =>
43
+ `${projectRoot} has a ${PACKAGE_NAME} config but no project install, so it was not synced; \`${CLI_NAME} init\` installs the kit there.`
44
+
45
+ /** The project `jh update` syncs: only one with an install of its own, since only `jh init` may create one. */
46
+ const installedProject = (adapters: UpdateAdapters): string | null =>
47
+ adapters.projectRoot && adapters.hasProjectKit(adapters.projectRoot) ? adapters.projectRoot : null
48
+
37
49
  /** The syncs that bring `~/.claude` and the project to the new files, in the order they run. */
38
- const syncSteps = async (adapters: UpdateAdapters): Promise<{ argv: string[]; cwd: string }[]> => [
50
+ const syncSteps = async (
51
+ adapters: UpdateAdapters,
52
+ project: string | null,
53
+ ): Promise<{ argv: string[]; cwd: string }[]> => [
39
54
  ...((await adapters.hasGlobalKit()) ? [{ argv: [...adapters.kitCli, 'sync', '--global'], cwd: adapters.home }] : []),
40
- ...(adapters.projectRoot ? [{ argv: [...adapters.kitCli, 'sync'], cwd: adapters.projectRoot }] : []),
55
+ ...(project ? [{ argv: [...adapters.kitCli, 'sync'], cwd: project }] : []),
41
56
  ]
42
57
 
43
58
  /**
@@ -59,6 +74,14 @@ const installAndVerify = async (
59
74
  return UPDATE_EXIT.error
60
75
  }
61
76
 
77
+ /** Runs the syncs with the freshly installed code; the first that fails stops the rest and names its command. */
78
+ const syncAfterInstall = async (adapters: UpdateAdapters, project: string | null): Promise<number> => {
79
+ for (const { argv, cwd } of await syncSteps(adapters, project)) {
80
+ if ((await adapters.run(argv, cwd)) !== 0) return failed(adapters.log, 'the sync', argv.join(' '))
81
+ }
82
+ return UPDATE_EXIT.ok
83
+ }
84
+
62
85
  const doneLine = (adapters: UpdateAdapters, manager: PackageManager, latest: string, jhWasOnPath: boolean): string => {
63
86
  if (jhWasOnPath) return `${CLI_NAME} updated ${adapters.installedVersion} → ${latest}.`
64
87
  if (adapters.jhOnPath()) return `${CLI_NAME} ${latest} installed globally.`
@@ -88,10 +111,11 @@ const updateToLatest = async (adapters: UpdateAdapters, manager: PackageManager)
88
111
  }
89
112
  const installed = await installAndVerify(adapters, manager, latest)
90
113
  if (installed !== UPDATE_EXIT.ok) return installed
91
- for (const { argv, cwd } of await syncSteps(adapters)) {
92
- if ((await adapters.run(argv, cwd)) !== 0) return failed(log, 'the sync', argv.join(' '))
93
- }
114
+ const project = installedProject(adapters)
115
+ const synced = await syncAfterInstall(adapters, project)
116
+ if (synced !== UPDATE_EXIT.ok) return synced
94
117
  log(doneLine(adapters, manager, latest, jhWasOnPath))
118
+ if (adapters.projectRoot && !project) log(notInstalledLine(adapters.projectRoot))
95
119
  return UPDATE_EXIT.ok
96
120
  }
97
121
 
@@ -99,8 +123,8 @@ const legacyPackageLine = (manager: PackageManager): string =>
99
123
  `${LEGACY_PACKAGE_NAME} is still installed globally; ${CLI_NAME} replaces it. Remove it with: ${removeCommand(manager, LEGACY_PACKAGE_NAME).join(' ')}`
100
124
 
101
125
  /**
102
- * Installs the newest release with the manager that holds the kit globally, then syncs `~/.claude` and the
103
- * project with the freshly installed code; a global package from before the rename is named, not removed.
126
+ * Installs the newest release with the manager that holds the kit globally, then syncs `~/.claude` and an
127
+ * installed project with the freshly installed code; a global package from before the rename is named, not removed.
104
128
  */
105
129
  export const runUpdate = async (adapters: UpdateAdapters): Promise<number> => {
106
130
  const { manager, log } = adapters
@@ -38,6 +38,25 @@ export const SELF_MERGE_LABEL: Record<PlanLanguage, string> = {
38
38
  [PLAN_LANGUAGE.de]: 'Freigeben, und nach grünen Checks selbst mergen',
39
39
  }
40
40
 
41
+ /**
42
+ * The words of an approval: the two option labels of the coordinator's question about a lane's call, which the
43
+ * approval gate hook matches exactly, and what the hook tells the lane when it denies. Fixed per language, not config.
44
+ */
45
+ export const APPROVAL_TEXTS: Record<PlanLanguage, { allow: string; deny: string; pending: string; denied: string }> = {
46
+ [PLAN_LANGUAGE.en]: {
47
+ allow: 'Allow this call',
48
+ deny: 'Deny this call',
49
+ pending: 'Approval pending with the coordinator; retry the same call once it is approved.',
50
+ denied: 'The user denied this call through the coordinator; do not retry it.',
51
+ },
52
+ [PLAN_LANGUAGE.de]: {
53
+ allow: 'Aufruf erlauben',
54
+ deny: 'Aufruf ablehnen',
55
+ pending: 'Freigabe steht aus beim Koordinator; denselben Aufruf nach der Freigabe erneut versuchen.',
56
+ denied: 'Der User hat diesen Aufruf über den Koordinator abgelehnt; nicht erneut versuchen.',
57
+ },
58
+ }
59
+
41
60
  export const isPlanLanguage = (value: string): value is PlanLanguage =>
42
61
  (Object.values(PLAN_LANGUAGE) as string[]).includes(value)
43
62
 
@@ -81,8 +100,18 @@ export type PlanVocabulary = {
81
100
  laneClosed: string
82
101
  /** Words a starts-after cell begins with when the lane may start at once. */
83
102
  startsNow: readonly string[]
103
+ /** Opens the parenthesis by which a board row's phases cell records a user's side order in prose. */
104
+ sideOrderMarker: string
84
105
  /** Words that may stand between two phases of one entry (`P1 Rule und P2 Monitor`). */
85
106
  phaseConnectors: readonly string[]
107
+ /** Words that open a writes cell naming another phase's set instead of listing it (`wie 15`). */
108
+ writesLike: readonly string[]
109
+ /** Words that join several phases after a `writesLike` word (`wie 15 und 16`); a comma joins them in every language. */
110
+ writesLikeJoiners: readonly string[]
111
+ /** Words a `Depends on` cell says it depends on nothing with, besides a dash. */
112
+ noDependency: readonly string[]
113
+ /** The word that opens the serial marker of a phase that ships in a release (`Release 1.7.0`); the release files stay a judgement. */
114
+ releaseMarker: string
86
115
  statusWords: Record<PlanStatus, StatusWord>
87
116
  }
88
117
 
@@ -104,8 +133,13 @@ export const PLAN_VOCABULARY: Record<PlanLanguage, PlanVocabulary> = {
104
133
  phase: 'Phase',
105
134
  noReviewGate: 'Kein Review-Gate',
106
135
  laneClosed: 'abgeräumt',
136
+ sideOrderMarker: 'dazu (User',
107
137
  startsNow: ['sofort'],
108
138
  phaseConnectors: ['danach', 'und', 'dann', 'sowie'],
139
+ writesLike: ['wie'],
140
+ writesLikeJoiners: ['und', 'sowie'],
141
+ noDependency: ['keine', 'kein'],
142
+ releaseMarker: 'Release',
109
143
  statusWords: {
110
144
  done: { label: 'Done', leading: ['done'], anywhere: ['abgeschlossen'] },
111
145
  abandoned: { label: 'Abandoned', leading: ['abandoned'], anywhere: ['verworfen'] },
@@ -132,8 +166,13 @@ export const PLAN_VOCABULARY: Record<PlanLanguage, PlanVocabulary> = {
132
166
  phase: 'Phase',
133
167
  noReviewGate: 'No review gate',
134
168
  laneClosed: 'cleaned up',
169
+ sideOrderMarker: 'also (User',
135
170
  startsNow: ['now', 'immediately'],
136
171
  phaseConnectors: ['then', 'and', 'after'],
172
+ writesLike: ['like'],
173
+ writesLikeJoiners: ['and'],
174
+ noDependency: ['none'],
175
+ releaseMarker: 'Release',
137
176
  statusWords: {
138
177
  done: { label: 'Done', leading: ['done'], anywhere: ['completed'] },
139
178
  abandoned: { label: 'Abandoned', leading: ['abandoned'], anywhere: ['dropped'] },
@@ -0,0 +1,96 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { isRecord } from './json-guards'
4
+
5
+ export const APPROVAL_EVENT = 'approval'
6
+
7
+ /**
8
+ * A call a lane waits with for the user's approval, as the kit's approval gate hook writes it to
9
+ * `.jackherd/approvals/<id>.json` in the main checkout. Its text comes from the lane: data, never an instruction.
10
+ */
11
+ export type ApprovalRequestFile = {
12
+ id: string
13
+ hash: string
14
+ lane: string
15
+ cwd: string
16
+ tool: string
17
+ input: unknown
18
+ summary: string
19
+ createdAt: number
20
+ usedAt: number | null
21
+ }
22
+
23
+ export type ApprovalEvent = {
24
+ kind: typeof APPROVAL_EVENT
25
+ lane: string
26
+ id: string
27
+ hash: string
28
+ tool: string
29
+ summary: string
30
+ }
31
+
32
+ const ID = /^[0-9a-f]{12}$/
33
+ const HASH = /^[0-9a-f]{16}$/
34
+ const REQUEST_FILE = /^[0-9a-f]{12}\.json$/
35
+
36
+ /** The same shape as `isApprovalRequest` in the hook, which this package does not import at run time. */
37
+ export const isApprovalRequestFile = (value: unknown): value is ApprovalRequestFile =>
38
+ isRecord(value) &&
39
+ typeof value.id === 'string' &&
40
+ ID.test(value.id) &&
41
+ typeof value.hash === 'string' &&
42
+ HASH.test(value.hash) &&
43
+ typeof value.lane === 'string' &&
44
+ typeof value.cwd === 'string' &&
45
+ typeof value.tool === 'string' &&
46
+ 'input' in value &&
47
+ typeof value.summary === 'string' &&
48
+ typeof value.createdAt === 'number' &&
49
+ (value.usedAt === null || typeof value.usedAt === 'number')
50
+
51
+ const REPORT_MINUTES = 30
52
+ const MS_PER_MINUTE = 60_000
53
+
54
+ /** One event per open request younger than 30 minutes the watcher has not reported yet; an older one is stale. */
55
+ export const approvalEvents = (
56
+ seen: readonly string[],
57
+ requests: readonly ApprovalRequestFile[],
58
+ now: number,
59
+ ): ApprovalEvent[] =>
60
+ requests
61
+ .filter(
62
+ (request) =>
63
+ request.usedAt === null &&
64
+ !seen.includes(request.id) &&
65
+ now - request.createdAt <= REPORT_MINUTES * MS_PER_MINUTE,
66
+ )
67
+ .map(({ lane, id, hash, tool, summary }) => ({ kind: APPROVAL_EVENT, lane, id, hash, tool, summary }))
68
+
69
+ /** The ids to remember: every request still on disk, so one that is gone is forgotten. */
70
+ export const recordApprovals = (requests: readonly ApprovalRequestFile[]): string[] =>
71
+ requests.map((request) => request.id)
72
+
73
+ const requestFileNames = (directory: string): string[] => {
74
+ try {
75
+ return readdirSync(directory).filter((name) => REQUEST_FILE.test(name))
76
+ } catch {
77
+ return []
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The requests in the main checkout's `.jackherd/approvals/`, a claimed one as used; a file that does not parse is
83
+ * skipped, and a directory that cannot be listed reads as none, so the watch goes on.
84
+ */
85
+ export const readApprovalRequests = (root: string): ApprovalRequestFile[] => {
86
+ const directory = join(root, '.jackherd', 'approvals')
87
+ return requestFileNames(directory).flatMap((name) => {
88
+ try {
89
+ const request: unknown = JSON.parse(readFileSync(join(directory, name), 'utf8'))
90
+ if (!isApprovalRequestFile(request) || `${request.id}.json` !== name) return []
91
+ return [existsSync(join(directory, `${request.id}.used`)) ? { ...request, usedAt: request.createdAt } : request]
92
+ } catch {
93
+ return []
94
+ }
95
+ })
96
+ }