jackherd 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/INSTALL.md +4 -2
- package/{NOTICE → NOTICE.txt} +1 -1
- package/README.md +94 -1033
- package/assets/icons/bark-dark.svg +1 -1
- package/assets/icons/bark.svg +1 -1
- package/assets/icons/bowl-dark.svg +1 -1
- package/assets/icons/bowl.svg +1 -1
- package/assets/icons/decide-dark.svg +1 -1
- package/assets/icons/decide.svg +1 -1
- package/assets/icons/ear-dark.svg +1 -1
- package/assets/icons/ear.svg +1 -1
- package/assets/icons/lanes-dark.svg +1 -1
- package/assets/icons/lanes.svg +1 -1
- package/assets/icons/plan-dark.svg +1 -1
- package/assets/icons/plan.svg +1 -1
- package/dist/{chunk-OBUWTQ6Z.js → chunk-M6DDZEW4.js} +24 -10
- package/dist/config.d.ts +11 -5
- package/dist/config.js +53 -21
- package/dist/plan-format.d.ts +73 -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 +342 -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/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 +155 -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 +3 -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/legacy-names.ts +8 -0
- package/src/config/read-config.ts +12 -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-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/command.ts +16 -1
- 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 +4 -2
- package/src/update/update.ts +33 -9
- package/src/vocabulary/index.ts +47 -11
- package/src/watch/appointments.ts +205 -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/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
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `jh update`: installs the newest release with the package manager that holds the kit globally,
|
|
3
|
-
* then syncs `~/.claude` (and the project, when the working directory is inside one
|
|
4
|
-
* runs only when the user or a session that asked the user starts it; no hook runs it.
|
|
3
|
+
* then syncs `~/.claude` (and the project, when the working directory is inside one with its own install) with
|
|
4
|
+
* the new code. It runs only when the user or a session that asked the user starts it; no hook runs it.
|
|
5
5
|
*
|
|
6
6
|
* jh update
|
|
7
7
|
*/
|
|
8
8
|
import { existsSync, realpathSync } from 'node:fs'
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
10
|
import { join } from 'node:path'
|
|
11
|
+
import { hasProjectInstall } from '../config/legacy-names'
|
|
11
12
|
import { findProjectRoot } from '../config/location'
|
|
12
13
|
import { defaultUpdateCheck, lockedVersion } from '../sync/if-changed'
|
|
13
14
|
import { locateKit } from '../sync/kit-source'
|
|
@@ -50,6 +51,7 @@ const adaptersFor = async (): Promise<UpdateAdapters> => {
|
|
|
50
51
|
jhOnPath: () => Bun.which(CLI_NAME) !== null,
|
|
51
52
|
home,
|
|
52
53
|
projectRoot: findProjectRoot(process.cwd(), home, existsSync),
|
|
54
|
+
hasProjectKit: (root) => hasProjectInstall(root, existsSync),
|
|
53
55
|
log: (line) => console.log(line),
|
|
54
56
|
}
|
|
55
57
|
}
|
package/src/update/update.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CLI_NAME, isNewerVersion, isRelease } from '../version/release'
|
|
1
|
+
import { CLI_NAME, isNewerVersion, isRelease, PACKAGE_NAME } from '../version/release'
|
|
2
2
|
import { installCommand, LEGACY_PACKAGE_NAME, type PackageManager, removeCommand } from './package-manager'
|
|
3
3
|
|
|
4
4
|
export const UPDATE_EXIT = { ok: 0, error: 1 } as const
|
|
@@ -21,8 +21,13 @@ export type UpdateAdapters = {
|
|
|
21
21
|
/** Whether a `jh` command is on the path; false when only the `@crossdelta/agent-kit` shell carries this jackherd. Asked before and after the install. */
|
|
22
22
|
jhOnPath: () => boolean
|
|
23
23
|
home: string
|
|
24
|
-
/** The project the working directory belongs to, or null. */
|
|
24
|
+
/** The project the working directory belongs to (the nearest directory with a project config), or null. */
|
|
25
25
|
projectRoot: string | null
|
|
26
|
+
/**
|
|
27
|
+
* True when `root` holds a project install (a lock of its own); a config alone is settings for the global kit.
|
|
28
|
+
* The lock's existence counts, not its validity: a broken lock is synced, and the sync names the error.
|
|
29
|
+
*/
|
|
30
|
+
hasProjectKit: (root: string) => boolean
|
|
26
31
|
log: (line: string) => void
|
|
27
32
|
}
|
|
28
33
|
|
|
@@ -34,10 +39,20 @@ const failed = (log: UpdateAdapters['log'], step: string, command: string): numb
|
|
|
34
39
|
return UPDATE_EXIT.error
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
const notInstalledLine = (projectRoot: string): string =>
|
|
43
|
+
`${projectRoot} has a ${PACKAGE_NAME} config but no project install, so it was not synced; \`${CLI_NAME} init\` installs the kit there.`
|
|
44
|
+
|
|
45
|
+
/** The project `jh update` syncs: only one with an install of its own, since only `jh init` may create one. */
|
|
46
|
+
const installedProject = (adapters: UpdateAdapters): string | null =>
|
|
47
|
+
adapters.projectRoot && adapters.hasProjectKit(adapters.projectRoot) ? adapters.projectRoot : null
|
|
48
|
+
|
|
37
49
|
/** The syncs that bring `~/.claude` and the project to the new files, in the order they run. */
|
|
38
|
-
const syncSteps = async (
|
|
50
|
+
const syncSteps = async (
|
|
51
|
+
adapters: UpdateAdapters,
|
|
52
|
+
project: string | null,
|
|
53
|
+
): Promise<{ argv: string[]; cwd: string }[]> => [
|
|
39
54
|
...((await adapters.hasGlobalKit()) ? [{ argv: [...adapters.kitCli, 'sync', '--global'], cwd: adapters.home }] : []),
|
|
40
|
-
...(
|
|
55
|
+
...(project ? [{ argv: [...adapters.kitCli, 'sync'], cwd: project }] : []),
|
|
41
56
|
]
|
|
42
57
|
|
|
43
58
|
/**
|
|
@@ -59,6 +74,14 @@ const installAndVerify = async (
|
|
|
59
74
|
return UPDATE_EXIT.error
|
|
60
75
|
}
|
|
61
76
|
|
|
77
|
+
/** Runs the syncs with the freshly installed code; the first that fails stops the rest and names its command. */
|
|
78
|
+
const syncAfterInstall = async (adapters: UpdateAdapters, project: string | null): Promise<number> => {
|
|
79
|
+
for (const { argv, cwd } of await syncSteps(adapters, project)) {
|
|
80
|
+
if ((await adapters.run(argv, cwd)) !== 0) return failed(adapters.log, 'the sync', argv.join(' '))
|
|
81
|
+
}
|
|
82
|
+
return UPDATE_EXIT.ok
|
|
83
|
+
}
|
|
84
|
+
|
|
62
85
|
const doneLine = (adapters: UpdateAdapters, manager: PackageManager, latest: string, jhWasOnPath: boolean): string => {
|
|
63
86
|
if (jhWasOnPath) return `${CLI_NAME} updated ${adapters.installedVersion} → ${latest}.`
|
|
64
87
|
if (adapters.jhOnPath()) return `${CLI_NAME} ${latest} installed globally.`
|
|
@@ -88,10 +111,11 @@ const updateToLatest = async (adapters: UpdateAdapters, manager: PackageManager)
|
|
|
88
111
|
}
|
|
89
112
|
const installed = await installAndVerify(adapters, manager, latest)
|
|
90
113
|
if (installed !== UPDATE_EXIT.ok) return installed
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
114
|
+
const project = installedProject(adapters)
|
|
115
|
+
const synced = await syncAfterInstall(adapters, project)
|
|
116
|
+
if (synced !== UPDATE_EXIT.ok) return synced
|
|
94
117
|
log(doneLine(adapters, manager, latest, jhWasOnPath))
|
|
118
|
+
if (adapters.projectRoot && !project) log(notInstalledLine(adapters.projectRoot))
|
|
95
119
|
return UPDATE_EXIT.ok
|
|
96
120
|
}
|
|
97
121
|
|
|
@@ -99,8 +123,8 @@ const legacyPackageLine = (manager: PackageManager): string =>
|
|
|
99
123
|
`${LEGACY_PACKAGE_NAME} is still installed globally; ${CLI_NAME} replaces it. Remove it with: ${removeCommand(manager, LEGACY_PACKAGE_NAME).join(' ')}`
|
|
100
124
|
|
|
101
125
|
/**
|
|
102
|
-
* Installs the newest release with the manager that holds the kit globally, then syncs `~/.claude` and
|
|
103
|
-
* project with the freshly installed code; a global package from before the rename is named, not removed.
|
|
126
|
+
* Installs the newest release with the manager that holds the kit globally, then syncs `~/.claude` and an
|
|
127
|
+
* installed project with the freshly installed code; a global package from before the rename is named, not removed.
|
|
104
128
|
*/
|
|
105
129
|
export const runUpdate = async (adapters: UpdateAdapters): Promise<number> => {
|
|
106
130
|
const { manager, log } = adapters
|
package/src/vocabulary/index.ts
CHANGED
|
@@ -38,22 +38,54 @@ export const SELF_MERGE_LABEL: Record<PlanLanguage, string> = {
|
|
|
38
38
|
[PLAN_LANGUAGE.de]: 'Freigeben, und nach grünen Checks selbst mergen',
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The options of the question that approves a plan for all its lanes (``Plan `<path>` ``) and of the one that revokes
|
|
43
|
+
* that approval. The merge guard hook lifts its question for a plain merge of such a lane only when the newest answer
|
|
44
|
+
* to a question naming the plan is exactly `approve`, so the labels are fixed per language and not a config value.
|
|
45
|
+
*/
|
|
46
|
+
export const PLAN_APPROVAL_LABELS: Record<PlanLanguage, { approve: string; revoke: string }> = {
|
|
47
|
+
[PLAN_LANGUAGE.en]: { approve: 'Approve the plan', revoke: 'Revoke the plan approval' },
|
|
48
|
+
[PLAN_LANGUAGE.de]: { approve: 'Plan freigeben', revoke: 'Plan-Freigabe widerrufen' },
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What the approval gate hook tells the lane when it denies: jackherd's own words, English in every language. */
|
|
52
|
+
const APPROVAL_REASONS = {
|
|
53
|
+
pending: 'Approval pending with the coordinator; retry the same call once it is approved.',
|
|
54
|
+
denied: 'The user denied this call through the coordinator; do not retry it.',
|
|
55
|
+
}
|
|
56
|
+
|
|
41
57
|
/**
|
|
42
58
|
* The words of an approval: the two option labels of the coordinator's question about a lane's call, which the
|
|
43
|
-
* approval gate hook matches exactly, and what the hook tells the lane when it
|
|
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) and the lower-case words a status cell of a still open
|
|
70
|
+
* appointment starts with.
|
|
71
|
+
*/
|
|
72
|
+
export const APPOINTMENT_VOCABULARY: Record<
|
|
73
|
+
PlanLanguage,
|
|
74
|
+
{
|
|
75
|
+
heading: string
|
|
76
|
+
columns: { when: string; action: string; origin: string; status: string }
|
|
77
|
+
open: readonly string[]
|
|
78
|
+
}
|
|
79
|
+
> = {
|
|
52
80
|
[PLAN_LANGUAGE.de]: {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
81
|
+
heading: 'Termine',
|
|
82
|
+
columns: { when: 'Wann', action: 'Aktion', origin: 'Herkunft', status: 'Stand' },
|
|
83
|
+
open: ['offen'],
|
|
84
|
+
},
|
|
85
|
+
[PLAN_LANGUAGE.en]: {
|
|
86
|
+
heading: 'Appointments',
|
|
87
|
+
columns: { when: 'When', action: 'Action', origin: 'Origin', status: 'Status' },
|
|
88
|
+
open: ['open'],
|
|
57
89
|
},
|
|
58
90
|
}
|
|
59
91
|
|
|
@@ -98,6 +130,8 @@ export type PlanVocabulary = {
|
|
|
98
130
|
noReviewGate: string
|
|
99
131
|
/** Marks a lane as closed when its name cell contains it. */
|
|
100
132
|
laneClosed: string
|
|
133
|
+
/** Opens the line a lane's hand-over file must hold as `<word>: 0`, so the coordinator hands over only after a wave. */
|
|
134
|
+
openSubagents: string
|
|
101
135
|
/** Words a starts-after cell begins with when the lane may start at once. */
|
|
102
136
|
startsNow: readonly string[]
|
|
103
137
|
/** Opens the parenthesis by which a board row's phases cell records a user's side order in prose. */
|
|
@@ -133,6 +167,7 @@ export const PLAN_VOCABULARY: Record<PlanLanguage, PlanVocabulary> = {
|
|
|
133
167
|
phase: 'Phase',
|
|
134
168
|
noReviewGate: 'Kein Review-Gate',
|
|
135
169
|
laneClosed: 'abgeräumt',
|
|
170
|
+
openSubagents: 'offene Sub-Agenten',
|
|
136
171
|
sideOrderMarker: 'dazu (User',
|
|
137
172
|
startsNow: ['sofort'],
|
|
138
173
|
phaseConnectors: ['danach', 'und', 'dann', 'sowie'],
|
|
@@ -166,6 +201,7 @@ export const PLAN_VOCABULARY: Record<PlanLanguage, PlanVocabulary> = {
|
|
|
166
201
|
phase: 'Phase',
|
|
167
202
|
noReviewGate: 'No review gate',
|
|
168
203
|
laneClosed: 'cleaned up',
|
|
204
|
+
openSubagents: 'open subagents',
|
|
169
205
|
sideOrderMarker: 'also (User',
|
|
170
206
|
startsNow: ['now', 'immediately'],
|
|
171
207
|
phaseConnectors: ['then', 'and', 'after'],
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { firstTableWithHeader, sectionLines } from '../plan-format/markdown'
|
|
2
|
+
import { APPOINTMENT_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
3
|
+
import { isRecord } from './json-guards'
|
|
4
|
+
|
|
5
|
+
export const APPOINTMENT_EVENT = { due: 'due', unreadable: 'appointment unreadable' } as const
|
|
6
|
+
|
|
7
|
+
/** One row of the board's appointment table whose time could be read; `key` is what the watcher remembers it by. */
|
|
8
|
+
export type Appointment = { when: Date; whenText: string; action: string; open: boolean; key: string }
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An open row whose time cell could not be read, as written; or a table that lacks a column the watcher reads, with
|
|
12
|
+
* its header as `whenText` and the missing names as `action`.
|
|
13
|
+
*/
|
|
14
|
+
export type UnreadableAppointment = { whenText: string; action: string }
|
|
15
|
+
|
|
16
|
+
export type AppointmentTable = { appointments: Appointment[]; unreadable: UnreadableAppointment[] }
|
|
17
|
+
|
|
18
|
+
/** `when` is the time cell as the board wrote it. */
|
|
19
|
+
export type AppointmentEvent = {
|
|
20
|
+
kind: (typeof APPOINTMENT_EVENT)[keyof typeof APPOINTMENT_EVENT]
|
|
21
|
+
when: string
|
|
22
|
+
action: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The keys of the appointments reported as due and of the unreadable rows reported, each once. */
|
|
26
|
+
export type AppointmentState = { reported: string[]; unreadable: string[] }
|
|
27
|
+
|
|
28
|
+
type DateParts = { year: number | null; month: number; day: number; hours: number; minutes: number }
|
|
29
|
+
|
|
30
|
+
const DAY_FIRST = /^(?:\p{L}{2,3}\.?,?\s+)?(\d{1,2})\.(\d{1,2})\.(\d{4})?\s+(\d{1,2}):(\d{2})$/u
|
|
31
|
+
const ISO = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{1,2}):(\d{2})$/
|
|
32
|
+
const KEY_TIME_LENGTH = 'YYYY-MM-DDThh:mm'.length
|
|
33
|
+
const DAY_MS = 86_400_000
|
|
34
|
+
const KEEP_GONE_MS = 7 * DAY_MS
|
|
35
|
+
|
|
36
|
+
const APPOINTMENT_KINDS: readonly string[] = Object.values(APPOINTMENT_EVENT)
|
|
37
|
+
|
|
38
|
+
export const isAppointmentEvent = (event: { kind: string }): event is AppointmentEvent =>
|
|
39
|
+
APPOINTMENT_KINDS.includes(event.kind)
|
|
40
|
+
|
|
41
|
+
export const emptyAppointmentState = (): AppointmentState => ({ reported: [], unreadable: [] })
|
|
42
|
+
|
|
43
|
+
const isStringList = (value: unknown): boolean =>
|
|
44
|
+
Array.isArray(value) && value.every((entry) => typeof entry === 'string')
|
|
45
|
+
|
|
46
|
+
export const isAppointmentState = (value: unknown): value is AppointmentState =>
|
|
47
|
+
isRecord(value) && isStringList(value.reported) && isStringList(value.unreadable)
|
|
48
|
+
|
|
49
|
+
const datePartsOf = (text: string): DateParts | null => {
|
|
50
|
+
const iso = ISO.exec(text)
|
|
51
|
+
if (iso) {
|
|
52
|
+
const [year, month, day, hours, minutes] = iso.slice(1).map(Number)
|
|
53
|
+
return { year, month, day, hours, minutes }
|
|
54
|
+
}
|
|
55
|
+
const dayFirst = DAY_FIRST.exec(text)
|
|
56
|
+
if (!dayFirst) return null
|
|
57
|
+
const [day, month, year, hours, minutes] = dayFirst.slice(1).map((part) => (part === undefined ? null : Number(part)))
|
|
58
|
+
return { year, month: month ?? 0, day: day ?? 0, hours: hours ?? 0, minutes: minutes ?? 0 }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The local time of the parts in `year`, or null when the parts name no such time (a 31st of February, hour 24). */
|
|
62
|
+
const localTime = ({ month, day, hours, minutes }: DateParts, year: number): Date | null => {
|
|
63
|
+
const date = new Date(year, month - 1, day, hours, minutes)
|
|
64
|
+
const exact =
|
|
65
|
+
date.getFullYear() === year &&
|
|
66
|
+
date.getMonth() === month - 1 &&
|
|
67
|
+
date.getDate() === day &&
|
|
68
|
+
date.getHours() === hours &&
|
|
69
|
+
date.getMinutes() === minutes
|
|
70
|
+
return exact ? date : null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Of the previous, the clock's and the next year, the one that puts the date nearest to `now`. */
|
|
74
|
+
const nearestToClock = (parts: DateParts, now: Date): Date | null => {
|
|
75
|
+
const year = now.getFullYear()
|
|
76
|
+
const candidates = [year - 1, year, year + 1].flatMap((candidate) => localTime(parts, candidate) ?? [])
|
|
77
|
+
const distance = (date: Date) => Math.abs(date.getTime() - now.getTime())
|
|
78
|
+
return candidates.reduce<Date | null>((best, date) => (best && distance(best) <= distance(date) ? best : date), null)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The local time of a board's time cell: `<day name> DD.MM. hh:mm` (the day name is ignored), `DD.MM. hh:mm`,
|
|
83
|
+
* `DD.MM.YYYY hh:mm`, `YYYY-MM-DD hh:mm`. A year written in the cell wins; without one, the year that puts the date
|
|
84
|
+
* nearest to `now`, so a January appointment written in December lands in the next year. Null for anything else,
|
|
85
|
+
* a time of day included: an appointment has a minute.
|
|
86
|
+
*/
|
|
87
|
+
export const parseAppointmentTime = (text: string, now: Date): Date | null => {
|
|
88
|
+
const parts = datePartsOf(text.trim())
|
|
89
|
+
if (!parts) return null
|
|
90
|
+
return parts.year === null ? nearestToClock(parts, now) : localTime(parts, parts.year)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const twoDigits = (value: number): string => String(value).padStart(2, '0')
|
|
94
|
+
|
|
95
|
+
/** The local time to the minute and the action: a moved appointment gets a new key, a reworded day name does not. */
|
|
96
|
+
export const appointmentKey = (when: Date, action: string): string =>
|
|
97
|
+
`${when.getFullYear()}-${twoDigits(when.getMonth() + 1)}-${twoDigits(when.getDate())}T${twoDigits(when.getHours())}:${twoDigits(when.getMinutes())} ${action}`
|
|
98
|
+
|
|
99
|
+
const unreadableKey = ({ whenText, action }: UnreadableAppointment): string => `${whenText} ${action}`
|
|
100
|
+
|
|
101
|
+
const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim()
|
|
102
|
+
|
|
103
|
+
/** An empty status counts as open: a row nobody closed is not dropped in silence. */
|
|
104
|
+
const isOpen = (status: string, language: PlanLanguage): boolean => {
|
|
105
|
+
const lower = status.toLowerCase()
|
|
106
|
+
return lower === '' || APPOINTMENT_VOCABULARY[language].open.some((word) => lower.startsWith(word))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The row as an appointment, as an unreadable row when it is open and its time cannot be read, else as nothing. */
|
|
110
|
+
const tableOfRow = (
|
|
111
|
+
cells: string[],
|
|
112
|
+
[when, action, status]: number[],
|
|
113
|
+
language: PlanLanguage,
|
|
114
|
+
now: Date,
|
|
115
|
+
): AppointmentTable => {
|
|
116
|
+
const whenText = cells[when] ?? ''
|
|
117
|
+
const actionText = oneLine(cells[action] ?? '')
|
|
118
|
+
const open = isOpen(cells[status] ?? '', language)
|
|
119
|
+
const time = parseAppointmentTime(whenText, now)
|
|
120
|
+
if (time)
|
|
121
|
+
return {
|
|
122
|
+
appointments: [{ when: time, whenText, action: actionText, open, key: appointmentKey(time, actionText) }],
|
|
123
|
+
unreadable: [],
|
|
124
|
+
}
|
|
125
|
+
return { appointments: [], unreadable: open ? [{ whenText, action: actionText }] : [] }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The rows of the board's appointment section in `language` (`## Termine`, `## Appointments`), its columns found by
|
|
130
|
+
* their header names in any order; the origin column is not read. A row whose time cannot be read is not an
|
|
131
|
+
* appointment; while it is open it is listed as unreadable. A table without the when, action or status column is
|
|
132
|
+
* listed as unreadable as a whole, with its header and the missing names; a section without a table has no rows.
|
|
133
|
+
*/
|
|
134
|
+
export const readAppointmentTable = (board: string, language: PlanLanguage, now: Date): AppointmentTable => {
|
|
135
|
+
const { heading, columns } = APPOINTMENT_VOCABULARY[language]
|
|
136
|
+
const { header, rows } = firstTableWithHeader(sectionLines(board, heading))
|
|
137
|
+
if (header.length === 0) return { appointments: [], unreadable: [] }
|
|
138
|
+
const needed = [columns.when, columns.action, columns.status]
|
|
139
|
+
const missing = needed.filter((name) => !header.includes(name))
|
|
140
|
+
if (missing.length > 0)
|
|
141
|
+
return {
|
|
142
|
+
appointments: [],
|
|
143
|
+
unreadable: [{ whenText: header.join(' | '), action: `missing columns: ${missing.join(', ')}` }],
|
|
144
|
+
}
|
|
145
|
+
const positions = needed.map((name) => header.indexOf(name))
|
|
146
|
+
const tables = rows.map((cells) => tableOfRow(cells, positions, language, now))
|
|
147
|
+
return {
|
|
148
|
+
appointments: tables.flatMap(({ appointments }) => appointments),
|
|
149
|
+
unreadable: tables.flatMap(({ unreadable }) => unreadable),
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const dueAppointments = (state: AppointmentState, table: AppointmentTable, now: number): Appointment[] =>
|
|
154
|
+
table.appointments
|
|
155
|
+
.filter(({ open, when, key }) => open && when.getTime() <= now && !state.reported.includes(key))
|
|
156
|
+
.sort((left, right) => left.when.getTime() - right.when.getTime())
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The open appointments due at `now` that were not reported yet, by time, then the open rows whose time could not be
|
|
160
|
+
* read and were not reported yet. Due means from its time on, however long ago: one that fell due while the watcher
|
|
161
|
+
* was down fires at the next run. No table read, no event.
|
|
162
|
+
*/
|
|
163
|
+
export const appointmentEvents = (
|
|
164
|
+
state: AppointmentState,
|
|
165
|
+
table: AppointmentTable | null,
|
|
166
|
+
now: number,
|
|
167
|
+
): AppointmentEvent[] => {
|
|
168
|
+
if (!table) return []
|
|
169
|
+
const due = dueAppointments(state, table, now).map(({ whenText, action }) => ({
|
|
170
|
+
kind: APPOINTMENT_EVENT.due,
|
|
171
|
+
when: whenText,
|
|
172
|
+
action,
|
|
173
|
+
}))
|
|
174
|
+
const unreadable = table.unreadable
|
|
175
|
+
.filter((row) => !state.unreadable.includes(unreadableKey(row)))
|
|
176
|
+
.map(({ whenText, action }) => ({ kind: APPOINTMENT_EVENT.unreadable, when: whenText, action }))
|
|
177
|
+
return [...due, ...unreadable]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Whether a key's time lies within the last week; a key that does not start with a time does not. */
|
|
181
|
+
const isRecent = (key: string, now: number): boolean => {
|
|
182
|
+
const when = parseAppointmentTime(key.slice(0, KEY_TIME_LENGTH), new Date(now))
|
|
183
|
+
return when !== null && now - when.getTime() <= KEEP_GONE_MS
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The state after `appointmentEvents` were reported: the due keys added. A reported key stays while its row is in
|
|
188
|
+
* the table, open or done, and for a week after its time once the row is gone, so a board read halfway through an
|
|
189
|
+
* edit does not report it twice. Unreadable rows are remembered while they stay unreadable. No table read keeps the
|
|
190
|
+
* state as it was.
|
|
191
|
+
*/
|
|
192
|
+
export const recordAppointments = (
|
|
193
|
+
state: AppointmentState,
|
|
194
|
+
table: AppointmentTable | null,
|
|
195
|
+
now: number,
|
|
196
|
+
): AppointmentState => {
|
|
197
|
+
if (!table) return state
|
|
198
|
+
const present = new Set(table.appointments.map(({ key }) => key))
|
|
199
|
+
const kept = state.reported.filter((key) => present.has(key) || isRecent(key, now))
|
|
200
|
+
const due = dueAppointments(state, table, now).map(({ key }) => key)
|
|
201
|
+
return {
|
|
202
|
+
reported: [...new Set([...kept, ...due])],
|
|
203
|
+
unreadable: table.unreadable.map(unreadableKey),
|
|
204
|
+
}
|
|
205
|
+
}
|