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.
- package/CHANGELOG.md +47 -0
- package/INSTALL.md +2 -0
- package/{NOTICE → NOTICE.txt} +1 -1
- package/README.md +86 -1037
- package/assets/branding/README.md +5 -7
- package/assets/branding/jackherd-logo-outline.svg +1 -0
- package/assets/branding/outline.test.ts +30 -0
- package/assets/icons/bark.svg +1 -1
- package/assets/icons/bowl.svg +1 -1
- package/assets/icons/decide.svg +1 -1
- package/assets/icons/ear.svg +1 -1
- package/assets/icons/lanes.svg +1 -1
- package/assets/icons/plan.svg +1 -1
- package/dist/{chunk-OBUWTQ6Z.js → chunk-OU2KALXP.js} +26 -10
- package/dist/config.d.ts +11 -5
- package/dist/config.js +53 -21
- package/dist/plan-format.d.ts +75 -7
- package/dist/plan-format.js +180 -100
- package/docs/workflow-dark.svg +1 -1
- package/docs/workflow.svg +1 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/herdr-panes.ts +2 -12
- package/kit/hooks/hooks/approval-gate/index.ts +10 -8
- package/kit/hooks/hooks/approval-gate/run.ts +21 -12
- package/kit/hooks/hooks/ask-hint/decision.ts +3 -3
- package/kit/hooks/hooks/git-guard/decision.ts +189 -15
- package/kit/hooks/hooks/git-guard/index.ts +13 -3
- package/kit/hooks/hooks/git-guard/push-target.ts +85 -0
- package/kit/hooks/hooks/git-guard/repository.ts +63 -0
- package/kit/hooks/hooks/merge-guard/check-free.ts +1 -13
- package/kit/hooks/hooks/merge-guard/decision.ts +344 -102
- package/kit/hooks/hooks/merge-guard/index.ts +6 -4
- package/kit/hooks/hooks/merge-guard/plain-path.ts +15 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +23 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-settings.ts +142 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +196 -0
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +168 -0
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +40 -0
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +194 -0
- package/kit/hooks/hooks/merge-guard/pull-request.ts +67 -21
- package/kit/hooks/hooks/merge-guard/run.ts +118 -29
- package/kit/hooks/hooks/merge-guard/settings.ts +8 -8
- package/kit/hooks/hooks/merge-guard/worker.ts +3 -3
- package/kit/hooks/hooks/merge-guard/xargs-program.ts +182 -0
- package/kit/hooks/hooks/shared/base-branch.ts +23 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +93 -0
- package/kit/hooks/hooks/shared/command-text-runners.ts +71 -0
- package/kit/hooks/hooks/shared/command.ts +14 -2
- package/kit/hooks/hooks/shared/commit.ts +2 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +4 -1
- package/kit/hooks/hooks/shared/json.ts +10 -0
- package/kit/hooks/hooks/shared/lane.ts +57 -0
- package/kit/hooks/hooks/shared/once.ts +8 -0
- package/kit/hooks/hooks/shared/own-pane.ts +31 -0
- package/kit/hooks/hooks/shared/pane-agent.ts +24 -0
- package/kit/hooks/hooks/shared/plan-open-phases.ts +114 -0
- package/kit/hooks/hooks/shared/program-position.ts +148 -5
- package/kit/hooks/hooks/shared/shell-aliases.ts +258 -0
- package/kit/hooks/hooks/shared/shell-snapshots.ts +68 -0
- package/kit/hooks/hooks/shared/shell.ts +167 -20
- package/kit/hooks/hooks.json +11 -2
- package/kit/hooks/statusline.sh +29 -16
- package/kit/review/skills/diagnose/SKILL.md +1 -1
- package/kit/review/skills/review/SKILL.md +2 -0
- package/kit/rules/rules/kit/testing.md +2 -5
- package/kit/rules/rules/kit/writing-for-agents.md +3 -1
- package/kit/workflow/agents/spec-review.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +3 -3
- package/kit/workflow/skills/grill/SKILL.md +1 -1
- package/kit/workflow/skills/implement/SKILL.md +10 -11
- package/kit/workflow/skills/orchestrate/SKILL.md +156 -117
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +17 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +27 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -0
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +23 -0
- package/kit/workflow/skills/plan/SKILL.md +5 -2
- package/kit/workflow/skills/plan/references/template-English.md +6 -0
- package/kit/workflow/skills/plan/references/template-German.md +6 -0
- package/package.json +4 -2
- package/recipes/README.md +21 -0
- package/recipes/github-issues.md +23 -0
- package/recipes/github-projects.md +22 -0
- package/recipes/jira-cli.md +26 -0
- package/recipes/jira-rest.md +24 -0
- package/recipes/linear.md +25 -0
- package/recipes/sentry.md +18 -0
- package/recipes/sources/jira.ts +51 -0
- package/recipes/sources/linear.ts +19 -0
- package/recipes/sources/sentry.ts +12 -0
- package/recipes/sources.md +80 -0
- package/recipes/status-page.md +19 -0
- package/schemas/jackherd.schema.json +42 -18
- package/src/check/command.ts +2 -2
- package/src/check/parallel-report.ts +19 -9
- package/src/cli.ts +1 -0
- package/src/config/context-shares.ts +130 -0
- package/src/config/read-config.ts +16 -4
- package/src/config/schema.ts +75 -20
- package/src/context/auto-compact.ts +68 -0
- package/src/context/command.ts +68 -17
- package/src/context/coordinator-name.ts +27 -9
- package/src/context/index.ts +0 -1
- package/src/context/level.ts +18 -11
- package/src/context/report.ts +6 -3
- package/src/plan-format/dependencies.ts +144 -32
- package/src/plan-format/index.ts +6 -0
- package/src/plan-format/lanes.ts +6 -0
- package/src/plan-format/progress.ts +19 -8
- package/src/plans/command.ts +14 -5
- package/src/plans/current-phase.ts +63 -7
- package/src/plans/current-task.ts +51 -0
- package/src/plans/ensure-panes.ts +25 -8
- package/src/plans/frame.ts +99 -0
- package/src/plans/git.ts +8 -0
- package/src/plans/herd-memory.ts +107 -0
- package/src/plans/lane-plans.ts +5 -3
- package/src/plans/lane-standing.ts +5 -4
- package/src/plans/lane-summary.ts +222 -0
- package/src/plans/layout-phase.ts +148 -0
- package/src/plans/layout-summary.ts +140 -0
- package/src/plans/layout.ts +164 -420
- package/src/plans/live.ts +11 -6
- package/src/plans/plan-panes.ts +15 -0
- package/src/plans/planned.ts +9 -42
- package/src/plans/selection.ts +2 -6
- package/src/plans/settings.ts +2 -7
- package/src/plans/snapshot.ts +38 -39
- package/src/plans/symbols.ts +2 -2
- package/src/plans/texts.ts +32 -31
- package/src/plans/view.ts +0 -1
- package/src/plans/worked-phases.ts +2 -2
- package/src/settled/board-row.ts +133 -0
- package/src/settled/check.ts +129 -0
- package/src/settled/command.ts +65 -0
- package/src/settled/evidence-version.ts +14 -0
- package/src/settled/options.ts +69 -0
- package/src/settled/send.ts +27 -0
- package/src/settled/settle.ts +87 -0
- package/src/settled/topics.ts +56 -0
- package/src/sync/render.ts +13 -4
- package/src/sync/sync.ts +25 -5
- package/src/terminal/herdr.ts +46 -10
- package/src/terminal/none.ts +1 -0
- package/src/terminal/types.ts +15 -2
- package/src/update/command.ts +2 -0
- package/src/update/config-shares.ts +30 -0
- package/src/update/update.ts +12 -1
- package/src/vocabulary/index.ts +51 -11
- package/src/watch/appointments.ts +206 -0
- package/src/watch/command.ts +62 -19
- package/src/watch/lane-report.ts +11 -3
- package/src/watch/texts.ts +35 -59
- package/src/watch/watch-events.ts +21 -28
- package/assets/icons/bark-dark.svg +0 -1
- package/assets/icons/bowl-dark.svg +0 -1
- package/assets/icons/decide-dark.svg +0 -1
- package/assets/icons/ear-dark.svg +0 -1
- package/assets/icons/lanes-dark.svg +0 -1
- package/assets/icons/plan-dark.svg +0 -1
- 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<
|
|
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
|
-
|
|
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
|
|
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
|
|
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> =>
|
package/src/terminal/herdr.ts
CHANGED
|
@@ -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 = {
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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)
|
package/src/terminal/none.ts
CHANGED
package/src/terminal/types.ts
CHANGED
|
@@ -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
|
|
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 = {
|
|
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
|
}
|
package/src/update/command.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/update/update.ts
CHANGED
|
@@ -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)
|
|
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
|
|
package/src/vocabulary/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
}
|