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
|
@@ -6,7 +6,7 @@ export type PhaseRef = { planHref: string; phaseId: string }
|
|
|
6
6
|
/** Per plan link and phase id, the state of its checkboxes as `1` (ticked) and `0` (open) in plan order. */
|
|
7
7
|
export type TickSnapshot = Record<string, Record<string, string>>
|
|
8
8
|
|
|
9
|
-
/** When a phase
|
|
9
|
+
/** When a phase was last ticked or unticked, in milliseconds, by `activityKey`. */
|
|
10
10
|
export type ActivityTimes = Readonly<Record<string, number>>
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -54,20 +54,30 @@ export const movedPhases = (previous: TickSnapshot, next: TickSnapshot): PhaseRe
|
|
|
54
54
|
}),
|
|
55
55
|
)
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
const tickCount = (ticks: string): number => [...ticks].filter((state) => state === '1').length
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The phases whose number of ticks changed between two reads of a plan `previous` read already, a phase new in `next`
|
|
61
|
+
* counted from none: a tick is activity, task lines written into a phase are not, so phases a lane only wrote down
|
|
62
|
+
* never run (17:10). A phase remembered in the format before D67 has no state to move from.
|
|
63
|
+
*/
|
|
64
|
+
const tickedPhases = (previous: TickSnapshot, next: TickSnapshot): PhaseRef[] =>
|
|
59
65
|
Object.entries(next).flatMap(([planHref, phases]) => {
|
|
60
66
|
const before = previous[planHref]
|
|
61
67
|
if (before === undefined) return []
|
|
62
|
-
return Object.
|
|
68
|
+
return Object.entries(phases).flatMap(([phaseId, ticks]) => {
|
|
69
|
+
const old = before[phaseId]
|
|
70
|
+
if (old !== undefined && !old.startsWith(TASK_TICKS)) return []
|
|
71
|
+
return tickCount(old ?? '') === tickCount(ticks) ? [] : [{ planHref, phaseId }]
|
|
72
|
+
})
|
|
63
73
|
})
|
|
64
74
|
|
|
65
75
|
/** When each plan file was last written, in milliseconds, by plan link; a plan without an entry is bounded by the clock. */
|
|
66
76
|
export type PlanModified = Readonly<Record<string, number>>
|
|
67
77
|
|
|
68
78
|
/**
|
|
69
|
-
* When each phase last
|
|
70
|
-
* since `previous
|
|
79
|
+
* When each phase was last ticked, after a read at `now` whose ticks are `ticks`: a phase whose number of ticks changed
|
|
80
|
+
* since `previous` (`tickedPhases`), a new one in a plan `previous` read already included, takes the time its plan file was last written
|
|
71
81
|
* (`modified`), bounded by `now`, so a change first seen long after it was made is not taken for a fresh one; the
|
|
72
82
|
* others keep their time. The first read knows no movement, so without a remembered read no phase has a time.
|
|
73
83
|
*/
|
|
@@ -78,7 +88,7 @@ export const nextActivity = (
|
|
|
78
88
|
modified: PlanModified = {},
|
|
79
89
|
): ActivityTimes => {
|
|
80
90
|
if (!previous) return {}
|
|
81
|
-
const changed =
|
|
91
|
+
const changed = tickedPhases(previous.ticks, ticks)
|
|
82
92
|
return {
|
|
83
93
|
...previous.activity,
|
|
84
94
|
...Object.fromEntries(
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { parseLogEntry } from '../context/session-log'
|
|
1
2
|
import type { PlanPhase } from '../plan-format'
|
|
3
|
+
import { isRecord } from '../shared/is-record'
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* A hint that a phase's task is being worked on: `taskIndex` is the task's index in the phase's checkboxes, `at` when
|
|
5
|
-
* it was seen, in milliseconds.
|
|
7
|
+
* it was seen, in milliseconds. `taskSignalsOf` derives them from what the lane's agents did lately.
|
|
6
8
|
*/
|
|
7
|
-
export type
|
|
9
|
+
export type TaskActivity = { phaseId: string; taskIndex: number; at: number }
|
|
8
10
|
|
|
9
11
|
/** A phase as `currentTasks` reads it: its id and checkboxes. */
|
|
10
12
|
export type TaskPhase = Pick<PlanPhase, 'id' | 'items'>
|
|
@@ -14,14 +16,17 @@ const isOpenTask = (phase: TaskPhase, index: number): boolean => phase.items[ind
|
|
|
14
16
|
/** The task the newest signal younger than `staleAfterMs` points at, when it is still open; null otherwise. */
|
|
15
17
|
const signalledTask = (
|
|
16
18
|
phase: TaskPhase,
|
|
17
|
-
signals: readonly
|
|
19
|
+
signals: readonly TaskActivity[],
|
|
18
20
|
now: number,
|
|
19
21
|
staleAfterMs: number,
|
|
20
22
|
): number | null => {
|
|
21
23
|
const fresh = signals
|
|
22
24
|
.filter((signal) => signal.phaseId === phase.id && now - signal.at < staleAfterMs)
|
|
23
25
|
.filter((signal) => isOpenTask(phase, signal.taskIndex))
|
|
24
|
-
const newest = fresh.reduce<
|
|
26
|
+
const newest = fresh.reduce<TaskActivity | null>(
|
|
27
|
+
(last, signal) => (last && last.at >= signal.at ? last : signal),
|
|
28
|
+
null,
|
|
29
|
+
)
|
|
25
30
|
return newest?.taskIndex ?? null
|
|
26
31
|
}
|
|
27
32
|
|
|
@@ -38,7 +43,7 @@ const firstOpenTask = ({ items }: TaskPhase): number => {
|
|
|
38
43
|
*/
|
|
39
44
|
export const currentTasks = (
|
|
40
45
|
plan: readonly TaskPhase[],
|
|
41
|
-
signals: readonly
|
|
46
|
+
signals: readonly TaskActivity[],
|
|
42
47
|
now: number,
|
|
43
48
|
staleAfterMs: number,
|
|
44
49
|
): ReadonlyMap<string, number> =>
|
|
@@ -49,3 +54,56 @@ export const currentTasks = (
|
|
|
49
54
|
return [[phase.id, signalledTask(phase, signals, now, staleAfterMs) ?? first] as const]
|
|
50
55
|
}),
|
|
51
56
|
)
|
|
57
|
+
|
|
58
|
+
const WORD = /[a-zäöüß][a-zäöüß0-9-]{5,}/g
|
|
59
|
+
/** The share of a task's distinctive words the agent used, from which on it counts as working on that task. */
|
|
60
|
+
const SIGNAL_SHARE = 0.4
|
|
61
|
+
const SIGNAL_MIN_WORDS = 3
|
|
62
|
+
/** Only the start of a task's text is read: the rest is reasons and origin. */
|
|
63
|
+
const TASK_TEXT_LENGTH = 200
|
|
64
|
+
|
|
65
|
+
const wordsOf = (text: string): Set<string> => new Set(text.toLowerCase().match(WORD) ?? [])
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The signal each phase gets from what an agent of the lane did lately (`activity`, see `activityTextOf`): the open
|
|
69
|
+
* task of which the agent used at least `SIGNAL_SHARE` of the distinctive words (six letters or more, from the first
|
|
70
|
+
* 200 characters) and at least three, and clearly the most of all open tasks of the phase. Measured against the logs of
|
|
71
|
+
* the lane's agents: the prompt alone names several tasks, the words of the last entries single one out; the first open
|
|
72
|
+
* task stays the fallback when no task leads.
|
|
73
|
+
*/
|
|
74
|
+
export const taskSignalsOf = (plan: readonly TaskPhase[], activity: string, at: number): TaskActivity[] => {
|
|
75
|
+
const used = wordsOf(activity)
|
|
76
|
+
if (used.size === 0) return []
|
|
77
|
+
return plan.flatMap((phase) => {
|
|
78
|
+
const scored = phase.items.flatMap((item, taskIndex) => {
|
|
79
|
+
if (item.done) return []
|
|
80
|
+
const words = [...wordsOf(item.text.slice(0, TASK_TEXT_LENGTH))]
|
|
81
|
+
const hits = words.filter((word) => used.has(word)).length
|
|
82
|
+
return words.length > 0 && hits >= SIGNAL_MIN_WORDS && hits / words.length >= SIGNAL_SHARE
|
|
83
|
+
? [{ taskIndex, hits }]
|
|
84
|
+
: []
|
|
85
|
+
})
|
|
86
|
+
const [best, second] = scored.sort((left, right) => right.hits - left.hits)
|
|
87
|
+
return best && best.hits > (second?.hits ?? 0) ? [{ phaseId: phase.id, taskIndex: best.taskIndex, at }] : []
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const contentTexts = (content: unknown): string[] => {
|
|
92
|
+
if (typeof content === 'string') return [content]
|
|
93
|
+
if (!Array.isArray(content)) return []
|
|
94
|
+
return content.flatMap((block) => {
|
|
95
|
+
if (typeof block !== 'object' || block === null) return []
|
|
96
|
+
const { text, input } = block as { text?: unknown; input?: unknown }
|
|
97
|
+
return [typeof text === 'string' ? text : '', input === undefined ? '' : JSON.stringify(input)]
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The text of an agent's last `entries` log entries (messages and tool inputs), for `taskSignalsOf`; lines that are no JSON are skipped. */
|
|
102
|
+
export const activityTextOf = (lines: readonly string[], entries: number): string =>
|
|
103
|
+
lines
|
|
104
|
+
.slice(-entries)
|
|
105
|
+
.flatMap((line) => {
|
|
106
|
+
const message = parseLogEntry(line)?.message
|
|
107
|
+
return isRecord(message) ? contentTexts(message.content) : []
|
|
108
|
+
})
|
|
109
|
+
.join(' ')
|
package/src/plans/fetch.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { errorText } from './error-text'
|
|
2
2
|
import type { PullRequestState } from './github'
|
|
3
|
+
import { pullHeadRefspec } from './plan-heads'
|
|
4
|
+
import type { PullRequest } from './pull-requests'
|
|
3
5
|
|
|
4
6
|
/** The background fetch of the base branch: when it last ran, when it last worked and why it failed. */
|
|
5
7
|
export type FetchState = {
|
|
@@ -70,12 +72,13 @@ export const fetchNotice = (state: FetchState, baseRef: string, locale: string):
|
|
|
70
72
|
return `fetch failed (${state.failure}), reading ${baseRef} as it is: ${known}`
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
/** Fetches
|
|
74
|
-
export const
|
|
75
|
+
/** Fetches `refspecs` from `remote` in one call; returns the reason when it did not work, null when it did or there was nothing to fetch. Never rejects. */
|
|
76
|
+
export const fetchRefs = async (root: string, remote: string, refspecs: readonly string[]): Promise<string | null> => {
|
|
77
|
+
if (refspecs.length === 0) return null
|
|
75
78
|
let timedOut = false
|
|
76
79
|
let timer: Timer | undefined
|
|
77
80
|
try {
|
|
78
|
-
const child = Bun.spawn(['git', '-C', root, 'fetch', '--quiet', remote,
|
|
81
|
+
const child = Bun.spawn(['git', '-C', root, 'fetch', '--quiet', remote, ...refspecs], {
|
|
79
82
|
stdout: 'ignore',
|
|
80
83
|
stderr: 'pipe',
|
|
81
84
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
@@ -94,3 +97,23 @@ export const fetchBase = async (root: string, remote: string, branch: string): P
|
|
|
94
97
|
clearTimeout(timer)
|
|
95
98
|
}
|
|
96
99
|
}
|
|
100
|
+
|
|
101
|
+
/** Fetches the base branch; returns the reason when it did not work, null when it did. Never rejects. */
|
|
102
|
+
export const fetchBase = (root: string, remote: string, branch: string): Promise<string | null> =>
|
|
103
|
+
fetchRefs(root, remote, [branch])
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Fetches the heads of the given pull requests into the herd's own refs (`pullHeadRef`), in one call after the base.
|
|
107
|
+
* A failure is not reported: a head that was not fetched reads nothing, and the plan stays as the base has it.
|
|
108
|
+
*/
|
|
109
|
+
export const fetchPlanHeads = async (
|
|
110
|
+
root: string,
|
|
111
|
+
remote: string,
|
|
112
|
+
pullRequests: readonly Pick<PullRequest, 'number'>[],
|
|
113
|
+
): Promise<void> => {
|
|
114
|
+
await fetchRefs(
|
|
115
|
+
root,
|
|
116
|
+
remote,
|
|
117
|
+
pullRequests.map(({ number }) => pullHeadRefspec(number)),
|
|
118
|
+
)
|
|
119
|
+
}
|
package/src/plans/git.ts
CHANGED
|
@@ -35,18 +35,32 @@ export const commitOf = (root: string, ref: string): string | null =>
|
|
|
35
35
|
/** Reads files as they are at the base ref. */
|
|
36
36
|
export type BaseReader = (path: string) => string | null
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
/** How many commits keep their texts: the base ref and the heads of the open pull requests that change a plan. */
|
|
39
|
+
const CACHED_COMMITS = 16
|
|
40
|
+
|
|
41
|
+
/** The texts read per `root\0commit`, the one asked last at the end. */
|
|
42
|
+
const cachedTexts = new Map<string, Map<string, string>>()
|
|
43
|
+
|
|
44
|
+
const textsOf = (root: string, commit: string): Map<string, string> => {
|
|
45
|
+
const key = `${root}\0${commit}`
|
|
46
|
+
const texts = cachedTexts.get(key) ?? new Map<string, string>()
|
|
47
|
+
cachedTexts.delete(key)
|
|
48
|
+
cachedTexts.set(key, texts)
|
|
49
|
+
const oldest = cachedTexts.keys().next().value
|
|
50
|
+
if (cachedTexts.size > CACHED_COMMITS && oldest !== undefined) cachedTexts.delete(oldest)
|
|
51
|
+
return texts
|
|
52
|
+
}
|
|
39
53
|
|
|
40
54
|
/**
|
|
41
55
|
* A reader for the files at `ref`, with the commit resolved once. A file at a commit never changes, so what was
|
|
42
|
-
* read is kept for as long as the
|
|
43
|
-
* rev-parse` instead of one `git show` per plan
|
|
56
|
+
* read is kept for as long as the commit is among the last `CACHED_COMMITS` asked: a re-read of the sprint costs
|
|
57
|
+
* one `git rev-parse` per ref instead of one `git show` per plan, and the base and the pull request heads read in
|
|
58
|
+
* the same round do not evict each other.
|
|
44
59
|
*/
|
|
45
60
|
export const baseReaderOf = (root: string, ref: string): BaseReader => {
|
|
46
61
|
const commit = commitOf(root, ref)
|
|
47
62
|
if (commit === null) return () => null
|
|
48
|
-
|
|
49
|
-
const { texts } = cached
|
|
63
|
+
const texts = textsOf(root, commit)
|
|
50
64
|
return (path) => {
|
|
51
65
|
const kept = texts.get(path)
|
|
52
66
|
if (kept !== undefined) return kept
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readFileSync, readlinkSync, unlinkSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
|
|
5
|
+
import {
|
|
6
|
+
type LocationSystem,
|
|
7
|
+
readPrivateText,
|
|
8
|
+
runtimeBaseOf,
|
|
9
|
+
systemLocation,
|
|
10
|
+
userDirectoryOf,
|
|
11
|
+
} from '../shared/private-location'
|
|
12
|
+
import { HERD_COMMAND } from './herd-command'
|
|
13
|
+
import { CHILD_MARKER, TAKEN_OVER_CODE, TAKEOVER_SIGNAL } from './restart-decision'
|
|
14
|
+
|
|
15
|
+
const DIRECTORY_PREFIX = 'jackherd-herd'
|
|
16
|
+
const POLL_MS = 50
|
|
17
|
+
const POLLS_BEFORE_KILL = 20
|
|
18
|
+
|
|
19
|
+
export type ClaimDeps = {
|
|
20
|
+
selfPid: number
|
|
21
|
+
prepare: () => void
|
|
22
|
+
readRecord: () => string | null
|
|
23
|
+
writeRecord: (pid: number) => void
|
|
24
|
+
removeRecordIf: (pid: number) => void
|
|
25
|
+
isHerdView: (pid: number) => boolean
|
|
26
|
+
isGone: (pid: number) => boolean
|
|
27
|
+
signal: (pid: number, signal: NodeJS.Signals) => void
|
|
28
|
+
sleep: (ms: number) => Promise<void>
|
|
29
|
+
onExit: (run: () => void) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A process id as the record file holds it; nothing for anything but a positive integer. */
|
|
33
|
+
export const parsePid = (text: string | null): number | null => {
|
|
34
|
+
if (text === null || !/^\d+\s*$/.test(text)) return null
|
|
35
|
+
const pid = Number(text)
|
|
36
|
+
return pid > 0 ? pid : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The older herd view of this terminal that has to end before `selfPid` draws: the recorded pid when it is not
|
|
41
|
+
* this process and still is a herd view (a number the system has reused for something else is not).
|
|
42
|
+
*/
|
|
43
|
+
export const olderHerdToStop = (
|
|
44
|
+
recorded: number | null,
|
|
45
|
+
selfPid: number,
|
|
46
|
+
isHerdView: (pid: number) => boolean,
|
|
47
|
+
): number | null => (recorded === null || recorded === selfPid || !isHerdView(recorded) ? null : recorded)
|
|
48
|
+
|
|
49
|
+
const endOlderHerd = async (pid: number, deps: ClaimDeps): Promise<void> => {
|
|
50
|
+
deps.signal(pid, TAKEOVER_SIGNAL)
|
|
51
|
+
for (let poll = 0; poll < POLLS_BEFORE_KILL; poll++) {
|
|
52
|
+
if (deps.isGone(pid)) return
|
|
53
|
+
await deps.sleep(POLL_MS)
|
|
54
|
+
}
|
|
55
|
+
if (!deps.isGone(pid) && deps.isHerdView(pid)) deps.signal(pid, 'SIGKILL')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Makes this process the one herd view of its terminal: an older one is sent `TAKEOVER_SIGNAL` and, when it has not ended
|
|
60
|
+
* after about a second and its pid still is a herd view, SIGKILL; then the own pid is recorded and removed again when the process ends. The claim
|
|
61
|
+
* directory is verified first: a record in a directory of someone else is never read, and a directory that cannot be
|
|
62
|
+
* verified throws before anything is read, signalled or written.
|
|
63
|
+
*/
|
|
64
|
+
export const claimTerminal = async (deps: ClaimDeps): Promise<void> => {
|
|
65
|
+
deps.prepare()
|
|
66
|
+
const older = olderHerdToStop(parsePid(deps.readRecord()), deps.selfPid, deps.isHerdView)
|
|
67
|
+
if (older !== null) await endOlderHerd(older, deps)
|
|
68
|
+
deps.writeRecord(deps.selfPid)
|
|
69
|
+
deps.onExit(() => deps.removeRecordIf(deps.selfPid))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The directory holding the claim records: one private level `jackherd-herd-<uid>` below `runtimeBaseOf`. */
|
|
73
|
+
export const claimDirectoryOf = (system: LocationSystem): string =>
|
|
74
|
+
userDirectoryOf(runtimeBaseOf(system), DIRECTORY_PREFIX, system.uid)
|
|
75
|
+
|
|
76
|
+
const exitWhenTakenOver = (): void => {
|
|
77
|
+
process.on(TAKEOVER_SIGNAL, () => process.exit(TAKEN_OVER_CODE))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Runs `start` once the takeover is done; a takeover that cannot be made (no terminal, no `/proc`) does not stop the
|
|
82
|
+
* view. `listen` runs first, so a newer herd's takeover signal that arrives while the claim is made ends this process
|
|
83
|
+
* as a wanted end instead of by the signal's default action; the view replaces that handler with its own.
|
|
84
|
+
*/
|
|
85
|
+
export const startExclusive = async (
|
|
86
|
+
claim: () => Promise<void>,
|
|
87
|
+
start: () => void,
|
|
88
|
+
listen: () => void = exitWhenTakenOver,
|
|
89
|
+
): Promise<void> => {
|
|
90
|
+
listen()
|
|
91
|
+
try {
|
|
92
|
+
await claim()
|
|
93
|
+
} catch {
|
|
94
|
+
// the view still starts: one stale herd is better than none
|
|
95
|
+
}
|
|
96
|
+
start()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const terminalOfStdin = (): string | null => {
|
|
100
|
+
try {
|
|
101
|
+
const target = readlinkSync('/proc/self/fd/0')
|
|
102
|
+
return target.startsWith('/dev/') ? target : null
|
|
103
|
+
} catch {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const readText = (path: string): string | null => {
|
|
109
|
+
try {
|
|
110
|
+
return readFileSync(path, 'utf8')
|
|
111
|
+
} catch {
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const isHerdViewProcess = (pid: number): boolean => {
|
|
117
|
+
const environment = readText(`/proc/${pid}/environ`)
|
|
118
|
+
const command = readText(`/proc/${pid}/cmdline`)
|
|
119
|
+
if (environment === null || command === null) return false
|
|
120
|
+
return environment.split('\0').includes(`${CHILD_MARKER}=1`) && command.split('\0').includes(HERD_COMMAND.name)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const isGoneProcess = (pid: number): boolean => {
|
|
124
|
+
const stat = readText(`/proc/${pid}/stat`)
|
|
125
|
+
if (stat === null) return true
|
|
126
|
+
const stateIndex = stat.lastIndexOf(')') + 2
|
|
127
|
+
return stat.charAt(stateIndex) === 'Z'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const signalProcess = (pid: number, signal: NodeJS.Signals): void => {
|
|
131
|
+
try {
|
|
132
|
+
process.kill(pid, signal)
|
|
133
|
+
} catch {
|
|
134
|
+
// already gone
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const recordFileOf = (terminal: string): string =>
|
|
139
|
+
join(claimDirectoryOf(systemLocation()), `${createHash('sha256').update(terminal).digest('hex').slice(0, 16)}.pid`)
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The claim operations for one record file; the claim directory is verified before the record is read or written, and
|
|
143
|
+
* a record that is a link or a FIFO is not read.
|
|
144
|
+
*/
|
|
145
|
+
export const herdClaimDeps = (file: string): ClaimDeps => ({
|
|
146
|
+
selfPid: process.pid,
|
|
147
|
+
prepare: () => ensurePrivateDirectory(join(file, '..')),
|
|
148
|
+
readRecord: () => readPrivateText(file),
|
|
149
|
+
writeRecord: (pid) => {
|
|
150
|
+
ensurePrivateDirectory(join(file, '..'))
|
|
151
|
+
writePrivateFile(file, `${pid}\n`)
|
|
152
|
+
},
|
|
153
|
+
removeRecordIf: (pid) => {
|
|
154
|
+
try {
|
|
155
|
+
if (parsePid(readPrivateText(file)) === pid) unlinkSync(file)
|
|
156
|
+
} catch {
|
|
157
|
+
// the next herd of this terminal overwrites it
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
isHerdView: isHerdViewProcess,
|
|
161
|
+
isGone: isGoneProcess,
|
|
162
|
+
signal: signalProcess,
|
|
163
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
164
|
+
onExit: (run) => {
|
|
165
|
+
process.on('exit', run)
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
/** Takes the terminal of stdin over from an older herd view; without a terminal (or `/proc`) it does nothing. */
|
|
170
|
+
export const claimOwnTerminal = async (): Promise<void> => {
|
|
171
|
+
const terminal = terminalOfStdin()
|
|
172
|
+
if (terminal !== null) await claimTerminal(herdClaimDeps(recordFileOf(terminal)))
|
|
173
|
+
}
|
package/src/plans/herd-memory.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { tmpdir } from 'node:os'
|
|
3
|
-
import { join } from 'node:path'
|
|
1
|
+
import { dirname, join } from 'node:path'
|
|
4
2
|
import { z } from 'zod'
|
|
3
|
+
import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
|
|
4
|
+
import {
|
|
5
|
+
ensurePrivateLevels,
|
|
6
|
+
type LocationSystem,
|
|
7
|
+
readPrivateText,
|
|
8
|
+
runtimeBaseOf,
|
|
9
|
+
systemLocation,
|
|
10
|
+
userDirectoryOf,
|
|
11
|
+
} from '../shared/private-location'
|
|
5
12
|
import { repositoryKey } from '../watch/watcher-lock'
|
|
6
13
|
import type { LaneMemory } from './current-phase'
|
|
7
14
|
|
|
8
15
|
const DIRECTORY_PREFIX = 'jackherd-herd'
|
|
9
16
|
const MEMORY_FILE = 'herd-memory.json'
|
|
10
|
-
const PRIVATE_DIRECTORY = 0o700
|
|
11
|
-
const PRIVATE_FILE = 0o600
|
|
12
17
|
|
|
13
18
|
const phaseRef = z.object({ planHref: z.string(), phaseId: z.string() })
|
|
14
19
|
|
|
@@ -54,41 +59,46 @@ export const mergedMemories = (
|
|
|
54
59
|
* Where the herds of the repository at `root` keep what they saw of each lane, one directory per user and
|
|
55
60
|
* repository like the watcher's, so a `--once` run and a restarted herd know when a phase last moved.
|
|
56
61
|
*/
|
|
57
|
-
const
|
|
58
|
-
join(
|
|
62
|
+
export const herdMemoryDirectoryOf = (root: string, system: LocationSystem): string =>
|
|
63
|
+
join(userDirectoryOf(runtimeBaseOf(system), DIRECTORY_PREFIX, system.uid), repositoryKey(root))
|
|
59
64
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
return readFileSync(file, 'utf8')
|
|
65
|
-
} catch {
|
|
66
|
-
return null
|
|
67
|
-
}
|
|
65
|
+
/** The memory directory of `root` with both levels verified private (`ensurePrivateLevels`); throws when one is not. */
|
|
66
|
+
const ensureHerdMemoryDirectory = (root: string, system: LocationSystem): string => {
|
|
67
|
+
const directory = herdMemoryDirectoryOf(root, system)
|
|
68
|
+
return ensurePrivateLevels(dirname(directory), directory)
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
export const memoryFileOf = (directory: string): string => join(directory, MEMORY_FILE)
|
|
72
|
+
|
|
70
73
|
export const readLaneMemories = (directory: string): Map<string, LaneMemory> =>
|
|
71
|
-
parseLaneMemories(
|
|
74
|
+
parseLaneMemories(readPrivateText(memoryFileOf(directory)))
|
|
72
75
|
|
|
73
76
|
/**
|
|
74
77
|
* Writes the memories of `lanes` into the file of `directory`, beside what other herds keep there; readable by the
|
|
75
|
-
* user only, and replaced in one step
|
|
78
|
+
* user only (the directory is verified with `ensurePrivateDirectory`), and replaced in one step through
|
|
79
|
+
* `writePrivateFile`, so a herd reading at the same time never sees half a file and a planted link is never followed.
|
|
76
80
|
*/
|
|
77
81
|
export const writeLaneMemories = (
|
|
78
82
|
directory: string,
|
|
79
83
|
memories: ReadonlyMap<string, LaneMemory>,
|
|
80
84
|
lanes: readonly string[],
|
|
81
85
|
): void => {
|
|
82
|
-
|
|
83
|
-
const file = memoryFileOf(directory)
|
|
86
|
+
ensurePrivateDirectory(directory)
|
|
84
87
|
const merged = mergedMemories(readLaneMemories(directory), memories, lanes)
|
|
85
|
-
|
|
86
|
-
writeFileSync(partial, JSON.stringify(merged), { mode: PRIVATE_FILE })
|
|
87
|
-
renameSync(partial, file)
|
|
88
|
+
writePrivateFile(memoryFileOf(directory), JSON.stringify(merged))
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
/**
|
|
91
|
-
|
|
91
|
+
/**
|
|
92
|
+
* The memories the herds of the repository at `root` kept; none when the directory cannot be verified private, so a
|
|
93
|
+
* file planted in a directory of someone else is never read.
|
|
94
|
+
*/
|
|
95
|
+
export const recallLanes = (root: string, system: LocationSystem = systemLocation()): Map<string, LaneMemory> => {
|
|
96
|
+
try {
|
|
97
|
+
return readLaneMemories(ensureHerdMemoryDirectory(root, system))
|
|
98
|
+
} catch {
|
|
99
|
+
return new Map()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
92
102
|
|
|
93
103
|
/**
|
|
94
104
|
* Keeps the memories of `lanes` for the next herd. A memory that cannot be written costs only the times of the next
|
|
@@ -98,9 +108,10 @@ export const rememberLanes = (
|
|
|
98
108
|
root: string,
|
|
99
109
|
memories: ReadonlyMap<string, LaneMemory>,
|
|
100
110
|
lanes: readonly string[],
|
|
111
|
+
system: LocationSystem = systemLocation(),
|
|
101
112
|
): void => {
|
|
102
113
|
try {
|
|
103
|
-
writeLaneMemories(
|
|
114
|
+
writeLaneMemories(ensureHerdMemoryDirectory(root, system), memories, lanes)
|
|
104
115
|
} catch {
|
|
105
116
|
return
|
|
106
117
|
}
|
package/src/plans/lane-plans.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import { type
|
|
3
|
+
import { type PlanPhase, type PlansByHref, resolvePlanPath, type SprintLane } from '../plan-format'
|
|
4
4
|
import type { BaseReader } from './git'
|
|
5
|
-
import { hasOwnWork, mergePlanPhases } from './phase-source'
|
|
5
|
+
import { hasOwnWork, mergePlanPhases, newerPhases } from './phase-source'
|
|
6
|
+
import { type PlanRound, parsedPlanPhases } from './plan-cache'
|
|
7
|
+
import type { PlanHead } from './plan-heads'
|
|
6
8
|
import type { PlansSettings } from './settings'
|
|
7
9
|
|
|
8
10
|
/** Paths that could not be read at the base ref and were read from a checkout instead. */
|
|
@@ -10,8 +12,20 @@ export type Fallbacks = string[]
|
|
|
10
12
|
|
|
11
13
|
export type LaneWork = { worktree: string | null; ahead: number | null; dirty: number }
|
|
12
14
|
|
|
15
|
+
/** Parses a plan's text, kept under `key` in the round's cache. */
|
|
16
|
+
type ParsePlan = (markdown: string, key: string) => PlanPhase[]
|
|
17
|
+
|
|
13
18
|
/** What reading a lane's plans needs: the main checkout, the settings, the base ref's reader, the board's directory. */
|
|
14
|
-
export type PlanSources = {
|
|
19
|
+
export type PlanSources = {
|
|
20
|
+
root: string
|
|
21
|
+
settings: PlansSettings
|
|
22
|
+
base: BaseReader
|
|
23
|
+
sprintDirectory: string
|
|
24
|
+
/** The heads of the open pull requests that change a plan, besides the lane's own; a phase they ticked further counts. */
|
|
25
|
+
heads: readonly PlanHead[]
|
|
26
|
+
/** Kept between reads, so a plan whose text did not change is not parsed again; `used` collects the keys of one round. */
|
|
27
|
+
parsed?: PlanRound
|
|
28
|
+
}
|
|
15
29
|
|
|
16
30
|
const isOutsideRepository = (path: string): boolean => path.startsWith('..')
|
|
17
31
|
|
|
@@ -49,10 +63,30 @@ export const laneWorkOf = (worktree: string, status: { ahead: number | null; dir
|
|
|
49
63
|
})
|
|
50
64
|
|
|
51
65
|
/**
|
|
52
|
-
* The
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
|
|
66
|
+
* The base's phases with each phase an open pull request ticked further taken from that pull request's head, in
|
|
67
|
+
* the heads' order. A head whose ref was not fetched reads nothing and changes nothing; its phases are never the
|
|
68
|
+
* lane's work, so none is marked `touched`.
|
|
69
|
+
*/
|
|
70
|
+
const withPullRequestHeads = (
|
|
71
|
+
base: PlanPhase[],
|
|
72
|
+
heads: readonly PlanHead[],
|
|
73
|
+
path: string,
|
|
74
|
+
parse: ParsePlan,
|
|
75
|
+
): PlanPhase[] =>
|
|
76
|
+
heads
|
|
77
|
+
.filter((head) => head.files.includes(path))
|
|
78
|
+
.reduce((phases, head) => {
|
|
79
|
+
const headText = head.read(path)
|
|
80
|
+
return headText === null ? phases : newerPhases(phases, parse(headText, `${path}\0pr${head.number}`))
|
|
81
|
+
}, base)
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The plans of a lane. The base ref is the truth, raised by the heads of open pull requests that ticked a phase
|
|
85
|
+
* further (`withPullRequestHeads`); while the lane has work of its own in its worktree, the phases its rows name,
|
|
86
|
+
* and those new there or ticked further, come from the worktree (`mergePlanPhases`). A plan the base ref lacks is
|
|
87
|
+
* read from the lane's worktree, else from the main checkout (a repository that ignores `docs/` keeps plan and
|
|
88
|
+
* board only there). `files` are the plan files to watch for changes, `modified` when each was last written, by
|
|
89
|
+
* plan link (`nextActivity` bounds a movement by it).
|
|
56
90
|
*/
|
|
57
91
|
export const readPlans = async (lane: SprintLane, sources: PlanSources, work: LaneWork, fallbacks: Fallbacks) => {
|
|
58
92
|
const { root, settings, sprintDirectory } = sources
|
|
@@ -68,12 +102,9 @@ export const readPlans = async (lane: SprintLane, sources: PlanSources, work: La
|
|
|
68
102
|
if (text === null) continue
|
|
69
103
|
const own = worktreeFile === file && hasOwnWork(work) ? await Bun.file(file).text() : null
|
|
70
104
|
const laneIds = lane.steps.filter((step) => step.planHref === href).flatMap((step) => step.phaseIds)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
laneIds,
|
|
75
|
-
work,
|
|
76
|
-
)
|
|
105
|
+
const parse: ParsePlan = (markdown, key) => parsedPlanPhases(sources.parsed, key, markdown, settings.planLanguage)
|
|
106
|
+
const base = withPullRequestHeads(parse(text, path), sources.heads, path, parse)
|
|
107
|
+
plans[href] = mergePlanPhases(base, own === null ? null : parse(own, `${file}\0own`), laneIds, work)
|
|
77
108
|
files.push(file)
|
|
78
109
|
texts[href] = text
|
|
79
110
|
if (existsSync(file)) modified[href] = statSync(file).mtimeMs
|
|
@@ -45,15 +45,20 @@ const hasOpenWork = (progress: StandingInput['progress']): boolean => {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* Where a lane stands on the board.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
48
|
+
* Where a lane stands on the board. An agent, an open pull request or uncommitted changes in its worktree make a
|
|
49
|
+
* lane active (a small fix has no agent in herdr). The cleaned-up mark goes first, but uncommitted changes in the
|
|
50
|
+
* worktree beat it: work is going on, whatever the board says. Otherwise the mark closes a lane, unless an agent, a
|
|
51
|
+
* worktree or a pull request is still there, which keeps it listed. A worktree alone lists a lane. Without a
|
|
52
|
+
* worktree a lane waits while it has open work, else it is closed.
|
|
51
53
|
*/
|
|
52
54
|
export const laneStanding = (input: StandingInput): LaneStanding => {
|
|
53
55
|
const { progress, status, pullRequests } = input
|
|
54
56
|
const working = status.agent !== null || pullRequests.length > 0
|
|
55
57
|
const editedInWorktree = status.branch !== null && status.dirty > 0
|
|
56
|
-
if (progress.lane.closed)
|
|
58
|
+
if (progress.lane.closed) {
|
|
59
|
+
if (editedInWorktree) return LANE_STANDING.active
|
|
60
|
+
return working || status.branch !== null ? LANE_STANDING.noAgent : LANE_STANDING.closed
|
|
61
|
+
}
|
|
57
62
|
if (working || editedInWorktree) return LANE_STANDING.active
|
|
58
63
|
if (status.branch !== null) return LANE_STANDING.noAgent
|
|
59
64
|
return hasOpenWork(progress) ? LANE_STANDING.waiting : LANE_STANDING.closed
|