jackherd 1.7.0 → 1.8.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 (157) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/INSTALL.md +4 -2
  3. package/{NOTICE → NOTICE.txt} +1 -1
  4. package/README.md +94 -1033
  5. package/assets/icons/bark-dark.svg +1 -1
  6. package/assets/icons/bark.svg +1 -1
  7. package/assets/icons/bowl-dark.svg +1 -1
  8. package/assets/icons/bowl.svg +1 -1
  9. package/assets/icons/decide-dark.svg +1 -1
  10. package/assets/icons/decide.svg +1 -1
  11. package/assets/icons/ear-dark.svg +1 -1
  12. package/assets/icons/ear.svg +1 -1
  13. package/assets/icons/lanes-dark.svg +1 -1
  14. package/assets/icons/lanes.svg +1 -1
  15. package/assets/icons/plan-dark.svg +1 -1
  16. package/assets/icons/plan.svg +1 -1
  17. package/dist/{chunk-OBUWTQ6Z.js → chunk-M6DDZEW4.js} +24 -10
  18. package/dist/config.d.ts +11 -5
  19. package/dist/config.js +53 -21
  20. package/dist/plan-format.d.ts +73 -7
  21. package/dist/plan-format.js +180 -100
  22. package/docs/workflow-dark.svg +1 -1
  23. package/docs/workflow.svg +1 -1
  24. package/herdr-plugin/herdr-plugin.toml +1 -1
  25. package/install.sh +1 -1
  26. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +2 -12
  27. package/kit/hooks/hooks/approval-gate/index.ts +10 -8
  28. package/kit/hooks/hooks/approval-gate/run.ts +21 -12
  29. package/kit/hooks/hooks/ask-hint/decision.ts +3 -3
  30. package/kit/hooks/hooks/git-guard/decision.ts +189 -15
  31. package/kit/hooks/hooks/git-guard/index.ts +13 -3
  32. package/kit/hooks/hooks/git-guard/push-target.ts +85 -0
  33. package/kit/hooks/hooks/git-guard/repository.ts +63 -0
  34. package/kit/hooks/hooks/merge-guard/check-free.ts +1 -13
  35. package/kit/hooks/hooks/merge-guard/decision.ts +342 -102
  36. package/kit/hooks/hooks/merge-guard/index.ts +6 -4
  37. package/kit/hooks/hooks/merge-guard/plain-path.ts +15 -0
  38. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +23 -0
  39. package/kit/hooks/hooks/merge-guard/plan-approval-settings.ts +142 -0
  40. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +196 -0
  41. package/kit/hooks/hooks/merge-guard/plan-approval.ts +168 -0
  42. package/kit/hooks/hooks/merge-guard/plan-growth.ts +40 -0
  43. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +194 -0
  44. package/kit/hooks/hooks/merge-guard/pull-request.ts +67 -21
  45. package/kit/hooks/hooks/merge-guard/run.ts +118 -29
  46. package/kit/hooks/hooks/merge-guard/settings.ts +8 -8
  47. package/kit/hooks/hooks/merge-guard/worker.ts +3 -3
  48. package/kit/hooks/hooks/shared/base-branch.ts +23 -0
  49. package/kit/hooks/hooks/shared/board-lanes.ts +93 -0
  50. package/kit/hooks/hooks/shared/command-text-runners.ts +71 -0
  51. package/kit/hooks/hooks/shared/command.ts +14 -2
  52. package/kit/hooks/hooks/shared/commit.ts +2 -0
  53. package/kit/hooks/hooks/shared/dialog-answers.ts +4 -1
  54. package/kit/hooks/hooks/shared/json.ts +10 -0
  55. package/kit/hooks/hooks/shared/lane.ts +57 -0
  56. package/kit/hooks/hooks/shared/once.ts +8 -0
  57. package/kit/hooks/hooks/shared/own-pane.ts +31 -0
  58. package/kit/hooks/hooks/shared/pane-agent.ts +24 -0
  59. package/kit/hooks/hooks/shared/plan-open-phases.ts +114 -0
  60. package/kit/hooks/hooks/shared/program-position.ts +148 -5
  61. package/kit/hooks/hooks/shared/shell-aliases.ts +258 -0
  62. package/kit/hooks/hooks/shared/shell-snapshots.ts +68 -0
  63. package/kit/hooks/hooks/shared/shell.ts +167 -20
  64. package/kit/hooks/hooks.json +11 -2
  65. package/kit/hooks/statusline.sh +29 -16
  66. package/kit/review/skills/diagnose/SKILL.md +1 -1
  67. package/kit/review/skills/review/SKILL.md +2 -0
  68. package/kit/rules/rules/kit/testing.md +2 -5
  69. package/kit/rules/rules/kit/writing-for-agents.md +3 -1
  70. package/kit/workflow/agents/spec-review.md +1 -1
  71. package/kit/workflow/skills/follow/SKILL.md +3 -3
  72. package/kit/workflow/skills/grill/SKILL.md +1 -1
  73. package/kit/workflow/skills/implement/SKILL.md +10 -11
  74. package/kit/workflow/skills/orchestrate/SKILL.md +155 -117
  75. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +17 -0
  76. package/kit/workflow/skills/orchestrate/references/herd.md +27 -0
  77. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -0
  78. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +23 -0
  79. package/kit/workflow/skills/plan/SKILL.md +5 -2
  80. package/kit/workflow/skills/plan/references/template-English.md +6 -0
  81. package/kit/workflow/skills/plan/references/template-German.md +6 -0
  82. package/package.json +3 -2
  83. package/recipes/README.md +21 -0
  84. package/recipes/github-issues.md +23 -0
  85. package/recipes/github-projects.md +22 -0
  86. package/recipes/jira-cli.md +26 -0
  87. package/recipes/jira-rest.md +24 -0
  88. package/recipes/linear.md +25 -0
  89. package/recipes/sentry.md +18 -0
  90. package/recipes/sources/jira.ts +51 -0
  91. package/recipes/sources/linear.ts +19 -0
  92. package/recipes/sources/sentry.ts +12 -0
  93. package/recipes/sources.md +80 -0
  94. package/recipes/status-page.md +19 -0
  95. package/schemas/jackherd.schema.json +42 -18
  96. package/src/check/command.ts +2 -2
  97. package/src/check/parallel-report.ts +19 -9
  98. package/src/cli.ts +1 -0
  99. package/src/config/context-shares.ts +130 -0
  100. package/src/config/legacy-names.ts +8 -0
  101. package/src/config/read-config.ts +12 -4
  102. package/src/config/schema.ts +75 -20
  103. package/src/context/auto-compact.ts +68 -0
  104. package/src/context/command.ts +68 -17
  105. package/src/context/coordinator-name.ts +27 -9
  106. package/src/context/index.ts +0 -1
  107. package/src/context/level.ts +18 -11
  108. package/src/context/report.ts +6 -3
  109. package/src/plan-format/dependencies.ts +144 -32
  110. package/src/plan-format/index.ts +6 -0
  111. package/src/plan-format/lanes.ts +6 -0
  112. package/src/plan-format/progress.ts +19 -8
  113. package/src/plans/command.ts +14 -5
  114. package/src/plans/current-phase.ts +63 -7
  115. package/src/plans/current-task.ts +51 -0
  116. package/src/plans/ensure-panes.ts +25 -8
  117. package/src/plans/frame.ts +99 -0
  118. package/src/plans/git.ts +8 -0
  119. package/src/plans/herd-memory.ts +107 -0
  120. package/src/plans/lane-plans.ts +5 -3
  121. package/src/plans/lane-summary.ts +222 -0
  122. package/src/plans/layout-phase.ts +148 -0
  123. package/src/plans/layout-summary.ts +140 -0
  124. package/src/plans/layout.ts +164 -420
  125. package/src/plans/live.ts +11 -6
  126. package/src/plans/plan-panes.ts +15 -0
  127. package/src/plans/planned.ts +9 -42
  128. package/src/plans/selection.ts +2 -6
  129. package/src/plans/settings.ts +2 -7
  130. package/src/plans/snapshot.ts +38 -39
  131. package/src/plans/symbols.ts +2 -2
  132. package/src/plans/texts.ts +32 -31
  133. package/src/plans/view.ts +0 -1
  134. package/src/plans/worked-phases.ts +2 -2
  135. package/src/settled/board-row.ts +133 -0
  136. package/src/settled/check.ts +129 -0
  137. package/src/settled/command.ts +65 -0
  138. package/src/settled/evidence-version.ts +14 -0
  139. package/src/settled/options.ts +69 -0
  140. package/src/settled/send.ts +27 -0
  141. package/src/settled/settle.ts +87 -0
  142. package/src/settled/topics.ts +56 -0
  143. package/src/sync/command.ts +16 -1
  144. package/src/sync/render.ts +13 -4
  145. package/src/sync/sync.ts +25 -5
  146. package/src/terminal/herdr.ts +46 -10
  147. package/src/terminal/none.ts +1 -0
  148. package/src/terminal/types.ts +15 -2
  149. package/src/update/command.ts +4 -2
  150. package/src/update/update.ts +33 -9
  151. package/src/vocabulary/index.ts +47 -11
  152. package/src/watch/appointments.ts +205 -0
  153. package/src/watch/command.ts +62 -19
  154. package/src/watch/lane-report.ts +11 -3
  155. package/src/watch/texts.ts +35 -59
  156. package/src/watch/watch-events.ts +21 -28
  157. package/kit/hooks/hooks/approval-gate/lane.ts +0 -14
package/src/sync/sync.ts CHANGED
@@ -2,6 +2,7 @@ import { homedir } from 'node:os'
2
2
  import { basename, dirname, join } from 'node:path'
3
3
  import { CLAUDE_DIRECTORY, SETTINGS_FILE } from '../claude/paths'
4
4
  import type { JackherdConfig } from '../config'
5
+ import { contextSharesTextInTokens } from '../config/context-shares'
5
6
  import type { RepositoryFacts } from '../config/detect'
6
7
  import { CONFIG_FILE, MANIFEST_FILE, TOOL_DIRECTORY } from '../config/location'
7
8
  import { readRepositoryFacts } from '../config/repository-facts'
@@ -41,6 +42,9 @@ import {
41
42
  } from './sync-plan'
42
43
  import type { SyncResult } from './sync-result'
43
44
 
45
+ /** The config with the notes of reading it, and its text in the new form when reading it changed the file's. */
46
+ type ReadConfig = { config: JackherdConfig; notes: string[]; rewrite: string | null }
47
+
44
48
  /** A sync's result with the config it was read from, so a caller can name what the config decided. */
45
49
  export type ConfiguredSync = { result: SyncResult; config: JackherdConfig }
46
50
 
@@ -97,10 +101,22 @@ const readConfig = async (
97
101
  location: string,
98
102
  initCommand: string,
99
103
  facts?: RepositoryFacts,
100
- ): Promise<{ config: JackherdConfig; notes: string[] }> => {
104
+ ): Promise<ReadConfig> => {
101
105
  const text = await readRegularFile(toolDirectory, CONFIG_FILE)
102
106
  if (text === undefined) throw new Error(`No ${location}/${CONFIG_FILE} found. Run \`${initCommand}\` first.`)
103
- return parseConfig(text, location, facts)
107
+ parseJson(text, CONFIG_FILE, location)
108
+ const converted = contextSharesTextInTokens(text)
109
+ const parsed = parseConfig(converted.text, location, facts)
110
+ return {
111
+ ...parsed,
112
+ notes: [...converted.notes, ...parsed.notes],
113
+ rewrite: converted.text === text ? null : converted.text,
114
+ }
115
+ }
116
+
117
+ /** A config file rewritten in the new form is written with the rest of the plan, never before it is validated. */
118
+ const writeConfigRewrite = async (toolDirectory: string, { rewrite }: ReadConfig): Promise<void> => {
119
+ if (rewrite !== null) await writeFileSafely(toolDirectory, CONFIG_FILE, rewrite, FILE_MODE.regular)
104
120
  }
105
121
 
106
122
  const readManifest = async (toolDirectory: string): Promise<{ manifest: Manifest; text: string | undefined }> => {
@@ -277,7 +293,8 @@ export const syncProjectWithConfig = async (request: SyncRequest): Promise<Confi
277
293
  const toolDirectory = join(request.projectRoot, TOOL_DIRECTORY)
278
294
  const moved = request.check ? [] : await moveProjectLegacyFiles(request.projectRoot)
279
295
  const facts = request.facts ?? readRepositoryFacts(request.projectRoot)
280
- const { config, notes } = await readConfig(toolDirectory, TOOL_DIRECTORY, `${CLI_NAME} init`, facts)
296
+ const read = await readConfig(toolDirectory, TOOL_DIRECTORY, `${CLI_NAME} init`, facts)
297
+ const { config, notes } = read
281
298
  const directories = { claudeDirectory, toolDirectory, locate: inClaudeDirectory(claudeDirectory) }
282
299
  const plan = await buildPlan({
283
300
  ...directories,
@@ -287,6 +304,7 @@ export const syncProjectWithConfig = async (request: SyncRequest): Promise<Confi
287
304
  readHooks: projectHooks(claudeDirectory, request.kit, config),
288
305
  })
289
306
  const keptDirectoryNotes = request.check ? [] : await applyPlan(directories, plan)
307
+ if (!request.check) await writeConfigRewrite(toolDirectory, read)
290
308
  if (!request.check) await ensureGitignoreLines(request.projectRoot)
291
309
  return { result: { ...syncResultOf(plan), notes: [...moved, ...notes, ...keptDirectoryNotes] }, config }
292
310
  }
@@ -305,7 +323,8 @@ export const syncGlobalWithConfig = async (request: GlobalSyncRequest): Promise<
305
323
  const claudeDirectory = join(request.home, CLAUDE_DIRECTORY)
306
324
  const toolDirectory = join(request.home, TOOL_DIRECTORY)
307
325
  const moved = await moveGlobalLegacyFiles(request.home, request.configHome)
308
- const { config } = await readConfig(toolDirectory, GLOBAL_TOOL_LOCATION, `${CLI_NAME} init --global`)
326
+ const read = await readConfig(toolDirectory, GLOBAL_TOOL_LOCATION, `${CLI_NAME} init --global`)
327
+ const { config } = read
309
328
  if (config.skillPrefix === '') {
310
329
  throw new Error(
311
330
  `${GLOBAL_TOOL_LOCATION}/${CONFIG_FILE} has no skillPrefix; a global install needs one (e.g. "kit-") so it cannot shadow project skills`,
@@ -321,8 +340,9 @@ export const syncGlobalWithConfig = async (request: GlobalSyncRequest): Promise<
321
340
  overrides: REPOSITORY_TOKENS,
322
341
  })
323
342
  const keptDirectoryNotes = await applyPlan(directories, plan)
343
+ await writeConfigRewrite(toolDirectory, read)
324
344
  await removeEmptyLegacyDirectories(claudeDirectory)
325
- return { result: { ...syncResultOf(plan), notes: [...moved, ...keptDirectoryNotes] }, config }
345
+ return { result: { ...syncResultOf(plan), notes: [...moved, ...read.notes, ...keptDirectoryNotes] }, config }
326
346
  }
327
347
 
328
348
  export const runGlobalSync = async (request: GlobalSyncRequest): Promise<SyncResult> =>
@@ -1,9 +1,18 @@
1
1
  import { herdrBinary } from '../sync/tools'
2
2
  import type { AdoptedName, LaneAgent, LaneStatusSource } from './types'
3
3
 
4
- type HerdrAgent = { name?: string; pane_id: string; agent_status: string; agent_session?: unknown; cwd?: unknown }
4
+ type HerdrAgent = {
5
+ name?: string
6
+ pane_id: string
7
+ agent_status: string
8
+ agent_session?: unknown
9
+ cwd?: unknown
10
+ terminal_title_stripped?: unknown
11
+ }
5
12
 
6
13
  const METADATA_SOURCE = 'pf-plans'
14
+ /** The title Claude Code shows until a session has one of its own. */
15
+ const UNTITLED_SESSION = 'Claude Code'
7
16
 
8
17
  const isHerdrAgent = (value: unknown): value is HerdrAgent => {
9
18
  if (typeof value !== 'object' || value === null) return false
@@ -21,13 +30,23 @@ const sessionOf = ({ agent_session }: HerdrAgent): string | null => {
21
30
  return typeof value === 'string' && value !== '' ? value : null
22
31
  }
23
32
 
24
- const laneAgentOf = (agent: HerdrAgent, name: string): LaneAgent => ({
25
- name,
26
- pane: agent.pane_id,
27
- status: agent.agent_status,
28
- session: sessionOf(agent),
29
- cwd: typeof agent.cwd === 'string' ? agent.cwd : null,
30
- })
33
+ /** The session's own title from the terminal title without its status glyph; null for none or the default one. */
34
+ const titleOf = ({ terminal_title_stripped }: HerdrAgent): string | null => {
35
+ const title = typeof terminal_title_stripped === 'string' ? terminal_title_stripped.trim() : ''
36
+ return title === '' || title === UNTITLED_SESSION ? null : title
37
+ }
38
+
39
+ const laneAgentOf = (agent: HerdrAgent, name: string): LaneAgent => {
40
+ const title = titleOf(agent)
41
+ return {
42
+ name,
43
+ pane: agent.pane_id,
44
+ status: agent.agent_status,
45
+ session: sessionOf(agent),
46
+ cwd: typeof agent.cwd === 'string' ? agent.cwd : null,
47
+ ...(title ? { title } : {}),
48
+ }
49
+ }
31
50
 
32
51
  /**
33
52
  * Reads the JSON of `herdr agent list`; throws on anything else, so a changed format is seen, not guessed.
@@ -55,6 +74,14 @@ export const titleArguments = (pane: string, title: string | null): string[] =>
55
74
  ...(title ? ['--title', title] : ['--clear-title']),
56
75
  ]
57
76
 
77
+ /** Sets the label of a pane, or with null clears it; the pane id goes before the option, as in `titleArguments`. */
78
+ export const renameArguments = (pane: string, label: string | null): string[] => [
79
+ 'pane',
80
+ 'rename',
81
+ pane,
82
+ label ?? '--clear',
83
+ ]
84
+
58
85
  /** Runs the herdr CLI with these arguments and returns its stdout; throws when it fails. */
59
86
  export type HerdrRunner = (args: string[]) => string
60
87
 
@@ -76,7 +103,8 @@ export const herdrRunner =
76
103
 
77
104
  export const runHerdr: HerdrRunner = herdrRunner(process.env)
78
105
 
79
- export const createHerdrLaneStatusSource = (run: HerdrRunner): LaneStatusSource => ({
106
+ /** `ownPane` is herdr's id of the pane this process runs in (`HERDR_PANE_ID`), undefined outside herdr. */
107
+ export const createHerdrLaneStatusSource = (run: HerdrRunner, ownPane?: string): LaneStatusSource => ({
80
108
  listAgents: (adopt) => parseAgentList(run(['agent', 'list']), adopt),
81
109
  setTitle: (pane, title) => {
82
110
  try {
@@ -86,6 +114,14 @@ export const createHerdrLaneStatusSource = (run: HerdrRunner): LaneStatusSource
86
114
  return `${pane}: ${error instanceof Error ? error.message : String(error)}`
87
115
  }
88
116
  },
117
+ labelOwnPane: (label) => {
118
+ if (!ownPane) return
119
+ try {
120
+ run(renameArguments(ownPane, label))
121
+ } catch {
122
+ // The label is decoration: a herdr that cannot be asked must not stop the caller.
123
+ }
124
+ },
89
125
  })
90
126
 
91
- export const herdrLaneStatusSource = createHerdrLaneStatusSource(runHerdr)
127
+ export const herdrLaneStatusSource = createHerdrLaneStatusSource(runHerdr, process.env.HERDR_PANE_ID)
@@ -4,4 +4,5 @@ import type { LaneStatusSource } from './types'
4
4
  export const noneLaneStatusSource: LaneStatusSource = {
5
5
  listAgents: () => [],
6
6
  setTitle: () => null,
7
+ labelOwnPane: () => {},
7
8
  }
@@ -11,9 +11,17 @@ export type AdoptedName = { pane: string; name: string }
11
11
 
12
12
  /**
13
13
  * One agent as a terminal adapter reports it; `pane` identifies where its sidebar title is set, `session` is the
14
- * id of its Claude Code session (the name of its log), null when the terminal does not say. `cwd` is the directory the agent runs in.
14
+ * id of its Claude Code session (the name of its log), null when the terminal does not say. `cwd` is the directory the
15
+ * agent runs in, `title` the title of its session as the terminal shows it, absent when the session has none of its own.
15
16
  */
16
- export type LaneAgent = { name: string; pane: string; status: string; session: string | null; cwd: string | null }
17
+ export type LaneAgent = {
18
+ name: string
19
+ pane: string
20
+ status: string
21
+ session: string | null
22
+ cwd: string | null
23
+ title?: string
24
+ }
17
25
 
18
26
  /**
19
27
  * Where lane agents run. The herd reads agent status and sets a short title per lane through
@@ -27,4 +35,9 @@ export type LaneStatusSource = {
27
35
  listAgents: (adopt?: AdoptedName) => LaneAgent[]
28
36
  /** Sets, or with null clears, the title of a pane. Returns the error text, or null on success. */
29
37
  setTitle: (pane: string, title: string | null) => string | null
38
+ /**
39
+ * Labels, or with null unlabels, the pane this process runs in; does nothing where the terminal cannot tell
40
+ * which pane that is. Never throws: the label is decoration, a failure is dropped.
41
+ */
42
+ labelOwnPane: (label: string | null) => void
30
43
  }
@@ -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,22 +38,54 @@ 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 options of the question that approves a plan for all its lanes (``Plan `<path>` ``) and of the one that revokes
43
+ * that approval. The merge guard hook lifts its question for a plain merge of such a lane only when the newest answer
44
+ * to a question naming the plan is exactly `approve`, so the labels are fixed per language and not a config value.
45
+ */
46
+ export const PLAN_APPROVAL_LABELS: Record<PlanLanguage, { approve: string; revoke: string }> = {
47
+ [PLAN_LANGUAGE.en]: { approve: 'Approve the plan', revoke: 'Revoke the plan approval' },
48
+ [PLAN_LANGUAGE.de]: { approve: 'Plan freigeben', revoke: 'Plan-Freigabe widerrufen' },
49
+ }
50
+
51
+ /** What the approval gate hook tells the lane when it denies: jackherd's own words, English in every language. */
52
+ const APPROVAL_REASONS = {
53
+ pending: 'Approval pending with the coordinator; retry the same call once it is approved.',
54
+ denied: 'The user denied this call through the coordinator; do not retry it.',
55
+ }
56
+
41
57
  /**
42
58
  * 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.
59
+ * approval gate hook matches exactly and which follow the response language, and what the hook tells the lane when it
60
+ * denies, which is English. Fixed per language, not config.
44
61
  */
45
62
  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
- },
63
+ [PLAN_LANGUAGE.en]: { allow: 'Allow this call', deny: 'Deny this call', ...APPROVAL_REASONS },
64
+ [PLAN_LANGUAGE.de]: { allow: 'Aufruf erlauben', deny: 'Aufruf ablehnen', ...APPROVAL_REASONS },
65
+ }
66
+
67
+ /**
68
+ * The board section of agreed times, which the watcher reads: its heading, the header names of its columns (when,
69
+ * action, origin, status; found by name, in any order) and the lower-case words a status cell of a still open
70
+ * appointment starts with.
71
+ */
72
+ export const APPOINTMENT_VOCABULARY: Record<
73
+ PlanLanguage,
74
+ {
75
+ heading: string
76
+ columns: { when: string; action: string; origin: string; status: string }
77
+ open: readonly string[]
78
+ }
79
+ > = {
52
80
  [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.',
81
+ heading: 'Termine',
82
+ columns: { when: 'Wann', action: 'Aktion', origin: 'Herkunft', status: 'Stand' },
83
+ open: ['offen'],
84
+ },
85
+ [PLAN_LANGUAGE.en]: {
86
+ heading: 'Appointments',
87
+ columns: { when: 'When', action: 'Action', origin: 'Origin', status: 'Status' },
88
+ open: ['open'],
57
89
  },
58
90
  }
59
91
 
@@ -98,6 +130,8 @@ export type PlanVocabulary = {
98
130
  noReviewGate: string
99
131
  /** Marks a lane as closed when its name cell contains it. */
100
132
  laneClosed: string
133
+ /** Opens the line a lane's hand-over file must hold as `<word>: 0`, so the coordinator hands over only after a wave. */
134
+ openSubagents: string
101
135
  /** Words a starts-after cell begins with when the lane may start at once. */
102
136
  startsNow: readonly string[]
103
137
  /** Opens the parenthesis by which a board row's phases cell records a user's side order in prose. */
@@ -133,6 +167,7 @@ export const PLAN_VOCABULARY: Record<PlanLanguage, PlanVocabulary> = {
133
167
  phase: 'Phase',
134
168
  noReviewGate: 'Kein Review-Gate',
135
169
  laneClosed: 'abgeräumt',
170
+ openSubagents: 'offene Sub-Agenten',
136
171
  sideOrderMarker: 'dazu (User',
137
172
  startsNow: ['sofort'],
138
173
  phaseConnectors: ['danach', 'und', 'dann', 'sowie'],
@@ -166,6 +201,7 @@ export const PLAN_VOCABULARY: Record<PlanLanguage, PlanVocabulary> = {
166
201
  phase: 'Phase',
167
202
  noReviewGate: 'No review gate',
168
203
  laneClosed: 'cleaned up',
204
+ openSubagents: 'open subagents',
169
205
  sideOrderMarker: 'also (User',
170
206
  startsNow: ['now', 'immediately'],
171
207
  phaseConnectors: ['then', 'and', 'after'],
@@ -0,0 +1,205 @@
1
+ import { firstTableWithHeader, sectionLines } from '../plan-format/markdown'
2
+ import { APPOINTMENT_VOCABULARY, type PlanLanguage } from '../vocabulary'
3
+ import { isRecord } from './json-guards'
4
+
5
+ export const APPOINTMENT_EVENT = { due: 'due', unreadable: 'appointment unreadable' } as const
6
+
7
+ /** One row of the board's appointment table whose time could be read; `key` is what the watcher remembers it by. */
8
+ export type Appointment = { when: Date; whenText: string; action: string; open: boolean; key: string }
9
+
10
+ /**
11
+ * An open row whose time cell could not be read, as written; or a table that lacks a column the watcher reads, with
12
+ * its header as `whenText` and the missing names as `action`.
13
+ */
14
+ export type UnreadableAppointment = { whenText: string; action: string }
15
+
16
+ export type AppointmentTable = { appointments: Appointment[]; unreadable: UnreadableAppointment[] }
17
+
18
+ /** `when` is the time cell as the board wrote it. */
19
+ export type AppointmentEvent = {
20
+ kind: (typeof APPOINTMENT_EVENT)[keyof typeof APPOINTMENT_EVENT]
21
+ when: string
22
+ action: string
23
+ }
24
+
25
+ /** The keys of the appointments reported as due and of the unreadable rows reported, each once. */
26
+ export type AppointmentState = { reported: string[]; unreadable: string[] }
27
+
28
+ type DateParts = { year: number | null; month: number; day: number; hours: number; minutes: number }
29
+
30
+ const DAY_FIRST = /^(?:\p{L}{2,3}\.?,?\s+)?(\d{1,2})\.(\d{1,2})\.(\d{4})?\s+(\d{1,2}):(\d{2})$/u
31
+ const ISO = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{1,2}):(\d{2})$/
32
+ const KEY_TIME_LENGTH = 'YYYY-MM-DDThh:mm'.length
33
+ const DAY_MS = 86_400_000
34
+ const KEEP_GONE_MS = 7 * DAY_MS
35
+
36
+ const APPOINTMENT_KINDS: readonly string[] = Object.values(APPOINTMENT_EVENT)
37
+
38
+ export const isAppointmentEvent = (event: { kind: string }): event is AppointmentEvent =>
39
+ APPOINTMENT_KINDS.includes(event.kind)
40
+
41
+ export const emptyAppointmentState = (): AppointmentState => ({ reported: [], unreadable: [] })
42
+
43
+ const isStringList = (value: unknown): boolean =>
44
+ Array.isArray(value) && value.every((entry) => typeof entry === 'string')
45
+
46
+ export const isAppointmentState = (value: unknown): value is AppointmentState =>
47
+ isRecord(value) && isStringList(value.reported) && isStringList(value.unreadable)
48
+
49
+ const datePartsOf = (text: string): DateParts | null => {
50
+ const iso = ISO.exec(text)
51
+ if (iso) {
52
+ const [year, month, day, hours, minutes] = iso.slice(1).map(Number)
53
+ return { year, month, day, hours, minutes }
54
+ }
55
+ const dayFirst = DAY_FIRST.exec(text)
56
+ if (!dayFirst) return null
57
+ const [day, month, year, hours, minutes] = dayFirst.slice(1).map((part) => (part === undefined ? null : Number(part)))
58
+ return { year, month: month ?? 0, day: day ?? 0, hours: hours ?? 0, minutes: minutes ?? 0 }
59
+ }
60
+
61
+ /** The local time of the parts in `year`, or null when the parts name no such time (a 31st of February, hour 24). */
62
+ const localTime = ({ month, day, hours, minutes }: DateParts, year: number): Date | null => {
63
+ const date = new Date(year, month - 1, day, hours, minutes)
64
+ const exact =
65
+ date.getFullYear() === year &&
66
+ date.getMonth() === month - 1 &&
67
+ date.getDate() === day &&
68
+ date.getHours() === hours &&
69
+ date.getMinutes() === minutes
70
+ return exact ? date : null
71
+ }
72
+
73
+ /** Of the previous, the clock's and the next year, the one that puts the date nearest to `now`. */
74
+ const nearestToClock = (parts: DateParts, now: Date): Date | null => {
75
+ const year = now.getFullYear()
76
+ const candidates = [year - 1, year, year + 1].flatMap((candidate) => localTime(parts, candidate) ?? [])
77
+ const distance = (date: Date) => Math.abs(date.getTime() - now.getTime())
78
+ return candidates.reduce<Date | null>((best, date) => (best && distance(best) <= distance(date) ? best : date), null)
79
+ }
80
+
81
+ /**
82
+ * The local time of a board's time cell: `<day name> DD.MM. hh:mm` (the day name is ignored), `DD.MM. hh:mm`,
83
+ * `DD.MM.YYYY hh:mm`, `YYYY-MM-DD hh:mm`. A year written in the cell wins; without one, the year that puts the date
84
+ * nearest to `now`, so a January appointment written in December lands in the next year. Null for anything else,
85
+ * a time of day included: an appointment has a minute.
86
+ */
87
+ export const parseAppointmentTime = (text: string, now: Date): Date | null => {
88
+ const parts = datePartsOf(text.trim())
89
+ if (!parts) return null
90
+ return parts.year === null ? nearestToClock(parts, now) : localTime(parts, parts.year)
91
+ }
92
+
93
+ const twoDigits = (value: number): string => String(value).padStart(2, '0')
94
+
95
+ /** The local time to the minute and the action: a moved appointment gets a new key, a reworded day name does not. */
96
+ export const appointmentKey = (when: Date, action: string): string =>
97
+ `${when.getFullYear()}-${twoDigits(when.getMonth() + 1)}-${twoDigits(when.getDate())}T${twoDigits(when.getHours())}:${twoDigits(when.getMinutes())} ${action}`
98
+
99
+ const unreadableKey = ({ whenText, action }: UnreadableAppointment): string => `${whenText} ${action}`
100
+
101
+ const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim()
102
+
103
+ /** An empty status counts as open: a row nobody closed is not dropped in silence. */
104
+ const isOpen = (status: string, language: PlanLanguage): boolean => {
105
+ const lower = status.toLowerCase()
106
+ return lower === '' || APPOINTMENT_VOCABULARY[language].open.some((word) => lower.startsWith(word))
107
+ }
108
+
109
+ /** The row as an appointment, as an unreadable row when it is open and its time cannot be read, else as nothing. */
110
+ const tableOfRow = (
111
+ cells: string[],
112
+ [when, action, status]: number[],
113
+ language: PlanLanguage,
114
+ now: Date,
115
+ ): AppointmentTable => {
116
+ const whenText = cells[when] ?? ''
117
+ const actionText = oneLine(cells[action] ?? '')
118
+ const open = isOpen(cells[status] ?? '', language)
119
+ const time = parseAppointmentTime(whenText, now)
120
+ if (time)
121
+ return {
122
+ appointments: [{ when: time, whenText, action: actionText, open, key: appointmentKey(time, actionText) }],
123
+ unreadable: [],
124
+ }
125
+ return { appointments: [], unreadable: open ? [{ whenText, action: actionText }] : [] }
126
+ }
127
+
128
+ /**
129
+ * The rows of the board's appointment section in `language` (`## Termine`, `## Appointments`), its columns found by
130
+ * their header names in any order; the origin column is not read. A row whose time cannot be read is not an
131
+ * appointment; while it is open it is listed as unreadable. A table without the when, action or status column is
132
+ * listed as unreadable as a whole, with its header and the missing names; a section without a table has no rows.
133
+ */
134
+ export const readAppointmentTable = (board: string, language: PlanLanguage, now: Date): AppointmentTable => {
135
+ const { heading, columns } = APPOINTMENT_VOCABULARY[language]
136
+ const { header, rows } = firstTableWithHeader(sectionLines(board, heading))
137
+ if (header.length === 0) return { appointments: [], unreadable: [] }
138
+ const needed = [columns.when, columns.action, columns.status]
139
+ const missing = needed.filter((name) => !header.includes(name))
140
+ if (missing.length > 0)
141
+ return {
142
+ appointments: [],
143
+ unreadable: [{ whenText: header.join(' | '), action: `missing columns: ${missing.join(', ')}` }],
144
+ }
145
+ const positions = needed.map((name) => header.indexOf(name))
146
+ const tables = rows.map((cells) => tableOfRow(cells, positions, language, now))
147
+ return {
148
+ appointments: tables.flatMap(({ appointments }) => appointments),
149
+ unreadable: tables.flatMap(({ unreadable }) => unreadable),
150
+ }
151
+ }
152
+
153
+ const dueAppointments = (state: AppointmentState, table: AppointmentTable, now: number): Appointment[] =>
154
+ table.appointments
155
+ .filter(({ open, when, key }) => open && when.getTime() <= now && !state.reported.includes(key))
156
+ .sort((left, right) => left.when.getTime() - right.when.getTime())
157
+
158
+ /**
159
+ * The open appointments due at `now` that were not reported yet, by time, then the open rows whose time could not be
160
+ * read and were not reported yet. Due means from its time on, however long ago: one that fell due while the watcher
161
+ * was down fires at the next run. No table read, no event.
162
+ */
163
+ export const appointmentEvents = (
164
+ state: AppointmentState,
165
+ table: AppointmentTable | null,
166
+ now: number,
167
+ ): AppointmentEvent[] => {
168
+ if (!table) return []
169
+ const due = dueAppointments(state, table, now).map(({ whenText, action }) => ({
170
+ kind: APPOINTMENT_EVENT.due,
171
+ when: whenText,
172
+ action,
173
+ }))
174
+ const unreadable = table.unreadable
175
+ .filter((row) => !state.unreadable.includes(unreadableKey(row)))
176
+ .map(({ whenText, action }) => ({ kind: APPOINTMENT_EVENT.unreadable, when: whenText, action }))
177
+ return [...due, ...unreadable]
178
+ }
179
+
180
+ /** Whether a key's time lies within the last week; a key that does not start with a time does not. */
181
+ const isRecent = (key: string, now: number): boolean => {
182
+ const when = parseAppointmentTime(key.slice(0, KEY_TIME_LENGTH), new Date(now))
183
+ return when !== null && now - when.getTime() <= KEEP_GONE_MS
184
+ }
185
+
186
+ /**
187
+ * The state after `appointmentEvents` were reported: the due keys added. A reported key stays while its row is in
188
+ * the table, open or done, and for a week after its time once the row is gone, so a board read halfway through an
189
+ * edit does not report it twice. Unreadable rows are remembered while they stay unreadable. No table read keeps the
190
+ * state as it was.
191
+ */
192
+ export const recordAppointments = (
193
+ state: AppointmentState,
194
+ table: AppointmentTable | null,
195
+ now: number,
196
+ ): AppointmentState => {
197
+ if (!table) return state
198
+ const present = new Set(table.appointments.map(({ key }) => key))
199
+ const kept = state.reported.filter((key) => present.has(key) || isRecent(key, now))
200
+ const due = dueAppointments(state, table, now).map(({ key }) => key)
201
+ return {
202
+ reported: [...new Set([...kept, ...due])],
203
+ unreadable: table.unreadable.map(unreadableKey),
204
+ }
205
+ }