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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
4
|
+
import { locateConfigFile, PROJECT_CONFIG_PATH } from '../config/location'
|
|
5
|
+
import { type ResolvedConfig, readResolvedConfig } from '../config/read-config'
|
|
6
|
+
import { readRepositoryIdentity } from '../config/repository-identity'
|
|
7
|
+
import type { JackherdConfig } from '../config/schema'
|
|
8
|
+
import {
|
|
9
|
+
type FileStamps,
|
|
10
|
+
type LoadedSources,
|
|
11
|
+
nextLoadedSources,
|
|
12
|
+
type ReadFailure,
|
|
13
|
+
readWatchedSources,
|
|
14
|
+
reasonOf,
|
|
15
|
+
} from './source-reload'
|
|
16
|
+
import type { SourceSettings } from './sources'
|
|
17
|
+
import type { WorkflowRun } from './workflow-runs'
|
|
18
|
+
|
|
19
|
+
/** What a line starts with when a changed config left the values as they were. */
|
|
20
|
+
export const CONFIG_NOT_RELOADED = 'config not reloaded:'
|
|
21
|
+
|
|
22
|
+
/** The parts of `watch` the loop takes up while it runs, in the order a reload names them. */
|
|
23
|
+
const LIVE_PARTS = ['deploy', 'feedback', 'previews', 'checks'] as const
|
|
24
|
+
|
|
25
|
+
export type LivePart = (typeof LIVE_PARTS)[number]
|
|
26
|
+
|
|
27
|
+
/** The values of `watch` that change while the watcher runs: what it reports and how it reads it. */
|
|
28
|
+
export type LiveValues = Pick<JackherdConfig['watch'], LivePart>
|
|
29
|
+
|
|
30
|
+
/** The sources and the live values the watcher runs; the change times of their files travel with `sources`. */
|
|
31
|
+
export type LoadedConfig = { sources: LoadedSources; live: LiveValues }
|
|
32
|
+
|
|
33
|
+
/** One read of the repository config and the personal file: the live values and merged sources with the notes, or the line naming the file that failed. */
|
|
34
|
+
export type ConfigRead = { ok: true; live: LiveValues; sources: SourceSettings[]; notes: string[] } | ReadFailure
|
|
35
|
+
|
|
36
|
+
/** What a reload starts counting from: runs and heads that existed before it are the baseline, not news. */
|
|
37
|
+
export type Baselines = { deploy: boolean; previews: boolean }
|
|
38
|
+
|
|
39
|
+
export const liveValuesOf = (config: JackherdConfig): LiveValues => ({
|
|
40
|
+
deploy: config.watch.deploy,
|
|
41
|
+
feedback: config.watch.feedback,
|
|
42
|
+
previews: config.watch.previews,
|
|
43
|
+
checks: config.watch.checks,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
/** The parts of `next` that differ from `previous` by value. */
|
|
47
|
+
export const changedParts = (previous: LiveValues, next: LiveValues): LivePart[] =>
|
|
48
|
+
LIVE_PARTS.filter((part) => !isDeepStrictEqual(previous[part], next[part]))
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether the reload starts a baseline: a deploy workflow that is new or another one (a removed `deploy` reports
|
|
52
|
+
* nothing anyway), and previews that were off before. A change of the environments or of the preview authors keeps
|
|
53
|
+
* what the watcher has recorded.
|
|
54
|
+
*/
|
|
55
|
+
export const baselinesOf = (previous: LiveValues, next: LiveValues): Baselines => ({
|
|
56
|
+
deploy: next.deploy !== undefined && next.deploy.workflow !== previous.deploy?.workflow,
|
|
57
|
+
previews: next.previews !== undefined && previous.previews === undefined,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/** The runs created at or after `since`, all of them while there is no baseline (null). */
|
|
61
|
+
export const withoutRunsBefore = (runs: WorkflowRun[], since: number | null): WorkflowRun[] =>
|
|
62
|
+
since === null ? runs : runs.filter((run) => Date.parse(run.createdAt) >= since)
|
|
63
|
+
|
|
64
|
+
const configPathOf = (roots: string[], home: string): string =>
|
|
65
|
+
locateConfigFile(roots, home, existsSync) ?? join(roots[0] ?? home, PROJECT_CONFIG_PATH)
|
|
66
|
+
|
|
67
|
+
const readRepositoryConfig = (
|
|
68
|
+
roots: string[],
|
|
69
|
+
home: string,
|
|
70
|
+
path: string,
|
|
71
|
+
): { ok: true; resolved: ResolvedConfig } | ReadFailure => {
|
|
72
|
+
try {
|
|
73
|
+
return { ok: true, resolved: readResolvedConfig(roots, home) }
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return { ok: false, line: `${CONFIG_NOT_RELOADED} ${path}: ${reasonOf(path, error)}` }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reads the repository config once (`readResolvedConfig` finds it in `roots`) and the personal sources of `home`:
|
|
81
|
+
* the live values and the merged sources, for the repository `origin` of the last of `roots` (the main checkout)
|
|
82
|
+
* names (`readRepositoryIdentity`). An invalid or missing config is the line `config not reloaded:` with its
|
|
83
|
+
* file and reason; an invalid personal file keeps the whole reading back (`readWatchedSources`).
|
|
84
|
+
*/
|
|
85
|
+
export const readWatchedConfig = (roots: string[], home: string): ConfigRead => {
|
|
86
|
+
const path = configPathOf(roots, home)
|
|
87
|
+
const repository = readRepositoryConfig(roots, home, path)
|
|
88
|
+
if (!repository.ok) return repository
|
|
89
|
+
const { config, notes } = repository.resolved
|
|
90
|
+
const mainRoot = roots.at(-1)
|
|
91
|
+
const identity = mainRoot === undefined ? null : readRepositoryIdentity(mainRoot)
|
|
92
|
+
const sources = readWatchedSources({ path, read: () => config.watch.sources }, home, identity)
|
|
93
|
+
return sources.ok
|
|
94
|
+
? { ok: true, live: liveValuesOf(config), sources: sources.sources, notes: [...notes, ...sources.notes] }
|
|
95
|
+
: sources
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The config after one cycle: with the stamps as they were, as it was and without a read; after a change, what `read`
|
|
100
|
+
* gives, with `config reloaded: <parts>` when live values differ; when a changed file is invalid, everything as it was
|
|
101
|
+
* and the line naming the file. The new stamps are taken either way, so one change reports once.
|
|
102
|
+
*/
|
|
103
|
+
export const nextLoadedConfig = (
|
|
104
|
+
previous: LoadedConfig,
|
|
105
|
+
stamps: FileStamps,
|
|
106
|
+
read: () => ConfigRead,
|
|
107
|
+
): { loaded: LoadedConfig; lines: string[] } => {
|
|
108
|
+
const taken: { live?: LiveValues } = {}
|
|
109
|
+
const sources = nextLoadedSources(previous.sources, stamps, () => {
|
|
110
|
+
const result = read()
|
|
111
|
+
if (!result.ok) return result
|
|
112
|
+
taken.live = result.live
|
|
113
|
+
return { ok: true, sources: result.sources, notes: result.notes }
|
|
114
|
+
})
|
|
115
|
+
const live = taken.live ?? previous.live
|
|
116
|
+
const changed = changedParts(previous.live, live)
|
|
117
|
+
const lines = changed.length > 0 ? [...sources.lines, `config reloaded: ${changed.join(', ')}`] : sources.lines
|
|
118
|
+
return { loaded: { sources: sources.loaded, live }, lines }
|
|
119
|
+
}
|
package/src/watch/lane-model.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { differsFromColumn } from '../plan-format'
|
|
1
|
+
import { differsFromColumn, type LaneProgress, type SprintLane } from '../plan-format'
|
|
2
2
|
import { isRecord } from '../shared/is-record'
|
|
3
3
|
|
|
4
4
|
export const LANE_MODEL_EVENT = 'model'
|
|
@@ -14,6 +14,16 @@ export type LaneModelReading = { model: string | null; idle: boolean; column: st
|
|
|
14
14
|
|
|
15
15
|
export type LaneModelEvent = { kind: typeof LANE_MODEL_EVENT; lane: string; model: string; column: string }
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The model column a lane's agent is measured against: the row of the phase it works on (`progress.current`), else
|
|
19
|
+
* the lane's newest row, which also stands in when the current step has no board row of its own. Undefined for a
|
|
20
|
+
* lane without rows.
|
|
21
|
+
*/
|
|
22
|
+
export const modelColumnOf = (lane: SprintLane, progress: LaneProgress | undefined): string | undefined => {
|
|
23
|
+
const current = progress?.current ? progress.steps[progress.current.stepIndex]?.step : undefined
|
|
24
|
+
return (current && !current.withoutRow ? current : lane.steps.at(-1))?.model
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
const differs = (reading: LaneModelReading): reading is LaneModelReading & { model: string } =>
|
|
18
28
|
reading.model !== null && differsFromColumn(reading.column, reading.model)
|
|
19
29
|
|
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
|
-
import {
|
|
3
|
+
import { laneBuild } from '../check/plan-structure-check'
|
|
4
|
+
import { isObjectId } from '../lane/close-survey'
|
|
5
|
+
import {
|
|
6
|
+
currentPhase,
|
|
7
|
+
type LaneProgress,
|
|
8
|
+
mergedPhasesWithOpenBoxes,
|
|
9
|
+
type PlanStep,
|
|
10
|
+
parallelGroupsOfPlan,
|
|
11
|
+
phaseIdsOfCell,
|
|
12
|
+
resolvePlanPath,
|
|
13
|
+
type SprintLane,
|
|
14
|
+
} from '../plan-format'
|
|
4
15
|
import { boardRowsOf } from '../plans/board-rows'
|
|
5
16
|
import { type BaseReader, baseReaderOf, gitStatusOf } from '../plans/git'
|
|
6
17
|
import { laneWorkOf, readPlans, textAtBase } from '../plans/lane-plans'
|
|
7
18
|
import { laneHeads, type PlanPullRequest, planPullRequests } from '../plans/plan-heads'
|
|
8
19
|
import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from '../plans/settings'
|
|
9
20
|
import { workedLaneProgress } from '../plans/worked-phases'
|
|
21
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
10
22
|
import { parallelBatchOf } from './lane-parallel'
|
|
11
|
-
import { type ReadyPhase, readyPhasesOf } from './lane-report'
|
|
23
|
+
import { type OpenBoxesOf, type ReadyPhase, readyPhasesOf } from './lane-report'
|
|
12
24
|
import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
|
|
13
25
|
|
|
14
26
|
/** The open pull requests of the watcher's list that change a plan, as the herd chooses them. */
|
|
@@ -107,6 +119,29 @@ export const watchedLaneParallel = (
|
|
|
107
119
|
return groups === null ? [] : parallelBatchOf(groups)
|
|
108
120
|
}
|
|
109
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Reads the open boxes of the phase a merged lane built from the lane's worktree, which still holds the pull request's
|
|
124
|
+
* plan and board row, against the merge commit's parent (what the pull request ticked): the check's `laneBuild`. Null for a lane without a worktree or a phase without open boxes, and
|
|
125
|
+
* when anything on the way throws, so the merge line stays the plain duty.
|
|
126
|
+
*/
|
|
127
|
+
export const worktreeOpenBoxes =
|
|
128
|
+
(root: string, settings: PlansSettings): OpenBoxesOf =>
|
|
129
|
+
(lane, mergeCommit) => {
|
|
130
|
+
try {
|
|
131
|
+
const build = laneBuild({
|
|
132
|
+
root: worktreeOf(root, settings, lane),
|
|
133
|
+
lane,
|
|
134
|
+
planDirectory: settings.planDirectory,
|
|
135
|
+
board: settings.board,
|
|
136
|
+
language: settings.planLanguage,
|
|
137
|
+
baseOf: mergeCommit !== null && isObjectId(mergeCommit) ? baseReaderOf(root, `${mergeCommit}^`) : () => null,
|
|
138
|
+
})
|
|
139
|
+
return build?.open ? { phaseId: build.open.id, lines: build.open.openLines } : null
|
|
140
|
+
} catch {
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
110
145
|
/** A phase the lanes work on, keyed by its plan link as the board spells it, since two plans may share an id. */
|
|
111
146
|
const workedPhaseKey = (planHref: string, id: string): string => `${planHref}#${id}`
|
|
112
147
|
|
|
@@ -126,6 +161,30 @@ export const workedPhaseKeys = (progress: Readonly<Record<string, LaneProgress>>
|
|
|
126
161
|
}),
|
|
127
162
|
)
|
|
128
163
|
|
|
164
|
+
/** A clause of a board row that reports a merged pull request: `gemergt (#<number>)`, `merged (#<number>)`. */
|
|
165
|
+
const MERGED_CLAUSE = /(?:gemergt|merged)\s*\(#\d+/i
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The phases of `planHref` that are merged: those a clause (`;`-separated) of a board row's phases cell marks `gemergt
|
|
169
|
+
* (#…)`, and those the base branch carries while the plan still has open boxes (`mergedPhasesWithOpenBoxes`, what a
|
|
170
|
+
* merged pull request leaves behind, so no extra GitHub call). Without a base plan, the board rows alone count.
|
|
171
|
+
*/
|
|
172
|
+
const mergedPhaseIdsOf = (
|
|
173
|
+
steps: readonly PlanStep[],
|
|
174
|
+
planHref: string,
|
|
175
|
+
text: string,
|
|
176
|
+
base: string | null,
|
|
177
|
+
language: PlanLanguage,
|
|
178
|
+
): Set<string> => {
|
|
179
|
+
const fromRows = steps
|
|
180
|
+
.filter((step) => step.planHref === planHref)
|
|
181
|
+
.flatMap((step) => step.label.split(';'))
|
|
182
|
+
.filter((clause) => MERGED_CLAUSE.test(clause))
|
|
183
|
+
.flatMap((clause) => phaseIdsOfCell(clause, language))
|
|
184
|
+
const fromBase = base === null ? [] : mergedPhasesWithOpenBoxes(text, base, language).map(({ id }) => id)
|
|
185
|
+
return new Set([...fromRows, ...fromBase])
|
|
186
|
+
}
|
|
187
|
+
|
|
129
188
|
/**
|
|
130
189
|
* Per lane, the open phases of its plan whose dependencies are all ticked off and that no lane works on (`worked`, from
|
|
131
190
|
* `workedPhaseKeys`), by the plan's dependency table and ticks, as the lane's worktree has them; empty for a lane
|
|
@@ -142,12 +201,15 @@ export const watchedReadyPhases = (
|
|
|
142
201
|
lanes.map((lane) => {
|
|
143
202
|
const planHref = newestPlanHref(lane)
|
|
144
203
|
const text = watchedLanePlanText(root, settings, boardFile, lane)
|
|
145
|
-
return [
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
204
|
+
if (text === null || planHref === null) return [lane.name, []]
|
|
205
|
+
const merged = mergedPhaseIdsOf(
|
|
206
|
+
lane.steps,
|
|
207
|
+
planHref,
|
|
208
|
+
text,
|
|
209
|
+
baseLanePlanText(root, settings, boardFile, lane),
|
|
210
|
+
settings.planLanguage,
|
|
211
|
+
)
|
|
212
|
+
return [lane.name, readyPhasesOf(text, settings.planLanguage, phaseIdsWorkedIn(worked, planHref), merged)]
|
|
151
213
|
}),
|
|
152
214
|
)
|
|
153
215
|
|
package/src/watch/lane-report.ts
CHANGED
|
@@ -26,6 +26,7 @@ import type { LaneModelEvent } from './lane-model'
|
|
|
26
26
|
import type { LaneParallelEvent } from './lane-parallel'
|
|
27
27
|
import { laneStalledLine } from './lane-stalled'
|
|
28
28
|
import { type LaneSnapshot, NO_AGENT } from './lane-watch'
|
|
29
|
+
import { MERGE_OVERLAP, type MergeOverlap } from './merge-duties'
|
|
29
30
|
import { PLAN_MISSING_EVENT, type PlanMissingEvent, planMissingLine } from './plan-missing'
|
|
30
31
|
import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
|
|
31
32
|
import { SOURCE_FIELD } from './source-fields'
|
|
@@ -308,18 +309,62 @@ type MergeEvent = PullRequestEvent & { kind: typeof WATCH_EVENT.merged }
|
|
|
308
309
|
|
|
309
310
|
const isMerge = (event: WatchEvent): event is MergeEvent => event.kind === WATCH_EVENT.merged
|
|
310
311
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
312
|
+
/** Which checks a merge asks of a lane that rebases; null where the overlap could not be told: in doubt, all of them run. */
|
|
313
|
+
export type OverlapOf = (lane: string, mergeCommit: string | null) => MergeOverlap | null
|
|
314
|
+
|
|
315
|
+
const checksText = (overlap: MergeOverlap, texts: WatchTexts): string => {
|
|
316
|
+
if (overlap.kind === MERGE_OVERLAP.all) return texts.mergeChecksAll
|
|
317
|
+
if (overlap.kind === MERGE_OVERLAP.none) return texts.mergeChecksNone
|
|
318
|
+
return texts.mergeChecksRoots(overlap.roots.map(terminalSafe).join(', '))
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const mergeDuty = (merged: MergeEvent, lane: string, texts: WatchTexts, overlapOf: OverlapOf | undefined): string => {
|
|
322
|
+
const prefix = `${texts.afterMerge(merged.number)}: ${terminalSafe(lane)}`
|
|
323
|
+
if (lane === merged.lane) return `${prefix} ${texts.mergedLaneDuty}`
|
|
324
|
+
if (overlapOf === undefined) return `${prefix} ${texts.otherLaneDuty}`
|
|
325
|
+
const overlap = overlapOf(lane, merged.mergeCommit) ?? { kind: MERGE_OVERLAP.all }
|
|
326
|
+
return `${prefix} ${texts.otherLaneDuty} · ${checksText(overlap, texts)}`
|
|
314
327
|
}
|
|
315
328
|
|
|
316
329
|
/**
|
|
317
330
|
* What a merge asks of every watched lane (`orchestrate` § 6), one line per lane and merge: the
|
|
318
|
-
* merged lane goes on or closes, every other lane rebases and goes on or waits with a reason
|
|
319
|
-
*
|
|
331
|
+
* merged lane goes on or closes, every other lane rebases and goes on or waits with a reason, and
|
|
332
|
+
* `overlapOf` adds which checks that rebase needs where the files the merge changed reach the lane.
|
|
333
|
+
* The watcher only names the duty; the coordinator decides and prompts the lanes.
|
|
334
|
+
*/
|
|
335
|
+
export const mergeDutyLines = (
|
|
336
|
+
events: readonly WatchEvent[],
|
|
337
|
+
lanes: readonly string[],
|
|
338
|
+
texts: WatchTexts,
|
|
339
|
+
overlapOf?: OverlapOf,
|
|
340
|
+
): string[] =>
|
|
341
|
+
events.filter(isMerge).flatMap((merged) => lanes.map((lane) => mergeDuty(merged, lane, texts, overlapOf)))
|
|
342
|
+
|
|
343
|
+
/** The phase a merged lane built with the lines of its open boxes in the plan. */
|
|
344
|
+
export type OpenBoxesFound = { phaseId: string; lines: readonly number[] }
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Reads the open boxes of the phase a merged lane built, the plan of the pull request against its parent
|
|
348
|
+
* (`mergeCommit`, null where unknown); null where there is none or the lane cannot be read.
|
|
349
|
+
*/
|
|
350
|
+
export type OpenBoxesOf = (lane: string, mergeCommit: string | null) => OpenBoxesFound | null
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* What a merged phase with open boxes asks of the coordinator (`orchestrate` § 6): one line per merge whose lane
|
|
354
|
+
* (`openBoxesOf`) leaves boxes open, `<lane> P<n> merged with <k> open boxes at <lines>`. A merged pull request without
|
|
355
|
+
* a lane has no phase to read.
|
|
320
356
|
*/
|
|
321
|
-
export const
|
|
322
|
-
events
|
|
357
|
+
export const openBoxesDutyLines = (
|
|
358
|
+
events: readonly WatchEvent[],
|
|
359
|
+
openBoxesOf: OpenBoxesOf,
|
|
360
|
+
texts: WatchTexts,
|
|
361
|
+
): string[] =>
|
|
362
|
+
events.filter(isMerge).flatMap(({ lane, mergeCommit }) => {
|
|
363
|
+
const found = lane === null ? null : openBoxesOf(lane, mergeCommit)
|
|
364
|
+
return lane === null || found === null
|
|
365
|
+
? []
|
|
366
|
+
: [`${terminalSafe(lane)} ${texts.mergedWithOpenBoxes(phaseLabel(found.phaseId), found.lines)}`]
|
|
367
|
+
})
|
|
323
368
|
|
|
324
369
|
const isLaneAlert = (event: WatchEvent): event is LaneFillEvent =>
|
|
325
370
|
event.kind === WATCH_EVENT.laneFill && event.level === CONTEXT_LEVEL.alert
|
|
@@ -388,10 +433,16 @@ export type ReadyPhase = { id: string; after: string[] }
|
|
|
388
433
|
|
|
389
434
|
/**
|
|
390
435
|
* The phases of a plan that can start now, in table order: open, not worked on (`worked`, the ids of the phases the
|
|
391
|
-
* lanes work on),
|
|
392
|
-
*
|
|
436
|
+
* lanes work on), not merged (`merged`: a merged phase may keep an open box, e.g. its acceptance), every phase of the
|
|
437
|
+
* `Depends on` cell finished and no external gate in it. Empty for a plan without a dependency table or a row without
|
|
438
|
+
* a phase in the plan.
|
|
393
439
|
*/
|
|
394
|
-
export const readyPhasesOf = (
|
|
440
|
+
export const readyPhasesOf = (
|
|
441
|
+
markdown: string,
|
|
442
|
+
language: PlanLanguage,
|
|
443
|
+
worked: ReadonlySet<string>,
|
|
444
|
+
merged: ReadonlySet<string> = new Set(),
|
|
445
|
+
): ReadyPhase[] => {
|
|
395
446
|
const table = parseDependencyTable(markdown, language) ?? []
|
|
396
447
|
const phases = parsePlanPhases(markdown, language)
|
|
397
448
|
const finished = new Set(phases.filter(isPhaseFinished).map(({ id }) => id))
|
|
@@ -399,7 +450,11 @@ export const readyPhasesOf = (markdown: string, language: PlanLanguage, worked:
|
|
|
399
450
|
return table
|
|
400
451
|
.filter(
|
|
401
452
|
({ id, after, external }) =>
|
|
402
|
-
open.has(id) &&
|
|
453
|
+
open.has(id) &&
|
|
454
|
+
!worked.has(id) &&
|
|
455
|
+
!merged.has(id) &&
|
|
456
|
+
external.length === 0 &&
|
|
457
|
+
after.every((each) => finished.has(each)),
|
|
403
458
|
)
|
|
404
459
|
.map(({ id, after }) => ({ id, after }))
|
|
405
460
|
}
|
|
@@ -15,6 +15,10 @@ export const behindCountOf = (revList: string | null): number | null => {
|
|
|
15
15
|
return COUNT.test(count) ? Number(count) : null
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** True when the main checkout has the base checked out and lacks commits of the remote base. */
|
|
19
|
+
export const isBehindOnBase = (read: MainCheckoutRead | null, baseBranch: string): boolean =>
|
|
20
|
+
read !== null && read.branch === baseBranch && (read.behind ?? 0) > 0
|
|
21
|
+
|
|
18
22
|
/**
|
|
19
23
|
* The duty line of the main checkout, in every report and heartbeat until it is resolved. With another branch or a
|
|
20
24
|
* detached head checked out: `main checkout on <branch>, not <base>`, since the main checkout is the coordinator's seat
|
|
@@ -29,6 +33,6 @@ export const mainCheckoutDutyLine = (
|
|
|
29
33
|
): string | null => {
|
|
30
34
|
if (read === null) return null
|
|
31
35
|
if (read.branch !== baseBranch) return terminalSafe(texts.mainCheckoutOffBase(read.branch, baseBranch))
|
|
32
|
-
if (read
|
|
36
|
+
if (!isBehindOnBase(read, baseBranch) || read.behind === null) return null
|
|
33
37
|
return terminalSafe(texts.mainCheckoutBehind(read.remoteBase, read.behind))
|
|
34
38
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** What a lane writes: the paths of its phases' writes cells (`''` is the whole repository) and the plan it links. */
|
|
2
|
+
export type LaneScope = { writes: readonly string[]; plan: string | null }
|
|
3
|
+
|
|
4
|
+
export const MERGE_OVERLAP = { all: 'all', roots: 'roots', none: 'none' } as const
|
|
5
|
+
|
|
6
|
+
/** What a merge asks of a lane's checks: all of them, those of the named roots (or plans), or none. */
|
|
7
|
+
export type MergeOverlap =
|
|
8
|
+
| { kind: typeof MERGE_OVERLAP.all }
|
|
9
|
+
| { kind: typeof MERGE_OVERLAP.roots; roots: string[] }
|
|
10
|
+
| { kind: typeof MERGE_OVERLAP.none }
|
|
11
|
+
|
|
12
|
+
const withoutTrailingSlash = (path: string): string => path.replace(/\/+$/, '')
|
|
13
|
+
|
|
14
|
+
const isUnder = (file: string, path: string): boolean => file === path || file.startsWith(`${path}/`)
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether the files a merge changed reach a lane (`orchestrate` § 6): a change to the lock file or the root manifest
|
|
18
|
+
* (`sharedFiles`), a lane that writes the whole repository, or one whose writes are unknown run every check; a change
|
|
19
|
+
* under a path the lane writes, or to its plan, runs the checks of that path; anything else needs a rebase and no run.
|
|
20
|
+
*/
|
|
21
|
+
export const overlapOf = (
|
|
22
|
+
changed: readonly string[],
|
|
23
|
+
{ writes, plan }: LaneScope,
|
|
24
|
+
sharedFiles: readonly string[],
|
|
25
|
+
): MergeOverlap => {
|
|
26
|
+
if (changed.length === 0) return { kind: MERGE_OVERLAP.none }
|
|
27
|
+
if (changed.some((file) => sharedFiles.includes(file))) return { kind: MERGE_OVERLAP.all }
|
|
28
|
+
if (writes.length === 0 || writes.includes('')) return { kind: MERGE_OVERLAP.all }
|
|
29
|
+
const hits = [...writes.map(withoutTrailingSlash), ...(plan === null ? [] : [plan])].filter((path) =>
|
|
30
|
+
changed.some((file) => isUnder(file, path)),
|
|
31
|
+
)
|
|
32
|
+
return hits.length === 0 ? { kind: MERGE_OVERLAP.none } : { kind: MERGE_OVERLAP.roots, roots: [...new Set(hits)] }
|
|
33
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { STACKS } from '../config/detect'
|
|
2
|
+
import { isObjectId } from '../lane/close-survey'
|
|
3
|
+
import { parseSprintLanes } from '../plan-format'
|
|
4
|
+
import type { PlansSettings } from '../plans/settings'
|
|
5
|
+
import { lanePlanPath, watchedLanePlanText } from './lane-progress'
|
|
6
|
+
import type { OverlapOf } from './lane-report'
|
|
7
|
+
import { type LaneScope, type MergeOverlap, overlapOf } from './merge-duties'
|
|
8
|
+
import { planTaskViewOf } from './task-parallel-input'
|
|
9
|
+
|
|
10
|
+
/** The files at the repository root that every package depends on: the lock files and manifests of the known stacks. */
|
|
11
|
+
const SHARED_FILES: readonly string[] = [
|
|
12
|
+
...new Set(Object.values(STACKS).flatMap((stack) => [...stack.lockFiles, stack.rootMarker])),
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
/** The files the merge commit changed, null when git cannot say (no such commit, a root commit). */
|
|
16
|
+
const changedFilesOf = (root: string, mergeCommit: string): string[] | null => {
|
|
17
|
+
if (!isObjectId(mergeCommit)) return null
|
|
18
|
+
const result = Bun.spawnSync(
|
|
19
|
+
['git', '-C', root, 'diff', '--name-only', '--end-of-options', `${mergeCommit}^`, mergeCommit],
|
|
20
|
+
{ stdout: 'pipe', stderr: 'ignore' },
|
|
21
|
+
)
|
|
22
|
+
return result.exitCode === 0 ? result.stdout.toString().split('\n').filter(Boolean) : null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The overlap of one lane; null when anything on the way throws (git, the plan file), so the line stays safe. */
|
|
26
|
+
const overlapFor = (read: () => MergeOverlap | null): MergeOverlap | null => {
|
|
27
|
+
try {
|
|
28
|
+
return read()
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What a lane writes, from the writes cells of its plan and the plan itself; null without a readable plan. */
|
|
35
|
+
const scopeOf = (
|
|
36
|
+
root: string,
|
|
37
|
+
settings: PlansSettings,
|
|
38
|
+
boardFile: string,
|
|
39
|
+
boardText: string,
|
|
40
|
+
name: string,
|
|
41
|
+
): LaneScope | null => {
|
|
42
|
+
const lane = parseSprintLanes(boardText, settings.planLanguage).find((candidate) => candidate.name === name)
|
|
43
|
+
if (lane === undefined) return null
|
|
44
|
+
const text = watchedLanePlanText(root, settings, boardFile, lane)
|
|
45
|
+
if (text === null) return null
|
|
46
|
+
const writes = Object.values(planTaskViewOf(text, settings.planLanguage).phaseWrites).flat()
|
|
47
|
+
return { writes, plan: lanePlanPath(root, settings, boardFile, lane) }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Which checks each merge asks of each rebasing lane: the files the merge commit changed against what the lane
|
|
52
|
+
* writes (`overlapOf`). Null for a lane or a commit it cannot tell for, so the line stays the plain duty.
|
|
53
|
+
*/
|
|
54
|
+
export const mergeOverlapReader = (
|
|
55
|
+
root: string,
|
|
56
|
+
settings: PlansSettings,
|
|
57
|
+
boardFile: string,
|
|
58
|
+
boardText: string,
|
|
59
|
+
): OverlapOf => {
|
|
60
|
+
const changed = new Map<string, string[] | null>()
|
|
61
|
+
return (lane, mergeCommit) =>
|
|
62
|
+
overlapFor(() => {
|
|
63
|
+
if (mergeCommit === null) return null
|
|
64
|
+
if (!changed.has(mergeCommit)) changed.set(mergeCommit, changedFilesOf(root, mergeCommit))
|
|
65
|
+
const files = changed.get(mergeCommit) ?? null
|
|
66
|
+
const scope = scopeOf(root, settings, boardFile, boardText, lane)
|
|
67
|
+
return files === null || scope === null ? null : overlapOf(files, scope, SHARED_FILES)
|
|
68
|
+
})
|
|
69
|
+
}
|
package/src/watch/plan-views.ts
CHANGED
|
@@ -18,7 +18,12 @@ export type PlanViews = { failureReported: boolean }
|
|
|
18
18
|
|
|
19
19
|
type Ensured = { ok: true; lines: string[] } | { ok: false; error: string }
|
|
20
20
|
|
|
21
|
-
type Effects = {
|
|
21
|
+
type Effects = {
|
|
22
|
+
ensure: () => string[]
|
|
23
|
+
log: (line: string) => void
|
|
24
|
+
/** ` · read <path>` of the reference that holds the handling of a failure; none when omitted. */
|
|
25
|
+
failureHint?: () => string
|
|
26
|
+
}
|
|
22
27
|
|
|
23
28
|
export const insideHerdr = (adapter: PlansSettings['adapter'], env: Record<string, string | undefined>): boolean =>
|
|
24
29
|
adapter === TERMINAL_ADAPTER.herdr && env.HERDR_ENV === '1'
|
|
@@ -32,11 +37,18 @@ export const ensureDue = (trigger: EnsureTrigger): boolean =>
|
|
|
32
37
|
trigger.kind === ENSURE_TRIGGER.start || trigger.added.length > 0 || trigger.withNewAgent.length > 0
|
|
33
38
|
|
|
34
39
|
/** The lines one run leaves in the watcher's output: what it brought back, or a failure once per streak. */
|
|
35
|
-
export const ensuredLines = (
|
|
40
|
+
export const ensuredLines = (
|
|
41
|
+
ensured: Ensured,
|
|
42
|
+
views: PlanViews,
|
|
43
|
+
failureHint = '',
|
|
44
|
+
): { lines: string[]; views: PlanViews } => {
|
|
36
45
|
if (ensured.ok)
|
|
37
46
|
return { lines: ensured.lines.map((line) => `herd --ensure: ${line}`), views: { failureReported: false } }
|
|
38
47
|
if (views.failureReported) return { lines: [], views }
|
|
39
|
-
return {
|
|
48
|
+
return {
|
|
49
|
+
lines: [`herd --ensure failed, still watching: ${ensured.error}${failureHint}`],
|
|
50
|
+
views: { failureReported: true },
|
|
51
|
+
}
|
|
40
52
|
}
|
|
41
53
|
|
|
42
54
|
const ensuredOf = (ensure: () => string[]): Ensured => {
|
|
@@ -53,7 +65,7 @@ const ensuredOf = (ensure: () => string[]): Ensured => {
|
|
|
53
65
|
*/
|
|
54
66
|
export const keepPlanViews = (trigger: EnsureTrigger, views: PlanViews, effects: Effects): PlanViews => {
|
|
55
67
|
if (!ensureDue(trigger)) return views
|
|
56
|
-
const { lines, views: next } = ensuredLines(ensuredOf(effects.ensure), views)
|
|
68
|
+
const { lines, views: next } = ensuredLines(ensuredOf(effects.ensure), views, effects.failureHint?.())
|
|
57
69
|
for (const line of lines) effects.log(line)
|
|
58
70
|
return next
|
|
59
71
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { claudePath, SKILLS_DIRECTORY } from '../claude/paths'
|
|
3
3
|
import { CONTEXT_LEVEL } from '../context'
|
|
4
|
+
import { type LaneSetChange, laneSetLines } from '../plans/lane-discovery'
|
|
4
5
|
import { APPOINTMENT_EVENT } from './appointments'
|
|
5
6
|
import { UPDATE_AVAILABLE_EVENT, WATCH_EVENT } from './watch-events'
|
|
6
7
|
|
|
@@ -14,6 +15,14 @@ export const REFERENCE = {
|
|
|
14
15
|
coordinatorHandover: 'references/coordinator-handover.md',
|
|
15
16
|
closeLane: 'references/close-lane.md',
|
|
16
17
|
laneHandover: 'references/lane-handover.md',
|
|
18
|
+
startLane: 'references/start-lane.md',
|
|
19
|
+
appointments: 'references/appointments.md',
|
|
20
|
+
stageApproval: 'references/stage-approval.md',
|
|
21
|
+
planCommit: 'references/plan-commit.md',
|
|
22
|
+
herdAsk: 'references/herd-ask.md',
|
|
23
|
+
watchWithoutMonitor: 'references/watch-without-monitor.md',
|
|
24
|
+
adoptPlan: 'references/adopt-plan.md',
|
|
25
|
+
waiting: 'references/waiting.md',
|
|
17
26
|
} as const
|
|
18
27
|
|
|
19
28
|
export type Reference = (typeof REFERENCE)[keyof typeof REFERENCE]
|
|
@@ -24,9 +33,9 @@ export type SkillCopy = { shown: string; path: string }
|
|
|
24
33
|
const SKILL_ENTRY = 'SKILL.md'
|
|
25
34
|
|
|
26
35
|
/**
|
|
27
|
-
* The
|
|
28
|
-
* the checks, `heartbeat`, `approval
|
|
29
|
-
*
|
|
36
|
+
* The events that need a reference and the file that holds their handling. The frequent ones (`blocked`, `done`/`idle`,
|
|
37
|
+
* `merged`, the checks, `heartbeat`, an `approval` of a tool that is no standing stage tool) have no path, so their
|
|
38
|
+
* lines do not grow. A lane's fill and an approval depend on more than the kind: see `eventReference`.
|
|
30
39
|
*/
|
|
31
40
|
const EVENT_REFERENCES: Readonly<Record<string, Reference>> = {
|
|
32
41
|
[WATCH_EVENT.feedback]: REFERENCE.events,
|
|
@@ -38,6 +47,9 @@ const EVENT_REFERENCES: Readonly<Record<string, Reference>> = {
|
|
|
38
47
|
[WATCH_EVENT.noTaskSignal]: REFERENCE.events,
|
|
39
48
|
[WATCH_EVENT.unlinkedArtifact]: REFERENCE.events,
|
|
40
49
|
[WATCH_EVENT.planMissing]: REFERENCE.events,
|
|
50
|
+
[WATCH_EVENT.deploy]: REFERENCE.events,
|
|
51
|
+
[WATCH_EVENT.laneStalled]: REFERENCE.events,
|
|
52
|
+
[WATCH_EVENT.due]: REFERENCE.appointments,
|
|
41
53
|
[UPDATE_AVAILABLE_EVENT]: REFERENCE.events,
|
|
42
54
|
[WATCH_EVENT.laneModel]: REFERENCE.laneSession,
|
|
43
55
|
}
|
|
@@ -45,13 +57,30 @@ const EVENT_REFERENCES: Readonly<Record<string, Reference>> = {
|
|
|
45
57
|
const isCoordinatorHandoverLevel = (level: unknown): boolean =>
|
|
46
58
|
level === CONTEXT_LEVEL.warn || level === CONTEXT_LEVEL.alert
|
|
47
59
|
|
|
48
|
-
/** The reference
|
|
49
|
-
|
|
60
|
+
/** The reference of a lane whose context rose: its hand-over at `alert`, the events at `warn`. */
|
|
61
|
+
const laneFillReference = (level: unknown): Reference =>
|
|
62
|
+
level === CONTEXT_LEVEL.alert ? REFERENCE.laneHandover : REFERENCE.events
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The reference to read for `event`; null for a frequent event, for a coordinator still at `ok` and for an approval
|
|
66
|
+
* whose tool is no standing stage tool (`isStageTool`, which only the config can tell).
|
|
67
|
+
*/
|
|
68
|
+
export const eventReference = (
|
|
69
|
+
event: { kind: string; level?: unknown; tool?: string },
|
|
70
|
+
isStageTool: (tool: string) => boolean = () => false,
|
|
71
|
+
): Reference | null => {
|
|
50
72
|
if (event.kind === WATCH_EVENT.coordinator)
|
|
51
73
|
return isCoordinatorHandoverLevel(event.level) ? REFERENCE.coordinatorHandover : null
|
|
74
|
+
if (event.kind === WATCH_EVENT.laneFill) return laneFillReference(event.level)
|
|
75
|
+
if (event.kind === WATCH_EVENT.approval)
|
|
76
|
+
return event.tool !== undefined && isStageTool(event.tool) ? REFERENCE.stageApproval : null
|
|
52
77
|
return EVENT_REFERENCES[event.kind] ?? null
|
|
53
78
|
}
|
|
54
79
|
|
|
80
|
+
/** The tools of `guard.approvals.standing`, the calls a standing approval may let through; none without one. */
|
|
81
|
+
export const stageToolsOf = (standing: { tools: Readonly<Record<string, string>> } | undefined): string[] =>
|
|
82
|
+
Object.keys(standing?.tools ?? {})
|
|
83
|
+
|
|
55
84
|
/**
|
|
56
85
|
* The places the orchestrate skill is installed, the project's first: `<root>/.claude/skills/<prefix>orchestrate`, and
|
|
57
86
|
* the global copy `~/.claude/skills/<globalPrefix>orchestrate` that `jh sync --global` writes (`toGlobalKit`). The
|
|
@@ -84,6 +113,18 @@ export const referenceHint = (reference: Reference, skillDirectory: string | nul
|
|
|
84
113
|
? ` · read ${ORCHESTRATE_SKILL_NAME} skill ${reference}`
|
|
85
114
|
: ` · read ${skillDirectory}/${reference}`
|
|
86
115
|
|
|
116
|
+
/** A hint as a line of its own, without its leading separator; no line for an empty hint. */
|
|
117
|
+
export const referenceLine = (hint: string): string[] => (hint === '' ? [] : [hint.trimStart()])
|
|
118
|
+
|
|
87
119
|
/** `lines` with `hint` on the first one, the header of a multi-line event. */
|
|
88
120
|
export const annotateFirstLine = (lines: readonly string[], hint: string): string[] =>
|
|
89
121
|
lines.map((line, index) => (index === 0 ? `${line}${hint}` : line))
|
|
122
|
+
|
|
123
|
+
/** `lines` with `hint` on each of them, the duty lines that all name the same reference. */
|
|
124
|
+
export const annotateEach = (lines: readonly string[], hint: string): string[] => lines.map((line) => `${line}${hint}`)
|
|
125
|
+
|
|
126
|
+
/** `lane added` and `lane removed` lines, the hint on the removed ones only: an added lane is handled in the core. */
|
|
127
|
+
export const laneSetEventLines = ({ added, removed }: LaneSetChange, hint: string): string[] => [
|
|
128
|
+
...laneSetLines({ added, removed: [] }),
|
|
129
|
+
...annotateEach(laneSetLines({ added: [], removed }), hint),
|
|
130
|
+
]
|
|
@@ -8,9 +8,12 @@ const CODE_FENCE = /^\s*(```|~~~)/
|
|
|
8
8
|
const LIST_ITEM = /^(?:\d+[.)]|[-*])\s/
|
|
9
9
|
const CONTINUATION_LINE = /^\s/
|
|
10
10
|
const LIST_MARKER = /^(?:\d+[.)]|[-*])\s*/
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* What a section writes when it has nothing to ask: a sentence that opens with "Keine"/"None" and asks nothing
|
|
13
|
+
* ("Keine Rückfragen.", "None, all decided."), the short forms "No questions." / "Nein.", "n/a", a dash.
|
|
14
|
+
*/
|
|
12
15
|
const NO_QUESTIONS =
|
|
13
|
-
/^(?:(?:keine|none
|
|
16
|
+
/^(?:(?:keine|none)\b[^?]*|(?:no|nein)(?:\s+(?:(?:offen(?:en?)?|open)(?:\s+ones?)?|(?:offenen\s+)?(?:fragen?|questions?)))?\.?|n\/a|[—–])$/i
|
|
14
17
|
|
|
15
18
|
/** What the questions part of a message may take: whole up to these limits, else cut question by question. */
|
|
16
19
|
const QUESTION_LINES = 20
|