jackherd 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/INSTALL.md +1 -1
- package/README.md +65 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-RMMXD4G5.js} +18 -6
- package/dist/config.d.ts +99 -4
- package/dist/config.js +105 -29
- package/dist/plan-format.d.ts +51 -6
- package/dist/plan-format.js +49 -7
- 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/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 -187
- 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 +57 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- package/kit/hooks/hooks/sent-file/store.ts +3 -12
- 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 +13 -5
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/rules/rules/kit/pull-requests.md +2 -0
- package/kit/workflow/skills/follow/SKILL.md +3 -10
- package/kit/workflow/skills/implement/SKILL.md +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +218 -433
- 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 +3 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +5 -1
- package/schemas/jackherd.schema.json +139 -5
- 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 +2 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +82 -25
- package/src/config/tones.ts +27 -0
- 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/additions.ts +14 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +93 -0
- package/src/lane-prompt/input.ts +157 -0
- package/src/lane-prompt/options.ts +70 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/mermaid/command.ts +36 -0
- package/src/mermaid/problems.ts +48 -0
- package/src/plan-format/clock.ts +8 -0
- package/src/plan-format/dependencies.ts +9 -6
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +22 -4
- package/src/plans/appointment-rows.ts +83 -0
- package/src/plans/ask-coordinator.ts +111 -0
- package/src/plans/ask-target.ts +36 -0
- package/src/plans/board-close.ts +85 -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/fetch.ts +26 -3
- package/src/plans/frame.ts +5 -12
- package/src/plans/git.ts +54 -5
- package/src/plans/groomed.ts +27 -0
- 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-summary.ts +253 -45
- package/src/plans/layout-phase.ts +36 -11
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +502 -98
- package/src/plans/link-chips.ts +23 -4
- package/src/plans/live.ts +178 -23
- package/src/plans/next-rows.ts +44 -0
- package/src/plans/phase-ids.ts +8 -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/planned.ts +22 -0
- package/src/plans/pull-requests.ts +17 -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/row-actions.ts +52 -0
- package/src/plans/row-focus.ts +148 -0
- package/src/plans/row-targets.ts +7 -0
- package/src/plans/selection.ts +111 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +12 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +162 -81
- package/src/plans/source-asked.ts +19 -0
- package/src/plans/source-prefix.ts +17 -0
- package/src/plans/source-rows.ts +188 -0
- 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 +66 -17
- package/src/plans/subagents.ts +64 -6
- 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 +48 -6
- package/src/plans/tones.ts +30 -0
- package/src/plans/view.ts +25 -11
- package/src/plans/waiting-click.ts +295 -35
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +25 -3
- package/src/shared/private-location.ts +89 -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/claude-read.ts +27 -10
- package/src/terminal/text.ts +16 -3
- package/src/terminal/types.ts +4 -0
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +26 -6
- package/src/waiting/command.ts +22 -7
- package/src/watch/appointments.ts +1 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +330 -105
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-progress.ts +40 -8
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +32 -4
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +22 -9
- package/src/watch/plan-badges.ts +10 -2
- package/src/watch/pull-requests.ts +6 -2
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/report-questions.ts +4 -3
- 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-entries.ts +55 -0
- package/src/watch/source-fields.ts +1 -0
- package/src/watch/source-statuses.ts +60 -0
- package/src/watch/source-tickets.ts +21 -1
- package/src/watch/sources.ts +47 -4
- package/src/watch/stream-output.ts +4 -3
- 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 +5 -15
- package/src/watch/task-parallel-read.ts +3 -1
- package/src/watch/task-parallel.ts +36 -7
- 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/watch-events.ts +23 -0
- package/src/watch/watcher-lock.ts +25 -29
- 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,208 @@
|
|
|
1
|
+
import { ALWAYS_ASK_PATHS, ENV_PATHS, MIGRATION_PATHS } from '../../kit/hooks/hooks/merge-guard/always-ask'
|
|
2
|
+
import { MAX_AUTO_WINDOW_DAYS, windowDays } from '../config/auto-window'
|
|
3
|
+
import type { JackherdConfig } from '../config/schema'
|
|
4
|
+
import { twoDigits } from '../plan-format/clock'
|
|
5
|
+
import {
|
|
6
|
+
AUTO_VERDICT,
|
|
7
|
+
type AutoVerdict,
|
|
8
|
+
GLOB_CHARACTER,
|
|
9
|
+
pathsOfTask,
|
|
10
|
+
type TaskParallelInput,
|
|
11
|
+
type TaskParallelProposal,
|
|
12
|
+
} from './task-parallel'
|
|
13
|
+
|
|
14
|
+
/** The window of `watch.parallelTasks.auto`: two `YYYY-MM-DD` days, both included. */
|
|
15
|
+
export type AutoWindow = NonNullable<JackherdConfig['watch']['parallelTasks']['auto']>
|
|
16
|
+
|
|
17
|
+
/** The switch, unset when the project has none, and every path a task may not name to start without a question. */
|
|
18
|
+
export type AutoSettings = {
|
|
19
|
+
auto?: AutoWindow
|
|
20
|
+
askPaths: readonly string[]
|
|
21
|
+
/** `watch.parallelTasks.maxTokens` of the same config as the switch. */
|
|
22
|
+
maxTokens: number
|
|
23
|
+
/** Whether the repository holds a path of `DATA_PATHS` at or below a directory, relative to its root. */
|
|
24
|
+
holdsDataPath: (directory: string) => boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const MAX_AUTO_EXTRA_AGENTS = 2
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The paths that always ask whose work takes effect before the merge: a migration may be pushed and a secret written
|
|
31
|
+
* while the pull request is open, so the merge guard comes too late. A directory of a writes set that holds one asks.
|
|
32
|
+
*/
|
|
33
|
+
export const DATA_PATHS: readonly string[] = [...MIGRATION_PATHS, ...ENV_PATHS]
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The paths that always ask (`ALWAYS_ASK_PATHS`, plus every package.json and CHANGELOG.md, which the merge guard
|
|
37
|
+
* reads apart): work on them is tier C and never starts without a question. The project's `guard.alwaysAsk` come on top.
|
|
38
|
+
*/
|
|
39
|
+
export const AUTO_ASK_PATHS: readonly string[] = [...ALWAYS_ASK_PATHS, '**/package.json', '**/CHANGELOG.md']
|
|
40
|
+
|
|
41
|
+
const PROBE = 'x'
|
|
42
|
+
/** Shorter literal pieces of a pattern (`**`, `*`) say nothing about a task's text. */
|
|
43
|
+
const MIN_FRAGMENT = 3
|
|
44
|
+
/**
|
|
45
|
+
* A path that may leave the repository or hide where it lies: absolute, from home, a variable, a drive, a backslash, or
|
|
46
|
+
* a `.` or `..` segment.
|
|
47
|
+
*/
|
|
48
|
+
const OUTSIDE_REPOSITORY = /^[/~]|^[A-Za-z]:|[\\%$]|(?:^|\/)\.{1,2}(?:\/|$)/
|
|
49
|
+
|
|
50
|
+
/** Whether a path may leave the repository or hide where it lies (`OUTSIDE_REPOSITORY`). */
|
|
51
|
+
export const leavesRepository = (path: string): boolean => OUTSIDE_REPOSITORY.test(path)
|
|
52
|
+
|
|
53
|
+
const hasFileName = (path: string): boolean => /\.[^/]*$/.test(path.slice(path.lastIndexOf('/') + 1))
|
|
54
|
+
|
|
55
|
+
/** A path up to the directory its first wildcard lies in; a path without one is itself. */
|
|
56
|
+
const literalPrefix = (path: string): string => {
|
|
57
|
+
const glob = path.search(GLOB_CHARACTER)
|
|
58
|
+
return glob === -1 ? path : path.slice(0, Math.max(path.lastIndexOf('/', glob), 0))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether one path lies inside the other or both are the same; `''` is the whole repository. */
|
|
62
|
+
const nested = (left: string, right: string): boolean =>
|
|
63
|
+
left === '' || right === '' || left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)
|
|
64
|
+
|
|
65
|
+
const matchesAnyPattern = (path: string, patterns: readonly string[]): boolean => {
|
|
66
|
+
const lower = path.toLowerCase()
|
|
67
|
+
const globs = patterns.map((pattern) => new Bun.Glob(pattern.toLowerCase()))
|
|
68
|
+
return globs.some((glob) => glob.match(lower) || glob.match(`${lower}/${PROBE}`))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether a path a task names may touch a path that always asks. Only a named file inside a directory of the
|
|
73
|
+
* repository is matched (in lower case, as the merge guard does): a path that may leave the repository, a directory,
|
|
74
|
+
* a glob or a bare file name could reach such a path somewhere, so it counts as one. A dotted name is also matched as
|
|
75
|
+
* a directory, so `.claude` counts like `.claude/**`.
|
|
76
|
+
*/
|
|
77
|
+
export const touchesAskPath = (path: string, patterns: readonly string[]): boolean => {
|
|
78
|
+
if (OUTSIDE_REPOSITORY.test(path) || !path.includes('/') || GLOB_CHARACTER.test(path) || !hasFileName(path))
|
|
79
|
+
return true
|
|
80
|
+
return matchesAnyPattern(path, patterns)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Whether a path relative to the repository root is, or lies in, one of `DATA_PATHS`. */
|
|
84
|
+
export const isDataPath = (path: string): boolean => matchesAnyPattern(path, DATA_PATHS)
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether an entry of a phase's writes set may touch a path that always asks: the whole repository, a path that may
|
|
88
|
+
* leave it, an entry a pattern matches as written (`packages/*\/package.json`), a directory or glob that overlaps a
|
|
89
|
+
* pattern anchored at the root (`.claude/**`), or one under whose literal part the repository holds a migration or an
|
|
90
|
+
* env file (`holdsDataPath`). Other patterns that hold anywhere (`**\/package.json`) are not looked for below a
|
|
91
|
+
* directory: their work waits for the merge guard.
|
|
92
|
+
*/
|
|
93
|
+
export const writesTouchAskPath = (
|
|
94
|
+
entry: string,
|
|
95
|
+
patterns: readonly string[],
|
|
96
|
+
holdsDataPath: (directory: string) => boolean,
|
|
97
|
+
): boolean => {
|
|
98
|
+
if (entry === '' || OUTSIDE_REPOSITORY.test(entry)) return true
|
|
99
|
+
if (matchesAnyPattern(entry, patterns)) return true
|
|
100
|
+
if (!GLOB_CHARACTER.test(entry) && hasFileName(entry)) return false
|
|
101
|
+
const literal = literalPrefix(entry.toLowerCase())
|
|
102
|
+
const rootAnchored = patterns.filter((pattern) => !pattern.startsWith('**'))
|
|
103
|
+
return (
|
|
104
|
+
rootAnchored.some((pattern) => nested(literal, literalPrefix(pattern.toLowerCase()))) ||
|
|
105
|
+
holdsDataPath(literalPrefix(entry))
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The literal pieces of the patterns (`.claude`, `package.json`, `migrations`), in lower case. */
|
|
110
|
+
const fragmentsOf = (patterns: readonly string[]): string[] => [
|
|
111
|
+
...new Set(
|
|
112
|
+
patterns.flatMap((pattern) =>
|
|
113
|
+
pattern
|
|
114
|
+
.toLowerCase()
|
|
115
|
+
.split(/[/*?[\]{}]+/)
|
|
116
|
+
.filter((piece) => piece.length >= MIN_FRAGMENT),
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
/** The local calendar day of `at`, as `YYYY-MM-DD`. */
|
|
122
|
+
export const localDay = (at: number): string => {
|
|
123
|
+
const date = new Date(at)
|
|
124
|
+
return `${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const windowRefusal = (auto: AutoWindow, today: string): string | null => {
|
|
128
|
+
if (!(windowDays(auto.since, auto.until) <= MAX_AUTO_WINDOW_DAYS))
|
|
129
|
+
return `window longer than ${MAX_AUTO_WINDOW_DAYS} days`
|
|
130
|
+
if (today < auto.since) return `off until ${auto.since}`
|
|
131
|
+
if (today > auto.until) return `ended ${auto.until}`
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const extraAgentsAtOnce = (input: TaskParallelInput, proposal: TaskParallelProposal): number =>
|
|
136
|
+
Math.max(input.active.length - 1, 0) + proposal.extraAgents
|
|
137
|
+
|
|
138
|
+
const taskPathRefusal = (proposal: TaskParallelProposal, patterns: readonly string[]): string | null => {
|
|
139
|
+
const path = proposal.tasks.flatMap((task) => pathsOfTask(task.text)).find((entry) => touchesAskPath(entry, patterns))
|
|
140
|
+
return path === undefined ? null : `a task names ${path}, which always asks`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A task's text may name such a path without backticks or extension (`.husky/pre-commit`, `CODEOWNERS`). */
|
|
144
|
+
const taskTextRefusal = (proposal: TaskParallelProposal, patterns: readonly string[]): string | null => {
|
|
145
|
+
const fragments = fragmentsOf(patterns)
|
|
146
|
+
const fragment = proposal.tasks
|
|
147
|
+
.map((task) => task.text.toLowerCase())
|
|
148
|
+
.flatMap((text) => fragments.filter((piece) => text.includes(piece)))[0]
|
|
149
|
+
return fragment === undefined ? null : `a task mentions ${fragment}, which always asks`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The task's text does not bind the subagent; its phase's writes set is what it may write, read from the plan at the
|
|
154
|
+
* base branch, so the lane's own copy widens nothing. A phase the base branch does not have asks.
|
|
155
|
+
*/
|
|
156
|
+
const phaseWritesRefusal = (input: TaskParallelInput, proposal: TaskParallelProposal, settings: AutoSettings) => {
|
|
157
|
+
for (const task of proposal.tasks) {
|
|
158
|
+
const writes = input.baseWrites[task.phase]
|
|
159
|
+
if (writes === undefined || writes.length === 0) return `phase ${task.phase} has no writes set on the base branch`
|
|
160
|
+
const entry = writes.find((path) => writesTouchAskPath(path, settings.askPaths, settings.holdsDataPath))
|
|
161
|
+
if (entry !== undefined)
|
|
162
|
+
return `phase ${task.phase} writes ${entry === '' ? 'the whole repository' : entry}, which always asks`
|
|
163
|
+
}
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const limitRefusal = (input: TaskParallelInput, proposal: TaskParallelProposal, settings: AutoSettings) => {
|
|
168
|
+
const { askPaths: patterns, maxTokens } = settings
|
|
169
|
+
if (input.active.length === 0) return 'no running work in the lane'
|
|
170
|
+
const atOnce = extraAgentsAtOnce(input, proposal)
|
|
171
|
+
if (atOnce > MAX_AUTO_EXTRA_AGENTS) return `${atOnce} extra agents at once, at most ${MAX_AUTO_EXTRA_AGENTS}`
|
|
172
|
+
if (proposal.estimateTokens >= Math.min(input.thresholdTokens, maxTokens)) return 'over the token limit'
|
|
173
|
+
return (
|
|
174
|
+
taskPathRefusal(proposal, patterns) ??
|
|
175
|
+
taskTextRefusal(proposal, patterns) ??
|
|
176
|
+
phaseWritesRefusal(input, proposal, settings)
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Whether the coordinator may start `proposal` without asking: the switch holds on `today`, the lane has running work
|
|
182
|
+
* (the extra agents are its subagents, never a new lane), at most two extra agents run at once with the subagents
|
|
183
|
+
* already running, the cost stays under the limit, and neither a task's paths, its text nor its phase's writes set
|
|
184
|
+
* reaches a path that always asks. Every doubt asks.
|
|
185
|
+
*/
|
|
186
|
+
export const autoVerdict = (
|
|
187
|
+
input: TaskParallelInput,
|
|
188
|
+
proposal: TaskParallelProposal,
|
|
189
|
+
settings: AutoSettings,
|
|
190
|
+
today: string,
|
|
191
|
+
): AutoVerdict => {
|
|
192
|
+
if (settings.auto === undefined) return { kind: AUTO_VERDICT.off }
|
|
193
|
+
const reason = windowRefusal(settings.auto, today) ?? limitRefusal(input, proposal, settings)
|
|
194
|
+
return reason === null ? { kind: AUTO_VERDICT.auto, until: settings.auto.until } : { kind: AUTO_VERDICT.asks, reason }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The line that the switch ended, once per last day: `ended` is the last day already reported (null before any), the
|
|
199
|
+
* answer the one to keep. Nothing while the switch holds or is unset.
|
|
200
|
+
*/
|
|
201
|
+
export const autoEndedReport = (
|
|
202
|
+
ended: string | null,
|
|
203
|
+
auto: AutoWindow | undefined,
|
|
204
|
+
today: string,
|
|
205
|
+
): { ended: string | null; line: string | null } =>
|
|
206
|
+
auto === undefined || today <= auto.until || ended === auto.until
|
|
207
|
+
? { ended, line: null }
|
|
208
|
+
: { ended: auto.until, line: `parallel tasks auto ended ${auto.until}: every proposal asks again` }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { isDataPath, leavesRepository } from './task-parallel-auto'
|
|
4
|
+
|
|
5
|
+
type Child = { path: string; searched: boolean }
|
|
6
|
+
|
|
7
|
+
/** Directories a search below a writes entry skips: installed packages and the repository's own store. */
|
|
8
|
+
const SKIPPED = new Set(['node_modules', '.git'])
|
|
9
|
+
/** How many entries one search reads before it gives up; a directory larger than this counts as holding one. */
|
|
10
|
+
const MAX_ENTRIES = 20_000
|
|
11
|
+
|
|
12
|
+
/** The children of a directory below the root, none when it cannot be read; `searched` marks a directory to descend. */
|
|
13
|
+
const childrenOf = (root: string, directory: string): Child[] => {
|
|
14
|
+
try {
|
|
15
|
+
return readdirSync(join(root, directory), { withFileTypes: true }).map((entry) => ({
|
|
16
|
+
path: directory === '' ? entry.name : `${directory}/${entry.name}`,
|
|
17
|
+
searched: entry.isDirectory() && !SKIPPED.has(entry.name),
|
|
18
|
+
}))
|
|
19
|
+
} catch {
|
|
20
|
+
return []
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether a path below `relative` is a data path; `true` once the search has read more than `limit` entries. */
|
|
25
|
+
const searchBelow = (root: string, relative: string, limit: number): boolean => {
|
|
26
|
+
const pending = [relative]
|
|
27
|
+
let read = 0
|
|
28
|
+
while (pending.length > 0) {
|
|
29
|
+
for (const child of childrenOf(root, pending.pop() ?? '')) {
|
|
30
|
+
read += 1
|
|
31
|
+
if (read > limit || isDataPath(child.path)) return true
|
|
32
|
+
if (child.searched) pending.push(child.path)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `holdsDataPath` for the repository at `root`: whether a migration or an env file lies at or below a directory,
|
|
40
|
+
* read from the file system at each call. A path that may leave the repository counts, a directory that does not exist
|
|
41
|
+
* holds nothing, and a search that reads more than `limit` entries counts as a hit, so a doubt asks.
|
|
42
|
+
*/
|
|
43
|
+
export const holdsDataPathIn =
|
|
44
|
+
(root: string, limit: number = MAX_ENTRIES) =>
|
|
45
|
+
(directory: string): boolean =>
|
|
46
|
+
leavesRepository(directory) || isDataPath(directory) || searchBelow(root, directory, limit)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isPhaseFinished, isPhaseOpen, type PlanLanguage, type PlanPhase, parsePlanPhases } from '../plan-format'
|
|
2
|
-
import { type PhaseDependency, parseDependencyTable } from '../plan-format/dependencies'
|
|
2
|
+
import { type PhaseDependency, parseDependencyTable, writeSetOf } from '../plan-format/dependencies'
|
|
3
3
|
import { matchSignals, type OpenPlanTask } from '../task-signals/matcher'
|
|
4
4
|
import type { TaskSignal } from '../task-signals/types'
|
|
5
5
|
import type { ActiveWork, OpenTask, TaskParallelInput } from './task-parallel'
|
|
@@ -12,24 +12,11 @@ export type PlanTaskView = {
|
|
|
12
12
|
phaseWrites: Record<string, string[]>
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
const writeSetOf = (
|
|
16
|
-
row: PhaseDependency,
|
|
17
|
-
byId: ReadonlyMap<string, PhaseDependency>,
|
|
18
|
-
chain: readonly string[],
|
|
19
|
-
): string[] | null => {
|
|
20
|
-
if (chain.includes(row.id)) return null
|
|
21
|
-
const like = row.writesLike.map((id) => {
|
|
22
|
-
const target = byId.get(id)
|
|
23
|
-
return target ? writeSetOf(target, byId, [...chain, row.id]) : null
|
|
24
|
-
})
|
|
25
|
-
return like.some((set) => set === null) ? null : [...row.writes, ...like.flatMap((set) => set ?? [])]
|
|
26
|
-
}
|
|
27
|
-
|
|
28
15
|
const phaseWritesOf = (rows: readonly PhaseDependency[]): Record<string, string[]> => {
|
|
29
16
|
const byId = new Map(rows.map((row) => [row.id, row]))
|
|
30
17
|
return Object.fromEntries(
|
|
31
18
|
rows.flatMap((row) => {
|
|
32
|
-
const writes = writeSetOf(row, byId
|
|
19
|
+
const writes = writeSetOf(row, byId)
|
|
33
20
|
return writes === null ? [] : [[row.id, writes]]
|
|
34
21
|
}),
|
|
35
22
|
)
|
|
@@ -77,6 +64,8 @@ export type TaskParallelFacts = {
|
|
|
77
64
|
touchedFiles: readonly string[]
|
|
78
65
|
/** The lane's own agent is working, with or without a task report. */
|
|
79
66
|
laneWorking: boolean
|
|
67
|
+
/** The writes sets of the plan at the base branch; empty when the base branch has no such plan. */
|
|
68
|
+
baseWrites: Record<string, string[]>
|
|
80
69
|
baselineTokens: number
|
|
81
70
|
thresholdTokens: number
|
|
82
71
|
}
|
|
@@ -112,6 +101,7 @@ export const taskParallelInputOf = (facts: TaskParallelFacts): TaskParallelInput
|
|
|
112
101
|
open: openTasksWithout(facts),
|
|
113
102
|
active: activeWorkOf(facts),
|
|
114
103
|
phaseWrites: facts.plan.phaseWrites,
|
|
104
|
+
baseWrites: facts.baseWrites,
|
|
115
105
|
doneCount: facts.plan.doneCount,
|
|
116
106
|
baselineTokens: facts.baselineTokens,
|
|
117
107
|
thresholdTokens: facts.thresholdTokens,
|
|
@@ -11,7 +11,7 @@ import { recordSignals } from '../task-signals/record'
|
|
|
11
11
|
import { readTaskRecords } from '../task-signals/record-store'
|
|
12
12
|
import type { TaskSignal } from '../task-signals/types'
|
|
13
13
|
import { AGENT_STATUS, type LaneAgent } from '../terminal/types'
|
|
14
|
-
import { watchedLanePlanText } from './lane-progress'
|
|
14
|
+
import { baseLanePlanText, watchedLanePlanText } from './lane-progress'
|
|
15
15
|
import type { TaskParallelInput } from './task-parallel'
|
|
16
16
|
import { planTaskViewOf, taskParallelInputOf } from './task-parallel-input'
|
|
17
17
|
import { isWithinWorktree, touchedFilesOf } from './task-parallel-touched'
|
|
@@ -134,9 +134,11 @@ export const readTaskParallelInputs = (
|
|
|
134
134
|
const signals = uniqueSignals([...reads.flatMap((read) => read.signals), ...records]).filter((signal) =>
|
|
135
135
|
isWithinWorktree(signal.cwd, worktree),
|
|
136
136
|
)
|
|
137
|
+
const baseText = baseLanePlanText(sources.root, sources.settings, sources.boardFile, lane)
|
|
137
138
|
const input = taskParallelInputOf({
|
|
138
139
|
lane: lane.name,
|
|
139
140
|
plan: planTaskViewOf(text, sources.settings.planLanguage),
|
|
141
|
+
baseWrites: baseText === null ? {} : planTaskViewOf(baseText, sources.settings.planLanguage).phaseWrites,
|
|
140
142
|
signals,
|
|
141
143
|
touchedFiles: reads.flatMap((read) => read.tails.flatMap((tail) => touchedFilesOf(tail, worktree, since))),
|
|
142
144
|
laneWorking: agents.some((agent) => agent.name === lane.name && agent.status === AGENT_STATUS.working),
|
|
@@ -2,8 +2,11 @@ import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
|
|
|
2
2
|
|
|
3
3
|
export const TASK_PARALLEL_EVENT = 'parallel tasks'
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
|
|
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 }
|
|
7
10
|
|
|
8
11
|
/** Lane → the fingerprint last seen at a heartbeat; a lane without an entry has had none. */
|
|
9
12
|
export type TaskParallelFingerprints = Record<string, string>
|
|
@@ -25,6 +28,8 @@ export type TaskParallelInput = {
|
|
|
25
28
|
active: ActiveWork[]
|
|
26
29
|
/** Phase id → the paths of its writes cell; `''` is the whole repository. Absent for a phase without one. */
|
|
27
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[]>
|
|
28
33
|
doneCount: number
|
|
29
34
|
/** Tokens a fresh agent costs before it does anything, measured by the caller. */
|
|
30
35
|
baselineTokens: number
|
|
@@ -45,11 +50,22 @@ export type TaskParallelResult =
|
|
|
45
50
|
|
|
46
51
|
export type TaskParallelProposal = Extract<TaskParallelResult, { proposed: true }>
|
|
47
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
|
+
|
|
48
64
|
const MIN_TASKS_BESIDE_AGENT = 1
|
|
49
65
|
const MIN_TASKS_WITHOUT_AGENT = 2
|
|
50
66
|
const TEXT_LIMIT = 60
|
|
51
67
|
const TOKENS_PER_K = 1000
|
|
52
|
-
const GLOB_CHARACTER = /[*?[{]/
|
|
68
|
+
export const GLOB_CHARACTER = /[*?[{]/
|
|
53
69
|
const LINE_SUFFIX = /(?::\d+(?:[-:]\d+)*|#L\d+.*)$/
|
|
54
70
|
const PATH_EXTENSIONS = 'ts|tsx|js|mjs|json|md|sh|yml|yaml|css|html|toml|sql'
|
|
55
71
|
const BARE_FILE = new RegExp(`(?<![\\w./\`-])[\\w./-]+\\.(?:${PATH_EXTENSIONS})(?::\\d+(?:[-:]\\d+)*)?(?![\\w/])`, 'g')
|
|
@@ -194,13 +210,26 @@ export const taskParallelReport = (
|
|
|
194
210
|
}
|
|
195
211
|
|
|
196
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
|
|
197
215
|
|
|
198
216
|
const proposalTaskText = (text: string): string =>
|
|
199
|
-
ellipsize(
|
|
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
|
+
}
|
|
200
226
|
|
|
201
|
-
/**
|
|
202
|
-
|
|
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 => {
|
|
203
232
|
const tasks = proposal.tasks.map((task) => `"${proposalTaskText(task.text)}"`).join(' ∥ ')
|
|
204
233
|
const agents = proposal.extraAgents === 1 ? 'agent' : 'agents'
|
|
205
|
-
return `parallel tasks ${proposalTaskText(lane)}: ${tasks} · ~${kilo(proposal.estimateTokens)} tokens for ${proposal.extraAgents} extra ${agents}`
|
|
234
|
+
return `parallel tasks ${proposalTaskText(lane)}: ${tasks} · ~${kilo(proposal.estimateTokens)} tokens for ${proposal.extraAgents} extra ${agents}${autoSuffix(verdict)}`
|
|
206
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
|
}
|
|
@@ -24,8 +24,17 @@ import {
|
|
|
24
24
|
type TaskParallelEvent,
|
|
25
25
|
type TaskParallelFingerprints,
|
|
26
26
|
} from './task-parallel'
|
|
27
|
+
import {
|
|
28
|
+
isNoSignalState,
|
|
29
|
+
NO_TASK_SIGNAL_EVENT,
|
|
30
|
+
type NoSignalState,
|
|
31
|
+
type NoTaskSignalEvent,
|
|
32
|
+
} from './task-signal-missing'
|
|
27
33
|
import { RUN_EVENT, RUN_STATUS, type WorkflowRun } from './workflow-runs'
|
|
28
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
|
+
|
|
29
38
|
/** What the coordinator is told besides a lane that stopped. The values are the words of the report. */
|
|
30
39
|
export const WATCH_EVENT = {
|
|
31
40
|
merged: 'merged',
|
|
@@ -41,6 +50,7 @@ export const WATCH_EVENT = {
|
|
|
41
50
|
laneModel: LANE_MODEL_EVENT,
|
|
42
51
|
laneParallel: LANE_PARALLEL_EVENT,
|
|
43
52
|
taskParallel: TASK_PARALLEL_EVENT,
|
|
53
|
+
noTaskSignal: NO_TASK_SIGNAL_EVENT,
|
|
44
54
|
approval: APPROVAL_EVENT,
|
|
45
55
|
source: SOURCE_EVENT,
|
|
46
56
|
due: APPOINTMENT_EVENT.due,
|
|
@@ -95,12 +105,18 @@ export type WatchState = {
|
|
|
95
105
|
laneParallels: LaneParallels
|
|
96
106
|
/** The fingerprint of the parallel-task situation of each watched lane at the last heartbeat; a proposal is made only when it moved. */
|
|
97
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
|
|
98
112
|
/** The ids of the approval requests on disk when they were last read, each reported once while it is open. */
|
|
99
113
|
approvals: string[]
|
|
100
114
|
/** Per configured source of `watch.sources`: the entries seen (`[id, fingerprint]`) and the start of a failure streak. */
|
|
101
115
|
sources: SourceState
|
|
102
116
|
/** Lane → change time of the report file last shown for it, null when it had none; a lane without an entry is unseen. */
|
|
103
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>
|
|
104
120
|
/** The lane lines of the status block last printed; null before the first report. */
|
|
105
121
|
statusLines: string[] | null
|
|
106
122
|
/** The board's appointments reported as due and its unreadable appointment rows reported, each once (`appointments.ts`). */
|
|
@@ -149,6 +165,7 @@ export type WatchEvent =
|
|
|
149
165
|
| LaneModelEvent
|
|
150
166
|
| LaneParallelEvent
|
|
151
167
|
| TaskParallelEvent
|
|
168
|
+
| NoTaskSignalEvent
|
|
152
169
|
| ApprovalEvent
|
|
153
170
|
| SourceEvent
|
|
154
171
|
| AppointmentEvent
|
|
@@ -184,9 +201,12 @@ export const emptyWatchState = (): WatchState => ({
|
|
|
184
201
|
laneModels: {},
|
|
185
202
|
laneParallels: {},
|
|
186
203
|
taskParallels: {},
|
|
204
|
+
parallelAutoEnded: null,
|
|
205
|
+
noTaskSignals: {},
|
|
187
206
|
approvals: [],
|
|
188
207
|
sources: emptySourceState(),
|
|
189
208
|
reportMtimes: {},
|
|
209
|
+
reportQuestions: {},
|
|
190
210
|
statusLines: null,
|
|
191
211
|
appointments: emptyAppointmentState(),
|
|
192
212
|
})
|
|
@@ -230,10 +250,13 @@ const isWatchState = (value: Record<string, unknown>): value is WatchState =>
|
|
|
230
250
|
isLaneModels(value.laneModels) &&
|
|
231
251
|
isLaneParallels(value.laneParallels) &&
|
|
232
252
|
isTaskParallelFingerprints(value.taskParallels) &&
|
|
253
|
+
(value.parallelAutoEnded === null || typeof value.parallelAutoEnded === 'string') &&
|
|
254
|
+
isNoSignalState(value.noTaskSignals) &&
|
|
233
255
|
Array.isArray(value.approvals) &&
|
|
234
256
|
value.approvals.every((id) => typeof id === 'string') &&
|
|
235
257
|
isSourceState(value.sources) &&
|
|
236
258
|
everyValue(value.reportMtimes, isSeq) &&
|
|
259
|
+
everyValue(value.reportQuestions, (entry) => typeof entry === 'string') &&
|
|
237
260
|
(value.statusLines === null ||
|
|
238
261
|
(Array.isArray(value.statusLines) && value.statusLines.every((line) => typeof line === 'string'))) &&
|
|
239
262
|
isAppointmentState(value.appointments)
|