jackherd 1.7.1 → 1.8.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 (161) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/INSTALL.md +2 -0
  3. package/{NOTICE → NOTICE.txt} +1 -1
  4. package/README.md +86 -1037
  5. package/assets/branding/README.md +5 -7
  6. package/assets/branding/jackherd-logo-outline.svg +1 -0
  7. package/assets/branding/outline.test.ts +30 -0
  8. package/assets/icons/bark.svg +1 -1
  9. package/assets/icons/bowl.svg +1 -1
  10. package/assets/icons/decide.svg +1 -1
  11. package/assets/icons/ear.svg +1 -1
  12. package/assets/icons/lanes.svg +1 -1
  13. package/assets/icons/plan.svg +1 -1
  14. package/dist/{chunk-OBUWTQ6Z.js → chunk-OU2KALXP.js} +26 -10
  15. package/dist/config.d.ts +11 -5
  16. package/dist/config.js +53 -21
  17. package/dist/plan-format.d.ts +75 -7
  18. package/dist/plan-format.js +180 -100
  19. package/docs/workflow-dark.svg +1 -1
  20. package/docs/workflow.svg +1 -1
  21. package/herdr-plugin/herdr-plugin.toml +1 -1
  22. package/install.sh +1 -1
  23. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +2 -12
  24. package/kit/hooks/hooks/approval-gate/index.ts +10 -8
  25. package/kit/hooks/hooks/approval-gate/run.ts +21 -12
  26. package/kit/hooks/hooks/ask-hint/decision.ts +3 -3
  27. package/kit/hooks/hooks/git-guard/decision.ts +189 -15
  28. package/kit/hooks/hooks/git-guard/index.ts +13 -3
  29. package/kit/hooks/hooks/git-guard/push-target.ts +85 -0
  30. package/kit/hooks/hooks/git-guard/repository.ts +63 -0
  31. package/kit/hooks/hooks/merge-guard/check-free.ts +1 -13
  32. package/kit/hooks/hooks/merge-guard/decision.ts +344 -102
  33. package/kit/hooks/hooks/merge-guard/index.ts +6 -4
  34. package/kit/hooks/hooks/merge-guard/plain-path.ts +15 -0
  35. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +23 -0
  36. package/kit/hooks/hooks/merge-guard/plan-approval-settings.ts +142 -0
  37. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +196 -0
  38. package/kit/hooks/hooks/merge-guard/plan-approval.ts +168 -0
  39. package/kit/hooks/hooks/merge-guard/plan-growth.ts +40 -0
  40. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +194 -0
  41. package/kit/hooks/hooks/merge-guard/pull-request.ts +67 -21
  42. package/kit/hooks/hooks/merge-guard/run.ts +118 -29
  43. package/kit/hooks/hooks/merge-guard/settings.ts +8 -8
  44. package/kit/hooks/hooks/merge-guard/worker.ts +3 -3
  45. package/kit/hooks/hooks/merge-guard/xargs-program.ts +182 -0
  46. package/kit/hooks/hooks/shared/base-branch.ts +23 -0
  47. package/kit/hooks/hooks/shared/board-lanes.ts +93 -0
  48. package/kit/hooks/hooks/shared/command-text-runners.ts +71 -0
  49. package/kit/hooks/hooks/shared/command.ts +14 -2
  50. package/kit/hooks/hooks/shared/commit.ts +2 -0
  51. package/kit/hooks/hooks/shared/dialog-answers.ts +4 -1
  52. package/kit/hooks/hooks/shared/json.ts +10 -0
  53. package/kit/hooks/hooks/shared/lane.ts +57 -0
  54. package/kit/hooks/hooks/shared/once.ts +8 -0
  55. package/kit/hooks/hooks/shared/own-pane.ts +31 -0
  56. package/kit/hooks/hooks/shared/pane-agent.ts +24 -0
  57. package/kit/hooks/hooks/shared/plan-open-phases.ts +114 -0
  58. package/kit/hooks/hooks/shared/program-position.ts +148 -5
  59. package/kit/hooks/hooks/shared/shell-aliases.ts +258 -0
  60. package/kit/hooks/hooks/shared/shell-snapshots.ts +68 -0
  61. package/kit/hooks/hooks/shared/shell.ts +167 -20
  62. package/kit/hooks/hooks.json +11 -2
  63. package/kit/hooks/statusline.sh +29 -16
  64. package/kit/review/skills/diagnose/SKILL.md +1 -1
  65. package/kit/review/skills/review/SKILL.md +2 -0
  66. package/kit/rules/rules/kit/testing.md +2 -5
  67. package/kit/rules/rules/kit/writing-for-agents.md +3 -1
  68. package/kit/workflow/agents/spec-review.md +1 -1
  69. package/kit/workflow/skills/follow/SKILL.md +3 -3
  70. package/kit/workflow/skills/grill/SKILL.md +1 -1
  71. package/kit/workflow/skills/implement/SKILL.md +10 -11
  72. package/kit/workflow/skills/orchestrate/SKILL.md +156 -117
  73. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +17 -0
  74. package/kit/workflow/skills/orchestrate/references/herd.md +27 -0
  75. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -0
  76. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +23 -0
  77. package/kit/workflow/skills/plan/SKILL.md +5 -2
  78. package/kit/workflow/skills/plan/references/template-English.md +6 -0
  79. package/kit/workflow/skills/plan/references/template-German.md +6 -0
  80. package/package.json +4 -2
  81. package/recipes/README.md +21 -0
  82. package/recipes/github-issues.md +23 -0
  83. package/recipes/github-projects.md +22 -0
  84. package/recipes/jira-cli.md +26 -0
  85. package/recipes/jira-rest.md +24 -0
  86. package/recipes/linear.md +25 -0
  87. package/recipes/sentry.md +18 -0
  88. package/recipes/sources/jira.ts +51 -0
  89. package/recipes/sources/linear.ts +19 -0
  90. package/recipes/sources/sentry.ts +12 -0
  91. package/recipes/sources.md +80 -0
  92. package/recipes/status-page.md +19 -0
  93. package/schemas/jackherd.schema.json +42 -18
  94. package/src/check/command.ts +2 -2
  95. package/src/check/parallel-report.ts +19 -9
  96. package/src/cli.ts +1 -0
  97. package/src/config/context-shares.ts +130 -0
  98. package/src/config/read-config.ts +16 -4
  99. package/src/config/schema.ts +75 -20
  100. package/src/context/auto-compact.ts +68 -0
  101. package/src/context/command.ts +68 -17
  102. package/src/context/coordinator-name.ts +27 -9
  103. package/src/context/index.ts +0 -1
  104. package/src/context/level.ts +18 -11
  105. package/src/context/report.ts +6 -3
  106. package/src/plan-format/dependencies.ts +144 -32
  107. package/src/plan-format/index.ts +6 -0
  108. package/src/plan-format/lanes.ts +6 -0
  109. package/src/plan-format/progress.ts +19 -8
  110. package/src/plans/command.ts +14 -5
  111. package/src/plans/current-phase.ts +63 -7
  112. package/src/plans/current-task.ts +51 -0
  113. package/src/plans/ensure-panes.ts +25 -8
  114. package/src/plans/frame.ts +99 -0
  115. package/src/plans/git.ts +8 -0
  116. package/src/plans/herd-memory.ts +107 -0
  117. package/src/plans/lane-plans.ts +5 -3
  118. package/src/plans/lane-standing.ts +5 -4
  119. package/src/plans/lane-summary.ts +222 -0
  120. package/src/plans/layout-phase.ts +148 -0
  121. package/src/plans/layout-summary.ts +140 -0
  122. package/src/plans/layout.ts +164 -420
  123. package/src/plans/live.ts +11 -6
  124. package/src/plans/plan-panes.ts +15 -0
  125. package/src/plans/planned.ts +9 -42
  126. package/src/plans/selection.ts +2 -6
  127. package/src/plans/settings.ts +2 -7
  128. package/src/plans/snapshot.ts +38 -39
  129. package/src/plans/symbols.ts +2 -2
  130. package/src/plans/texts.ts +32 -31
  131. package/src/plans/view.ts +0 -1
  132. package/src/plans/worked-phases.ts +2 -2
  133. package/src/settled/board-row.ts +133 -0
  134. package/src/settled/check.ts +129 -0
  135. package/src/settled/command.ts +65 -0
  136. package/src/settled/evidence-version.ts +14 -0
  137. package/src/settled/options.ts +69 -0
  138. package/src/settled/send.ts +27 -0
  139. package/src/settled/settle.ts +87 -0
  140. package/src/settled/topics.ts +56 -0
  141. package/src/sync/render.ts +13 -4
  142. package/src/sync/sync.ts +25 -5
  143. package/src/terminal/herdr.ts +46 -10
  144. package/src/terminal/none.ts +1 -0
  145. package/src/terminal/types.ts +15 -2
  146. package/src/update/command.ts +2 -0
  147. package/src/update/config-shares.ts +30 -0
  148. package/src/update/update.ts +12 -1
  149. package/src/vocabulary/index.ts +51 -11
  150. package/src/watch/appointments.ts +206 -0
  151. package/src/watch/command.ts +62 -19
  152. package/src/watch/lane-report.ts +11 -3
  153. package/src/watch/texts.ts +35 -59
  154. package/src/watch/watch-events.ts +21 -28
  155. package/assets/icons/bark-dark.svg +0 -1
  156. package/assets/icons/bowl-dark.svg +0 -1
  157. package/assets/icons/decide-dark.svg +0 -1
  158. package/assets/icons/ear-dark.svg +0 -1
  159. package/assets/icons/lanes-dark.svg +0 -1
  160. package/assets/icons/plan-dark.svg +0 -1
  161. 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
  }
@@ -14,6 +14,7 @@ import { defaultUpdateCheck, lockedVersion } from '../sync/if-changed'
14
14
  import { locateKit } from '../sync/kit-source'
15
15
  import { readInstalledVersion } from '../version/installed-version'
16
16
  import { CLI_NAME } from '../version/release'
17
+ import { rewriteConfigShares } from './config-shares'
17
18
  import { detectPackageManager, legacyPackageRoot, shellHostedGlobalRoot } from './package-manager'
18
19
  import { runUpdate, UPDATE_EXIT, type UpdateAdapters } from './update'
19
20
 
@@ -52,6 +53,7 @@ const adaptersFor = async (): Promise<UpdateAdapters> => {
52
53
  home,
53
54
  projectRoot: findProjectRoot(process.cwd(), home, existsSync),
54
55
  hasProjectKit: (root) => hasProjectInstall(root, existsSync),
56
+ rewriteConfigShares,
55
57
  log: (line) => console.log(line),
56
58
  }
57
59
  }
@@ -0,0 +1,30 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { contextSharesTextInTokens } from '../config/context-shares'
4
+ import { PROJECT_CONFIG_PATH } from '../config/location'
5
+
6
+ type Converted = { text: string; notes: string[] }
7
+
8
+ /** The file's text with its shares in tokens; null when it is missing or not JSON, which reading the config names. */
9
+ const converted = (path: string): Converted | null => {
10
+ if (!existsSync(path)) return null
11
+ try {
12
+ return contextSharesTextInTokens(readFileSync(path, 'utf8'))
13
+ } catch {
14
+ return null
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Writes the context thresholds of `root`'s `.jackherd/config.json` as tokens when the file still gives them as a
20
+ * share, and returns a line for each block that changed. A project without an install of its own has no sync that
21
+ * would do it. A missing or unreadable file is left alone: reading the config names that error. A failed write
22
+ * throws, so `jh update` reports it.
23
+ */
24
+ export const rewriteConfigShares = (root: string): string[] => {
25
+ const path = join(root, PROJECT_CONFIG_PATH)
26
+ const result = converted(path)
27
+ if (result === null || result.notes.length === 0) return []
28
+ writeFileSync(path, result.text)
29
+ return result.notes.map((note) => `${PROJECT_CONFIG_PATH}: ${note}`)
30
+ }
@@ -28,6 +28,8 @@ export type UpdateAdapters = {
28
28
  * The lock's existence counts, not its validity: a broken lock is synced, and the sync names the error.
29
29
  */
30
30
  hasProjectKit: (root: string) => boolean
31
+ /** Writes the context thresholds in the project's config as tokens when they are still a share; returns a line for each change. */
32
+ rewriteConfigShares: (root: string) => string[]
31
33
  log: (line: string) => void
32
34
  }
33
35
 
@@ -46,6 +48,11 @@ const notInstalledLine = (projectRoot: string): string =>
46
48
  const installedProject = (adapters: UpdateAdapters): string | null =>
47
49
  adapters.projectRoot && adapters.hasProjectKit(adapters.projectRoot) ? adapters.projectRoot : null
48
50
 
51
+ /** Rewrites the context thresholds of a project without an install, which no sync does, and says what changed. */
52
+ const logConfigShares = (adapters: UpdateAdapters, root: string): void => {
53
+ for (const line of adapters.rewriteConfigShares(root)) adapters.log(line)
54
+ }
55
+
49
56
  /** The syncs that bring `~/.claude` and the project to the new files, in the order they run. */
50
57
  const syncSteps = async (
51
58
  adapters: UpdateAdapters,
@@ -107,6 +114,7 @@ const updateToLatest = async (adapters: UpdateAdapters, manager: PackageManager)
107
114
  const jhWasOnPath = adapters.jhOnPath()
108
115
  if (jhWasOnPath && !isNewerVersion(latest, installedVersion)) {
109
116
  log(`${CLI_NAME} ${installedVersion} is the latest release.`)
117
+ if (adapters.projectRoot && !installedProject(adapters)) logConfigShares(adapters, adapters.projectRoot)
110
118
  return UPDATE_EXIT.ok
111
119
  }
112
120
  const installed = await installAndVerify(adapters, manager, latest)
@@ -115,7 +123,10 @@ const updateToLatest = async (adapters: UpdateAdapters, manager: PackageManager)
115
123
  const synced = await syncAfterInstall(adapters, project)
116
124
  if (synced !== UPDATE_EXIT.ok) return synced
117
125
  log(doneLine(adapters, manager, latest, jhWasOnPath))
118
- if (adapters.projectRoot && !project) log(notInstalledLine(adapters.projectRoot))
126
+ if (adapters.projectRoot && !project) {
127
+ logConfigShares(adapters, adapters.projectRoot)
128
+ log(notInstalledLine(adapters.projectRoot))
129
+ }
119
130
  return UPDATE_EXIT.ok
120
131
  }
121
132
 
@@ -38,22 +38,58 @@ 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; the status column may also carry a name of `statusAlso`) 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
+ /** Further header names that count as the status column. */
78
+ statusAlso: readonly string[]
79
+ open: readonly string[]
80
+ }
81
+ > = {
52
82
  [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.',
83
+ heading: 'Termine',
84
+ columns: { when: 'Wann', action: 'Aktion', origin: 'Herkunft', status: 'Stand' },
85
+ statusAlso: ['Status'],
86
+ open: ['offen'],
87
+ },
88
+ [PLAN_LANGUAGE.en]: {
89
+ heading: 'Appointments',
90
+ columns: { when: 'When', action: 'Action', origin: 'Origin', status: 'Status' },
91
+ statusAlso: [],
92
+ open: ['open'],
57
93
  },
58
94
  }
59
95
 
@@ -98,6 +134,8 @@ export type PlanVocabulary = {
98
134
  noReviewGate: string
99
135
  /** Marks a lane as closed when its name cell contains it. */
100
136
  laneClosed: string
137
+ /** Opens the line a lane's hand-over file must hold as `<word>: 0`, so the coordinator hands over only after a wave. */
138
+ openSubagents: string
101
139
  /** Words a starts-after cell begins with when the lane may start at once. */
102
140
  startsNow: readonly string[]
103
141
  /** Opens the parenthesis by which a board row's phases cell records a user's side order in prose. */
@@ -133,6 +171,7 @@ export const PLAN_VOCABULARY: Record<PlanLanguage, PlanVocabulary> = {
133
171
  phase: 'Phase',
134
172
  noReviewGate: 'Kein Review-Gate',
135
173
  laneClosed: 'abgeräumt',
174
+ openSubagents: 'offene Sub-Agenten',
136
175
  sideOrderMarker: 'dazu (User',
137
176
  startsNow: ['sofort'],
138
177
  phaseConnectors: ['danach', 'und', 'dann', 'sowie'],
@@ -166,6 +205,7 @@ export const PLAN_VOCABULARY: Record<PlanLanguage, PlanVocabulary> = {
166
205
  phase: 'Phase',
167
206
  noReviewGate: 'No review gate',
168
207
  laneClosed: 'cleaned up',
208
+ openSubagents: 'open subagents',
169
209
  sideOrderMarker: 'also (User',
170
210
  startsNow: ['now', 'immediately'],
171
211
  phaseConnectors: ['then', 'and', 'after'],
@@ -0,0 +1,206 @@
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, statusAlso } = APPOINTMENT_VOCABULARY[language]
136
+ const { header, rows } = firstTableWithHeader(sectionLines(board, heading))
137
+ if (header.length === 0) return { appointments: [], unreadable: [] }
138
+ const statusName = [columns.status, ...statusAlso].find((name) => header.includes(name)) ?? columns.status
139
+ const needed = [columns.when, columns.action, statusName]
140
+ const missing = needed.filter((name) => !header.includes(name))
141
+ if (missing.length > 0)
142
+ return {
143
+ appointments: [],
144
+ unreadable: [{ whenText: header.join(' | '), action: `missing columns: ${missing.join(', ')}` }],
145
+ }
146
+ const positions = needed.map((name) => header.indexOf(name))
147
+ const tables = rows.map((cells) => tableOfRow(cells, positions, language, now))
148
+ return {
149
+ appointments: tables.flatMap(({ appointments }) => appointments),
150
+ unreadable: tables.flatMap(({ unreadable }) => unreadable),
151
+ }
152
+ }
153
+
154
+ const dueAppointments = (state: AppointmentState, table: AppointmentTable, now: number): Appointment[] =>
155
+ table.appointments
156
+ .filter(({ open, when, key }) => open && when.getTime() <= now && !state.reported.includes(key))
157
+ .sort((left, right) => left.when.getTime() - right.when.getTime())
158
+
159
+ /**
160
+ * The open appointments due at `now` that were not reported yet, by time, then the open rows whose time could not be
161
+ * read and were not reported yet. Due means from its time on, however long ago: one that fell due while the watcher
162
+ * was down fires at the next run. No table read, no event.
163
+ */
164
+ export const appointmentEvents = (
165
+ state: AppointmentState,
166
+ table: AppointmentTable | null,
167
+ now: number,
168
+ ): AppointmentEvent[] => {
169
+ if (!table) return []
170
+ const due = dueAppointments(state, table, now).map(({ whenText, action }) => ({
171
+ kind: APPOINTMENT_EVENT.due,
172
+ when: whenText,
173
+ action,
174
+ }))
175
+ const unreadable = table.unreadable
176
+ .filter((row) => !state.unreadable.includes(unreadableKey(row)))
177
+ .map(({ whenText, action }) => ({ kind: APPOINTMENT_EVENT.unreadable, when: whenText, action }))
178
+ return [...due, ...unreadable]
179
+ }
180
+
181
+ /** Whether a key's time lies within the last week; a key that does not start with a time does not. */
182
+ const isRecent = (key: string, now: number): boolean => {
183
+ const when = parseAppointmentTime(key.slice(0, KEY_TIME_LENGTH), new Date(now))
184
+ return when !== null && now - when.getTime() <= KEEP_GONE_MS
185
+ }
186
+
187
+ /**
188
+ * The state after `appointmentEvents` were reported: the due keys added. A reported key stays while its row is in
189
+ * the table, open or done, and for a week after its time once the row is gone, so a board read halfway through an
190
+ * edit does not report it twice. Unreadable rows are remembered while they stay unreadable. No table read keeps the
191
+ * state as it was.
192
+ */
193
+ export const recordAppointments = (
194
+ state: AppointmentState,
195
+ table: AppointmentTable | null,
196
+ now: number,
197
+ ): AppointmentState => {
198
+ if (!table) return state
199
+ const present = new Set(table.appointments.map(({ key }) => key))
200
+ const kept = state.reported.filter((key) => present.has(key) || isRecent(key, now))
201
+ const due = dueAppointments(state, table, now).map(({ key }) => key)
202
+ return {
203
+ reported: [...new Set([...kept, ...due])],
204
+ unreadable: table.unreadable.map(unreadableKey),
205
+ }
206
+ }