jackherd 1.8.1 → 1.10.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 +60 -0
- package/INSTALL.md +3 -1
- package/README.md +75 -64
- package/dist/{chunk-OU2KALXP.js → chunk-5MQBCUQ5.js} +24 -4
- package/dist/config.d.ts +45 -4
- package/dist/config.js +84 -27
- package/dist/plan-format.d.ts +63 -7
- package/dist/plan-format.js +66 -12
- package/docs/workflow-dark.svg +67 -63
- package/docs/workflow.svg +67 -63
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +15 -0
- package/kit/hooks/hooks/approval-gate/index.ts +22 -4
- package/kit/hooks/hooks/approval-gate/run.ts +29 -6
- package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
- package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
- package/kit/hooks/hooks/ask-hint/index.ts +15 -19
- package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
- package/kit/hooks/hooks/git-guard/decision.ts +50 -102
- package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
- package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
- package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +127 -140
- package/kit/hooks/hooks/merge-guard/index.ts +6 -3
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
- package/kit/hooks/hooks/merge-guard/run.ts +59 -54
- package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
- package/kit/hooks/hooks/open-question/index.ts +26 -0
- package/kit/hooks/hooks/open-question/record.ts +135 -0
- package/kit/hooks/hooks/open-question/run.ts +52 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- package/kit/hooks/hooks/sent-file/index.ts +19 -0
- package/kit/hooks/hooks/sent-file/record.ts +128 -0
- package/kit/hooks/hooks/sent-file/run.ts +48 -0
- package/kit/hooks/hooks/sent-file/store.ts +9 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
- package/kit/hooks/hooks/shared/config.ts +7 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
- package/kit/hooks/hooks/shared/files.ts +7 -0
- package/kit/hooks/hooks/shared/lane.ts +1 -1
- package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
- package/kit/hooks/hooks/shared/private-directory.ts +56 -9
- package/kit/hooks/hooks/shared/program-position.ts +2 -75
- package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
- package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
- package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
- package/kit/hooks/hooks/task-hint/decision.ts +49 -0
- package/kit/hooks/hooks/task-hint/index.ts +79 -0
- package/kit/hooks/hooks/task-hint/run.ts +62 -0
- package/kit/hooks/hooks/task-hint/store.ts +51 -0
- package/kit/hooks/hooks.json +21 -4
- package/kit/hooks/statusline.sh +1 -1
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +1 -8
- package/kit/workflow/skills/implement/SKILL.md +10 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +213 -412
- package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
- package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
- package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
- package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
- package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
- package/kit/workflow/skills/orchestrate/references/events.md +86 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
- package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
- package/kit/workflow/skills/plan/SKILL.md +6 -5
- package/kit/workflow/skills/plan/references/template-English.md +4 -4
- package/kit/workflow/skills/plan/references/template-German.md +4 -4
- package/package.json +4 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +1 -1
- package/schemas/jackherd.schema.json +85 -7
- package/src/check/agent-text-reviewers.ts +23 -0
- package/src/check/command.ts +2 -0
- package/src/check/plan-structure-check.ts +49 -2
- package/src/check/plan.ts +8 -1
- package/src/check/schema.ts +9 -0
- package/src/check/skill-budget-defaults.ts +55 -0
- package/src/check/skill-budgets.ts +185 -0
- package/src/cli.ts +4 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +80 -26
- package/src/context/fill.ts +12 -0
- package/src/context/session-log.ts +43 -1
- package/src/lane/close-board.ts +106 -0
- package/src/lane/close-check.ts +183 -0
- package/src/lane/close-command-line.ts +11 -0
- package/src/lane/close-command.ts +217 -0
- package/src/lane/close-options.ts +43 -0
- package/src/lane/close-survey.ts +83 -0
- package/src/lane/close.ts +141 -0
- package/src/lane/command.ts +13 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +87 -0
- package/src/lane-prompt/input.ts +116 -0
- package/src/lane-prompt/options.ts +68 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/plan-format/clock.ts +12 -0
- package/src/plan-format/dependencies.ts +9 -9
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/lanes.ts +30 -5
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/terminal-text.ts +4 -0
- package/src/plan-format/waiting.ts +53 -0
- package/src/plans/active-tasks.ts +60 -0
- package/src/plans/appointment-rows.ts +59 -0
- package/src/plans/ask-coordinator.ts +77 -0
- package/src/plans/ask-target.ts +25 -0
- package/src/plans/child-stop.ts +51 -0
- package/src/plans/closable-records.ts +34 -0
- package/src/plans/command.ts +3 -2
- package/src/plans/current-phase.ts +17 -7
- package/src/plans/current-task.ts +63 -5
- package/src/plans/fetch.ts +26 -3
- package/src/plans/git.ts +19 -5
- package/src/plans/herd-claim.ts +173 -0
- package/src/plans/herd-memory.ts +36 -25
- package/src/plans/lane-plans.ts +44 -13
- package/src/plans/lane-standing.ts +9 -4
- package/src/plans/lane-summary.ts +367 -92
- package/src/plans/lane-tickets.ts +15 -0
- package/src/plans/layout-phase.ts +80 -19
- package/src/plans/layout-summary.ts +163 -74
- package/src/plans/layout.ts +472 -114
- package/src/plans/link-chips.ts +1 -0
- package/src/plans/live.ts +119 -10
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-models.ts +1 -1
- package/src/plans/phase-ranges.ts +45 -0
- package/src/plans/phase-source.ts +15 -0
- package/src/plans/plan-cache.ts +28 -0
- package/src/plans/plan-heads.ts +54 -0
- package/src/plans/pull-requests.ts +23 -2
- package/src/plans/question-records.ts +48 -0
- package/src/plans/questions-read.ts +13 -0
- package/src/plans/read-cache.ts +8 -0
- package/src/plans/record-id.ts +4 -0
- package/src/plans/restart-decision.ts +13 -4
- package/src/plans/selection.ts +117 -34
- package/src/plans/sent-files-read.ts +13 -0
- package/src/plans/sent-files.ts +114 -0
- package/src/plans/settings.ts +2 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +62 -0
- package/src/plans/snapshot.ts +279 -60
- package/src/plans/sprint-export.ts +11 -11
- package/src/plans/sprint-page.ts +5 -5
- package/src/plans/subagent-phases.ts +95 -0
- package/src/plans/subagent-tasks.ts +51 -0
- package/src/plans/subagents-read.ts +131 -0
- package/src/plans/subagents.ts +190 -0
- package/src/plans/supervisor.ts +14 -4
- package/src/plans/symbols.ts +3 -0
- package/src/plans/task-name.ts +34 -0
- package/src/plans/texts.ts +36 -14
- package/src/plans/unlisted-lanes.ts +10 -5
- package/src/plans/view.ts +20 -12
- package/src/plans/waiting-click.ts +231 -0
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +105 -0
- package/src/preview/embed.ts +209 -0
- package/src/preview/images.ts +89 -0
- package/src/preview/open.ts +12 -0
- package/src/preview/options.ts +32 -0
- package/src/preview/page.ts +89 -0
- package/src/preview/render.ts +41 -0
- package/src/settled/board-row.ts +1 -2
- package/src/shared/is-record.ts +2 -0
- package/src/shared/private-location.ts +89 -0
- package/src/shared/private-records.ts +48 -0
- package/src/shared/stat-cache.ts +48 -0
- package/src/sync/kit-source.ts +29 -10
- package/src/sync/kit.ts +2 -0
- package/src/sync/manifest.ts +2 -0
- package/src/sync/plan-ask-rules.ts +94 -0
- package/src/sync/plan-hooks.ts +2 -0
- package/src/sync/render.ts +3 -3
- package/src/sync/settings-hooks.ts +4 -1
- package/src/sync/sync-plan.ts +1 -0
- package/src/sync/sync.ts +24 -3
- package/src/task-signals/agent.ts +15 -0
- package/src/task-signals/claude-read.ts +89 -0
- package/src/task-signals/claude-signals.ts +112 -0
- package/src/task-signals/command.ts +71 -0
- package/src/task-signals/fixtures/session.jsonl +7 -0
- package/src/task-signals/matcher.ts +96 -0
- package/src/task-signals/record-store.ts +40 -0
- package/src/task-signals/record.ts +68 -0
- package/src/task-signals/text.ts +13 -0
- package/src/task-signals/types.ts +14 -0
- package/src/terminal/open.ts +13 -6
- package/src/terminal/text.ts +16 -4
- package/src/terminal/width.ts +2 -1
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +44 -6
- package/src/waiting/command.ts +50 -0
- package/src/watch/appointments.ts +2 -4
- package/src/watch/approvals.ts +2 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +401 -90
- package/src/watch/failed-tests.ts +56 -10
- package/src/watch/failure-read.ts +18 -6
- package/src/watch/failures.ts +29 -5
- package/src/watch/feedback.ts +1 -1
- package/src/watch/gh-process.ts +9 -3
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/json-guards.ts +0 -3
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-fill.ts +1 -1
- package/src/watch/lane-model.ts +1 -1
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-parallel.ts +1 -1
- package/src/watch/lane-progress.ts +58 -14
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +37 -5
- package/src/watch/lane-rest.ts +28 -0
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +13 -7
- package/src/watch/plan-badges.ts +9 -2
- package/src/watch/previews.ts +1 -1
- package/src/watch/pull-requests.ts +8 -3
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/running-subagents.ts +43 -0
- package/src/watch/skill-change.ts +124 -32
- package/src/watch/source-change.ts +48 -0
- package/src/watch/source-path.ts +1 -1
- package/src/watch/sources.ts +7 -4
- package/src/watch/stream-output.ts +14 -8
- package/src/watch/task-parallel-auto.ts +208 -0
- package/src/watch/task-parallel-data-paths.ts +46 -0
- package/src/watch/task-parallel-input.ts +108 -0
- package/src/watch/task-parallel-read.ts +151 -0
- package/src/watch/task-parallel-touched.ts +39 -0
- package/src/watch/task-parallel.ts +235 -0
- package/src/watch/task-signal-missing.ts +69 -0
- package/src/watch/task-signal-read.ts +29 -0
- package/src/watch/texts.ts +10 -0
- package/src/watch/update-available.ts +6 -3
- package/src/watch/waiting-line.ts +19 -0
- package/src/watch/watch-events.ts +36 -1
- package/src/watch/watcher-lock.ts +25 -29
- package/src/watch/workflow-runs.ts +2 -1
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
- package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
- package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
|
|
3
|
+
export const TASK_PARALLEL_EVENT = 'parallel tasks'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A proposal: the line `formatTaskParallelLine` made for `lane`; with `lane` null, the line that the switch
|
|
7
|
+
* `watch.parallelTasks.auto` ended.
|
|
8
|
+
*/
|
|
9
|
+
export type TaskParallelEvent = { kind: typeof TASK_PARALLEL_EVENT; lane: string | null; line: string }
|
|
10
|
+
|
|
11
|
+
/** Lane → the fingerprint last seen at a heartbeat; a lane without an entry has had none. */
|
|
12
|
+
export type TaskParallelFingerprints = Record<string, string>
|
|
13
|
+
|
|
14
|
+
export const isTaskParallelFingerprints = (value: unknown): value is TaskParallelFingerprints =>
|
|
15
|
+
typeof value === 'object' &&
|
|
16
|
+
value !== null &&
|
|
17
|
+
!Array.isArray(value) &&
|
|
18
|
+
Object.values(value).every((entry) => typeof entry === 'string')
|
|
19
|
+
|
|
20
|
+
export type OpenTask = { text: string; phase: string }
|
|
21
|
+
|
|
22
|
+
/** One agent's recent work: the files it touched, its active task text and phase when the signal names them. */
|
|
23
|
+
export type ActiveWork = { name: string; files: string[]; taskText?: string; phase?: string }
|
|
24
|
+
|
|
25
|
+
export type TaskParallelInput = {
|
|
26
|
+
lane: string
|
|
27
|
+
open: OpenTask[]
|
|
28
|
+
active: ActiveWork[]
|
|
29
|
+
/** Phase id → the paths of its writes cell; `''` is the whole repository. Absent for a phase without one. */
|
|
30
|
+
phaseWrites: Record<string, string[]>
|
|
31
|
+
/** The same from the plan at the base branch, which the lane cannot change; what an `auto` mark trusts. */
|
|
32
|
+
baseWrites: Record<string, string[]>
|
|
33
|
+
doneCount: number
|
|
34
|
+
/** Tokens a fresh agent costs before it does anything, measured by the caller. */
|
|
35
|
+
baselineTokens: number
|
|
36
|
+
/** A proposal is made only when the estimate stays below this. */
|
|
37
|
+
thresholdTokens: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type TaskParallelResult =
|
|
41
|
+
| { proposed: false; reason: string }
|
|
42
|
+
| {
|
|
43
|
+
proposed: true
|
|
44
|
+
tasks: OpenTask[]
|
|
45
|
+
waiting: number
|
|
46
|
+
extraAgents: number
|
|
47
|
+
estimateTokens: number
|
|
48
|
+
reason: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type TaskParallelProposal = Extract<TaskParallelResult, { proposed: true }>
|
|
52
|
+
|
|
53
|
+
export const AUTO_VERDICT = { auto: 'auto', off: 'off', asks: 'asks' } as const
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether a proposal starts without a question (`task-parallel-auto.ts`): `auto` with the switch's last day, `off`
|
|
57
|
+
* without a switch, `asks` with the limit that held it back.
|
|
58
|
+
*/
|
|
59
|
+
export type AutoVerdict =
|
|
60
|
+
| { kind: typeof AUTO_VERDICT.auto; until: string }
|
|
61
|
+
| { kind: typeof AUTO_VERDICT.off }
|
|
62
|
+
| { kind: typeof AUTO_VERDICT.asks; reason: string }
|
|
63
|
+
|
|
64
|
+
const MIN_TASKS_BESIDE_AGENT = 1
|
|
65
|
+
const MIN_TASKS_WITHOUT_AGENT = 2
|
|
66
|
+
const TEXT_LIMIT = 60
|
|
67
|
+
const TOKENS_PER_K = 1000
|
|
68
|
+
export const GLOB_CHARACTER = /[*?[{]/
|
|
69
|
+
const LINE_SUFFIX = /(?::\d+(?:[-:]\d+)*|#L\d+.*)$/
|
|
70
|
+
const PATH_EXTENSIONS = 'ts|tsx|js|mjs|json|md|sh|yml|yaml|css|html|toml|sql'
|
|
71
|
+
const BARE_FILE = new RegExp(`(?<![\\w./\`-])[\\w./-]+\\.(?:${PATH_EXTENSIONS})(?::\\d+(?:[-:]\\d+)*)?(?![\\w/])`, 'g')
|
|
72
|
+
const BACKTICKED = /`([^`]+)`/g
|
|
73
|
+
const FILE_LIKE = new RegExp(`/|\\.(?:${PATH_EXTENSIONS})$|^\\.[\\w-]+`)
|
|
74
|
+
|
|
75
|
+
const normalise = (entry: string): string | null => {
|
|
76
|
+
const trimmed = entry.trim()
|
|
77
|
+
if (trimmed === '' || /\s/.test(trimmed)) return null
|
|
78
|
+
const path = trimmed.replace(LINE_SUFFIX, '').replace(/^\.\//, '').replace(/\/+$/, '')
|
|
79
|
+
return FILE_LIKE.test(path) && path !== '' ? path : null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The paths a task line names: backticked entries that look like a path or file name and bare file names with a known
|
|
84
|
+
* extension. `:line` suffixes and `./` are cut, globs stay globs, duplicates drop. A task naming none is unknown, and
|
|
85
|
+
* unknown is never proposed.
|
|
86
|
+
*/
|
|
87
|
+
export const pathsOfTask = (text: string): string[] => {
|
|
88
|
+
const backticked = [...text.matchAll(BACKTICKED)].map((match) => match[1] ?? '')
|
|
89
|
+
const bare = text.replace(BACKTICKED, ' ').match(BARE_FILE) ?? []
|
|
90
|
+
return [...new Set([...backticked, ...bare].map(normalise).filter((path): path is string => path !== null))]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const literalPrefix = (path: string): string => {
|
|
94
|
+
const glob = path.search(GLOB_CHARACTER)
|
|
95
|
+
return glob === -1 ? path : path.slice(0, Math.max(path.lastIndexOf('/', glob), 0))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const isInside = (inner: string, outer: string): boolean =>
|
|
99
|
+
outer === '' || inner === outer || inner.startsWith(`${outer}/`)
|
|
100
|
+
|
|
101
|
+
const holdsName = (name: string, path: string): boolean => `/${path}/`.includes(`/${name}/`) || path === name
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Whether two paths may touch the same files. Directories contain what lies below them, a glob stands for the
|
|
105
|
+
* directory its literal part ends in, `''` is the whole repository, and a bare name matches that name in any directory.
|
|
106
|
+
* When unsure the answer is yes.
|
|
107
|
+
*/
|
|
108
|
+
export const pathsOverlap = (left: string, right: string): boolean => {
|
|
109
|
+
const [a, b] = [literalPrefix(left), literalPrefix(right)]
|
|
110
|
+
const bareLeft = !left.includes('/') && !GLOB_CHARACTER.test(left) && left !== ''
|
|
111
|
+
const bareRight = !right.includes('/') && !GLOB_CHARACTER.test(right) && right !== ''
|
|
112
|
+
if (bareLeft || bareRight) {
|
|
113
|
+
const [name, other] = bareLeft ? [left, right] : [right, left]
|
|
114
|
+
return GLOB_CHARACTER.test(other) ? true : other === '' || holdsName(name, other)
|
|
115
|
+
}
|
|
116
|
+
return isInside(a, b) || isInside(b, a)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const clash = (left: readonly string[], right: readonly string[]): boolean =>
|
|
120
|
+
left.some((a) => right.some((b) => pathsOverlap(a, b)))
|
|
121
|
+
|
|
122
|
+
const activeFootprint = (input: TaskParallelInput): string[] =>
|
|
123
|
+
input.active.flatMap((work) => [
|
|
124
|
+
...work.files,
|
|
125
|
+
...pathsOfTask(work.taskText ?? ''),
|
|
126
|
+
...(work.phase === undefined ? [] : (input.phaseWrites[work.phase] ?? [])),
|
|
127
|
+
])
|
|
128
|
+
|
|
129
|
+
const estimateTokens = (input: TaskParallelInput, taskCount: number): { extraAgents: number; tokens: number } => {
|
|
130
|
+
const extraAgents = input.active.length > 0 ? taskCount : taskCount - 1
|
|
131
|
+
return { extraAgents, tokens: input.baselineTokens * extraAgents }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const kilo = (tokens: number): string => `${Math.round(tokens / TOKENS_PER_K)}k`
|
|
135
|
+
|
|
136
|
+
/** The first group of open tasks, in plan order, whose paths clash neither with the running work nor with each other. */
|
|
137
|
+
const parallelGroup = (input: TaskParallelInput): OpenTask[] => {
|
|
138
|
+
const running = activeFootprint(input)
|
|
139
|
+
const chosen: { task: OpenTask; paths: string[] }[] = []
|
|
140
|
+
for (const task of input.open) {
|
|
141
|
+
const paths = pathsOfTask(task.text)
|
|
142
|
+
const free = paths.length > 0 && !clash(paths, running) && chosen.every((other) => !clash(paths, other.paths))
|
|
143
|
+
if (free) chosen.push({ task, paths })
|
|
144
|
+
}
|
|
145
|
+
return chosen.map(({ task }) => task)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Open tasks that could start now beside the running work: no path overlap with it or with each other, none without a
|
|
150
|
+
* named path. Proposed for one task beside running work or two tasks without, and when the baseline cost of the extra agents stays below the
|
|
151
|
+
* threshold; the reason says in plain words which way it went.
|
|
152
|
+
*/
|
|
153
|
+
export const proposeTaskParallel = (input: TaskParallelInput): TaskParallelResult => {
|
|
154
|
+
const tasks = parallelGroup(input)
|
|
155
|
+
const needed = input.active.length > 0 ? MIN_TASKS_BESIDE_AGENT : MIN_TASKS_WITHOUT_AGENT
|
|
156
|
+
if (tasks.length < needed) return { proposed: false, reason: 'no task can run beside another' }
|
|
157
|
+
const { extraAgents, tokens } = estimateTokens(input, tasks.length)
|
|
158
|
+
if (tokens >= input.thresholdTokens)
|
|
159
|
+
return {
|
|
160
|
+
proposed: false,
|
|
161
|
+
reason: `~${kilo(tokens)} tokens for ${extraAgents} extra agents is not below the ${kilo(input.thresholdTokens)} limit`,
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
proposed: true,
|
|
165
|
+
tasks,
|
|
166
|
+
waiting: input.open.length,
|
|
167
|
+
extraAgents,
|
|
168
|
+
estimateTokens: tokens,
|
|
169
|
+
reason: `${tasks.length} of ${input.open.length} waiting tasks name disjoint paths that the running work does not touch; ~${kilo(tokens)} tokens for ${extraAgents} extra ${extraAgents === 1 ? 'agent' : 'agents'}`,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const FNV_OFFSET = 0x811c9dc5
|
|
174
|
+
const FNV_PRIME = 0x01000193
|
|
175
|
+
|
|
176
|
+
const hash = (text: string): string => {
|
|
177
|
+
let value = FNV_OFFSET
|
|
178
|
+
for (const character of text) {
|
|
179
|
+
value = Math.imul(value ^ (character.codePointAt(0) ?? 0), FNV_PRIME) >>> 0
|
|
180
|
+
}
|
|
181
|
+
return value.toString(16).padStart(8, '0')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A stable fingerprint of what decides a proposal: ticks, active tasks and open tasks, order-independent. Files an
|
|
186
|
+
* agent touches are left out, they move on every keystroke and would repeat the report.
|
|
187
|
+
*/
|
|
188
|
+
export const taskParallelFingerprint = (input: TaskParallelInput): string =>
|
|
189
|
+
hash(
|
|
190
|
+
JSON.stringify([
|
|
191
|
+
input.lane,
|
|
192
|
+
input.doneCount,
|
|
193
|
+
input.active.map((work) => `${work.phase ?? ''}|${work.taskText ?? ''}`).sort(),
|
|
194
|
+
input.open.map((task) => `${task.phase}|${task.text}`).sort(),
|
|
195
|
+
]),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The proposal to report this heartbeat, if any: only when the fingerprint moved since `previous`, and only a real
|
|
200
|
+
* proposal. The returned fingerprint is what the caller keeps for the next heartbeat, proposal or not.
|
|
201
|
+
*/
|
|
202
|
+
export const taskParallelReport = (
|
|
203
|
+
previous: string | undefined,
|
|
204
|
+
input: TaskParallelInput,
|
|
205
|
+
): { fingerprint: string; proposal: TaskParallelProposal | null } => {
|
|
206
|
+
const fingerprint = taskParallelFingerprint(input)
|
|
207
|
+
if (fingerprint === previous) return { fingerprint, proposal: null }
|
|
208
|
+
const result = proposeTaskParallel(input)
|
|
209
|
+
return { fingerprint, proposal: result.proposed ? result : null }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const CHECKBOX = /^\s*(?:[-*]\s*)?\[[ xX]\]\s*/
|
|
213
|
+
/** The separators of the line itself, so a task's text cannot fake a mark such as `· auto until`. */
|
|
214
|
+
const SEPARATORS = /[·∥]/g
|
|
215
|
+
|
|
216
|
+
const proposalTaskText = (text: string): string =>
|
|
217
|
+
ellipsize(
|
|
218
|
+
terminalSafe(text.replace(/\s+/g, ' ')).replace(CHECKBOX, '').replaceAll('"', "'").replace(SEPARATORS, '-').trim(),
|
|
219
|
+
TEXT_LIMIT,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
const autoSuffix = (verdict: AutoVerdict): string => {
|
|
223
|
+
if (verdict.kind === AUTO_VERDICT.auto) return ` · auto until ${verdict.until}`
|
|
224
|
+
return verdict.kind === AUTO_VERDICT.asks ? ` · asks: ${terminalSafe(verdict.reason)}` : ''
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* `parallel tasks <lane>: "<a>" ∥ "<b>" · ~<n>k tokens for 1 extra agent`, one line; under a set switch it ends in
|
|
229
|
+
* `· auto until <day>` or `· asks: <the limit that held>`.
|
|
230
|
+
*/
|
|
231
|
+
export const formatTaskParallelLine = (lane: string, proposal: TaskParallelProposal, verdict: AutoVerdict): string => {
|
|
232
|
+
const tasks = proposal.tasks.map((task) => `"${proposalTaskText(task.text)}"`).join(' ∥ ')
|
|
233
|
+
const agents = proposal.extraAgents === 1 ? 'agent' : 'agents'
|
|
234
|
+
return `parallel tasks ${proposalTaskText(lane)}: ${tasks} · ~${kilo(proposal.estimateTokens)} tokens for ${proposal.extraAgents} extra ${agents}${autoSuffix(verdict)}`
|
|
235
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { MS_PER_MINUTE } from '../plan-format/clock'
|
|
2
|
+
import { isRecord } from '../shared/is-record'
|
|
3
|
+
|
|
4
|
+
export const NO_TASK_SIGNAL_EVENT = 'no task signal'
|
|
5
|
+
|
|
6
|
+
/** The key a lane without a current phase is reported under, so it too is reported once. */
|
|
7
|
+
const NO_PHASE = ''
|
|
8
|
+
|
|
9
|
+
/** What the heartbeat read of a lane: whether its agent works, whether a task signal is open, its current phase. */
|
|
10
|
+
export type NoSignalReading = { working: boolean; signalled: boolean; phaseId: string | null }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Per lane: since when it works without a signal (null while it does not), and the phase it was last reported for
|
|
14
|
+
* (`''` for a lane without a phase, null before any report).
|
|
15
|
+
*/
|
|
16
|
+
export type NoSignalState = Record<string, { since: number | null; reported: string | null }>
|
|
17
|
+
|
|
18
|
+
export type NoTaskSignalEvent = { kind: typeof NO_TASK_SIGNAL_EVENT; lane: string; phaseId: string | null }
|
|
19
|
+
|
|
20
|
+
type LaneEntry = NoSignalState[string]
|
|
21
|
+
|
|
22
|
+
const nextEntry = (
|
|
23
|
+
previous: LaneEntry | undefined,
|
|
24
|
+
{ working, signalled }: NoSignalReading,
|
|
25
|
+
now: number,
|
|
26
|
+
): LaneEntry => ({
|
|
27
|
+
since: working && !signalled ? (previous?.since ?? now) : null,
|
|
28
|
+
reported: previous?.reported ?? null,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const isDue = (entry: LaneEntry, phaseKey: string, now: number, limitMinutes: number): boolean =>
|
|
32
|
+
entry.since !== null && now - entry.since >= limitMinutes * MS_PER_MINUTE && entry.reported !== phaseKey
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The lanes that worked `limitMinutes` or longer without a task signal (`jh task start` or an entry of their own task
|
|
36
|
+
* list), one event per lane and phase (D112): a signal or a lane that stops working starts the clock again, a phase
|
|
37
|
+
* already reported is not reported again, a new one is. A lane without a reading keeps what it had.
|
|
38
|
+
*/
|
|
39
|
+
export const noTaskSignalEvents = (
|
|
40
|
+
previous: NoSignalState,
|
|
41
|
+
readings: Readonly<Record<string, NoSignalReading>>,
|
|
42
|
+
now: number,
|
|
43
|
+
limitMinutes: number,
|
|
44
|
+
): { events: NoTaskSignalEvent[]; state: NoSignalState } => {
|
|
45
|
+
const events: NoTaskSignalEvent[] = []
|
|
46
|
+
const state: NoSignalState = { ...previous }
|
|
47
|
+
for (const [lane, reading] of Object.entries(readings)) {
|
|
48
|
+
const entry = nextEntry(previous[lane], reading, now)
|
|
49
|
+
const phaseKey = reading.phaseId ?? NO_PHASE
|
|
50
|
+
if (isDue(entry, phaseKey, now, limitMinutes)) {
|
|
51
|
+
events.push({ kind: NO_TASK_SIGNAL_EVENT, lane, phaseId: reading.phaseId })
|
|
52
|
+
entry.reported = phaseKey
|
|
53
|
+
}
|
|
54
|
+
state[lane] = entry
|
|
55
|
+
}
|
|
56
|
+
return { events, state }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The line the coordinator reads: `lane <name> no task signal` with the phase when one is known. */
|
|
60
|
+
export const noTaskSignalLine = ({ lane, phaseId }: NoTaskSignalEvent): string =>
|
|
61
|
+
`lane ${lane} ${NO_TASK_SIGNAL_EVENT}${phaseId === null ? '' : ` (P${phaseId})`}`
|
|
62
|
+
|
|
63
|
+
const isEntry = (value: unknown): boolean =>
|
|
64
|
+
isRecord(value) &&
|
|
65
|
+
(value.since === null || typeof value.since === 'number') &&
|
|
66
|
+
(value.reported === null || typeof value.reported === 'string')
|
|
67
|
+
|
|
68
|
+
export const isNoSignalState = (value: unknown): value is NoSignalState =>
|
|
69
|
+
isRecord(value) && Object.values(value).every(isEntry)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { defaultProjectsDirectory } from '../context/session-log'
|
|
3
|
+
import { laneOfDirectory } from '../plans/active-tasks'
|
|
4
|
+
import { readAllSignals } from '../plans/signals-read'
|
|
5
|
+
|
|
6
|
+
export type SignalSources = {
|
|
7
|
+
/** The main checkout, which holds `.jackherd/tasks/`. */
|
|
8
|
+
root: string
|
|
9
|
+
/** The lanes' worktree directory, relative to `root`. */
|
|
10
|
+
worktreeDirectory: string
|
|
11
|
+
now: number
|
|
12
|
+
/** Where Claude Code keeps its session logs; the user's by default. */
|
|
13
|
+
projectsDirectory?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The lanes with an open task signal, read as the herd reads them (`readAllSignals`, the lane by the signal's
|
|
18
|
+
* directory): a task in progress in a recent session of the lane's worktree, a subagent's among them, or an open
|
|
19
|
+
* `jh task` record whose directory lies in that worktree.
|
|
20
|
+
*/
|
|
21
|
+
export const signalledLanes = (lanes: readonly string[], sources: SignalSources): Set<string> => {
|
|
22
|
+
const worktrees = join(sources.root, sources.worktreeDirectory)
|
|
23
|
+
const signals = readAllSignals(
|
|
24
|
+
{ projects: sources.projectsDirectory ?? defaultProjectsDirectory(), root: sources.root, worktrees },
|
|
25
|
+
sources.now,
|
|
26
|
+
)
|
|
27
|
+
const signalled = new Set(signals.flatMap(({ cwd }) => laneOfDirectory(cwd, worktrees) ?? []))
|
|
28
|
+
return new Set(lanes.filter((lane) => signalled.has(lane)))
|
|
29
|
+
}
|
package/src/watch/texts.ts
CHANGED
|
@@ -26,8 +26,14 @@ export type WatchTexts = {
|
|
|
26
26
|
laneModelDuty: string
|
|
27
27
|
/** What phases of one lane that can run together ask of the coordinator, with the phases as `P19 ∥ P20`. */
|
|
28
28
|
parallelDuty: (phases: string) => string
|
|
29
|
+
/** What a lane that works without a task signal asks of the coordinator. */
|
|
30
|
+
noTaskSignalDuty: string
|
|
29
31
|
/** What a lane's call waiting for approval asks of the coordinator, with the mark (request id and call hash) the question must carry. */
|
|
30
32
|
approvalDuty: (id: string, hash: string) => string
|
|
33
|
+
/** The standing duty while the main checkout is behind its base branch on the remote (`origin/main`), with the count of commits. */
|
|
34
|
+
mainCheckoutBehind: (remoteBase: string, count: number) => string
|
|
35
|
+
/** The standing duty while the main checkout has another branch than the base checked out (null for a detached head). */
|
|
36
|
+
mainCheckoutOffBase: (branch: string | null, baseBranch: string) => string
|
|
31
37
|
/** The reminder after the coordinator line: `<term>: <count> rows only in the checkout`. */
|
|
32
38
|
checkoutRows: (term: string, count: number) => string
|
|
33
39
|
/** The head of the status line of the user's own open pull requests without a lane. */
|
|
@@ -63,8 +69,12 @@ export const watchTexts = (dialogLanguage: PlanLanguage): WatchTexts => ({
|
|
|
63
69
|
laneAlertDuty: 'request the hand-over at the next phase boundary (§ 8)',
|
|
64
70
|
parallelDuty: (phases) =>
|
|
65
71
|
`${phases} can run in parallel: propose it to the user, check content and release files yourself (the table covers paths and markers only)`,
|
|
72
|
+
noTaskSignalDuty: 'remind the lane to report its task with jh task start; do not stop it',
|
|
66
73
|
laneModelDuty:
|
|
67
74
|
'switch it while idle: /exit, then claude --resume <session> --model <id> in the same pane (§ 2 step 6)',
|
|
75
|
+
mainCheckoutBehind: (remoteBase, count) =>
|
|
76
|
+
`main checkout behind ${remoteBase} by ${count} (git merge --ff-only ${remoteBase})`,
|
|
77
|
+
mainCheckoutOffBase: (branch, baseBranch) => `main checkout on ${branch ?? 'a detached head'}, not ${baseBranch}`,
|
|
68
78
|
checkoutRows: (term, count) => `${term}: ${count} ${count === 1 ? 'row' : 'rows'} only in the checkout`,
|
|
69
79
|
otherPullRequests: 'other PRs',
|
|
70
80
|
approvalDuty: (id, hash) =>
|
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
import { latestVersionIfDue, type UpdateCheck } from '../sync/if-changed'
|
|
2
2
|
import { isNewerVersion } from '../version/release'
|
|
3
|
+
import { UPDATE_AVAILABLE_EVENT } from './watch-events'
|
|
3
4
|
|
|
4
5
|
/** The one line that wakes the coordinator when npm has a newer release; the orchestrate skill asks the user. */
|
|
5
6
|
export const updateAvailableLine = (installed: string, latest: string): string =>
|
|
6
|
-
`===
|
|
7
|
+
`=== ${UPDATE_AVAILABLE_EVENT} ${installed} → ${latest}`
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* The watcher's daily look at npm, on the hook's timestamp and check, so a day holds one registry question across
|
|
10
11
|
* both. Prints a line only for a newer plain release it has not announced yet; otherwise nothing, so a quiet
|
|
11
|
-
* day costs no tokens. Call it on every poll: the timestamp decides whether npm is asked at all.
|
|
12
|
+
* day costs no tokens. Call it on every poll: the timestamp decides whether npm is asked at all. `hint` is what the line
|
|
13
|
+
* carries after it, the path of the reference that holds the handling (`referenceHint`).
|
|
12
14
|
*/
|
|
13
15
|
export const updateAnnouncer = (
|
|
14
16
|
home: string,
|
|
15
17
|
installed: string,
|
|
16
18
|
check: UpdateCheck,
|
|
17
19
|
log: (line: string) => void,
|
|
20
|
+
hint: () => string = () => '',
|
|
18
21
|
): (() => Promise<void>) => {
|
|
19
22
|
let announced: string | null = null
|
|
20
23
|
return async () => {
|
|
21
24
|
const latest = await latestVersionIfDue(home, check)
|
|
22
25
|
if (latest === null || latest === announced || !isNewerVersion(latest, installed)) return
|
|
23
26
|
announced = latest
|
|
24
|
-
log(updateAvailableLine(installed, latest))
|
|
27
|
+
log(`${updateAvailableLine(installed, latest)}${hint()}`)
|
|
25
28
|
}
|
|
26
29
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
import type { WaitingItem } from '../plan-format/waiting'
|
|
3
|
+
import { shownWithMore } from '../plans/shown-with-more'
|
|
4
|
+
|
|
5
|
+
const SHOWN_ITEMS = 3
|
|
6
|
+
const ITEM_LENGTH = 40
|
|
7
|
+
|
|
8
|
+
const itemText = ({ item }: WaitingItem): string => ellipsize(terminalSafe(item).replace(/\s+/g, ' '), ITEM_LENGTH)
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The line that brings back what waits on the user's answer, in every heartbeat and status until the item is closed:
|
|
12
|
+
* `for you (<n>): <item>; …`, three items and `+<n>` for the rest. Null when no item is open.
|
|
13
|
+
*/
|
|
14
|
+
export const waitingLine = (items: readonly WaitingItem[]): string | null => {
|
|
15
|
+
const open = items.filter(({ open: isOpen }) => isOpen)
|
|
16
|
+
if (open.length === 0) return null
|
|
17
|
+
const { shown, more } = shownWithMore(open, SHOWN_ITEMS)
|
|
18
|
+
return `for you (${open.length}): ${[...shown.map(itemText), ...(more > 0 ? [`+${more}`] : [])].join('; ')}`
|
|
19
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CHECKS, type ChecksState } from '../plans/pull-requests'
|
|
2
|
+
import { isRecord } from '../shared/is-record'
|
|
2
3
|
import {
|
|
3
4
|
APPOINTMENT_EVENT,
|
|
4
5
|
type AppointmentEvent,
|
|
@@ -10,7 +11,6 @@ import { APPROVAL_EVENT, type ApprovalEvent } from './approvals'
|
|
|
10
11
|
import { type CoordinatorLevel, type CoordinatorReading, isContextLevel, levelStep } from './coordinator-fill'
|
|
11
12
|
import { emptyFailureState, type FailureState, failedTestsOf, isFailureState } from './failures'
|
|
12
13
|
import { emptyFeedbackState, FEEDBACK_EVENT, type FeedbackEvent, type FeedbackState, isFeedbackState } from './feedback'
|
|
13
|
-
import { isRecord } from './json-guards'
|
|
14
14
|
import { isLaneLevels, LANE_FILL_EVENT, type LaneFillEvent, type LaneLevels } from './lane-fill'
|
|
15
15
|
import { isLaneModels, LANE_MODEL_EVENT, type LaneModelEvent, type LaneModels } from './lane-model'
|
|
16
16
|
import { isLaneParallels, LANE_PARALLEL_EVENT, type LaneParallelEvent, type LaneParallels } from './lane-parallel'
|
|
@@ -18,8 +18,23 @@ import { type LaneSnapshot, type SeenSeqs, seenSeqsOf } from './lane-watch'
|
|
|
18
18
|
import { emptyPreviewState, isPreviewState, PREVIEW_EVENT, type PreviewEvent, type PreviewState } from './previews'
|
|
19
19
|
import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
|
|
20
20
|
import { emptySourceState, isSourceState, SOURCE_EVENT, type SourceEvent, type SourceState } from './sources'
|
|
21
|
+
import {
|
|
22
|
+
isTaskParallelFingerprints,
|
|
23
|
+
TASK_PARALLEL_EVENT,
|
|
24
|
+
type TaskParallelEvent,
|
|
25
|
+
type TaskParallelFingerprints,
|
|
26
|
+
} from './task-parallel'
|
|
27
|
+
import {
|
|
28
|
+
isNoSignalState,
|
|
29
|
+
NO_TASK_SIGNAL_EVENT,
|
|
30
|
+
type NoSignalState,
|
|
31
|
+
type NoTaskSignalEvent,
|
|
32
|
+
} from './task-signal-missing'
|
|
21
33
|
import { RUN_EVENT, RUN_STATUS, type WorkflowRun } from './workflow-runs'
|
|
22
34
|
|
|
35
|
+
/** The event `updateAvailableLine` prints; it is no `WatchEvent`, the announcer logs it on its own. */
|
|
36
|
+
export const UPDATE_AVAILABLE_EVENT = 'update available'
|
|
37
|
+
|
|
23
38
|
/** What the coordinator is told besides a lane that stopped. The values are the words of the report. */
|
|
24
39
|
export const WATCH_EVENT = {
|
|
25
40
|
merged: 'merged',
|
|
@@ -34,6 +49,8 @@ export const WATCH_EVENT = {
|
|
|
34
49
|
laneFill: LANE_FILL_EVENT,
|
|
35
50
|
laneModel: LANE_MODEL_EVENT,
|
|
36
51
|
laneParallel: LANE_PARALLEL_EVENT,
|
|
52
|
+
taskParallel: TASK_PARALLEL_EVENT,
|
|
53
|
+
noTaskSignal: NO_TASK_SIGNAL_EVENT,
|
|
37
54
|
approval: APPROVAL_EVENT,
|
|
38
55
|
source: SOURCE_EVENT,
|
|
39
56
|
due: APPOINTMENT_EVENT.due,
|
|
@@ -86,12 +103,20 @@ export type WatchState = {
|
|
|
86
103
|
laneModels: LaneModels
|
|
87
104
|
/** The phases last reported as able to run together for each watched lane; a lane without two has none. */
|
|
88
105
|
laneParallels: LaneParallels
|
|
106
|
+
/** The fingerprint of the parallel-task situation of each watched lane at the last heartbeat; a proposal is made only when it moved. */
|
|
107
|
+
taskParallels: TaskParallelFingerprints
|
|
108
|
+
/** The last day of `watch.parallelTasks.auto` whose end was reported, so it is said once; null before any. */
|
|
109
|
+
parallelAutoEnded: string | null
|
|
110
|
+
/** Per watched lane: since when it works without a task signal and the phase last reported as `no task signal`. */
|
|
111
|
+
noTaskSignals: NoSignalState
|
|
89
112
|
/** The ids of the approval requests on disk when they were last read, each reported once while it is open. */
|
|
90
113
|
approvals: string[]
|
|
91
114
|
/** Per configured source of `watch.sources`: the entries seen (`[id, fingerprint]`) and the start of a failure streak. */
|
|
92
115
|
sources: SourceState
|
|
93
116
|
/** Lane → change time of the report file last shown for it, null when it had none; a lane without an entry is unseen. */
|
|
94
117
|
reportMtimes: Record<string, number | null>
|
|
118
|
+
/** Lane → the questions of its report last shown or seen (`questionsFingerprint`); a lane without an entry is unseen. */
|
|
119
|
+
reportQuestions: Record<string, string>
|
|
95
120
|
/** The lane lines of the status block last printed; null before the first report. */
|
|
96
121
|
statusLines: string[] | null
|
|
97
122
|
/** The board's appointments reported as due and its unreadable appointment rows reported, each once (`appointments.ts`). */
|
|
@@ -139,6 +164,8 @@ export type WatchEvent =
|
|
|
139
164
|
| LaneFillEvent
|
|
140
165
|
| LaneModelEvent
|
|
141
166
|
| LaneParallelEvent
|
|
167
|
+
| TaskParallelEvent
|
|
168
|
+
| NoTaskSignalEvent
|
|
142
169
|
| ApprovalEvent
|
|
143
170
|
| SourceEvent
|
|
144
171
|
| AppointmentEvent
|
|
@@ -173,9 +200,13 @@ export const emptyWatchState = (): WatchState => ({
|
|
|
173
200
|
laneLevels: {},
|
|
174
201
|
laneModels: {},
|
|
175
202
|
laneParallels: {},
|
|
203
|
+
taskParallels: {},
|
|
204
|
+
parallelAutoEnded: null,
|
|
205
|
+
noTaskSignals: {},
|
|
176
206
|
approvals: [],
|
|
177
207
|
sources: emptySourceState(),
|
|
178
208
|
reportMtimes: {},
|
|
209
|
+
reportQuestions: {},
|
|
179
210
|
statusLines: null,
|
|
180
211
|
appointments: emptyAppointmentState(),
|
|
181
212
|
})
|
|
@@ -218,10 +249,14 @@ const isWatchState = (value: Record<string, unknown>): value is WatchState =>
|
|
|
218
249
|
isLaneLevels(value.laneLevels) &&
|
|
219
250
|
isLaneModels(value.laneModels) &&
|
|
220
251
|
isLaneParallels(value.laneParallels) &&
|
|
252
|
+
isTaskParallelFingerprints(value.taskParallels) &&
|
|
253
|
+
(value.parallelAutoEnded === null || typeof value.parallelAutoEnded === 'string') &&
|
|
254
|
+
isNoSignalState(value.noTaskSignals) &&
|
|
221
255
|
Array.isArray(value.approvals) &&
|
|
222
256
|
value.approvals.every((id) => typeof id === 'string') &&
|
|
223
257
|
isSourceState(value.sources) &&
|
|
224
258
|
everyValue(value.reportMtimes, isSeq) &&
|
|
259
|
+
everyValue(value.reportQuestions, (entry) => typeof entry === 'string') &&
|
|
225
260
|
(value.statusLines === null ||
|
|
226
261
|
(Array.isArray(value.statusLines) && value.statusLines.every((line) => typeof line === 'string'))) &&
|
|
227
262
|
isAppointmentState(value.appointments)
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { rmSync } from 'node:fs'
|
|
3
|
+
import { basename, dirname, join } from 'node:path'
|
|
4
|
+
import { writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
|
|
5
|
+
import {
|
|
6
|
+
ensurePrivateLevels,
|
|
7
|
+
type LocationSystem,
|
|
8
|
+
readPrivateText,
|
|
9
|
+
runtimeBaseOf,
|
|
10
|
+
systemLocation,
|
|
11
|
+
userDirectoryOf,
|
|
12
|
+
} from '../shared/private-location'
|
|
5
13
|
import { CLI_NAME, PACKAGE_NAME } from '../version/release'
|
|
6
14
|
|
|
7
15
|
/** Looks up the command line of a process; null when there is no such process. */
|
|
@@ -17,8 +25,6 @@ const RUNTIME = 'bun'
|
|
|
17
25
|
const DIRECTORY_PREFIX = 'jackherd-watch'
|
|
18
26
|
const KEY_HASH_LENGTH = 12
|
|
19
27
|
const NOT_A_SEGMENT = /[^A-Za-z0-9._-]+/g
|
|
20
|
-
const PRIVATE_DIRECTORY = 0o700
|
|
21
|
-
const PRIVATE_FILE = 0o600
|
|
22
28
|
const STOP_WAIT_MS = 5_000
|
|
23
29
|
const STOP_POLL_MS = 50
|
|
24
30
|
|
|
@@ -73,14 +79,6 @@ export const repositoryKey = (root: string): string => {
|
|
|
73
79
|
return `${name}-${hash}`
|
|
74
80
|
}
|
|
75
81
|
|
|
76
|
-
const readText = (file: string): string | null => {
|
|
77
|
-
try {
|
|
78
|
-
return readFileSync(file, 'utf8')
|
|
79
|
-
} catch {
|
|
80
|
-
return null
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
82
|
const commandLineOf: CommandLineOf = (pid) => {
|
|
85
83
|
const result = Bun.spawnSync(['ps', '-o', 'command=', '-p', String(pid)], { stdout: 'pipe', stderr: 'ignore' })
|
|
86
84
|
return result.exitCode === 0 ? result.stdout.toString().trim() || null : null
|
|
@@ -96,36 +94,34 @@ const isAlive = (pid: number): boolean => {
|
|
|
96
94
|
}
|
|
97
95
|
|
|
98
96
|
/** Where `ensureWatcherDirectory` puts the watcher's files, without creating anything: for readers such as the herd. */
|
|
99
|
-
export const watcherDirectoryOf = (root: string): string =>
|
|
100
|
-
join(
|
|
97
|
+
export const watcherDirectoryOf = (root: string, system: LocationSystem = systemLocation()): string =>
|
|
98
|
+
join(userDirectoryOf(runtimeBaseOf(system), DIRECTORY_PREFIX, system.uid), repositoryKey(root))
|
|
101
99
|
|
|
102
100
|
/**
|
|
103
101
|
* The directory for the lock and state of the watcher of the repository at `root`, one per user and repository,
|
|
104
|
-
* readable by that user only:
|
|
105
|
-
*
|
|
102
|
+
* readable by that user only: without a private runtime directory the base is the temporary directory, which may be
|
|
103
|
+
* the shared `/tmp`, where another account could plant the files, so both levels are verified (`ensurePrivateLevels`)
|
|
104
|
+
* and this throws for a level that is a link or someone else's. A watcher in another repository has its own lock and
|
|
105
|
+
* does not supersede this one.
|
|
106
106
|
*/
|
|
107
|
-
export const ensureWatcherDirectory = (root: string): string => {
|
|
108
|
-
const directory = watcherDirectoryOf(root)
|
|
109
|
-
|
|
110
|
-
return directory
|
|
107
|
+
export const ensureWatcherDirectory = (root: string, system: LocationSystem = systemLocation()): string => {
|
|
108
|
+
const directory = watcherDirectoryOf(root, system)
|
|
109
|
+
return ensurePrivateLevels(dirname(directory), directory)
|
|
111
110
|
}
|
|
112
111
|
|
|
113
112
|
/** Whether the lock names a live process that is a watcher, so its saved state is being kept up to date. */
|
|
114
113
|
export const isWatcherRunning = (lockFile: string, lookUp: CommandLineOf = commandLineOf): boolean => {
|
|
115
|
-
const pid = lockedPid(
|
|
114
|
+
const pid = lockedPid(readPrivateText(lockFile))
|
|
116
115
|
return pid !== null && isAlive(pid) && isWatcherCommand(lookUp(pid) ?? '')
|
|
117
116
|
}
|
|
118
117
|
|
|
119
|
-
/** Writes a file only the user can read. */
|
|
120
|
-
export const writePrivate = (file: string, content: string) => writeFileSync(file, content, { mode: PRIVATE_FILE })
|
|
121
|
-
|
|
122
118
|
/**
|
|
123
119
|
* Takes the lock for this process. A watcher that held it gets SIGTERM and is waited for, so the state
|
|
124
120
|
* file it writes on the way out is complete before this one reads it.
|
|
125
121
|
*/
|
|
126
122
|
export const takeOverLock = async (lockFile: string): Promise<TakeOver> => {
|
|
127
|
-
const previous = supersedeTarget(
|
|
128
|
-
|
|
123
|
+
const previous = supersedeTarget(readPrivateText(lockFile), process.pid, commandLineOf)
|
|
124
|
+
writePrivateFile(lockFile, String(process.pid))
|
|
129
125
|
if (previous === null || !isAlive(previous)) return { replaced: null, stillRunning: null }
|
|
130
126
|
try {
|
|
131
127
|
process.kill(previous, 'SIGTERM')
|
|
@@ -137,9 +133,9 @@ export const takeOverLock = async (lockFile: string): Promise<TakeOver> => {
|
|
|
137
133
|
return isAlive(previous) ? { replaced: null, stillRunning: previous } : { replaced: previous, stillRunning: null }
|
|
138
134
|
}
|
|
139
135
|
|
|
140
|
-
export const stopNoticeOf = (lockFile: string): string => stopNotice(
|
|
136
|
+
export const stopNoticeOf = (lockFile: string): string => stopNotice(readPrivateText(lockFile), process.pid)
|
|
141
137
|
|
|
142
138
|
/** Removes the lock only while it still names this process; a successor's lock stays. */
|
|
143
139
|
export const releaseLock = (lockFile: string) => {
|
|
144
|
-
if (lockedPid(
|
|
140
|
+
if (lockedPid(readPrivateText(lockFile)) === process.pid) rmSync(lockFile, { force: true })
|
|
145
141
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { JackherdConfig } from '../config'
|
|
2
2
|
import { GITHUB_HOST } from '../plans/github'
|
|
3
3
|
import { runIdOfLink } from '../plans/pull-requests'
|
|
4
|
-
import { isRecord
|
|
4
|
+
import { isRecord } from '../shared/is-record'
|
|
5
|
+
import { parseArray } from './json-guards'
|
|
5
6
|
|
|
6
7
|
/** Which workflow's runs are deploys, and which environments count as their target (`watch.deploy`). */
|
|
7
8
|
export type DeployConfig = NonNullable<JackherdConfig['watch']['deploy']>
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
// PostToolUse hook for AskUserQuestion, shipped by jackherd. When the call asked about a plan (``Plan `<path>` ``) and
|
|
2
|
-
// offered the label that approves it or the one that revokes it, it leaves a pointer to that dialog in the main
|
|
3
|
-
// checkout's `.jackherd/approvals/plans/`, so the merge guard finds the answer again after the session ended. It writes
|
|
4
|
-
// only the pointer; the merge guard reads the answer from the transcript itself. argv[2] and argv[3] are the two labels,
|
|
5
|
-
// rendered into the hook entry by `sync`. It never blocks and prints nothing: a pointer it fails to write costs a question.
|
|
6
|
-
// The other way round it costs a revocation: when no pointer is written for a "revoke" answer, the merge guard never
|
|
7
|
-
// sees that answer, and an older approval keeps holding until the next plan question that is recorded.
|
|
8
|
-
import { mainCheckoutOf } from '../shared/checkout'
|
|
9
|
-
import { planPointersDirectory, pointerOf, writePointer } from './plan-pointers'
|
|
10
|
-
|
|
11
|
-
const record = async (): Promise<void> => {
|
|
12
|
-
const pointer = pointerOf(JSON.parse(await Bun.stdin.text()), process.argv.slice(2, 4), Date.now())
|
|
13
|
-
if (pointer === undefined) return
|
|
14
|
-
const projectDirectory = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()
|
|
15
|
-
writePointer(planPointersDirectory(mainCheckoutOf(projectDirectory)), pointer)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
try {
|
|
19
|
-
await record()
|
|
20
|
-
} catch {
|
|
21
|
-
// Nothing to do: without a pointer the merge asks.
|
|
22
|
-
}
|
|
23
|
-
process.exit(0)
|