jackherd 1.16.0 → 1.18.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 +28 -0
- package/INSTALL.md +2 -1
- package/README.md +29 -33
- package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
- package/dist/config.d.ts +33 -1
- package/dist/config.js +57 -5
- package/dist/plan-format.d.ts +18 -6
- package/dist/plan-format.js +8 -1
- package/docs/plan-contract.md +1 -0
- package/herdr-plugin/herdr-plugin.toml +4 -1
- package/herdr-plugin/restore-plans.ts +60 -26
- package/herdr-plugin/resume.ts +18 -0
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +11 -0
- package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
- package/kit/hooks/hooks/approval-gate/index.ts +36 -7
- package/kit/hooks/hooks/approval-gate/run.ts +72 -16
- package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
- package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
- package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
- package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
- package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
- package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
- package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
- package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +423 -35
- package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
- package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
- package/kit/hooks/hooks/merge-guard/run.ts +110 -28
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
- package/kit/hooks/hooks/plain-question/form.ts +89 -0
- package/kit/hooks/hooks/plain-question/index.ts +44 -4
- package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
- package/kit/hooks/hooks/plain-question/run.ts +14 -7
- package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
- package/kit/hooks/hooks.json +11 -2
- package/kit/review/agents/skill-review.md +3 -2
- package/kit/review/skills/pre-commit/SKILL.md +16 -22
- package/kit/rules/rules/kit/code-style.md +26 -54
- package/kit/rules/rules/kit/pull-requests.md +17 -40
- package/kit/rules/rules/kit/testing.md +6 -12
- package/kit/rules/rules/kit/writing-for-agents.md +3 -0
- package/kit/workflow/skills/follow/SKILL.md +2 -3
- package/kit/workflow/skills/implement/SKILL.md +28 -43
- package/kit/workflow/skills/orchestrate/SKILL.md +201 -458
- package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
- package/kit/workflow/skills/orchestrate/references/approvals.md +23 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
- package/kit/workflow/skills/orchestrate/references/events.md +46 -1
- package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
- package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +10 -2
- package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
- package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
- package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
- package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
- package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
- package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
- package/kit/workflow/skills/plan/SKILL.md +29 -48
- package/package.json +2 -1
- package/recipes/README.md +1 -0
- package/recipes/jira-cli.md +1 -1
- package/recipes/sources/google-calendar-today.ts +8 -2
- package/recipes/sources/jira.ts +10 -2
- package/recipes/sources/linear.ts +9 -3
- package/recipes/sources/sentry.ts +8 -2
- package/recipes/sources.md +4 -2
- package/schemas/jackherd.schema.json +67 -0
- package/schemas/sources.schema.json +9 -0
- package/src/approval/checked.ts +60 -0
- package/src/approval/command.ts +16 -0
- package/src/check/command.ts +51 -6
- package/src/check/lint-pointers.ts +79 -0
- package/src/check/lint-shaped.ts +196 -0
- package/src/check/plan-structure-check.ts +116 -4
- package/src/check/report.ts +9 -3
- package/src/check/schema.ts +37 -0
- package/src/check/settings.ts +4 -0
- package/src/check/skill-budget-defaults.ts +90 -32
- package/src/check/skill-budgets.ts +96 -18
- package/src/cli.ts +2 -0
- package/src/config/personal-sources.ts +46 -10
- package/src/config/repository-identity.ts +40 -0
- package/src/config/schema.ts +53 -1
- package/src/context/command.ts +33 -2
- package/src/context/options.ts +33 -8
- package/src/context/usage-logs.ts +53 -0
- package/src/context/usage.ts +110 -0
- package/src/lane/base-command.ts +35 -0
- package/src/lane/base.ts +43 -0
- package/src/lane/close-check.ts +49 -15
- package/src/lane/close-command.ts +71 -11
- package/src/lane/close-options.ts +29 -8
- package/src/lane/close-survey.ts +13 -1
- package/src/lane/close.ts +51 -12
- package/src/lane/command.ts +21 -3
- package/src/lane/send-command.ts +32 -0
- package/src/lane/send-herdr.ts +77 -0
- package/src/lane/send-options.ts +23 -0
- package/src/lane/send.ts +90 -0
- package/src/lane/skill-hint.ts +27 -0
- package/src/lane/space-command.ts +30 -0
- package/src/lane/space.ts +49 -0
- package/src/lane/start-lane-hint.ts +18 -0
- package/src/plan-contract/base-check-git.ts +33 -0
- package/src/plan-contract/base-check.ts +17 -0
- package/src/plan-contract/command.ts +25 -4
- package/src/plan-format/plan-structure.ts +17 -5
- package/src/plans/ask-coordinator.ts +23 -13
- package/src/plans/board-rows.ts +9 -4
- package/src/plans/click-hint.ts +4 -0
- package/src/plans/command.ts +6 -5
- package/src/plans/ensure-panes.ts +14 -3
- package/src/plans/git.ts +14 -0
- package/src/plans/github.ts +19 -10
- package/src/plans/layout.ts +82 -33
- package/src/plans/live.ts +8 -12
- package/src/plans/plan-panes.ts +21 -4
- package/src/plans/selection.ts +29 -0
- package/src/plans/settings.ts +4 -0
- package/src/resume/command.ts +213 -0
- package/src/resume/decision.ts +102 -0
- package/src/resume/keeper.ts +65 -0
- package/src/resume/location.ts +22 -0
- package/src/resume/log.ts +27 -0
- package/src/resume/ports.ts +102 -0
- package/src/resume/record.ts +116 -0
- package/src/resume/targets.ts +84 -0
- package/src/resume/texts.ts +27 -0
- package/src/secrets/command.ts +15 -5
- package/src/sync/herdr-plugin.ts +2 -11
- package/src/sync/plan-ask-rules.ts +11 -0
- package/src/sync/render.ts +3 -1
- package/src/vocabulary/index.ts +13 -0
- package/src/waiting/command.ts +13 -4
- package/src/watch/command.ts +177 -78
- package/src/watch/config-reload.ts +119 -0
- package/src/watch/lane-model.ts +11 -1
- package/src/watch/lane-progress.ts +70 -8
- package/src/watch/lane-report.ts +66 -11
- package/src/watch/main-checkout.ts +5 -1
- package/src/watch/merge-duties.ts +33 -0
- package/src/watch/merge-overlap-read.ts +69 -0
- package/src/watch/plan-views.ts +16 -4
- package/src/watch/reference-paths.ts +46 -5
- package/src/watch/report-questions.ts +5 -2
- package/src/watch/single-report.ts +3 -2
- package/src/watch/source-dry-run-command.ts +22 -4
- package/src/watch/source-reason.ts +58 -0
- package/src/watch/source-reload.ts +21 -17
- package/src/watch/source-run.ts +24 -7
- package/src/watch/sources.ts +9 -2
- package/src/watch/texts.ts +14 -3
- package/src/watch/waiting-line.ts +4 -3
|
@@ -3,22 +3,29 @@ import { homedir } from 'node:os'
|
|
|
3
3
|
import { resolve } from 'node:path'
|
|
4
4
|
import { findProjectRoot } from '../config/location'
|
|
5
5
|
import { readConfig } from '../config/read-config'
|
|
6
|
+
import { installedReferenceHint } from '../lane/skill-hint'
|
|
6
7
|
import { asksForHelp } from '../preview/options'
|
|
7
8
|
import { CLI_NAME } from '../version/release'
|
|
8
9
|
import type { PlanLanguage } from '../vocabulary'
|
|
10
|
+
import { REFERENCE, type Reference, referenceLine } from '../watch/reference-paths'
|
|
11
|
+
import { baseGapLinesFor } from './base-check-git'
|
|
9
12
|
import { contractOf, type PartFinding, type PlanContract } from './contract'
|
|
10
13
|
|
|
11
14
|
const CHECK_SUBCOMMAND = 'check'
|
|
12
15
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Exit codes of `jh plan check`: the contract is met, a part has a gap, the command line or the file is wrong, the
|
|
18
|
+
* contract is met but the plan or a board that links it is not on the base branch.
|
|
19
|
+
*/
|
|
20
|
+
export const PLAN_CHECK_EXIT = { met: 0, gaps: 1, error: 2, notOnBase: 3 } as const
|
|
15
21
|
|
|
16
22
|
export const USAGE = `usage: ${CLI_NAME} plan ${CHECK_SUBCOMMAND} <plan.md>
|
|
17
23
|
|
|
18
24
|
Checks one plan against the plan contract: phases, tasks in every phase, a model for every phase, a dependency
|
|
19
25
|
row for every phase (from two phases on). One line per part, \`file:line\` where a part is incomplete.
|
|
26
|
+
Where git has the base branch, a plan, or a board that links it, that the base branch lacks gets a \`base:\` line with its path.
|
|
20
27
|
The plan language comes from .jackherd/config.json.
|
|
21
|
-
Exit 0 met, 1 with gaps, 2 for a wrong command line or an unreadable file.`
|
|
28
|
+
Exit 0 met, 1 with gaps, 2 for a wrong command line or an unreadable file, 3 met but not on the base branch.`
|
|
22
29
|
|
|
23
30
|
const where = (file: string, line: number | null): string => (line === null ? '' : ` ${file}:${line}`)
|
|
24
31
|
|
|
@@ -45,6 +52,12 @@ const readPlan = (file: string): string | null => {
|
|
|
45
52
|
}
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
/** The line after the findings that names the reference to read for the gap, for exit 1 and exit 3; none without the skill. */
|
|
56
|
+
const printReference = (reference: Reference, home: string): void => {
|
|
57
|
+
const root = findProjectRoot(process.cwd(), home, existsSync) ?? process.cwd()
|
|
58
|
+
for (const line of referenceLine(installedReferenceHint(root, reference, home))) console.log(line)
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
const fail = (message: string): number => {
|
|
49
62
|
console.error(message)
|
|
50
63
|
return PLAN_CHECK_EXIT.error
|
|
@@ -65,5 +78,13 @@ export const run = (args: string[], home: string = homedir()): number => {
|
|
|
65
78
|
if ('error' in result) return fail(result.error)
|
|
66
79
|
const contract = contractOf(markdown, result.language)
|
|
67
80
|
for (const line of contractLines(file, contract)) console.log(line)
|
|
68
|
-
|
|
81
|
+
if (!contract.met) {
|
|
82
|
+
printReference(REFERENCE.adoptPlan, home)
|
|
83
|
+
return PLAN_CHECK_EXIT.gaps
|
|
84
|
+
}
|
|
85
|
+
const gapLines = baseGapLinesFor(file, process.cwd())
|
|
86
|
+
for (const line of gapLines) console.log(line)
|
|
87
|
+
if (gapLines.length === 0) return PLAN_CHECK_EXIT.met
|
|
88
|
+
printReference(REFERENCE.planCommit, home)
|
|
89
|
+
return PLAN_CHECK_EXIT.notOnBase
|
|
69
90
|
}
|
|
@@ -20,11 +20,11 @@ export type PhaseWithoutGate = PhaseHeading
|
|
|
20
20
|
/** A row of the lanes table (1-based line) whose phases cell names no phase id. */
|
|
21
21
|
export type RowWithoutPhase = OpenLaneRow
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*/
|
|
27
|
-
export type MergedPhaseWithOpenBoxes =
|
|
23
|
+
/** A `### Phase` heading (1-based line) whose own section has open checkboxes not moved on with `→ P<n>` (`openLines`, 1-based). */
|
|
24
|
+
export type PhaseWithOpenBoxes = PhaseWithoutGate & { openLines: number[] }
|
|
25
|
+
|
|
26
|
+
/** A `PhaseWithOpenBoxes` whose tasks are all ticked on the base branch. */
|
|
27
|
+
export type MergedPhaseWithOpenBoxes = PhaseWithOpenBoxes
|
|
28
28
|
|
|
29
29
|
type PhaseSection = PhaseWithoutGate & { tasks: boolean; gate: boolean; openLines: number[] }
|
|
30
30
|
|
|
@@ -118,6 +118,18 @@ export const mergedPhasesWithOpenBoxes = (
|
|
|
118
118
|
.map(({ line, id, title, openLines }) => ({ line, id, title, openLines }))
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* The phase `id` of `markdown` when its own section still has open checkboxes, gate boxes included, that no `→ P<n>`
|
|
123
|
+
* at their end moves on; null when it has none or the plan lacks the phase. Unlike `mergedPhasesWithOpenBoxes` it
|
|
124
|
+
* does not ask whether the phase counts as merged: the caller knows which phase a branch builds.
|
|
125
|
+
*/
|
|
126
|
+
export const openBoxesOfPhase = (markdown: string, language: PlanLanguage, id: string): PhaseWithOpenBoxes | null => {
|
|
127
|
+
const section = phaseSections(markdown, language).find((candidate) => candidate.id === id)
|
|
128
|
+
return section && section.openLines.length > 0
|
|
129
|
+
? { line: section.line, id: section.id, title: section.title, openLines: section.openLines }
|
|
130
|
+
: null
|
|
131
|
+
}
|
|
132
|
+
|
|
121
133
|
/**
|
|
122
134
|
* The changed rows of open lanes whose phases cell names no phase id (`P10c`), so neither the view nor the watcher
|
|
123
135
|
* can tie that step to a phase.
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { TERMINAL_ADAPTER, type TerminalAdapter } from '../config'
|
|
2
2
|
import { resolveCoordinatorName } from '../context/coordinator-name'
|
|
3
|
+
import { installedReferenceHintFor } from '../lane/skill-hint'
|
|
3
4
|
import { laneSenderForAsync } from '../settled/send'
|
|
4
5
|
import { type HerdrAsyncRunner, parseAgentList } from '../terminal/herdr'
|
|
5
6
|
import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
7
|
+
import { REFERENCE } from '../watch/reference-paths'
|
|
6
8
|
import { ASK_KIND, type AskTarget, boardRowKey } from './ask-target'
|
|
7
9
|
import { errorText } from './error-text'
|
|
8
10
|
import { isQuestionId } from './question-records'
|
|
@@ -18,29 +20,32 @@ export type AskDependencies = {
|
|
|
18
20
|
/** The repository's directory name, from which the coordinator's herdr name follows. */
|
|
19
21
|
repository: string
|
|
20
22
|
run: HerdrAsyncRunner
|
|
23
|
+
/** Where the repository is looked up for the skill's reference; the working directory when omitted. */
|
|
24
|
+
cwd?: string
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
/** A sentence takes the subject's key and, for an entry of a source, the source it belongs to (else `''`). */
|
|
24
|
-
type AskSentences = Record<AskTarget['kind'], (subject: string, scope: string) => string>
|
|
28
|
+
type AskSentences = Record<AskTarget['kind'], (subject: string, scope: string, hint: string) => string>
|
|
25
29
|
|
|
26
30
|
const ASK_LINES: Record<PlanLanguage, AskSentences> = {
|
|
27
31
|
[PLAN_LANGUAGE.de]: {
|
|
28
|
-
[ASK_KIND.question]: (id) => `Stell mir die offene Frage ${id} jetzt (jh waiting)
|
|
29
|
-
[ASK_KIND.boardRow]: (key) =>
|
|
32
|
+
[ASK_KIND.question]: (id, _scope, hint) => `Stell mir die offene Frage ${id} jetzt (jh waiting).${hint}`,
|
|
33
|
+
[ASK_KIND.boardRow]: (key, _scope, hint) =>
|
|
34
|
+
`Stell mir die offene Board-Zeile \`${key}\` jetzt (jh waiting).${hint}`,
|
|
30
35
|
[ASK_KIND.appointmentRun]: (key) => `Führe den Termin \`${key}\` jetzt aus, unter den Freigaben, die er braucht.`,
|
|
31
36
|
[ASK_KIND.appointmentMove]: (key) =>
|
|
32
37
|
`Frag den User per Dialog nach der neuen Zeit für den Termin \`${key}\` und trag sie ins Board ein (verschobene Zeile = neuer Termin).`,
|
|
33
|
-
[ASK_KIND.sourceEntry]: (title, source) =>
|
|
34
|
-
`Sieh dir die Meldung \`${title}\` der Quelle \`${source}\` an und stell mir, falls nötig, die Frage dazu (jh waiting)
|
|
38
|
+
[ASK_KIND.sourceEntry]: (title, source, hint) =>
|
|
39
|
+
`Sieh dir die Meldung \`${title}\` der Quelle \`${source}\` an und stell mir, falls nötig, die Frage dazu (jh waiting).${hint}`,
|
|
35
40
|
},
|
|
36
41
|
[PLAN_LANGUAGE.en]: {
|
|
37
|
-
[ASK_KIND.question]: (id) => `Ask me the open question ${id} now (jh waiting)
|
|
38
|
-
[ASK_KIND.boardRow]: (key) => `Ask me the open board row \`${key}\` now (jh waiting)
|
|
42
|
+
[ASK_KIND.question]: (id, _scope, hint) => `Ask me the open question ${id} now (jh waiting).${hint}`,
|
|
43
|
+
[ASK_KIND.boardRow]: (key, _scope, hint) => `Ask me the open board row \`${key}\` now (jh waiting).${hint}`,
|
|
39
44
|
[ASK_KIND.appointmentRun]: (key) => `Run the appointment \`${key}\` now, under the approvals it needs.`,
|
|
40
45
|
[ASK_KIND.appointmentMove]: (key) =>
|
|
41
46
|
`Ask the user in a dialog for the new time of the appointment \`${key}\` and write it into the board (a moved row is a new appointment).`,
|
|
42
|
-
[ASK_KIND.sourceEntry]: (title, source) =>
|
|
43
|
-
`Look at the report \`${title}\` of the source \`${source}\` and, if it needs it, put the question about it to me (jh waiting)
|
|
47
|
+
[ASK_KIND.sourceEntry]: (title, source, hint) =>
|
|
48
|
+
`Look at the report \`${title}\` of the source \`${source}\` and, if it needs it, put the question about it to me (jh waiting).${hint}`,
|
|
44
49
|
},
|
|
45
50
|
}
|
|
46
51
|
|
|
@@ -60,10 +65,11 @@ const scopeOf = (target: AskTarget): string =>
|
|
|
60
65
|
/**
|
|
61
66
|
* The line sent to the coordinator: a fixed sentence that carries the id of a question, or the sanitised key of a
|
|
62
67
|
* row (`boardRowKey`), and nothing else from the board or the question, so a prompt line cannot be steered by
|
|
63
|
-
* the file it reads. The caller checks the id with `isQuestionId`.
|
|
68
|
+
* the file it reads. The caller checks the id with `isQuestionId`. The sentences that send the coordinator to `jh waiting` end
|
|
69
|
+
* with `hint`, the reference that says how to put the question (none without the orchestrate skill).
|
|
64
70
|
*/
|
|
65
|
-
export const askLine = (target: AskTarget, language: PlanLanguage): string =>
|
|
66
|
-
ASK_LINES[language][target.kind](subjectKeyOf(target), scopeOf(target))
|
|
71
|
+
export const askLine = (target: AskTarget, language: PlanLanguage, hint = ''): string =>
|
|
72
|
+
ASK_LINES[language][target.kind](subjectKeyOf(target), scopeOf(target), hint)
|
|
67
73
|
|
|
68
74
|
const isAskable = (target: AskTarget): boolean =>
|
|
69
75
|
target.kind === ASK_KIND.question
|
|
@@ -114,7 +120,11 @@ export const askCoordinator = async (target: AskTarget, dependencies: AskDepende
|
|
|
114
120
|
if ('reason' in coordinator) return TEXTS.notAsked(coordinator.reason)
|
|
115
121
|
const failure = await laneSenderForAsync(dependencies.adapter, dependencies.run)(
|
|
116
122
|
coordinator.name,
|
|
117
|
-
askLine(
|
|
123
|
+
askLine(
|
|
124
|
+
target,
|
|
125
|
+
dependencies.language,
|
|
126
|
+
installedReferenceHintFor(dependencies.cwd ?? process.cwd(), REFERENCE.herdAsk),
|
|
127
|
+
),
|
|
118
128
|
)
|
|
119
129
|
return failure === null ? TEXTS.asked(coordinator.name, subjectOf(target)) : TEXTS.notAsked(failure)
|
|
120
130
|
}
|
package/src/plans/board-rows.ts
CHANGED
|
@@ -26,18 +26,23 @@ const newPartOf = (lane: SprintLane, step: PlanStep): PlanStep | null => {
|
|
|
26
26
|
return rest.phaseIds.length > 0 ? rest : null
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/**
|
|
29
|
+
/** Whether the lane is closed: the base says so, or the checkout marks it cleaned up before the mark is merged. */
|
|
30
|
+
const closedBy = (known: SprintLane, checkout: SprintLane | undefined): boolean =>
|
|
31
|
+
known.closed || checkout?.closed === true
|
|
32
|
+
|
|
33
|
+
/** The lane with the checkout's steps it does not have yet and its closed mark; the same object when nothing is new. */
|
|
30
34
|
const grownBy = (known: SprintLane, checkout: SprintLane | undefined): SprintLane => {
|
|
31
|
-
const grown: SprintLane = { ...known, steps: [...known.steps] }
|
|
35
|
+
const grown: SprintLane = { ...known, steps: [...known.steps], closed: closedBy(known, checkout) }
|
|
32
36
|
for (const step of checkout?.steps ?? []) {
|
|
33
37
|
const part = newPartOf(grown, step)
|
|
34
38
|
if (part !== null) grown.steps.push(part)
|
|
35
39
|
}
|
|
36
|
-
return grown.steps.length > known.steps.length ? grown : known
|
|
40
|
+
return grown.steps.length > known.steps.length || grown.closed !== known.closed ? grown : known
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
/**
|
|
40
|
-
* The board at the base ref with the rows only the checkout has. Base lanes keep their order and their rows
|
|
44
|
+
* The board at the base ref with the rows only the checkout has. Base lanes keep their order and their rows, and are
|
|
45
|
+
* closed when the checkout marks them cleaned up, so the herd drops a lane before the mark reaches the base;
|
|
41
46
|
* a lane only the checkout names is appended; a step of a known lane is added when the base does not name its
|
|
42
47
|
* phases for that plan yet (identity: plan link and phase id), a row without a plan link when its label is new.
|
|
43
48
|
* Both sides come parsed, this function reads no text.
|
package/src/plans/click-hint.ts
CHANGED
|
@@ -3,8 +3,10 @@ import { pullRequestNumberOfKey } from './pull-requests'
|
|
|
3
3
|
import { LINKS_KEY_PREFIX } from './row-focus'
|
|
4
4
|
import {
|
|
5
5
|
BACKLOG_KEY,
|
|
6
|
+
entryBlockOfKey,
|
|
6
7
|
finishedKey,
|
|
7
8
|
isBlockFolded,
|
|
9
|
+
isEntryBlockFolded,
|
|
8
10
|
isLaneOpen,
|
|
9
11
|
isLanesOpen,
|
|
10
12
|
isTaskRestKey,
|
|
@@ -55,6 +57,8 @@ const foldingHint = (key: string, state: Selection, agentOf: AgentOf): string |
|
|
|
55
57
|
const lane = laneOfKey(key)
|
|
56
58
|
if (lane !== null) return foldHint(isLaneOpen(state, lane, agentOf(lane)), lane)
|
|
57
59
|
if (key === LANES_KEY) return foldHint(isLanesOpen(state), TEXTS.lanesHeading)
|
|
60
|
+
const entrySource = entryBlockOfKey(key)
|
|
61
|
+
if (entrySource !== null) return foldHint(!isEntryBlockFolded(state, entrySource), entrySource)
|
|
58
62
|
const top = topBlockOfKey(key)
|
|
59
63
|
if (top !== null) return foldHint(!isBlockFolded(state, top), TOP_BLOCK_NAMES[top])
|
|
60
64
|
const open = state.expanded.has(key)
|
package/src/plans/command.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { laneStatusSourceFor } from '../terminal'
|
|
|
4
4
|
import { listHerdrAgents } from '../terminal/herdr'
|
|
5
5
|
import { startedKit } from '../version/started-kit'
|
|
6
6
|
import { boardFor, foundLanesOf } from './board-pick'
|
|
7
|
-
import {
|
|
7
|
+
import { ensurePlanPaneRun } from './ensure-panes'
|
|
8
8
|
import { mainCheckout, worktreeRoot } from './git'
|
|
9
9
|
import { fetchPullRequests } from './github'
|
|
10
10
|
import { claimOwnTerminal, startExclusive } from './herd-claim'
|
|
@@ -12,6 +12,7 @@ import { recallLanes, rememberLanes } from './herd-memory'
|
|
|
12
12
|
import { publishedKey } from './herd-state'
|
|
13
13
|
import { runLive } from './live'
|
|
14
14
|
import { parseOptions } from './options'
|
|
15
|
+
import { ENSURE_EXIT } from './plan-panes'
|
|
15
16
|
import { isSupervisedChild } from './restart-decision'
|
|
16
17
|
import { type PlansSettings, readSettings } from './settings'
|
|
17
18
|
import { loadSnapshot, type Source } from './snapshot'
|
|
@@ -55,12 +56,12 @@ export const run = async (args: string[], home: string = homedir()): Promise<num
|
|
|
55
56
|
foundLanesOf(root, settings, () => terminal.listAgents().map((agent) => agent.name)),
|
|
56
57
|
)
|
|
57
58
|
if (options.ensure) {
|
|
58
|
-
const lines =
|
|
59
|
+
const { lines, exitCode } =
|
|
59
60
|
settings.adapter === TERMINAL_ADAPTER.herdr
|
|
60
|
-
?
|
|
61
|
-
: ['the terminal adapter is not herdr: no panes to bring back']
|
|
61
|
+
? ensurePlanPaneRun(root, settings, picked.file)
|
|
62
|
+
: { lines: ['the terminal adapter is not herdr: no panes to bring back'], exitCode: ENSURE_EXIT.done }
|
|
62
63
|
for (const line of lines.length > 0 ? lines : ['every herd pane is running']) console.log(line)
|
|
63
|
-
return
|
|
64
|
+
return exitCode
|
|
64
65
|
}
|
|
65
66
|
const source: Source = { root, sprintFile: picked.file, options, settings, terminal, ...agentListOf(settings) }
|
|
66
67
|
if (options.once || !process.stdout.isTTY) await printOnceWithNotice(source, picked.notice)
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
agentPanesOf,
|
|
9
9
|
coordinatorLabelOf,
|
|
10
10
|
coordinatorPaneOf,
|
|
11
|
+
type EnsureExit,
|
|
12
|
+
ensureExitOf,
|
|
11
13
|
foregroundOf,
|
|
12
14
|
missingCoordinatorNote,
|
|
13
15
|
type PaneAction,
|
|
@@ -60,9 +62,14 @@ const labelCoordinator = (label: { pane: string; label: string } | null) => {
|
|
|
60
62
|
/**
|
|
61
63
|
* `herd --ensure`: brings back every missing herd pane beside the coordinator and each lane of the board, and
|
|
62
64
|
* leaves running ones alone, so it can run after every start. A lane gets its view once its agent runs. One line per
|
|
63
|
-
* action or skipped target, none when every view runs
|
|
65
|
+
* action or skipped target, none when every view runs, and the exit code (`ENSURE_EXIT`) that says whether the
|
|
66
|
+
* coordinator's pane was found. The coordinator's pane is labelled `🐾 Jack` on the way.
|
|
64
67
|
*/
|
|
65
|
-
export const
|
|
68
|
+
export const ensurePlanPaneRun = (
|
|
69
|
+
root: string,
|
|
70
|
+
settings: PlansSettings,
|
|
71
|
+
boardFile: string,
|
|
72
|
+
): { lines: string[]; exitCode: EnsureExit } => {
|
|
66
73
|
const agents = runHerdr(['agent', 'list'])
|
|
67
74
|
const listed = parseAgentList(agents)
|
|
68
75
|
const panes = panesWithForeground(agentPanesOf(agents))
|
|
@@ -84,5 +91,9 @@ export const ensurePlanPanes = (root: string, settings: PlansSettings, boardFile
|
|
|
84
91
|
command: (lane) => viewCommandOf(process.argv, lane),
|
|
85
92
|
missingCoordinator: missingCoordinatorNote(panes, root, coordinatorName),
|
|
86
93
|
})
|
|
87
|
-
return [...actions.map(apply), ...notes]
|
|
94
|
+
return { lines: [...actions.map(apply), ...notes], exitCode: ensureExitOf(panes, coordinator) }
|
|
88
95
|
}
|
|
96
|
+
|
|
97
|
+
/** The lines of `ensurePlanPaneRun`, for a caller that brings the panes back without an exit code (the watcher). */
|
|
98
|
+
export const ensurePlanPanes = (root: string, settings: PlansSettings, boardFile: string): string[] =>
|
|
99
|
+
ensurePlanPaneRun(root, settings, boardFile).lines
|
package/src/plans/git.ts
CHANGED
|
@@ -39,6 +39,11 @@ export const showAtRef = (root: string, ref: string, path: string): string | nul
|
|
|
39
39
|
return result.exitCode === 0 ? result.stdout.toString() : null
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** Whether `path` (from the repository root, without `..`) exists at `ref`. */
|
|
43
|
+
export const existsOnRef = (root: string, ref: string, path: string): boolean =>
|
|
44
|
+
Bun.spawnSync(['git', '-C', root, 'cat-file', '-e', `${ref}:${path}`], { stdout: 'ignore', stderr: 'ignore' })
|
|
45
|
+
.exitCode === 0
|
|
46
|
+
|
|
42
47
|
/** The commit `ref` points at, null when it does not exist. */
|
|
43
48
|
export const commitOf = (root: string, ref: string): string | null =>
|
|
44
49
|
git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
|
|
@@ -151,6 +156,15 @@ export const mainCheckout = (cwd: string): string => {
|
|
|
151
156
|
return dirname(commonDirectory)
|
|
152
157
|
}
|
|
153
158
|
|
|
159
|
+
/** `mainCheckout`, null outside a git checkout instead of git's error. */
|
|
160
|
+
export const mainCheckoutOrNull = (cwd: string): string | null => {
|
|
161
|
+
try {
|
|
162
|
+
return mainCheckout(cwd)
|
|
163
|
+
} catch {
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
154
168
|
/** The top of the checkout `cwd` is in: the worktree itself, not the main checkout. */
|
|
155
169
|
export const worktreeRoot = (cwd: string): string => {
|
|
156
170
|
const top = git(cwd, ['rev-parse', '--show-toplevel'])
|
package/src/plans/github.ts
CHANGED
|
@@ -4,31 +4,40 @@ import { PULL_REQUEST_FIELDS, type PullRequest, parsePullRequests, pullRequestsB
|
|
|
4
4
|
/** Open pull requests by head branch, or none with the reason when `gh` fails. */
|
|
5
5
|
export type PullRequestState = { byBranch: Map<string, PullRequest[]>; errors: string[] }
|
|
6
6
|
|
|
7
|
+
/** What `gh` answered: stdout on success, else the reason. */
|
|
8
|
+
export type GhAnswer = { ok: true; stdout: string } | { ok: false; reason: string }
|
|
9
|
+
|
|
7
10
|
export const noPullRequests = (): PullRequestState => ({ byBranch: new Map(), errors: [] })
|
|
8
11
|
|
|
9
12
|
/** The GitHub host whose pull request links are trusted; `GH_HOST` names an Enterprise server. */
|
|
10
13
|
export const GITHUB_HOST = process.env.GH_HOST ?? 'github.com'
|
|
11
14
|
|
|
12
|
-
const runGh = async (
|
|
13
|
-
const child = Bun.spawn(['gh',
|
|
14
|
-
cwd: root,
|
|
15
|
-
stdout: 'pipe',
|
|
16
|
-
stderr: 'pipe',
|
|
17
|
-
})
|
|
15
|
+
const runGh = async (args: readonly string[], root: string): Promise<GhAnswer> => {
|
|
16
|
+
const child = Bun.spawn(['gh', ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
|
|
18
17
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
19
18
|
new Response(child.stdout).text(),
|
|
20
19
|
new Response(child.stderr).text(),
|
|
21
20
|
child.exited,
|
|
22
21
|
])
|
|
23
|
-
return { stdout, stderr
|
|
22
|
+
return exitCode === 0 ? { ok: true, stdout } : { ok: false, reason: stderr.trim() || `exit ${exitCode}` }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Runs `gh`; never rejects, also not when `gh` is not installed, and the reason is terminal-safe. */
|
|
26
|
+
const askGh = async (args: readonly string[], root: string): Promise<GhAnswer> => {
|
|
27
|
+
try {
|
|
28
|
+
const answer = await runGh(args, root)
|
|
29
|
+
return answer.ok ? answer : { ok: false, reason: errorText(answer.reason) }
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return { ok: false, reason: errorText(error) }
|
|
32
|
+
}
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
/** Open pull requests, or none with the reason; never rejects, also not when `gh` is not installed. */
|
|
27
36
|
export const fetchPullRequests = async (root: string): Promise<PullRequestState> => {
|
|
37
|
+
const answer = await askGh(['pr', 'list', '--state', 'open', '--limit', '100', '--json', PULL_REQUEST_FIELDS], root)
|
|
38
|
+
if (!answer.ok) return { byBranch: new Map(), errors: [`gh pr list: ${answer.reason}`] }
|
|
28
39
|
try {
|
|
29
|
-
|
|
30
|
-
if (exitCode !== 0) throw new Error(`gh pr list: ${stderr.trim() || `exit ${exitCode}`}`)
|
|
31
|
-
return { byBranch: pullRequestsByBranch(parsePullRequests(stdout, GITHUB_HOST)), errors: [] }
|
|
40
|
+
return { byBranch: pullRequestsByBranch(parsePullRequests(answer.stdout, GITHUB_HOST)), errors: [] }
|
|
32
41
|
} catch (error) {
|
|
33
42
|
return { byBranch: new Map(), errors: [errorText(error)] }
|
|
34
43
|
}
|
package/src/plans/layout.ts
CHANGED
|
@@ -81,8 +81,10 @@ import {
|
|
|
81
81
|
import {
|
|
82
82
|
BACKLOG_KEY,
|
|
83
83
|
type ClickTarget,
|
|
84
|
+
entryBlockHeadKey,
|
|
84
85
|
isBlockFolded,
|
|
85
86
|
isBlockHeadKey,
|
|
87
|
+
isEntryBlockFolded,
|
|
86
88
|
isLaneOpen,
|
|
87
89
|
isLanesOpen,
|
|
88
90
|
LANES_KEY,
|
|
@@ -239,18 +241,23 @@ type TopBlock = {
|
|
|
239
241
|
hidden: number
|
|
240
242
|
expanded: boolean
|
|
241
243
|
rows: BlockRow[]
|
|
242
|
-
/** The mark in front of the name in place of the chevron, for a block that does not fold; absent for the chevron. */
|
|
243
|
-
headMark?: string
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
/** What the head of a block says: its name, its count, its balance and the paint of the count. */
|
|
247
|
-
type BlockHeadParts = Pick<TopBlock, 'name' | 'count' | 'balance' | 'countPaint'
|
|
247
|
+
type BlockHeadParts = Pick<TopBlock, 'name' | 'count' | 'balance' | 'countPaint'>
|
|
248
248
|
|
|
249
249
|
/** The blocks on top of the herd one under the other: their lines below the title, and the hit of each drawn block. */
|
|
250
250
|
type BlockStack = { lines: string[]; hits: (BlockHit | null)[] }
|
|
251
251
|
|
|
252
252
|
/** What every lane of the lanes block is drawn with. */
|
|
253
|
-
type LanesContext = {
|
|
253
|
+
type LanesContext = {
|
|
254
|
+
state: Selection
|
|
255
|
+
width: number
|
|
256
|
+
badgeTexts: BadgeTexts
|
|
257
|
+
taskStyle: TaskStyle
|
|
258
|
+
/** How a lane's `↳` activity line is drawn: shimmering while the herd does, so its task rests, dim otherwise. */
|
|
259
|
+
activityStyle: TaskStyle
|
|
260
|
+
}
|
|
254
261
|
|
|
255
262
|
/** In front of every line of the lanes block under its head. */
|
|
256
263
|
const LANE_INDENT = ' '
|
|
@@ -782,6 +789,14 @@ const detailPlacement = (
|
|
|
782
789
|
return { beside, room: beside ? besideRoom : width - visibleLength(indent) }
|
|
783
790
|
}
|
|
784
791
|
|
|
792
|
+
/**
|
|
793
|
+
* Whether the whole head and a detail still stand on one line with `right`. A lane that rests has no bar, only the
|
|
794
|
+
* padding that aligns its badges with the others; `laneLines` gives that back before it cuts the name.
|
|
795
|
+
*/
|
|
796
|
+
const detailFitsBeside = (head: string, right: string, width: number, indent: string): boolean =>
|
|
797
|
+
visibleLength(head) + COLUMN_SEPARATOR.length + visibleLength(right) < width &&
|
|
798
|
+
detailPlacement(head, right, width, indent).beside
|
|
799
|
+
|
|
785
800
|
/** The summary the snapshot read for the lane, or the one its own steps give. */
|
|
786
801
|
const summaryOf = (view: LaneView): LaneSummary =>
|
|
787
802
|
view.summary ?? laneSummary({ progress: view.progress, phases: {}, activity: {} }, 0)
|
|
@@ -805,16 +820,23 @@ const laneRight = (view: LaneView, tallyWidth: number): string => {
|
|
|
805
820
|
/** The cells of the phase count column, 0 when the pane leaves it out. */
|
|
806
821
|
const tallyWidthOf = ({ phases, tally }: LaneColumns): number => (phases ? tally : 0)
|
|
807
822
|
|
|
808
|
-
/**
|
|
809
|
-
const
|
|
823
|
+
/** The marks in the column of `columns.marks`, `padOf(right)` cells before the lane's own context and bar. */
|
|
824
|
+
const marksBeforeRight = (view: LaneView, columns: LaneColumns, padOf: (right: string) => number): string => {
|
|
810
825
|
const right = laneRight(view, tallyWidthOf(columns))
|
|
811
826
|
const marks = laneMarks(view)
|
|
812
827
|
if (!marks) return right
|
|
813
828
|
if (columns.right === 0) return padVisible(marks, columns.marks)
|
|
814
|
-
|
|
815
|
-
return `${padVisible(marks, columns.marks)}${COLUMN_SEPARATOR}${pad}${right}`
|
|
829
|
+
return `${padVisible(marks, columns.marks)}${COLUMN_SEPARATOR}${' '.repeat(padOf(right))}${right}`
|
|
816
830
|
}
|
|
817
831
|
|
|
832
|
+
/** A folded lane's marks right before context and bar, padded to the widest right column of the pane. */
|
|
833
|
+
const markedRight = (view: LaneView, columns: LaneColumns): string =>
|
|
834
|
+
marksBeforeRight(view, columns, (right) => Math.max(columns.right - visibleLength(right), 0))
|
|
835
|
+
|
|
836
|
+
/** `markedRight` without the padding a lane without a bar only has to align its marks with the other lanes'. */
|
|
837
|
+
const markedRightTight = (view: LaneView, columns: LaneColumns): string =>
|
|
838
|
+
marksBeforeRight(view, columns, (right) => (right ? Math.max(columns.right - visibleLength(right), 0) : 0))
|
|
839
|
+
|
|
818
840
|
/** The task row below a folded lane's phase row, under the lane's name; none while the lane reports no task. */
|
|
819
841
|
const foldedTaskRow = (view: LaneView, indent: string, width: number, style: TaskStyle): string[] => {
|
|
820
842
|
const phase = headPhase(view)
|
|
@@ -839,8 +861,9 @@ const laneLines = (
|
|
|
839
861
|
const nameStyle = restsFolded(view, open) ? paint.dim : paint.bold
|
|
840
862
|
const cells = padVisible(nameStyle(fit(progress.lane.name, columns.name)), columns.name)
|
|
841
863
|
const head = `${paint.dim(chevronOf(open))} ${symbolCell(status.agent)} ${cells}`
|
|
842
|
-
const right = open ? laneProgressColumn(view, tallyWidthOf(columns)) : markedRight(view, columns)
|
|
843
864
|
const nameIndent = ' '.repeat(visibleLength(head) - visibleLength(cells))
|
|
865
|
+
const aligned = open ? laneProgressColumn(view, tallyWidthOf(columns)) : markedRight(view, columns)
|
|
866
|
+
const right = open || detailFitsBeside(head, aligned, width, nameIndent) ? aligned : markedRightTight(view, columns)
|
|
844
867
|
const { room } = detailPlacement(head, right, width, nameIndent)
|
|
845
868
|
const lines = withDetail(head, laneDetail(view, open, reason, room), right, width, nameIndent)
|
|
846
869
|
return open ? lines : [...lines, ...foldedTaskRow(view, nameIndent, width, taskStyle)]
|
|
@@ -935,7 +958,7 @@ const activityFrame = (
|
|
|
935
958
|
key: string,
|
|
936
959
|
indent: string,
|
|
937
960
|
text: string,
|
|
938
|
-
{ state, width }: { state: Selection; width: number },
|
|
961
|
+
{ state, width, style }: { state: Selection; width: number; style: TaskStyle },
|
|
939
962
|
): Frame => {
|
|
940
963
|
const head = `${indent}${ACTIVITY_MARK} `
|
|
941
964
|
const cut = textWidth(`${head}${text}`) > width
|
|
@@ -945,44 +968,69 @@ const activityFrame = (
|
|
|
945
968
|
index === 0 ? `${head}${line}` : `${' '.repeat(textWidth(head))}${line}`,
|
|
946
969
|
)
|
|
947
970
|
: [fit(`${head}${text}`, width)]
|
|
948
|
-
const
|
|
949
|
-
return cut ? rowFrame(key,
|
|
971
|
+
const styled = lines.map(style)
|
|
972
|
+
return cut ? rowFrame(key, styled, [], { state, width }) : textFrame(styled)
|
|
950
973
|
}
|
|
951
974
|
|
|
952
975
|
/**
|
|
953
976
|
* The last action of a working lane's agent and of each of its running subagents, one dim line each, under the lane
|
|
954
|
-
* line in the column of its children. None while the lane does not work, or without a source for it.
|
|
977
|
+
* line in the column of its children. None while the lane does not work, or without a source for it. The first
|
|
978
|
+
* subagent line shimmers when nothing else of the lane does (`leadsSubagent`).
|
|
955
979
|
*/
|
|
956
980
|
const activityFrames = (
|
|
957
981
|
{ activity, status, progress }: LaneView,
|
|
958
|
-
|
|
982
|
+
{
|
|
983
|
+
activityStyle,
|
|
984
|
+
leadsSubagent,
|
|
985
|
+
...context
|
|
986
|
+
}: Pick<LanesContext, 'state' | 'width' | 'activityStyle'> & {
|
|
987
|
+
leadsSubagent: boolean
|
|
988
|
+
},
|
|
959
989
|
): Frame[] => {
|
|
960
990
|
if (!activity || status.agent !== AGENT_STATUS.working) return []
|
|
961
991
|
const lane = progress.lane.name
|
|
962
992
|
const indent = ' '.repeat(LANE_HEAD_CELLS)
|
|
993
|
+
const still = { ...context, style: paint.dim }
|
|
963
994
|
return [
|
|
964
|
-
...(activity.lane === null
|
|
965
|
-
|
|
995
|
+
...(activity.lane === null
|
|
996
|
+
? []
|
|
997
|
+
: [activityFrame(laneActivityKey(lane), indent, activity.lane, { ...context, style: activityStyle })]),
|
|
998
|
+
...activity.subagents.map(({ label, description, line }, index) =>
|
|
966
999
|
activityFrame(
|
|
967
1000
|
subagentActivityKey(lane, label),
|
|
968
1001
|
`${indent}${SUBAGENT_ACTIVITY_INDENT}`,
|
|
969
1002
|
`${ellipsize(description ?? label, SUBAGENT_NAME_CELLS)}: ${line}`,
|
|
970
|
-
context,
|
|
1003
|
+
leadsSubagent && index === 0 ? { ...context, style: activityStyle } : still,
|
|
971
1004
|
),
|
|
972
1005
|
),
|
|
973
1006
|
]
|
|
974
1007
|
}
|
|
975
1008
|
|
|
1009
|
+
/** True when the lane shows its own `↳` activity line: the one line of the lane that shimmers, its task rests. */
|
|
1010
|
+
const showsLaneActivity = ({ activity, status }: LaneView): boolean =>
|
|
1011
|
+
typeof activity?.lane === 'string' && status.agent === AGENT_STATUS.working
|
|
1012
|
+
|
|
1013
|
+
/** True when a task line of the lane shimmers: the pane shimmers its tasks and the lane reports one to show. */
|
|
1014
|
+
const showsShimmeringTask = (
|
|
1015
|
+
view: LaneView,
|
|
1016
|
+
{ width, taskStyle }: Pick<LanesContext, 'width' | 'taskStyle'>,
|
|
1017
|
+
): boolean => taskStyle === SHIMMERING_TASK && foldedTaskRow(view, '', width, taskStyle).length > 0
|
|
1018
|
+
|
|
1019
|
+
/** True when no line of the lane shimmers yet, so its first subagent line does: neither its activity nor its task. */
|
|
1020
|
+
const subagentLeads = (view: LaneView, context: Pick<LanesContext, 'width' | 'taskStyle'>): boolean =>
|
|
1021
|
+
!showsLaneActivity(view) && !showsShimmeringTask(view, context)
|
|
1022
|
+
|
|
976
1023
|
/**
|
|
977
1024
|
* The lane line is a row of its own; an open lane continues after a blank line with its session, links, pull requests
|
|
978
1025
|
* and phases (`openLaneFrame`), indented to the column of the lane's name.
|
|
979
1026
|
*/
|
|
980
1027
|
const laneBlock = (
|
|
981
1028
|
view: LaneView,
|
|
982
|
-
{ state, width, badgeTexts, taskStyle }: LanesContext,
|
|
1029
|
+
{ state, width, badgeTexts, taskStyle: lanesTaskStyle, activityStyle }: LanesContext,
|
|
983
1030
|
columns: LaneColumns,
|
|
984
1031
|
reason: LaneReason | null,
|
|
985
1032
|
): Frame => {
|
|
1033
|
+
const taskStyle = showsLaneActivity(view) ? STILL_TASK : lanesTaskStyle
|
|
986
1034
|
const context: LaneContext = {
|
|
987
1035
|
progress: view.progress,
|
|
988
1036
|
state,
|
|
@@ -997,7 +1045,8 @@ const laneBlock = (
|
|
|
997
1045
|
const open = isLaneOpen(state, view.progress.lane.name, view.status.agent)
|
|
998
1046
|
const lines = laneLines(view, open, { width, taskStyle }, columns, reason)
|
|
999
1047
|
const lane = rowFrame(laneKey(view.progress.lane.name), lines, [], context)
|
|
1000
|
-
const
|
|
1048
|
+
const leadsSubagent = subagentLeads(view, { width, taskStyle: lanesTaskStyle })
|
|
1049
|
+
const row = joinFrames([lane, ...activityFrames(view, { state, width, activityStyle, leadsSubagent })])
|
|
1001
1050
|
if (!open) return row
|
|
1002
1051
|
const inner = narrower(context, LANE_HEAD_CELLS)
|
|
1003
1052
|
const head = {
|
|
@@ -1131,13 +1180,9 @@ const blockBar = (): string => ' '
|
|
|
1131
1180
|
* above the block; `▸ <name> · <n> <balance>` while folded. The count (in `countPaint` while it is more than 0) is
|
|
1132
1181
|
* left out for a block without one; the balance comes painted.
|
|
1133
1182
|
*/
|
|
1134
|
-
const blockHead = (
|
|
1135
|
-
{ name, count, balance, countPaint, headMark }: BlockHeadParts,
|
|
1136
|
-
open: boolean,
|
|
1137
|
-
width: number,
|
|
1138
|
-
): string => {
|
|
1183
|
+
const blockHead = ({ name, count, balance, countPaint }: BlockHeadParts, open: boolean, width: number): string => {
|
|
1139
1184
|
const counted = count === null ? '' : ` ${ITEM_SEPARATOR} ${count > 0 ? countPaint(String(count)) : count}`
|
|
1140
|
-
const lead = `${paint.dim(
|
|
1185
|
+
const lead = `${paint.dim(chevronOf(open))} ${name}${counted}${balance ? ` ${balance}` : ''}`
|
|
1141
1186
|
if (!open) return fit(lead, width)
|
|
1142
1187
|
const cut = fit(lead, Math.max(width - 2, 1))
|
|
1143
1188
|
return `${cut} ${paint.dim(RULE.repeat(Math.max(width - visibleLength(cut) - 1, 0)))}`
|
|
@@ -1334,9 +1379,9 @@ const entryRowShares = (counts: readonly number[], budget: number): number[] =>
|
|
|
1334
1379
|
type EntryBlock = { source: HerdSource; now: number; share: number }
|
|
1335
1380
|
|
|
1336
1381
|
/**
|
|
1337
|
-
* The block of a source that shows its entries:
|
|
1338
|
-
*
|
|
1339
|
-
*
|
|
1382
|
+
* The block of a source that shows its entries: open at the start, headed by its label (else its name) and the number
|
|
1383
|
+
* of entries, folded and opened again by its head as in the other blocks, its `share` of entries and `+<n>` for the
|
|
1384
|
+
* rest, which opens all of them and folds them again. An entry with an address opens it.
|
|
1340
1385
|
*/
|
|
1341
1386
|
const entryBlockLines = ({ source, now, share }: EntryBlock, state: Selection, width: number): BlockLines => {
|
|
1342
1387
|
const moreKey = sourceMoreKey(source.name)
|
|
@@ -1344,7 +1389,7 @@ const entryBlockLines = ({ source, now, share }: EntryBlock, state: Selection, w
|
|
|
1344
1389
|
const hidden = source.entries.length - share
|
|
1345
1390
|
const block: TopBlock = {
|
|
1346
1391
|
name: source.label ?? source.name,
|
|
1347
|
-
count:
|
|
1392
|
+
count: source.entries.length,
|
|
1348
1393
|
balance: '',
|
|
1349
1394
|
markPaint: paint.dim,
|
|
1350
1395
|
focused: (key) => isFocused(state, key),
|
|
@@ -1354,10 +1399,11 @@ const entryBlockLines = ({ source, now, share }: EntryBlock, state: Selection, w
|
|
|
1354
1399
|
hidden,
|
|
1355
1400
|
expanded,
|
|
1356
1401
|
rows: allBlockRows(entryBlockItems(source, now, expanded ? source.entries.length : share), new Set(), width),
|
|
1357
|
-
headMark: ' ',
|
|
1358
1402
|
}
|
|
1359
|
-
const
|
|
1360
|
-
return
|
|
1403
|
+
const headKey = entryBlockHeadKey(source.name)
|
|
1404
|
+
return isEntryBlockFolded(state, source.name)
|
|
1405
|
+
? foldedBlockLines(block, headKey, width)
|
|
1406
|
+
: openBlockLines(block, headKey, width)
|
|
1361
1407
|
}
|
|
1362
1408
|
|
|
1363
1409
|
const entryBlocksOf = (sourceBlock: SourceBlock | undefined, state: Selection, width: number): BlockLines[] => {
|
|
@@ -1533,6 +1579,7 @@ export const renderScreen = (
|
|
|
1533
1579
|
width: number,
|
|
1534
1580
|
): Screen => {
|
|
1535
1581
|
const taskStyle = shimmer ? SHIMMERING_TASK : STILL_TASK
|
|
1582
|
+
const activityStyle = shimmer ? SHIMMERING_TASK : paint.dim
|
|
1536
1583
|
const { active, waiting, noAgent } = partitionLanes(lanes)
|
|
1537
1584
|
const badgeTexts = BADGE_TEXTS
|
|
1538
1585
|
const turnRowsOf = yourTurn ? [...yourTurn.turn.now, ...yourTurn.turn.planned] : []
|
|
@@ -1547,7 +1594,9 @@ export const renderScreen = (
|
|
|
1547
1594
|
const stack = stackBlocks([turn, ...entryBlocks, listed], above.length)
|
|
1548
1595
|
const sourcesSlot = 1 + entryBlocks.length
|
|
1549
1596
|
const sections = [
|
|
1550
|
-
...(active.length > 0
|
|
1597
|
+
...(active.length > 0
|
|
1598
|
+
? [lanesFrame(active, turnRowsOf, { state, width, badgeTexts, taskStyle, activityStyle })]
|
|
1599
|
+
: []),
|
|
1551
1600
|
...(waiting.length > 0 ? [waitingFrame(waiting, state, width)] : []),
|
|
1552
1601
|
textFrame(parkedBlock(noAgent, width)),
|
|
1553
1602
|
...(planned.length > 0 ? [backlogFrame(planned, state, width)] : []),
|