jackherd 1.15.2 → 1.17.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 +34 -0
- package/INSTALL.md +2 -1
- package/README.md +26 -22
- package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
- package/dist/config.d.ts +33 -1
- package/dist/config.js +57 -5
- package/dist/plan-format.d.ts +18 -6
- package/dist/plan-format.js +8 -1
- package/docs/plan-contract.md +1 -0
- package/herdr-plugin/herdr-plugin.toml +4 -1
- package/herdr-plugin/restore-plans.ts +60 -26
- package/herdr-plugin/resume.ts +18 -0
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +11 -0
- package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
- package/kit/hooks/hooks/approval-gate/index.ts +36 -7
- package/kit/hooks/hooks/approval-gate/run.ts +72 -16
- package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
- package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
- package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
- package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
- package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
- package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
- package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
- package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +438 -35
- package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
- package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
- package/kit/hooks/hooks/merge-guard/run.ts +110 -28
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
- package/kit/hooks/hooks/plain-question/form.ts +89 -0
- package/kit/hooks/hooks/plain-question/index.ts +44 -4
- package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
- package/kit/hooks/hooks/plain-question/run.ts +14 -7
- package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
- package/kit/hooks/hooks.json +11 -2
- package/kit/review/agents/skill-review.md +3 -2
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/rules/rules/kit/code-style.md +6 -6
- package/kit/rules/rules/kit/writing-for-agents.md +3 -0
- package/kit/workflow/skills/follow/SKILL.md +18 -11
- package/kit/workflow/skills/implement/SKILL.md +1 -1
- package/kit/workflow/skills/orchestrate/SKILL.md +124 -300
- package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
- package/kit/workflow/skills/orchestrate/references/events.md +46 -1
- package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
- package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +9 -2
- package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
- package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
- package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
- package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
- package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
- package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
- package/kit/workflow/skills/plan/SKILL.md +3 -2
- package/package.json +3 -1
- package/recipes/README.md +4 -2
- package/recipes/google-calendar.md +30 -0
- package/recipes/jira-cli.md +1 -1
- package/recipes/sources/google-calendar-today.ts +38 -0
- package/recipes/sources/google-calendar.ts +368 -0
- package/recipes/sources/jira.ts +10 -2
- package/recipes/sources/linear.ts +9 -3
- package/recipes/sources/sentry.ts +8 -2
- package/recipes/sources.md +17 -3
- package/schemas/jackherd.schema.json +67 -0
- package/schemas/sources.schema.json +9 -0
- package/src/approval/checked.ts +60 -0
- package/src/approval/command.ts +16 -0
- package/src/check/command.ts +51 -6
- package/src/check/lint-pointers.ts +79 -0
- package/src/check/lint-shaped.ts +196 -0
- package/src/check/plan-structure-check.ts +116 -4
- package/src/check/report.ts +9 -3
- package/src/check/schema.ts +37 -0
- package/src/check/settings.ts +4 -0
- package/src/check/skill-budget-defaults.ts +81 -23
- package/src/check/skill-budgets.ts +58 -10
- package/src/cli.ts +2 -0
- package/src/config/personal-sources.ts +46 -10
- package/src/config/repository-identity.ts +40 -0
- package/src/config/schema.ts +53 -1
- package/src/context/command.ts +33 -2
- package/src/context/options.ts +33 -8
- package/src/context/usage-logs.ts +53 -0
- package/src/context/usage.ts +110 -0
- package/src/lane/base-command.ts +35 -0
- package/src/lane/base.ts +43 -0
- package/src/lane/close-check.ts +49 -15
- package/src/lane/close-command.ts +71 -11
- package/src/lane/close-options.ts +29 -8
- package/src/lane/close-survey.ts +13 -1
- package/src/lane/close.ts +51 -12
- package/src/lane/command.ts +21 -3
- package/src/lane/send-command.ts +32 -0
- package/src/lane/send-herdr.ts +77 -0
- package/src/lane/send-options.ts +23 -0
- package/src/lane/send.ts +90 -0
- package/src/lane/skill-hint.ts +27 -0
- package/src/lane/space-command.ts +30 -0
- package/src/lane/space.ts +49 -0
- package/src/lane/start-lane-hint.ts +18 -0
- package/src/plan-contract/base-check-git.ts +33 -0
- package/src/plan-contract/base-check.ts +17 -0
- package/src/plan-contract/command.ts +25 -4
- package/src/plan-format/plan-structure.ts +17 -5
- package/src/plans/ask-coordinator.ts +23 -13
- package/src/plans/board-rows.ts +9 -4
- package/src/plans/click-hint.ts +4 -0
- package/src/plans/command.ts +6 -5
- package/src/plans/ensure-panes.ts +14 -3
- package/src/plans/git.ts +14 -0
- package/src/plans/github.ts +19 -10
- package/src/plans/layout.ts +191 -25
- package/src/plans/live.ts +57 -14
- package/src/plans/plan-panes.ts +21 -4
- package/src/plans/row-actions.ts +17 -10
- package/src/plans/selection.ts +29 -0
- package/src/plans/settings.ts +4 -0
- package/src/plans/snapshot.ts +8 -3
- package/src/plans/source-rows.ts +35 -2
- package/src/resume/command.ts +213 -0
- package/src/resume/decision.ts +102 -0
- package/src/resume/keeper.ts +65 -0
- package/src/resume/location.ts +22 -0
- package/src/resume/log.ts +27 -0
- package/src/resume/ports.ts +102 -0
- package/src/resume/record.ts +116 -0
- package/src/resume/targets.ts +84 -0
- package/src/resume/texts.ts +27 -0
- package/src/secrets/command.ts +15 -5
- package/src/sync/herdr-plugin.ts +2 -11
- package/src/sync/plan-ask-rules.ts +11 -0
- package/src/sync/render.ts +3 -1
- package/src/vocabulary/index.ts +13 -0
- package/src/waiting/command.ts +13 -4
- package/src/watch/command.ts +177 -78
- package/src/watch/config-reload.ts +119 -0
- package/src/watch/lane-model.ts +11 -1
- package/src/watch/lane-progress.ts +70 -8
- package/src/watch/lane-report.ts +68 -12
- package/src/watch/main-checkout.ts +5 -1
- package/src/watch/merge-duties.ts +33 -0
- package/src/watch/merge-overlap-read.ts +69 -0
- package/src/watch/plan-views.ts +16 -4
- package/src/watch/reference-paths.ts +46 -5
- package/src/watch/report-questions.ts +5 -2
- package/src/watch/single-report.ts +3 -2
- package/src/watch/source-dry-run-command.ts +22 -4
- package/src/watch/source-dry-run.ts +1 -1
- package/src/watch/source-reason.ts +58 -0
- package/src/watch/source-reload.ts +21 -17
- package/src/watch/source-run.ts +24 -7
- package/src/watch/sources.ts +9 -2
- package/src/watch/texts.ts +15 -4
- package/src/watch/waiting-line.ts +4 -3
package/src/plans/selection.ts
CHANGED
|
@@ -27,6 +27,8 @@ export type Selection = {
|
|
|
27
27
|
laneFolds: ReadonlyMap<string, LaneFold>
|
|
28
28
|
/** The top blocks the user opened; every other one is folded, at the start of the herd all of them. */
|
|
29
29
|
openBlocks?: ReadonlySet<TopBlockId>
|
|
30
|
+
/** The sources whose block of entries the user folded; absent or without the source while it is open, as at the start. */
|
|
31
|
+
foldedEntryBlocks?: ReadonlySet<string>
|
|
30
32
|
/** True once the user folded the lanes block, which is open at the start; absent or false while it is open. */
|
|
31
33
|
lanesFolded?: boolean
|
|
32
34
|
}
|
|
@@ -110,6 +112,26 @@ export const topBlockHeadKey = (block: TopBlockId): string => `${TOP_HEAD_KEY_PR
|
|
|
110
112
|
export const topBlockOfKey = (key: string): TopBlockId | null =>
|
|
111
113
|
Object.values(TOP_BLOCK).find((block) => topBlockHeadKey(block) === key) ?? null
|
|
112
114
|
|
|
115
|
+
const ENTRY_HEAD_KEY_PREFIX = `${TOP_HEAD_KEY_PREFIX}entries>`
|
|
116
|
+
|
|
117
|
+
/** The key of the head of the block of entries of a source, which the arrows reach like every other clickable row. */
|
|
118
|
+
export const entryBlockHeadKey = (source: string): string => `${ENTRY_HEAD_KEY_PREFIX}${source}`
|
|
119
|
+
|
|
120
|
+
/** The source whose block of entries a head key names, null for any other key. */
|
|
121
|
+
export const entryBlockOfKey = (key: string): string | null =>
|
|
122
|
+
key.startsWith(ENTRY_HEAD_KEY_PREFIX) ? key.slice(ENTRY_HEAD_KEY_PREFIX.length) : null
|
|
123
|
+
|
|
124
|
+
/** Whether the block of entries of `source` is drawn folded: it is open at the start, until the user folds it. */
|
|
125
|
+
export const isEntryBlockFolded = ({ foldedEntryBlocks }: Selection, source: string): boolean =>
|
|
126
|
+
foldedEntryBlocks?.has(source) === true
|
|
127
|
+
|
|
128
|
+
const withEntryBlockOpen = (selection: Selection, source: string, open: boolean): Selection => {
|
|
129
|
+
const folded = new Set(selection.foldedEntryBlocks)
|
|
130
|
+
if (open) folded.delete(source)
|
|
131
|
+
else folded.add(source)
|
|
132
|
+
return { ...selection, foldedEntryBlocks: folded }
|
|
133
|
+
}
|
|
134
|
+
|
|
113
135
|
/** Whether the lanes block is drawn open: it is at the start of the herd, until the user folds it. */
|
|
114
136
|
export const isLanesOpen = ({ lanesFolded }: Selection): boolean => !lanesFolded
|
|
115
137
|
|
|
@@ -274,6 +296,13 @@ const foldedHead = (selection: Selection, key: string, next: (open: boolean) =>
|
|
|
274
296
|
const lane = laneOfKey(key)
|
|
275
297
|
if (lane !== null) return { ...foldLane(selection, lane, next), selected: key }
|
|
276
298
|
if (key === LANES_KEY) return { ...withLanesOpen(selection, next(isLanesOpen(selection))), selected: key }
|
|
299
|
+
const entrySource = entryBlockOfKey(key)
|
|
300
|
+
if (entrySource !== null) {
|
|
301
|
+
return {
|
|
302
|
+
...withEntryBlockOpen(selection, entrySource, next(!isEntryBlockFolded(selection, entrySource))),
|
|
303
|
+
selected: key,
|
|
304
|
+
}
|
|
305
|
+
}
|
|
277
306
|
const block = topBlockOfKey(key)
|
|
278
307
|
if (block === null) return null
|
|
279
308
|
return { ...withBlockOpen(selection, block, next(!isBlockFolded(selection, block))), selected: key }
|
package/src/plans/settings.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { type JackherdConfig, parseJackherdConfig } from '../config'
|
|
|
4
4
|
import { PROJECT_CONFIG_PATH } from '../config/location'
|
|
5
5
|
import { readConfig } from '../config/read-config'
|
|
6
6
|
import type { ToneName } from '../config/tones'
|
|
7
|
+
import { DEFAULT_MIN_FREE_GB } from '../lane/space'
|
|
7
8
|
import type { TicketSource } from '../watch/plan-badges'
|
|
8
9
|
import type { SourceShow } from '../watch/sources'
|
|
9
10
|
import { type WatchedSource, watchedSourceOf } from '../watch/watched-source'
|
|
@@ -15,6 +16,8 @@ export type PlansSettings = Pick<
|
|
|
15
16
|
'planDirectory' | 'board' | 'worktreeDirectory' | 'baseBranch' | 'planLanguage' | 'responseLanguage' | 'skillPrefix'
|
|
16
17
|
> & {
|
|
17
18
|
adapter: JackherdConfig['terminal']['adapter']
|
|
19
|
+
/** Free gigabytes a new lane needs on the disk (`lanes.minFreeGb`). */
|
|
20
|
+
minFreeGb: number
|
|
18
21
|
fetchIntervalMinutes: number
|
|
19
22
|
/** Whether the running task of a folded lane shimmers; `NO_COLOR` keeps it still whatever this says. */
|
|
20
23
|
shimmer: boolean
|
|
@@ -58,6 +61,7 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
|
|
|
58
61
|
responseLanguage: config.responseLanguage,
|
|
59
62
|
skillPrefix: config.skillPrefix,
|
|
60
63
|
adapter: config.terminal.adapter,
|
|
64
|
+
minFreeGb: config.lanes.minFreeGb ?? DEFAULT_MIN_FREE_GB,
|
|
61
65
|
fetchIntervalMinutes: config.plans.fetchIntervalMinutes,
|
|
62
66
|
shimmer: config.plans.shimmer,
|
|
63
67
|
linkKinds: config.plans.linkKinds,
|
package/src/plans/snapshot.ts
CHANGED
|
@@ -635,15 +635,20 @@ const decoratedViews = (
|
|
|
635
635
|
),
|
|
636
636
|
)
|
|
637
637
|
|
|
638
|
-
/** Each source with the tone and the
|
|
638
|
+
/** Each source with the tone, the label and the `show` its config gives it. */
|
|
639
639
|
const withSourceStyle = (
|
|
640
640
|
statuses: readonly SourceStatus[],
|
|
641
641
|
{ herdSources, sourceLabels }: HerdSourceSettings,
|
|
642
642
|
): HerdSource[] =>
|
|
643
643
|
statuses.map((status) => {
|
|
644
644
|
const label = sourceLabels[status.name]
|
|
645
|
-
const
|
|
646
|
-
return {
|
|
645
|
+
const config = herdSources.find(({ name }) => name === status.name)
|
|
646
|
+
return {
|
|
647
|
+
...status,
|
|
648
|
+
tone: config?.tone ?? null,
|
|
649
|
+
...(label === undefined ? {} : { label }),
|
|
650
|
+
...(config?.show === undefined ? {} : { show: config.show }),
|
|
651
|
+
}
|
|
647
652
|
})
|
|
648
653
|
|
|
649
654
|
/**
|
package/src/plans/source-rows.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { TONE, type ToneName } from '../config/tones'
|
|
|
2
2
|
import { padVisible } from '../terminal/text'
|
|
3
3
|
import { textWidth } from '../terminal/width'
|
|
4
4
|
import type { EntryStatus, SourceStatus } from '../watch/source-statuses'
|
|
5
|
+
import { SOURCE_SHOW, type SourceShow } from '../watch/sources'
|
|
5
6
|
import { ASK_KIND } from './ask-target'
|
|
6
7
|
import type { AskedCheck } from './source-asked'
|
|
7
8
|
import { chevronOf } from './symbols'
|
|
@@ -9,9 +10,10 @@ import type { BlockItem } from './waiting-click'
|
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* A source's status with the tone its config names for the mark while its report is new (null where it names none) and
|
|
12
|
-
* the label its config gives it, absent where it gives none
|
|
13
|
+
* the label its config gives it, absent where it gives none, and `show` where it asks for its entries in a block of
|
|
14
|
+
* their own.
|
|
13
15
|
*/
|
|
14
|
-
export type HerdSource = SourceStatus & { tone?: ToneName | null; label?: string }
|
|
16
|
+
export type HerdSource = SourceStatus & { tone?: ToneName | null; label?: string; show?: SourceShow }
|
|
15
17
|
|
|
16
18
|
/** One source in the line of the folded sources block: what it says and the tone it wears, none for dim. */
|
|
17
19
|
export type SourceSummary = { text: string; tone?: ToneName }
|
|
@@ -179,6 +181,37 @@ const entryItems = (source: HerdSource, view: SourceView, now: number, newTone:
|
|
|
179
181
|
]
|
|
180
182
|
}
|
|
181
183
|
|
|
184
|
+
/** Whether the source asks for its entries in a block of their own. */
|
|
185
|
+
export const showsEntries = (source: HerdSource): boolean => source.show === SOURCE_SHOW.entries
|
|
186
|
+
|
|
187
|
+
/** The sources that have a block of their own: those that ask for it and have entries; an empty run draws none. */
|
|
188
|
+
export const entryBlockSources = (sources: readonly HerdSource[]): HerdSource[] =>
|
|
189
|
+
sources.filter((source) => showsEntries(source) && source.entries.length > 0)
|
|
190
|
+
|
|
191
|
+
/** The source as the `Sources` block draws it: a source with a block of its own keeps its status, not its entries. */
|
|
192
|
+
export const statusOnly = (source: HerdSource): HerdSource =>
|
|
193
|
+
showsEntries(source) ? { ...source, entries: [], latest: null } : source
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The rows of the block of a source that shows its entries: count, time and title as in the opened source, in the
|
|
197
|
+
* order the state holds them, in the tone of the source, none of them asking or expanding; a row with an address
|
|
198
|
+
* is linked. Only the first `limit` entries become rows, the columns measured over those.
|
|
199
|
+
*/
|
|
200
|
+
export const entryBlockItems = (source: HerdSource, now: number, limit: number): BlockItem[] => {
|
|
201
|
+
const shown = source.entries.slice(0, limit)
|
|
202
|
+
const columns = columnsOf(shown, now)
|
|
203
|
+
return shown.map((entry) => ({
|
|
204
|
+
key: entryKey(source.name, entry),
|
|
205
|
+
mark: ENTRY_MARK,
|
|
206
|
+
lead: entryLeadOf(entry, now, columns),
|
|
207
|
+
text: entry.title,
|
|
208
|
+
url: entry.url,
|
|
209
|
+
ask: null,
|
|
210
|
+
expands: false,
|
|
211
|
+
...(source.tone && { tone: source.tone }),
|
|
212
|
+
}))
|
|
213
|
+
}
|
|
214
|
+
|
|
182
215
|
const DEFAULT_VIEW: SourceView = { expanded: new Set(), asked: () => false }
|
|
183
216
|
|
|
184
217
|
/**
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { type SendPorts, sendLanePrompt } from '../lane/send'
|
|
3
|
+
import { errorText } from '../plans/error-text'
|
|
4
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
5
|
+
import { type ResumeDecision, resumeDecision } from './decision'
|
|
6
|
+
import { type ResumeRecord, resumedRecord } from './record'
|
|
7
|
+
import { type AgentPane, type Streak, steadyTarget, TARGET_MISS, type TargetMissed, targetFor } from './targets'
|
|
8
|
+
import { resumeTexts } from './texts'
|
|
9
|
+
|
|
10
|
+
type Limits = { attempts: number; intervalMs: number }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* After a crash the coordinator's session was back after ~8 s and the lanes' after ~3 s (`RESTORE_LIMITS`), but a slow
|
|
14
|
+
* machine resuming many sessions at once takes longer, and a message lost here is a lost restart: three minutes each
|
|
15
|
+
* for the coordinator and for the lanes, polled every two seconds.
|
|
16
|
+
*/
|
|
17
|
+
export const RESUME_LIMITS = {
|
|
18
|
+
coordinator: { attempts: 90, intervalMs: 2_000 },
|
|
19
|
+
lanes: { attempts: 90, intervalMs: 2_000 },
|
|
20
|
+
} as const satisfies Record<string, Limits>
|
|
21
|
+
|
|
22
|
+
/** A record file and what it holds; null for a damaged one. */
|
|
23
|
+
export type RecordEntry = { path: string; record: ResumeRecord | null }
|
|
24
|
+
|
|
25
|
+
/** What `jh resume` reads and does; `run` wires them to the files, the watcher lock and herdr. */
|
|
26
|
+
export type ResumePorts = {
|
|
27
|
+
now: () => number
|
|
28
|
+
records: () => RecordEntry[]
|
|
29
|
+
/** The record at `path` once more, right before it is marked. */
|
|
30
|
+
reread: (path: string) => ResumeRecord | null
|
|
31
|
+
writeRecord: (path: string, record: ResumeRecord) => void
|
|
32
|
+
watcherRunning: (root: string) => boolean
|
|
33
|
+
boardExists: (board: string) => boolean
|
|
34
|
+
settingsOf: (root: string) => { language: PlanLanguage; worktreeDirectory: string }
|
|
35
|
+
/** herdr's agent panes; throws with herdr's error when it does not answer. */
|
|
36
|
+
agentPanes: () => AgentPane[]
|
|
37
|
+
send: SendPorts
|
|
38
|
+
sleep: (ms: number) => Promise<void>
|
|
39
|
+
log: (root: string, message: string) => void
|
|
40
|
+
notify: (title: string, body: string) => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Resume = Extract<ResumeDecision, { kind: 'resume' }>
|
|
44
|
+
|
|
45
|
+
type Recipient = { name: string; directory: string }
|
|
46
|
+
|
|
47
|
+
type Search = { found: Map<Recipient, string>; missed: Map<Recipient, TargetMissed> }
|
|
48
|
+
|
|
49
|
+
const NOTE_TITLE = 'jackherd resume'
|
|
50
|
+
|
|
51
|
+
const missLine = (who: string, miss: TargetMissed): string => {
|
|
52
|
+
if (miss.reason === TARGET_MISS.ambiguous)
|
|
53
|
+
return `${who}: no target, ${miss.panes.length} unnamed agent panes (${miss.panes.join(', ')}); nothing sent`
|
|
54
|
+
if (miss.reason === TARGET_MISS.elsewhere)
|
|
55
|
+
return `${who}: no target, the agent of that name runs in another directory (${miss.panes.join(', ')}); nothing sent`
|
|
56
|
+
return `${who}: no target, the agent did not come back in time; nothing sent`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const planLine = ({ coordinator, interrupted, blocked }: Resume): string =>
|
|
60
|
+
`resume: coordinator ${coordinator}, interrupted ${interrupted.join(', ') || '-'}, blocked ${blocked.join(', ') || '-'}`
|
|
61
|
+
|
|
62
|
+
/** herdr's panes of one poll; none, with a log line, when herdr does not answer. */
|
|
63
|
+
const pollPanes = (ports: ResumePorts, root: string): AgentPane[] => {
|
|
64
|
+
try {
|
|
65
|
+
return ports.agentPanes()
|
|
66
|
+
} catch (error) {
|
|
67
|
+
ports.log(root, `herdr did not answer: ${errorText(error)}`)
|
|
68
|
+
return []
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Polls herdr until every recipient has a steady target (`steadyTarget`) or the window is used up; `onFound` runs for
|
|
74
|
+
* each as soon as it has one. Returns the targets found and, for the others, the last miss.
|
|
75
|
+
*/
|
|
76
|
+
const awaitTargets = async (
|
|
77
|
+
ports: ResumePorts,
|
|
78
|
+
root: string,
|
|
79
|
+
recipients: readonly Recipient[],
|
|
80
|
+
limits: Limits,
|
|
81
|
+
onFound: (target: string) => Promise<void>,
|
|
82
|
+
): Promise<Search> => {
|
|
83
|
+
const search: Search = { found: new Map(), missed: new Map() }
|
|
84
|
+
const streaks = new Map<Recipient, Streak>()
|
|
85
|
+
for (let attempt = 1; search.found.size < recipients.length; attempt++) {
|
|
86
|
+
const panes = pollPanes(ports, root)
|
|
87
|
+
for (const recipient of recipients.filter((each) => !search.found.has(each))) {
|
|
88
|
+
const steady = steadyTarget(targetFor(panes, recipient.name, recipient.directory), streaks.get(recipient) ?? null)
|
|
89
|
+
streaks.set(recipient, steady.streak)
|
|
90
|
+
if (steady.target.kind === 'none') search.missed.set(recipient, steady.target)
|
|
91
|
+
else {
|
|
92
|
+
search.found.set(recipient, steady.target.target)
|
|
93
|
+
search.missed.delete(recipient)
|
|
94
|
+
await onFound(steady.target.target)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (search.found.size === recipients.length || attempt >= limits.attempts) break
|
|
98
|
+
await ports.sleep(limits.intervalMs)
|
|
99
|
+
}
|
|
100
|
+
return search
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const reportMisses = (ports: ResumePorts, root: string, missed: Search['missed'], who: (name: string) => string) => {
|
|
104
|
+
for (const [recipient, miss] of missed) {
|
|
105
|
+
const line = missLine(who(recipient.name), miss)
|
|
106
|
+
ports.log(root, line)
|
|
107
|
+
if (miss.reason !== TARGET_MISS.missing) ports.notify(NOTE_TITLE, `${root} ${line}`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Marks the record as resumed, after reading it once more: a record another start marked or a new watcher replaced
|
|
113
|
+
* in between is left alone. False when it was left alone or could not be written, so nothing is sent.
|
|
114
|
+
*/
|
|
115
|
+
const marked = (ports: ResumePorts, path: string, record: ResumeRecord): boolean => {
|
|
116
|
+
const current = ports.reread(path)
|
|
117
|
+
if (current === null || current.resumedAt !== null || current.startedAt !== record.startedAt) {
|
|
118
|
+
ports.log(record.root, 'record changed since it was read; nothing sent')
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
ports.writeRecord(path, resumedRecord(current, ports.now()))
|
|
123
|
+
return true
|
|
124
|
+
} catch (error) {
|
|
125
|
+
ports.log(record.root, `cannot mark the record (${errorText(error)}); nothing sent`)
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const sendTo = async (ports: ResumePorts, root: string, target: string, text: string) => {
|
|
131
|
+
ports.log(root, (await sendLanePrompt(ports.send, target, text)).line)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The coordinator's target once it is back in the main checkout; null, logged, when it is not within the window. */
|
|
135
|
+
const awaitCoordinator = async (ports: ResumePorts, decision: Resume): Promise<string | null> => {
|
|
136
|
+
const recipient = { name: decision.coordinator, directory: decision.root }
|
|
137
|
+
const search = await awaitTargets(ports, decision.root, [recipient], RESUME_LIMITS.coordinator, async () => {})
|
|
138
|
+
reportMisses(ports, decision.root, search.missed, (name) => `no coordinator ${name}`)
|
|
139
|
+
return search.found.get(recipient) ?? null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const resumeLanes = async (ports: ResumePorts, decision: Resume, worktreeDirectory: string, text: string) => {
|
|
143
|
+
const recipients = decision.interrupted.map((name) => ({
|
|
144
|
+
name,
|
|
145
|
+
directory: join(decision.root, worktreeDirectory, name),
|
|
146
|
+
}))
|
|
147
|
+
const search = await awaitTargets(ports, decision.root, recipients, RESUME_LIMITS.lanes, (target) =>
|
|
148
|
+
sendTo(ports, decision.root, target, text),
|
|
149
|
+
)
|
|
150
|
+
reportMisses(ports, decision.root, search.missed, (name) => name)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Wakes one repository: waits for its coordinator first, since a coordinator that never comes back here runs in
|
|
155
|
+
* another herdr session and its record stays for that one; marks the record; then sends the coordinator its line and
|
|
156
|
+
* each interrupted lane its text.
|
|
157
|
+
*/
|
|
158
|
+
const resumeRepository = async (ports: ResumePorts, path: string, record: ResumeRecord, decision: Resume) => {
|
|
159
|
+
const { language, worktreeDirectory } = ports.settingsOf(decision.root)
|
|
160
|
+
const texts = resumeTexts(language)
|
|
161
|
+
ports.log(decision.root, planLine(decision))
|
|
162
|
+
if (decision.unsafe.length > 0) ports.log(decision.root, `left out, no lane names: ${decision.unsafe.join(', ')}`)
|
|
163
|
+
const coordinator = await awaitCoordinator(ports, decision)
|
|
164
|
+
if (coordinator === null || !marked(ports, path, record)) return
|
|
165
|
+
const line = texts.coordinatorLine(decision.board, decision.interrupted, decision.blocked)
|
|
166
|
+
await sendTo(ports, decision.root, coordinator, line)
|
|
167
|
+
await resumeLanes(ports, decision, worktreeDirectory, texts.laneText)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const resumeEntry = async (ports: ResumePorts, path: string, record: ResumeRecord) => {
|
|
171
|
+
const decision = resumeDecision({
|
|
172
|
+
record,
|
|
173
|
+
now: ports.now(),
|
|
174
|
+
watcherRunning: ports.watcherRunning(record.root),
|
|
175
|
+
boardExists: ports.boardExists(record.board),
|
|
176
|
+
})
|
|
177
|
+
if (decision.kind === 'none') ports.log(record.root, decision.reason)
|
|
178
|
+
else await resumeRepository(ports, path, record, decision)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Every repository with a record, one after the other; a failure in one is logged and the next goes on. Always 0. */
|
|
182
|
+
export const resumeAll = async (ports: ResumePorts): Promise<number> => {
|
|
183
|
+
try {
|
|
184
|
+
const entries = ports.records()
|
|
185
|
+
if (entries.length === 0) ports.log('-', 'no resume record')
|
|
186
|
+
for (const { path, record } of entries) {
|
|
187
|
+
try {
|
|
188
|
+
if (record === null) ports.log(path, 'damaged record, read as none')
|
|
189
|
+
else await resumeEntry(ports, path, record)
|
|
190
|
+
} catch (error) {
|
|
191
|
+
ports.log(record?.root ?? path, `failed: ${errorText(error)}`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} catch (error) {
|
|
195
|
+
ports.log('-', `cannot read the records: ${errorText(error)}`)
|
|
196
|
+
}
|
|
197
|
+
return 0
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* `jh resume`: herdr's second `[[startup]]` hook. After a crash of the watcher (its record still marked running, no
|
|
202
|
+
* watcher holding the lock now) sends the coordinator one fixed `/orchestrate … ` line and each lane that worked one
|
|
203
|
+
* fixed text, at most once per record. Every step goes to `~/.jackherd/logs/resume.log`; the exit code is always 0,
|
|
204
|
+
* a hook leaves nothing behind but its log.
|
|
205
|
+
*/
|
|
206
|
+
export const run = async (args: string[]): Promise<number> => {
|
|
207
|
+
if (args.length > 0) {
|
|
208
|
+
console.log('Usage: jh resume (run by the herdr plugin when herdr starts; takes no arguments)')
|
|
209
|
+
return args.includes('--help') || args.includes('-h') ? 0 : 1
|
|
210
|
+
}
|
|
211
|
+
const { systemResumePorts } = await import('./ports')
|
|
212
|
+
return resumeAll(systemResumePorts())
|
|
213
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { isAbsolute, relative, sep } from 'node:path'
|
|
2
|
+
import { AGENT_STATUS, type AgentStatus } from '../terminal/types'
|
|
3
|
+
import { SAFE_LANE_NAME } from '../watch/lane-name'
|
|
4
|
+
import type { ResumeRecord } from './record'
|
|
5
|
+
|
|
6
|
+
/** Why `jh resume` leaves a repository alone. */
|
|
7
|
+
export const RESUME_SKIP = {
|
|
8
|
+
noRecord: 'no-record',
|
|
9
|
+
stopped: 'stopped',
|
|
10
|
+
resumed: 'resumed',
|
|
11
|
+
stale: 'stale',
|
|
12
|
+
watcherRunning: 'watcher-running',
|
|
13
|
+
boardMissing: 'board-missing',
|
|
14
|
+
unsafeBoard: 'unsafe-board',
|
|
15
|
+
unsafeCoordinator: 'unsafe-coordinator',
|
|
16
|
+
} as const
|
|
17
|
+
|
|
18
|
+
export type ResumeSkip = (typeof RESUME_SKIP)[keyof typeof RESUME_SKIP]
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How long after the watcher last saved its record a herdr start still resumes: a crash in the evening
|
|
22
|
+
* and a herdr start the next morning resume, a start days later does not, since the interrupted commands then meet
|
|
23
|
+
* code that has moved on. Measured from `savedAt`.
|
|
24
|
+
*/
|
|
25
|
+
export const RESUME_MAX_AGE_MS = 12 * 60 * 60 * 1000
|
|
26
|
+
|
|
27
|
+
export type ResumeInput = {
|
|
28
|
+
record: ResumeRecord | null
|
|
29
|
+
/** The time of this herdr start, against which the record's age is measured. */
|
|
30
|
+
now: number
|
|
31
|
+
/** Whether a watcher holds the repository's lock now: then herdr was handed over live, nothing crashed. */
|
|
32
|
+
watcherRunning: boolean
|
|
33
|
+
boardExists: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type ResumeDecision =
|
|
37
|
+
| { kind: 'none'; reason: ResumeSkip }
|
|
38
|
+
| {
|
|
39
|
+
kind: 'resume'
|
|
40
|
+
root: string
|
|
41
|
+
/** The board relative to the main checkout, one path without blanks, as the coordinator line carries it. */
|
|
42
|
+
board: string
|
|
43
|
+
coordinator: string
|
|
44
|
+
/** The lanes that worked when the watcher went down; each gets the resume text. */
|
|
45
|
+
interrupted: string[]
|
|
46
|
+
/** The lanes that waited for an answer; only named to the coordinator. */
|
|
47
|
+
blocked: string[]
|
|
48
|
+
/** Lane names that are no single file name: never sent to, only logged. */
|
|
49
|
+
unsafe: string[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Blanks, control and format characters (bidi marks, zero-width ones): a board path with them is not one plain word. */
|
|
53
|
+
const NOT_ONE_WORD = /[\s\p{Cc}\p{Cf}]/u
|
|
54
|
+
|
|
55
|
+
/** The board relative to `root`, null when it is not a file inside it or would not stay one word on the line. */
|
|
56
|
+
const boardInside = (root: string, board: string): string | null => {
|
|
57
|
+
const path = relative(root, board)
|
|
58
|
+
const outside = path === '' || path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)
|
|
59
|
+
return outside || NOT_ONE_WORD.test(path) ? null : path
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A single file name that herdr cannot read as an option: `herdr agent prompt <target> …` takes it as an argument. */
|
|
63
|
+
const isSafeName = (name: string): boolean => SAFE_LANE_NAME.test(name) && !name.startsWith('-')
|
|
64
|
+
|
|
65
|
+
const lanesWith = (lanes: Record<string, string>, status: AgentStatus): string[] =>
|
|
66
|
+
Object.entries(lanes).flatMap(([name, laneStatus]) => (laneStatus === status && isSafeName(name) ? [name] : []))
|
|
67
|
+
|
|
68
|
+
const skip = (reason: ResumeSkip): ResumeDecision => ({ kind: 'none', reason })
|
|
69
|
+
|
|
70
|
+
/** The reason the record itself gives against a restart, checked before what the system says now. */
|
|
71
|
+
const recordSkip = (record: ResumeRecord, now: number): ResumeSkip | null => {
|
|
72
|
+
if (record.stoppedAt !== null) return RESUME_SKIP.stopped
|
|
73
|
+
if (record.resumedAt !== null) return RESUME_SKIP.resumed
|
|
74
|
+
if (now - record.savedAt > RESUME_MAX_AGE_MS) return RESUME_SKIP.stale
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether a herdr start follows a crash of the watcher and whom to wake: a record still marked running, never resumed,
|
|
80
|
+
* saved within `RESUME_MAX_AGE_MS`, with no watcher running now and its board in place. Only lanes that worked get a
|
|
81
|
+
* message; names that are no single file name, a coordinator name of that kind and a board outside the main checkout
|
|
82
|
+
* never reach a send.
|
|
83
|
+
*/
|
|
84
|
+
export const resumeDecision = ({ record, now, watcherRunning, boardExists }: ResumeInput): ResumeDecision => {
|
|
85
|
+
if (record === null) return skip(RESUME_SKIP.noRecord)
|
|
86
|
+
const reason = recordSkip(record, now)
|
|
87
|
+
if (reason !== null) return skip(reason)
|
|
88
|
+
if (watcherRunning) return skip(RESUME_SKIP.watcherRunning)
|
|
89
|
+
if (!isSafeName(record.coordinator)) return skip(RESUME_SKIP.unsafeCoordinator)
|
|
90
|
+
const board = boardInside(record.root, record.board)
|
|
91
|
+
if (board === null) return skip(RESUME_SKIP.unsafeBoard)
|
|
92
|
+
if (!boardExists) return skip(RESUME_SKIP.boardMissing)
|
|
93
|
+
return {
|
|
94
|
+
kind: 'resume',
|
|
95
|
+
root: record.root,
|
|
96
|
+
board,
|
|
97
|
+
coordinator: record.coordinator,
|
|
98
|
+
interrupted: lanesWith(record.lanes, AGENT_STATUS.working),
|
|
99
|
+
blocked: lanesWith(record.lanes, AGENT_STATUS.blocked),
|
|
100
|
+
unsafe: Object.keys(record.lanes).filter((name) => !isSafeName(name)),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { errorText } from '../plans/error-text'
|
|
2
|
+
import type { LaneSnapshot } from '../watch/lane-watch'
|
|
3
|
+
import { followedRecord, type RecordStart, type ResumeRecord, runningRecord, stoppedRecord } from './record'
|
|
4
|
+
|
|
5
|
+
/** The one record file of a repository; `read` gives null for a missing or damaged one. */
|
|
6
|
+
export type RecordStore = {
|
|
7
|
+
read: () => ResumeRecord | null
|
|
8
|
+
write: (record: ResumeRecord) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type KeeperPorts = {
|
|
12
|
+
store: RecordStore
|
|
13
|
+
log: (line: string) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** What the watcher calls: `follow` at every herdr poll, `stop` once on a clean end. */
|
|
17
|
+
export type RecordKeeper = {
|
|
18
|
+
follow: (lanes: LaneSnapshot, now: number) => void
|
|
19
|
+
stop: (now: number) => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const laneStatusesOf = (snapshot: LaneSnapshot): Record<string, string> =>
|
|
23
|
+
Object.fromEntries(Object.entries(snapshot).map(([name, lane]) => [name, lane.status]))
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Keeps the resume record of the watcher in `start.pid` beside its loop: running at the start, saved when a lane status
|
|
27
|
+
* changes or `RESUME_REFRESH_MS` is up, stamped as stopped at a clean end unless a successor holds the file by then (a
|
|
28
|
+
* missing or damaged file is left alone). A write that fails is retried at the next poll and logged once per series; it
|
|
29
|
+
* never ends the watch, since without the record a crash only goes unresumed.
|
|
30
|
+
*
|
|
31
|
+
* Accepted: `follow` writes without reading the file first, so a predecessor that outlived `takeOverLock`'s wait can
|
|
32
|
+
* overwrite its successor's record until the successor's next write.
|
|
33
|
+
*/
|
|
34
|
+
export const keepRecord = (start: RecordStart, now: number, ports: KeeperPorts): RecordKeeper => {
|
|
35
|
+
let failing = false
|
|
36
|
+
const attempt = (action: () => void): boolean => {
|
|
37
|
+
try {
|
|
38
|
+
action()
|
|
39
|
+
if (failing) ports.log('resume record written again')
|
|
40
|
+
failing = false
|
|
41
|
+
return true
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (!failing) ports.log(`resume record failed: ${errorText(error)}`)
|
|
44
|
+
failing = true
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
let current = runningRecord(start, now)
|
|
49
|
+
let unsaved = !attempt(() => ports.store.write(current))
|
|
50
|
+
return {
|
|
51
|
+
follow: (lanes, at) => {
|
|
52
|
+
const next = followedRecord(current, laneStatusesOf(lanes), at)
|
|
53
|
+
if (next === current && !unsaved) return
|
|
54
|
+
current = next
|
|
55
|
+
unsaved = !attempt(() => ports.store.write(next))
|
|
56
|
+
},
|
|
57
|
+
stop: (at) => {
|
|
58
|
+
attempt(() => {
|
|
59
|
+
const stored = ports.store.read()
|
|
60
|
+
const stopped = stored === null ? null : stoppedRecord(stored, start.pid, at)
|
|
61
|
+
if (stopped !== null) ports.store.write(stopped)
|
|
62
|
+
})
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { dirname } from 'node:path'
|
|
2
|
+
import { isOwnedBy } from '../../kit/hooks/hooks/shared/private-directory'
|
|
3
|
+
import { isPrivateLevels, type LocationSystem } from '../shared/private-location'
|
|
4
|
+
|
|
5
|
+
const OTHERS_WRITE = 0o002
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether `jh resume` may trust the records in `directory`: the directory itself is the user's, no link, closed to
|
|
9
|
+
* group and others (`isPrivateLevels`), and the level above (`~/.jackherd`) is the user's, no link and closed to others'
|
|
10
|
+
* writes, so nobody else can swap the directory. That level is checked less strictly: it holds the user's own config,
|
|
11
|
+
* and a umask of `002` leaves it open to the user's own group.
|
|
12
|
+
*/
|
|
13
|
+
export const isTrustedRecordDirectory = (directory: string, system: LocationSystem): boolean => {
|
|
14
|
+
const parent = system.entryAt(dirname(directory))
|
|
15
|
+
const parentHolds =
|
|
16
|
+
parent !== null &&
|
|
17
|
+
!parent.isSymbolicLink() &&
|
|
18
|
+
parent.isDirectory() &&
|
|
19
|
+
isOwnedBy(parent, system.uid) &&
|
|
20
|
+
(parent.mode & OTHERS_WRITE) === 0
|
|
21
|
+
return parentHolds && isPrivateLevels(directory, directory, system)
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { TOOL_DIRECTORY } from '../config/location'
|
|
3
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
4
|
+
|
|
5
|
+
/** How many lines the resume log keeps: a few restarts' worth, so one can be checked afterwards. */
|
|
6
|
+
export const RESUME_LOG_LINES = 200
|
|
7
|
+
|
|
8
|
+
const LOG_DIRECTORY = 'logs'
|
|
9
|
+
const LOG_FILE = 'resume.log'
|
|
10
|
+
|
|
11
|
+
export const resumeLogDirectory = (home: string): string => join(home, TOOL_DIRECTORY, LOG_DIRECTORY)
|
|
12
|
+
|
|
13
|
+
export const resumeLogPath = (home: string): string => join(resumeLogDirectory(home), LOG_FILE)
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One log line: the time, the repository and the step. A line break of herdr's answers becomes a blank, and every
|
|
17
|
+
* other control character is dropped (`terminalSafe`): the line is also printed, and a name read from a record must
|
|
18
|
+
* not act on the terminal.
|
|
19
|
+
*/
|
|
20
|
+
export const logLine = (now: number, root: string, message: string): string =>
|
|
21
|
+
terminalSafe(`${new Date(now).toISOString()} ${root} ${message}`.replace(/\s*\n\s*/g, ' '))
|
|
22
|
+
|
|
23
|
+
/** The log with `lines` appended, cut to its last `max` lines. */
|
|
24
|
+
export const trimmedLog = (existing: string | null, lines: readonly string[], max: number): string => {
|
|
25
|
+
const kept = [...(existing ?? '').split('\n').filter((line) => line !== ''), ...lines]
|
|
26
|
+
return `${kept.slice(-max).join('\n')}\n`
|
|
27
|
+
}
|