jackherd 1.9.0 → 1.11.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 (228) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +65 -32
  4. package/dist/{chunk-MCP4XJQH.js → chunk-RMMXD4G5.js} +18 -6
  5. package/dist/config.d.ts +99 -4
  6. package/dist/config.js +105 -29
  7. package/dist/plan-format.d.ts +51 -6
  8. package/dist/plan-format.js +49 -7
  9. package/docs/workflow-dark.svg +67 -63
  10. package/docs/workflow.svg +67 -63
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/ask-rules.json +15 -0
  14. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  15. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  16. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  17. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  18. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  19. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  20. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  21. package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
  22. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  23. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  24. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  25. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  26. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  27. package/kit/hooks/hooks/open-question/index.ts +26 -0
  28. package/kit/hooks/hooks/open-question/record.ts +135 -0
  29. package/kit/hooks/hooks/open-question/run.ts +57 -0
  30. package/kit/hooks/hooks/open-question/store.ts +39 -0
  31. package/kit/hooks/hooks/sent-file/store.ts +3 -12
  32. package/kit/hooks/hooks/shared/config.ts +7 -0
  33. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  34. package/kit/hooks/hooks/shared/files.ts +7 -0
  35. package/kit/hooks/hooks/shared/lane.ts +1 -1
  36. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  37. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  38. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  39. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  40. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  41. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  42. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  43. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  44. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  45. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  46. package/kit/hooks/hooks.json +13 -5
  47. package/kit/review/agents/skill-review.md +34 -0
  48. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  49. package/kit/rules/rules/kit/pull-requests.md +2 -0
  50. package/kit/workflow/skills/follow/SKILL.md +3 -10
  51. package/kit/workflow/skills/implement/SKILL.md +8 -6
  52. package/kit/workflow/skills/orchestrate/SKILL.md +218 -433
  53. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  54. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  55. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  56. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  57. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  58. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  59. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  60. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  61. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  62. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  63. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  64. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  65. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  66. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  67. package/kit/workflow/skills/plan/SKILL.md +6 -5
  68. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  69. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  70. package/package.json +3 -1
  71. package/recipes/README.md +2 -1
  72. package/recipes/sources.md +5 -1
  73. package/schemas/jackherd.schema.json +139 -5
  74. package/src/check/agent-text-reviewers.ts +23 -0
  75. package/src/check/command.ts +2 -0
  76. package/src/check/plan-structure-check.ts +49 -2
  77. package/src/check/plan.ts +8 -1
  78. package/src/check/schema.ts +9 -0
  79. package/src/check/skill-budget-defaults.ts +55 -0
  80. package/src/check/skill-budgets.ts +185 -0
  81. package/src/cli.ts +2 -0
  82. package/src/config/auto-window.ts +8 -0
  83. package/src/config/link-kinds.ts +7 -1
  84. package/src/config/schema.ts +82 -25
  85. package/src/config/tones.ts +27 -0
  86. package/src/lane/close-board.ts +106 -0
  87. package/src/lane/close-check.ts +183 -0
  88. package/src/lane/close-command-line.ts +11 -0
  89. package/src/lane/close-command.ts +217 -0
  90. package/src/lane/close-options.ts +43 -0
  91. package/src/lane/close-survey.ts +83 -0
  92. package/src/lane/close.ts +141 -0
  93. package/src/lane/command.ts +13 -0
  94. package/src/lane-prompt/additions.ts +14 -0
  95. package/src/lane-prompt/build.ts +74 -0
  96. package/src/lane-prompt/command.ts +93 -0
  97. package/src/lane-prompt/input.ts +157 -0
  98. package/src/lane-prompt/options.ts +70 -0
  99. package/src/lane-prompt/texts.ts +101 -0
  100. package/src/mermaid/command.ts +36 -0
  101. package/src/mermaid/problems.ts +48 -0
  102. package/src/plan-format/clock.ts +8 -0
  103. package/src/plan-format/dependencies.ts +9 -6
  104. package/src/plan-format/index.ts +1 -0
  105. package/src/plan-format/short-names.ts +71 -0
  106. package/src/plan-format/waiting.ts +22 -4
  107. package/src/plans/appointment-rows.ts +83 -0
  108. package/src/plans/ask-coordinator.ts +111 -0
  109. package/src/plans/ask-target.ts +36 -0
  110. package/src/plans/board-close.ts +85 -0
  111. package/src/plans/child-stop.ts +51 -0
  112. package/src/plans/closable-records.ts +34 -0
  113. package/src/plans/command.ts +3 -2
  114. package/src/plans/fetch.ts +26 -3
  115. package/src/plans/frame.ts +5 -12
  116. package/src/plans/git.ts +54 -5
  117. package/src/plans/groomed.ts +27 -0
  118. package/src/plans/herd-claim.ts +173 -0
  119. package/src/plans/herd-memory.ts +36 -25
  120. package/src/plans/lane-plans.ts +44 -13
  121. package/src/plans/lane-summary.ts +253 -45
  122. package/src/plans/layout-phase.ts +36 -11
  123. package/src/plans/layout-summary.ts +89 -18
  124. package/src/plans/layout.ts +502 -98
  125. package/src/plans/link-chips.ts +23 -4
  126. package/src/plans/live.ts +178 -23
  127. package/src/plans/next-rows.ts +44 -0
  128. package/src/plans/phase-ids.ts +8 -0
  129. package/src/plans/phase-source.ts +15 -0
  130. package/src/plans/plan-cache.ts +28 -0
  131. package/src/plans/plan-heads.ts +54 -0
  132. package/src/plans/planned.ts +22 -0
  133. package/src/plans/pull-requests.ts +17 -2
  134. package/src/plans/question-records.ts +48 -0
  135. package/src/plans/questions-read.ts +13 -0
  136. package/src/plans/read-cache.ts +8 -0
  137. package/src/plans/record-id.ts +4 -0
  138. package/src/plans/restart-decision.ts +13 -4
  139. package/src/plans/row-actions.ts +52 -0
  140. package/src/plans/row-focus.ts +148 -0
  141. package/src/plans/row-targets.ts +7 -0
  142. package/src/plans/selection.ts +111 -18
  143. package/src/plans/sent-files-read.ts +6 -15
  144. package/src/plans/sent-files.ts +7 -14
  145. package/src/plans/settings.ts +12 -1
  146. package/src/plans/signalled-phase.ts +42 -0
  147. package/src/plans/signals-read.ts +12 -4
  148. package/src/plans/snapshot.ts +162 -81
  149. package/src/plans/source-asked.ts +19 -0
  150. package/src/plans/source-prefix.ts +17 -0
  151. package/src/plans/source-rows.ts +188 -0
  152. package/src/plans/sprint-export.ts +11 -11
  153. package/src/plans/sprint-page.ts +5 -5
  154. package/src/plans/subagent-phases.ts +95 -0
  155. package/src/plans/subagent-tasks.ts +51 -0
  156. package/src/plans/subagents-read.ts +66 -17
  157. package/src/plans/subagents.ts +64 -6
  158. package/src/plans/supervisor.ts +14 -4
  159. package/src/plans/symbols.ts +3 -0
  160. package/src/plans/task-name.ts +34 -0
  161. package/src/plans/texts.ts +48 -6
  162. package/src/plans/tones.ts +30 -0
  163. package/src/plans/view.ts +25 -11
  164. package/src/plans/waiting-click.ts +295 -35
  165. package/src/plans/waiting-records.ts +39 -0
  166. package/src/plans/waiting-window.ts +8 -0
  167. package/src/preview/command.ts +25 -3
  168. package/src/shared/private-location.ts +89 -0
  169. package/src/shared/stat-cache.ts +48 -0
  170. package/src/sync/kit-source.ts +29 -10
  171. package/src/sync/kit.ts +2 -0
  172. package/src/sync/manifest.ts +2 -0
  173. package/src/sync/plan-ask-rules.ts +94 -0
  174. package/src/sync/plan-hooks.ts +2 -0
  175. package/src/sync/render.ts +3 -3
  176. package/src/sync/settings-hooks.ts +4 -1
  177. package/src/sync/sync-plan.ts +1 -0
  178. package/src/sync/sync.ts +24 -3
  179. package/src/task-signals/claude-read.ts +27 -10
  180. package/src/terminal/text.ts +16 -3
  181. package/src/terminal/types.ts +4 -0
  182. package/src/version/skill-diff.ts +46 -0
  183. package/src/vocabulary/index.ts +26 -6
  184. package/src/waiting/command.ts +22 -7
  185. package/src/watch/appointments.ts +1 -2
  186. package/src/watch/asking-lanes.ts +40 -0
  187. package/src/watch/base-tip.ts +11 -0
  188. package/src/watch/command.ts +330 -105
  189. package/src/watch/heartbeat-progress.ts +6 -18
  190. package/src/watch/kit-update.ts +28 -9
  191. package/src/watch/lane-name.ts +6 -0
  192. package/src/watch/lane-progress.ts +40 -8
  193. package/src/watch/lane-report-file.ts +20 -5
  194. package/src/watch/lane-report.ts +32 -4
  195. package/src/watch/lane-sources.ts +31 -3
  196. package/src/watch/main-checkout.ts +34 -0
  197. package/src/watch/plan-badges-read.ts +22 -9
  198. package/src/watch/plan-badges.ts +10 -2
  199. package/src/watch/pull-requests.ts +6 -2
  200. package/src/watch/reference-paths.ts +87 -0
  201. package/src/watch/report-questions.ts +4 -3
  202. package/src/watch/running-subagents.ts +43 -0
  203. package/src/watch/skill-change.ts +124 -32
  204. package/src/watch/source-change.ts +48 -0
  205. package/src/watch/source-entries.ts +55 -0
  206. package/src/watch/source-fields.ts +1 -0
  207. package/src/watch/source-statuses.ts +60 -0
  208. package/src/watch/source-tickets.ts +21 -1
  209. package/src/watch/sources.ts +47 -4
  210. package/src/watch/stream-output.ts +4 -3
  211. package/src/watch/task-parallel-auto.ts +208 -0
  212. package/src/watch/task-parallel-data-paths.ts +46 -0
  213. package/src/watch/task-parallel-input.ts +5 -15
  214. package/src/watch/task-parallel-read.ts +3 -1
  215. package/src/watch/task-parallel.ts +36 -7
  216. package/src/watch/task-signal-missing.ts +69 -0
  217. package/src/watch/task-signal-read.ts +29 -0
  218. package/src/watch/texts.ts +10 -0
  219. package/src/watch/update-available.ts +6 -3
  220. package/src/watch/watch-events.ts +23 -0
  221. package/src/watch/watcher-lock.ts +25 -29
  222. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  223. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  224. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  225. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  226. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  227. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  228. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -9,10 +9,15 @@ import { parseKitPath } from './kit-path'
9
9
 
10
10
  export type KitLocation = { kitDirectory: string; version: string; packageRoot: string }
11
11
 
12
- /** What one kit module ships: its files and the text of its `hooks.json`, if any. */
13
- type KitModule = { name: string; files: KitFile[]; hooksText: string | undefined }
12
+ /** What one kit module ships: its files and the texts of its `hooks.json` and `ask-rules.json`, if any. */
13
+ type KitModule = { name: string; files: KitFile[]; hooksText: string | undefined; askRulesText: string | undefined }
14
14
 
15
15
  const MODULE_HOOKS_FILE = 'hooks.json'
16
+ const MODULE_ASK_RULES_FILE = 'ask-rules.json'
17
+ /** Files a module ships to sync itself, never into `.claude/`. */
18
+ export const MODULE_DATA_FILES: readonly string[] = [MODULE_HOOKS_FILE, MODULE_ASK_RULES_FILE]
19
+
20
+ const askRulesSchema = z.array(z.string().min(1))
16
21
 
17
22
  /** A test beside a kit script runs in this package and never ships into a project. */
18
23
  const TEST_FILE = /\.test\.ts$/
@@ -68,6 +73,21 @@ const mergeModuleHooks = (modules: KitModule[]): string | undefined => {
68
73
  return Object.keys(merged).length > 0 ? JSON.stringify(merged, null, 2) : undefined
69
74
  }
70
75
 
76
+ /** The `permissions.ask` rules of every module's `ask-rules.json` (a JSON list of rules), each once, in module order. */
77
+ const mergeModuleAskRules = (modules: KitModule[]): string[] => {
78
+ const rules = modules.flatMap(({ name, askRulesText }) => {
79
+ if (askRulesText === undefined) return []
80
+ const invalid = new Error(`Kit ${name}/${MODULE_ASK_RULES_FILE} is not a JSON list of permission rules`)
81
+ const parsed = askRulesSchema.safeParse(parseJsonOr(askRulesText, invalid))
82
+ if (!parsed.success) throw invalid
83
+ return parsed.data
84
+ })
85
+ return [...new Set(rules)]
86
+ }
87
+
88
+ const readOptional = async (moduleDirectory: string, relatives: string[], file: string): Promise<string | undefined> =>
89
+ relatives.includes(file) ? readFile(join(moduleDirectory, file), 'utf8') : undefined
90
+
71
91
  const listFiles = async (directory: string, prefix = ''): Promise<string[]> => {
72
92
  const entries = await readdir(join(directory, prefix), { withFileTypes: true })
73
93
  const nested = await Promise.all(
@@ -94,12 +114,11 @@ const readKitFile = async (moduleDirectory: string, relative: string): Promise<K
94
114
  const readModule = async (kitDirectory: string, name: string): Promise<KitModule> => {
95
115
  const moduleDirectory = join(kitDirectory, name)
96
116
  const relatives = (await listFiles(moduleDirectory)).sort()
97
- const filePaths = relatives.filter((relative) => relative !== MODULE_HOOKS_FILE && !TEST_FILE.test(relative))
117
+ const filePaths = relatives.filter((relative) => !MODULE_DATA_FILES.includes(relative) && !TEST_FILE.test(relative))
98
118
  const files = await Promise.all(filePaths.map((relative) => readKitFile(moduleDirectory, relative)))
99
- const hooksText = relatives.includes(MODULE_HOOKS_FILE)
100
- ? await readFile(join(moduleDirectory, MODULE_HOOKS_FILE), 'utf8')
101
- : undefined
102
- return { name, files, hooksText }
119
+ const hooksText = await readOptional(moduleDirectory, relatives, MODULE_HOOKS_FILE)
120
+ const askRulesText = await readOptional(moduleDirectory, relatives, MODULE_ASK_RULES_FILE)
121
+ return { name, files, hooksText, askRulesText }
103
122
  }
104
123
 
105
124
  const listModuleNames = async (kitDirectory: string): Promise<string[]> => {
@@ -119,8 +138,8 @@ const listModuleNames = async (kitDirectory: string): Promise<string[]> => {
119
138
  /**
120
139
  * Reads the kit shipped with the package. Every directory under `kit/` is a module that mirrors
121
140
  * `.claude/` (`kit/workflow/skills/plan/SKILL.md` → `.claude/skills/plan/SKILL.md`); a module's
122
- * `hooks.json` holds its hooks by name, `{ "<name>": { event, matcher?, handler } }`, and is not
123
- * copied, nor is a `*.test.ts`. Files at the kit root, such as a README, belong to no module.
141
+ * `hooks.json` holds its hooks by name, `{ "<name>": { event, matcher?, handler } }`, and its
142
+ * `ask-rules.json` the `permissions.ask` rules a project install adds; neither is copied, nor is a `*.test.ts`. Files at the kit root, such as a README, belong to no module.
124
143
  *
125
144
  * A missing kit directory, or one without modules, is an error, never an empty kit: an empty kit
126
145
  * would make sync remove every untouched kit file from the project.
@@ -134,7 +153,7 @@ export const readKitSource = async ({
134
153
  const modules = await Promise.all(names.map((name) => readModule(kitDirectory, name)))
135
154
  assertUniquePaths(modules)
136
155
  const files = modules.flatMap((module) => module.files).sort((left, right) => (left.path < right.path ? -1 : 1))
137
- return { version, files, hooksTemplate: mergeModuleHooks(modules) }
156
+ return { version, files, hooksTemplate: mergeModuleHooks(modules), askRules: mergeModuleAskRules(modules) }
138
157
  }
139
158
 
140
159
  const readPackageJson = async (path: string) => packageJsonSchema.parse(JSON.parse(await readFile(path, 'utf8')))
package/src/sync/kit.ts CHANGED
@@ -6,4 +6,6 @@ export type KitSource = {
6
6
  files: KitFile[]
7
7
  /** The unrendered text of `kit/hooks.json`, if the kit ships hooks. */
8
8
  hooksTemplate: string | undefined
9
+ /** The `permissions.ask` rules of the kit's `ask-rules.json` files; none for a global install. */
10
+ askRules?: readonly string[]
9
11
  }
@@ -21,6 +21,8 @@ const manifestSchema = z.strictObject({
21
21
  z.string().regex(HOOK_NAME),
22
22
  z.strictObject({ base: hookDefinitionSchema, pending: sha256.optional() }),
23
23
  ),
24
+ /** The `permissions.ask` rules sync put into `settings.json`, which it removes once the kit drops them. */
25
+ askRules: z.array(z.string().min(1)).optional(),
24
26
  /** Checksum of the `settings.json.kit-new` sync wrote last. */
25
27
  settingsKitNew: sha256.optional(),
26
28
  })
@@ -0,0 +1,94 @@
1
+ import { HOOK_OUTCOME, type HookOutcome } from './plan-hooks'
2
+ import type { Settings } from './settings-hooks'
3
+
4
+ export type AskRulesInput = {
5
+ /** The `permissions.ask` rules the kit ships. */
6
+ kit: readonly string[]
7
+ /** The rules sync put into the settings before, from the lock. */
8
+ recorded: readonly string[]
9
+ settings: Settings
10
+ /** `--accept .claude/settings.json`: put back a kit rule the user removed. */
11
+ accept: boolean
12
+ }
13
+
14
+ export type AskRulesPlan = {
15
+ settings: Settings
16
+ changed: boolean
17
+ /** What the lock records next: every kit rule, a removed one included, so it is not added again. */
18
+ recorded: string[]
19
+ outcomes: { name: string; outcome: HookOutcome }[]
20
+ accepted: boolean
21
+ }
22
+
23
+ /**
24
+ * Every rule a jackherd kit has ever shipped in an `ask-rules.json`. Only these are ever removed: a lock is a file in
25
+ * the project, and a rule it lists that no kit shipped (a tampered lock) stays where it is. Add, never remove.
26
+ */
27
+ export const SHIPPED_ASK_RULES: readonly string[] = [
28
+ 'Bash(gh run rerun *)',
29
+ 'Bash(gh api *pulls/*/merge*)',
30
+ 'Bash(gh api *merges*)',
31
+ 'Bash(gh api *contents/*)',
32
+ 'Bash(gh api *dispatches*)',
33
+ 'Bash(gh api *rerun*)',
34
+ 'Bash(gh api *git/refs*)',
35
+ 'Bash(jh lane close*)',
36
+ 'Bash(jackherd lane close*)',
37
+ 'Bash(bunx jackherd* lane close*)',
38
+ 'Bash(bun */cli.ts lane close*)',
39
+ 'Bash(bunx jh lane close*)',
40
+ 'Bash(bun x jackherd* lane close*)',
41
+ ]
42
+
43
+ /** How a rule shows in the sync report, beside the hooks. */
44
+ export const askRuleName = (rule: string): string => `permissions.ask ${rule}`
45
+
46
+ const askOf = (settings: Settings): string[] => {
47
+ const permissions = settings.permissions
48
+ const ask =
49
+ typeof permissions === 'object' && permissions !== null ? (permissions as { ask?: unknown }).ask : undefined
50
+ return Array.isArray(ask) ? ask.filter((rule): rule is string => typeof rule === 'string') : []
51
+ }
52
+
53
+ /** The settings with this ask list; an emptied list, and permissions it empties, disappear. Returns a copy. */
54
+ const withAsk = (settings: Settings, ask: string[]): Settings => {
55
+ const { permissions: _old, ...rest } = settings
56
+ const { ask: _list, ...otherPermissions } = (settings.permissions ?? {}) as Record<string, unknown>
57
+ const permissions = ask.length > 0 ? { ...otherPermissions, ask } : otherPermissions
58
+ return Object.keys(permissions).length > 0 ? { ...rest, permissions } : rest
59
+ }
60
+
61
+ const outcomeOf = (rule: string, input: AskRulesInput, present: ReadonlySet<string>): HookOutcome => {
62
+ const shipped = input.kit.includes(rule)
63
+ if (!shipped)
64
+ return present.has(rule) && SHIPPED_ASK_RULES.includes(rule) ? HOOK_OUTCOME.removed : HOOK_OUTCOME.dropped
65
+ if (present.has(rule)) return HOOK_OUTCOME.unchanged
66
+ if (!input.recorded.includes(rule)) return HOOK_OUTCOME.created
67
+ return input.accept ? HOOK_OUTCOME.accepted : HOOK_OUTCOME.deselected
68
+ }
69
+
70
+ const ADDING: readonly HookOutcome[] = [HOOK_OUTCOME.created, HOOK_OUTCOME.accepted]
71
+
72
+ /**
73
+ * Merges the kit's ask rules into `permissions.ask`, the way `planHooks` merges hooks. Pure. Only a rule the lock
74
+ * records and some kit shipped (`SHIPPED_ASK_RULES`) is ever removed, and only once the kit stops shipping it; every other entry stays where it is. A kit rule
75
+ * the user removed is not put back unless `accept` is set.
76
+ */
77
+ export const planAskRules = (input: AskRulesInput): AskRulesPlan => {
78
+ const before = askOf(input.settings)
79
+ const present = new Set(before)
80
+ const rules = [...new Set([...input.kit, ...input.recorded])]
81
+ const decided = rules.map((rule) => ({ rule, outcome: outcomeOf(rule, input, present) }))
82
+ const removed = new Set(decided.filter(({ outcome }) => outcome === HOOK_OUTCOME.removed).map(({ rule }) => rule))
83
+ const added = decided.filter(({ outcome }) => ADDING.includes(outcome)).map(({ rule }) => rule)
84
+ const changed = removed.size > 0 || added.length > 0
85
+ return {
86
+ settings: changed
87
+ ? withAsk(input.settings, [...before.filter((rule) => !removed.has(rule)), ...added])
88
+ : input.settings,
89
+ changed,
90
+ recorded: [...input.kit],
91
+ outcomes: decided.map(({ rule, outcome }) => ({ name: askRuleName(rule), outcome })),
92
+ accepted: decided.some(({ outcome }) => outcome === HOOK_OUTCOME.accepted),
93
+ }
94
+ }
@@ -47,6 +47,8 @@ export type HooksPlan = {
47
47
  kitNewRecorded: string | undefined
48
48
  /** `--accept` was given for the settings but no hook was in conflict or deselected. */
49
49
  notAcceptable: boolean
50
+ /** The kit's `permissions.ask` rules the lock records next (`planAskRules`); none outside a project install. */
51
+ askRules?: string[]
50
52
  }
51
53
 
52
54
  type HookDecision = {
@@ -4,9 +4,9 @@ import {
4
4
  ADDRESS_FORM,
5
5
  APPROVAL_TEXTS,
6
6
  LANGUAGE_NAME,
7
- PLAN_APPROVAL_LABELS,
8
7
  PLAN_VOCABULARY,
9
8
  SELF_MERGE_LABEL,
9
+ STANDING_APPROVAL_TEXTS,
10
10
  } from '../vocabulary'
11
11
 
12
12
  const PLACEHOLDER = /\{\{\s*(kit\.[A-Za-z0-9_.-]*)\s*\}\}/g
@@ -47,7 +47,7 @@ const placeholderValues = (config: JackherdConfig): Map<string, string> => {
47
47
  ...prefixed('board', config.board),
48
48
  ['kit.guard.selfMergeLabel', SELF_MERGE_LABEL[config.responseLanguage]],
49
49
  ['kit.guard.selfMerge', config.guard.selfMerge.offer ? 'offered' : 'not offered'],
50
- ...prefixed('guard.planApproval', PLAN_APPROVAL_LABELS[config.responseLanguage]),
50
+ ...prefixed('guard.standingApproval', STANDING_APPROVAL_TEXTS[config.responseLanguage]),
51
51
  ...prefixed('approval', APPROVAL_TEXTS[config.responseLanguage]),
52
52
  ['kit.merge.withoutChecks', checkFreeList(config.merge.withoutChecks)],
53
53
  ...prefixed('heading', { ...header, ...sections, ...columns, phase }),
@@ -61,7 +61,7 @@ export const listPlaceholders = (template: string): string[] =>
61
61
  /**
62
62
  * Fills the kit placeholders from the project config: `{{kit.model.<role>}}`,
63
63
  * `{{kit.command.<test|typecheck|lint|format|install|dev>}}`, `{{kit.lanes.<copyCommand|lockFile|verifyInstall>}}`, `{{kit.word.<laneClosed|openSubagents>}}`,
64
- * `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.guard.planApproval.<approve|revoke>}}`, `{{kit.merge.withoutChecks}}`, `{{kit.heading.<key>}}` from the plan
64
+ * `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.guard.standingApproval.<approve|until>}}`, `{{kit.merge.withoutChecks}}`, `{{kit.heading.<key>}}` from the plan
65
65
  * vocabulary, the two languages, `{{kit.responseAddress}}`, the plan and worktree directory and the base branch. Other
66
66
  * `{{…}}` text is left alone; an unknown `kit.` placeholder throws rather than reaching a project
67
67
  * file unrendered. `overrides` win over the config: a global kit renders what each repository decides as
@@ -25,7 +25,10 @@ const hookGroupSchema = z.looseObject({
25
25
  hooks: z.array(z.record(z.string(), z.unknown())),
26
26
  })
27
27
 
28
- const settingsSchema = z.looseObject({ hooks: z.record(z.string(), z.array(hookGroupSchema)).optional() })
28
+ const settingsSchema = z.looseObject({
29
+ hooks: z.record(z.string(), z.array(hookGroupSchema)).optional(),
30
+ permissions: z.looseObject({ ask: z.array(z.string()).optional() }).optional(),
31
+ })
29
32
 
30
33
  export const parseSettings = (raw: unknown): SettingsResult => {
31
34
  const parsed = settingsSchema.safeParse(raw)
@@ -115,6 +115,7 @@ export const nextManifest = (kitVersion: string, files: FilePlan[], hooks: Hooks
115
115
  kitVersion,
116
116
  files: Object.fromEntries(files.flatMap(({ path, entry }) => (entry ? [[path, entry]] : []))),
117
117
  hooks: hooks.entries,
118
+ ...(hooks.askRules !== undefined && hooks.askRules.length > 0 ? { askRules: hooks.askRules } : {}),
118
119
  }
119
120
  return hooks.kitNewRecorded === undefined ? manifest : { ...manifest, settingsKitNew: hooks.kitNewRecorded }
120
121
  }
package/src/sync/sync.ts CHANGED
@@ -15,6 +15,7 @@ import type { KitSource } from './kit'
15
15
  import { kitNewPath, toolRootPath } from './kit-path'
16
16
  import { moveGlobalLegacyFiles, moveProjectLegacyFiles, removeEmptyLegacyDirectories } from './legacy-files'
17
17
  import { EMPTY_MANIFEST, type Manifest, parseManifest, serializeManifest } from './manifest'
18
+ import { planAskRules } from './plan-ask-rules'
18
19
  import { FILE_MODE, FILE_OUTCOME, type FileEffect, type FilePlan, planFile, type RenderedFile } from './plan-file'
19
20
  import { type HooksPlan, planHooks } from './plan-hooks'
20
21
  import {
@@ -191,11 +192,29 @@ const buildPlan = async ({
191
192
  return { files, hooks, manifest: nextManifest(kit.version, files, hooks), manifestText }
192
193
  }
193
194
 
194
- /** Merges the kit's hooks into the project's `settings.json`. */
195
+ /** The hooks plan with the kit's ask rules merged into the settings it writes, and their outcomes beside the hooks'. */
196
+ const withAskRules = (hooks: HooksPlan, kit: KitSource, manifest: Manifest, accept: AcceptRequest): HooksPlan => {
197
+ const rules = planAskRules({
198
+ kit: kit.askRules ?? [],
199
+ recorded: manifest.askRules ?? [],
200
+ settings: hooks.settings,
201
+ accept: accept.settings,
202
+ })
203
+ return {
204
+ ...hooks,
205
+ settings: rules.settings,
206
+ changed: hooks.changed || rules.changed,
207
+ outcomes: [...hooks.outcomes, ...rules.outcomes],
208
+ notAcceptable: hooks.notAcceptable && !rules.accepted,
209
+ askRules: rules.recorded,
210
+ }
211
+ }
212
+
213
+ /** Merges the kit's hooks and ask rules into the project's `settings.json`. */
195
214
  const projectHooks =
196
215
  (claudeDirectory: string, kit: KitSource, config: JackherdConfig) =>
197
- async (manifest: Manifest, accept: AcceptRequest): Promise<HooksPlan> =>
198
- planHooks({
216
+ async (manifest: Manifest, accept: AcceptRequest): Promise<HooksPlan> => {
217
+ const hooks = planHooks({
199
218
  kit: renderKitHooks(kit, (template) => renderTemplate(template, config)),
200
219
  entries: manifest.hooks,
201
220
  settings: await readSettings(claudeDirectory),
@@ -203,6 +222,8 @@ const projectHooks =
203
222
  kitNewRecorded: manifest.settingsKitNew,
204
223
  accept: accept.settings,
205
224
  })
225
+ return withAskRules(hooks, kit, manifest, accept)
226
+ }
206
227
 
207
228
  /** A global install ships no hooks and never reads the user's `settings.json`. */
208
229
  const noHooks = async (_manifest: Manifest, accept: AcceptRequest): Promise<HooksPlan> =>
@@ -2,6 +2,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { readTailLines } from '../context/session-log'
4
4
  import { isRecord } from '../shared/is-record'
5
+ import { readCached, type StatRound } from '../shared/stat-cache'
5
6
  import { claudeSignalsOf } from './claude-signals'
6
7
  import { cleanAgentName } from './text'
7
8
  import type { TaskSignal } from './types'
@@ -11,7 +12,12 @@ const SUBAGENT_LOG = /^(agent-[A-Za-z0-9]+)\.jsonl$/
11
12
  /** How much of the end of a log is read by default: sessions grow to hundreds of megabytes. */
12
13
  const SIGNAL_TAIL_BYTES = 8 * 1024 * 1024
13
14
 
15
+ /** The signals of each log by path, kept while its modification time and size stay; `used` collects the paths of one round. */
16
+ export type SignalCache = StatRound<TaskSignal[]>
17
+
14
18
  export type ReadOptions = {
19
+ /** Kept between reads, so a log that did not change is not parsed again. */
20
+ cache?: SignalCache
15
21
  /** How many bytes from the end of each log are read; a task created before that is unknown, so it gives no signal. */
16
22
  tailBytes?: number
17
23
  /** Subagent logs last written before this (epoch milliseconds) are left out. */
@@ -46,27 +52,38 @@ const isRecent = (path: string, notOlderThan: number): boolean => {
46
52
  }
47
53
  }
48
54
 
49
- const subagentSignals = (directory: string, tailBytes: number, notOlderThan: number): TaskSignal[] => {
55
+ const named = (signals: TaskSignal[], agent: string): TaskSignal[] => signals.map((signal) => ({ ...signal, agent }))
56
+
57
+ const subagentSignals = (
58
+ directory: string,
59
+ signalsOf: (path: string) => TaskSignal[],
60
+ notOlderThan: number,
61
+ ): TaskSignal[] => {
50
62
  try {
51
63
  return readdirSync(directory)
52
64
  .sort()
53
65
  .flatMap((name) => {
54
66
  const stem = SUBAGENT_LOG.exec(name)?.[1]
55
67
  const path = join(directory, name)
56
- return stem && isRecent(path, notOlderThan)
57
- ? claudeSignalsOf(linesOf(path, tailBytes), agentNameOf(directory, stem))
58
- : []
68
+ return stem && isRecent(path, notOlderThan) ? named(signalsOf(path), agentNameOf(directory, stem)) : []
59
69
  })
60
70
  } catch {
61
71
  return []
62
72
  }
63
73
  }
64
74
 
65
- /** The tasks in progress in a Claude Code session log and in every `<session>/subagents/agent-*.jsonl` beside it. */
75
+ /**
76
+ * The tasks in progress in a Claude Code session log and in every `<session>/subagents/agent-*.jsonl` beside it.
77
+ * With a `cache`, a log whose modification time and size are as at the last read is not read and parsed again.
78
+ */
66
79
  export const readClaudeSignals = (
67
80
  sessionLogPath: string,
68
- { tailBytes = SIGNAL_TAIL_BYTES, notOlderThan = 0 }: ReadOptions = {},
69
- ): TaskSignal[] => [
70
- ...claudeSignalsOf(linesOf(sessionLogPath, tailBytes), 'session'),
71
- ...subagentSignals(join(sessionLogPath.replace(/\.jsonl$/, ''), 'subagents'), tailBytes, notOlderThan),
72
- ]
81
+ { tailBytes = SIGNAL_TAIL_BYTES, notOlderThan = 0, cache }: ReadOptions = {},
82
+ ): TaskSignal[] => {
83
+ const parsed = (path: string): TaskSignal[] => claudeSignalsOf(linesOf(path, tailBytes), '')
84
+ const signalsOf = cache ? (path: string) => readCached(cache, path, parsed, `${path}\0${tailBytes}`) : parsed
85
+ return [
86
+ ...named(signalsOf(sessionLogPath), 'session'),
87
+ ...subagentSignals(join(sessionLogPath.replace(/\.jsonl$/, ''), 'subagents'), signalsOf, notOlderThan),
88
+ ]
89
+ }
@@ -47,6 +47,7 @@ export const paint = {
47
47
  red: style('31'),
48
48
  green: style('32'),
49
49
  yellow: style('33'),
50
+ blue: style('34'),
50
51
  magenta: style('35'),
51
52
  teal: style(tealCodeOf(process.env)),
52
53
  cyan: style('36'),
@@ -75,12 +76,24 @@ export const link = (url: string, text: string): string =>
75
76
  export const badge = (url: string, text: string, hint = '', tone: BadgeTone = BADGE_TONE.grey): string =>
76
77
  `${ESC}[${tone}m ${link(url, text)}${hint ? `${FAINT_ON} ${hint}${FAINT_OFF}` : ''} ${RESET}`
77
78
 
79
+ /**
80
+ * `text` as a button in square brackets, like `[ask]`: the text linked to `url` in the normal brightness, with `hint`
81
+ * after it inside the brackets, faint. A square bracket means a click does something.
82
+ */
83
+ export const bracket = (url: string, text: string, hint = ''): string =>
84
+ `[${link(url, text)}${hint ? `${FAINT_ON} ${hint}${FAINT_OFF}` : ''}]`
85
+
78
86
  /** `text` as a badge without a link, for what no click opens: the background of `tone`, a cell of padding either side. */
79
87
  export const box = (text: string, tone: BadgeTone = BADGE_TONE.grey): string => `${ESC}[${tone}m ${text} ${RESET}`
80
88
 
81
- /** A line for output that is not a terminal: styling dropped, each link written out as `text (url)`. */
82
- export const plainText = (text: string): string =>
83
- stripAnsi(text.replace(LINK, (_, url, inner) => `${stripAnsi(inner)} (${url})`))
89
+ /**
90
+ * A line for output that is not a terminal: styling dropped, each link written out as `text (url)`, except the
91
+ * ones `writesAddress` refuses, which keep the bare text.
92
+ */
93
+ export const plainText = (text: string, writesAddress: (url: string) => boolean = () => true): string =>
94
+ stripAnsi(
95
+ text.replace(LINK, (_, url, inner) => (writesAddress(url) ? `${stripAnsi(inner)} (${url})` : stripAnsi(inner))),
96
+ )
84
97
 
85
98
  /** The terminal cells a styled line takes: emoji count two. */
86
99
  export const visibleLength = (text: string): number => textWidth(stripAnsi(text))
@@ -6,6 +6,10 @@ export type AgentStatus = (typeof AGENT_STATUS)[keyof typeof AGENT_STATUS]
6
6
  export const isAgentStatus = (value: string): value is AgentStatus =>
7
7
  (Object.values(AGENT_STATUS) as string[]).includes(value)
8
8
 
9
+ /** The states of an agent that rests: it has finished or waits for the next task, and is not working or asking. */
10
+ export const isRestingAgent = (agent: string | null): boolean =>
11
+ agent === AGENT_STATUS.idle || agent === AGENT_STATUS.done
12
+
9
13
  /** A name to list the unnamed agent of one pane under. */
10
14
  export type AdoptedName = { pane: string; name: string }
11
15
 
@@ -6,6 +6,19 @@ export type SkillChange =
6
6
  | { kind: typeof SKILL_CHANGE.unchanged }
7
7
  | { kind: typeof SKILL_CHANGE.unavailable; reason: string }
8
8
 
9
+ /** How a reference file of the orchestrate skill differs between the kit at the start and the kit now. */
10
+ export const REFERENCE_CHANGE = { changed: 'changed', added: 'added', deleted: 'deleted' } as const
11
+
12
+ type ReferenceChangeKind = (typeof REFERENCE_CHANGE)[keyof typeof REFERENCE_CHANGE]
13
+
14
+ /** A reference that differs, before the diff is written or the path known. */
15
+ export type ReferenceDelta = { name: string; kind: ReferenceChangeKind }
16
+
17
+ export type ReferenceChange =
18
+ | { name: string; kind: typeof REFERENCE_CHANGE.changed; diffPath: string | null }
19
+ | { name: string; kind: typeof REFERENCE_CHANGE.added; path: string }
20
+ | { name: string; kind: typeof REFERENCE_CHANGE.deleted }
21
+
9
22
  const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/
10
23
  const SECTION_HEADING = /^## (.+)$/
11
24
  const NUMBERED_SECTION = /^(\d+)\.\s/
@@ -61,3 +74,36 @@ export const skillChangeLine = (change: SkillChange): string => {
61
74
  if (change.kind === SKILL_CHANGE.unchanged) return 'orchestrate skill unchanged'
62
75
  return `orchestrate skill diff unavailable (${change.reason}): read the skill again`
63
76
  }
77
+
78
+ const deltaOf = (name: string, before: string | undefined, after: string | undefined): ReferenceDelta[] => {
79
+ if (before === after) return []
80
+ if (before === undefined) return [{ name, kind: REFERENCE_CHANGE.added }]
81
+ return [{ name, kind: after === undefined ? REFERENCE_CHANGE.deleted : REFERENCE_CHANGE.changed }]
82
+ }
83
+
84
+ /** The references (file name → text) that changed, are new or are gone, by name; unchanged ones are left out. */
85
+ export const referenceChanges = (
86
+ before: Readonly<Record<string, string>>,
87
+ after: Readonly<Record<string, string>>,
88
+ ): ReferenceDelta[] =>
89
+ [...new Set([...Object.keys(before), ...Object.keys(after)])]
90
+ .sort()
91
+ .flatMap((name) => deltaOf(name, before[name], after[name]))
92
+
93
+ export const referenceChangeLine = (change: ReferenceChange): string => {
94
+ const head = `orchestrate reference ${change.name}`
95
+ if (change.kind === REFERENCE_CHANGE.deleted) return `${head} deleted`
96
+ if (change.kind === REFERENCE_CHANGE.added) return `${head} new · read ${change.path}`
97
+ return change.diffPath === null
98
+ ? `${head} changed · diff unavailable: read it again`
99
+ : `${head} changed · diff ${change.diffPath}`
100
+ }
101
+
102
+ /**
103
+ * The lines about the orchestrate skill after a kit update: the skill's own, then one per reference that changed. An
104
+ * unchanged skill is named only when no reference changed either, so a quiet update stays one line.
105
+ */
106
+ export const orchestrateChangeLines = (skill: SkillChange, references: readonly ReferenceChange[]): string[] => {
107
+ if (skill.kind === SKILL_CHANGE.unchanged && references.length > 0) return references.map(referenceChangeLine)
108
+ return [skillChangeLine(skill), ...references.map(referenceChangeLine)]
109
+ }
@@ -39,13 +39,23 @@ export const SELF_MERGE_LABEL: Record<PlanLanguage, string> = {
39
39
  }
40
40
 
41
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.
42
+ * The standing approval: the option that approves (`approve`) and the word between the plan and the end in its
43
+ * question (``Plan `<path>` <until> <YYYY-MM-DD HH:MM>``). The merge guard hook lifts its question for a plain merge of
44
+ * that plan's lanes only when the newest answer to such a question is exactly `approve`, so both are fixed per
45
+ * language and not a config value.
45
46
  */
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' },
47
+ export const STANDING_APPROVAL_TEXTS: Record<PlanLanguage, { approve: string; until: string }> = {
48
+ [PLAN_LANGUAGE.en]: { approve: 'Approve standing', until: 'until' },
49
+ [PLAN_LANGUAGE.de]: { approve: 'Stehend freigeben', until: 'bis' },
50
+ }
51
+
52
+ /**
53
+ * The words a lane prompt opens with, followed by the lane's name. The ask hint hook and the project's lane exception
54
+ * recognise a lane by them, so they are fixed per language and not a config value.
55
+ */
56
+ export const LANE_PROMPT_OPENING: Record<PlanLanguage, string> = {
57
+ [PLAN_LANGUAGE.en]: 'You are the lane',
58
+ [PLAN_LANGUAGE.de]: 'Du bist die Spur',
49
59
  }
50
60
 
51
61
  /** What the approval gate hook tells the lane when it denies: jackherd's own words, English in every language. */
@@ -77,6 +87,8 @@ export const APPOINTMENT_VOCABULARY: Record<
77
87
  /** Further header names that count as the status column. */
78
88
  statusAlso: readonly string[]
79
89
  open: readonly string[]
90
+ /** The lower-case word an action starts with when it is the weekly board grooming, which the herd's header counts from. */
91
+ grooming: string
80
92
  }
81
93
  > = {
82
94
  [PLAN_LANGUAGE.de]: {
@@ -84,15 +96,23 @@ export const APPOINTMENT_VOCABULARY: Record<
84
96
  columns: { when: 'Wann', action: 'Aktion', origin: 'Herkunft', status: 'Stand' },
85
97
  statusAlso: ['Status'],
86
98
  open: ['offen'],
99
+ grooming: 'board-pflege',
87
100
  },
88
101
  [PLAN_LANGUAGE.en]: {
89
102
  heading: 'Appointments',
90
103
  columns: { when: 'When', action: 'Action', origin: 'Origin', status: 'Status' },
91
104
  statusAlso: [],
92
105
  open: ['open'],
106
+ grooming: 'board grooming',
93
107
  },
94
108
  }
95
109
 
110
+ /** The heading of the board section of what the coordinator does next, a numbered list of at most three lines. */
111
+ export const NEXT_VOCABULARY: Record<PlanLanguage, { heading: string }> = {
112
+ [PLAN_LANGUAGE.de]: { heading: 'Als Nächstes' },
113
+ [PLAN_LANGUAGE.en]: { heading: 'Next' },
114
+ }
115
+
96
116
  /**
97
117
  * The board section of what waits on the user's answer (a sent preview, an open question, a report to accept): its
98
118
  * heading, the header names of its columns (since, item, origin, status, found by name in any order; the status
@@ -1,8 +1,9 @@
1
1
  import { terminalSafe } from '../plan-format/terminal-text'
2
2
  import type { WaitingItem } from '../plan-format/waiting'
3
3
  import { mainCheckout } from '../plans/git'
4
- import { sentWaitingItems } from '../plans/sent-files'
5
- import { closeAllSentRecords, closeSentRecords, readSentRecords } from '../plans/sent-files-read'
4
+ import { closeAllQuestionRecords, closeQuestionRecords } from '../plans/questions-read'
5
+ import { closeAllSentRecords, closeSentRecords } from '../plans/sent-files-read'
6
+ import { recordWaitingItems } from '../plans/waiting-records'
6
7
  import { CLI_NAME } from '../version/release'
7
8
 
8
9
  const USAGE = `usage: ${CLI_NAME} waiting [close <id> ... | close --all]`
@@ -13,14 +14,25 @@ export const waitingListing = (items: readonly WaitingItem[]): string[] =>
13
14
  ? ['nothing waits on you']
14
15
  : items.map(({ since, item, id }) => terminalSafe(`${id} · ${since} · ${item}`))
15
16
 
17
+ const CLOSE_ALL = '--all'
18
+
19
+ /** Closes the records the ids name, files and questions alike; how many it closed. */
20
+ export const closeWaiting = (root: string, ids: readonly string[], now: number): number =>
21
+ closeSentRecords(root, ids, now) + closeQuestionRecords(root, ids, now)
22
+
23
+ /** Closes every open record, files and questions alike; how many it closed. */
24
+ export const closeAllWaiting = (root: string, now: number): number =>
25
+ closeAllSentRecords(root, now) + closeAllQuestionRecords(root, now)
26
+
16
27
  /**
17
- * `jh waiting` lists the files sent to the user that no one closed (their id, time and caption);
18
- * `jh waiting close <id> ...` or `close --all` closes them once the user has answered. Exit 0, 1 on a wrong command line.
28
+ * `jh waiting` lists the files sent to the user and the questions left unanswered that no one closed (their id, time
29
+ * and text); `jh waiting close <id> ...` or `close --all` closes them once the user has answered. Exit 0, 1 on a wrong
30
+ * command line.
19
31
  */
20
32
  export const run = async (args: string[]): Promise<number> => {
21
33
  const root = mainCheckout(process.cwd())
22
34
  if (args.length === 0) {
23
- for (const line of waitingListing(sentWaitingItems(readSentRecords(root), Date.now()))) console.log(line)
35
+ for (const line of waitingListing(recordWaitingItems(root, Date.now()))) console.log(line)
24
36
  return 0
25
37
  }
26
38
  const [command, ...rest] = args
@@ -28,8 +40,11 @@ export const run = async (args: string[]): Promise<number> => {
28
40
  console.error(USAGE)
29
41
  return 1
30
42
  }
31
- const now = Date.now()
32
- const closed = rest.includes('--all') ? closeAllSentRecords(root, now) : closeSentRecords(root, rest, now)
43
+ if (rest.includes(CLOSE_ALL) && rest.length > 1) {
44
+ console.error(`${CLOSE_ALL} closes everything and takes no ids\n${USAGE}`)
45
+ return 1
46
+ }
47
+ const closed = rest[0] === CLOSE_ALL ? closeAllWaiting(root, Date.now()) : closeWaiting(root, rest, Date.now())
33
48
  console.log(`closed ${closed}`)
34
49
  return 0
35
50
  }
@@ -1,4 +1,4 @@
1
- import { twoDigits } from '../plan-format/clock'
1
+ import { DAY_MS, twoDigits } from '../plan-format/clock'
2
2
  import { firstTableWithHeader, sectionLines } from '../plan-format/markdown'
3
3
  import { isRecord } from '../shared/is-record'
4
4
  import { APPOINTMENT_VOCABULARY, type PlanLanguage } from '../vocabulary'
@@ -31,7 +31,6 @@ type DateParts = { year: number | null; month: number; day: number; hours: numbe
31
31
  const DAY_FIRST = /^(?:\p{L}{2,3}\.?,?\s+)?(\d{1,2})\.(\d{1,2})\.(\d{4})?\s+(\d{1,2}):(\d{2})$/u
32
32
  const ISO = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{1,2}):(\d{2})$/
33
33
  const KEY_TIME_LENGTH = 'YYYY-MM-DDThh:mm'.length
34
- const DAY_MS = 86_400_000
35
34
  const KEEP_GONE_MS = 7 * DAY_MS
36
35
 
37
36
  const APPOINTMENT_KINDS: readonly string[] = Object.values(APPOINTMENT_EVENT)