jackherd 1.9.0 → 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 +30 -0
- package/INSTALL.md +1 -1
- package/README.md +50 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-5MQBCUQ5.js} +9 -4
- package/dist/config.d.ts +39 -4
- package/dist/config.js +67 -26
- package/dist/plan-format.d.ts +45 -6
- package/dist/plan-format.js +46 -6
- package/docs/workflow-dark.svg +67 -63
- package/docs/workflow.svg +67 -63
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +15 -0
- package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
- package/kit/hooks/hooks/ask-hint/index.ts +15 -19
- package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
- package/kit/hooks/hooks/git-guard/decision.ts +50 -102
- package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
- package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
- package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
- package/kit/hooks/hooks/merge-guard/index.ts +6 -3
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
- package/kit/hooks/hooks/merge-guard/run.ts +59 -54
- package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
- package/kit/hooks/hooks/open-question/index.ts +26 -0
- package/kit/hooks/hooks/open-question/record.ts +135 -0
- package/kit/hooks/hooks/open-question/run.ts +52 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- package/kit/hooks/hooks/sent-file/store.ts +3 -12
- package/kit/hooks/hooks/shared/config.ts +7 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
- package/kit/hooks/hooks/shared/files.ts +7 -0
- package/kit/hooks/hooks/shared/lane.ts +1 -1
- package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
- package/kit/hooks/hooks/shared/private-directory.ts +56 -9
- package/kit/hooks/hooks/shared/program-position.ts +2 -75
- package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
- package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
- package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
- package/kit/hooks/hooks/task-hint/decision.ts +49 -0
- package/kit/hooks/hooks/task-hint/index.ts +79 -0
- package/kit/hooks/hooks/task-hint/run.ts +62 -0
- package/kit/hooks/hooks/task-hint/store.ts +51 -0
- package/kit/hooks/hooks.json +13 -5
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +1 -8
- package/kit/workflow/skills/implement/SKILL.md +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +210 -433
- package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
- package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
- package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
- package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
- package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
- package/kit/workflow/skills/orchestrate/references/events.md +86 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
- package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
- package/kit/workflow/skills/plan/SKILL.md +6 -5
- package/kit/workflow/skills/plan/references/template-English.md +4 -4
- package/kit/workflow/skills/plan/references/template-German.md +4 -4
- package/package.json +3 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +1 -1
- package/schemas/jackherd.schema.json +59 -5
- package/src/check/agent-text-reviewers.ts +23 -0
- package/src/check/command.ts +2 -0
- package/src/check/plan-structure-check.ts +49 -2
- package/src/check/plan.ts +8 -1
- package/src/check/schema.ts +9 -0
- package/src/check/skill-budget-defaults.ts +55 -0
- package/src/check/skill-budgets.ts +185 -0
- package/src/cli.ts +1 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +58 -24
- 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 +8 -0
- package/src/plan-format/dependencies.ts +7 -4
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +7 -2
- 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/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-summary.ts +253 -45
- package/src/plans/layout-phase.ts +29 -7
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +262 -68
- package/src/plans/link-chips.ts +1 -0
- package/src/plans/live.ts +107 -20
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-source.ts +15 -0
- package/src/plans/plan-cache.ts +28 -0
- package/src/plans/plan-heads.ts +54 -0
- package/src/plans/pull-requests.ts +17 -2
- package/src/plans/question-records.ts +48 -0
- package/src/plans/questions-read.ts +13 -0
- package/src/plans/read-cache.ts +8 -0
- package/src/plans/record-id.ts +4 -0
- package/src/plans/restart-decision.ts +13 -4
- package/src/plans/selection.ts +98 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +2 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +85 -62
- package/src/plans/sprint-export.ts +11 -11
- package/src/plans/sprint-page.ts +5 -5
- package/src/plans/subagent-phases.ts +95 -0
- package/src/plans/subagent-tasks.ts +51 -0
- package/src/plans/subagents-read.ts +66 -17
- package/src/plans/subagents.ts +64 -6
- package/src/plans/supervisor.ts +14 -4
- package/src/plans/symbols.ts +3 -0
- package/src/plans/task-name.ts +34 -0
- package/src/plans/texts.ts +23 -6
- package/src/plans/view.ts +17 -9
- package/src/plans/waiting-click.ts +192 -37
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +25 -3
- package/src/shared/private-location.ts +89 -0
- package/src/shared/stat-cache.ts +48 -0
- package/src/sync/kit-source.ts +29 -10
- package/src/sync/kit.ts +2 -0
- package/src/sync/manifest.ts +2 -0
- package/src/sync/plan-ask-rules.ts +94 -0
- package/src/sync/plan-hooks.ts +2 -0
- package/src/sync/render.ts +3 -3
- package/src/sync/settings-hooks.ts +4 -1
- package/src/sync/sync-plan.ts +1 -0
- package/src/sync/sync.ts +24 -3
- package/src/task-signals/claude-read.ts +27 -10
- package/src/terminal/text.ts +8 -3
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +16 -6
- package/src/waiting/command.ts +22 -7
- package/src/watch/appointments.ts +1 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +330 -105
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-progress.ts +40 -8
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +32 -4
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +13 -7
- package/src/watch/pull-requests.ts +6 -2
- 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/sources.ts +6 -3
- package/src/watch/stream-output.ts +4 -3
- package/src/watch/task-parallel-auto.ts +208 -0
- package/src/watch/task-parallel-data-paths.ts +46 -0
- package/src/watch/task-parallel-input.ts +5 -15
- package/src/watch/task-parallel-read.ts +3 -1
- package/src/watch/task-parallel.ts +36 -7
- package/src/watch/task-signal-missing.ts +69 -0
- package/src/watch/task-signal-read.ts +29 -0
- package/src/watch/texts.ts +10 -0
- package/src/watch/update-available.ts +6 -3
- package/src/watch/watch-events.ts +23 -0
- package/src/watch/watcher-lock.ts +25 -29
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
- package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
- package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
|
@@ -2,8 +2,11 @@ import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
|
|
|
2
2
|
|
|
3
3
|
export const TASK_PARALLEL_EVENT = 'parallel tasks'
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* A proposal: the line `formatTaskParallelLine` made for `lane`; with `lane` null, the line that the switch
|
|
7
|
+
* `watch.parallelTasks.auto` ended.
|
|
8
|
+
*/
|
|
9
|
+
export type TaskParallelEvent = { kind: typeof TASK_PARALLEL_EVENT; lane: string | null; line: string }
|
|
7
10
|
|
|
8
11
|
/** Lane → the fingerprint last seen at a heartbeat; a lane without an entry has had none. */
|
|
9
12
|
export type TaskParallelFingerprints = Record<string, string>
|
|
@@ -25,6 +28,8 @@ export type TaskParallelInput = {
|
|
|
25
28
|
active: ActiveWork[]
|
|
26
29
|
/** Phase id → the paths of its writes cell; `''` is the whole repository. Absent for a phase without one. */
|
|
27
30
|
phaseWrites: Record<string, string[]>
|
|
31
|
+
/** The same from the plan at the base branch, which the lane cannot change; what an `auto` mark trusts. */
|
|
32
|
+
baseWrites: Record<string, string[]>
|
|
28
33
|
doneCount: number
|
|
29
34
|
/** Tokens a fresh agent costs before it does anything, measured by the caller. */
|
|
30
35
|
baselineTokens: number
|
|
@@ -45,11 +50,22 @@ export type TaskParallelResult =
|
|
|
45
50
|
|
|
46
51
|
export type TaskParallelProposal = Extract<TaskParallelResult, { proposed: true }>
|
|
47
52
|
|
|
53
|
+
export const AUTO_VERDICT = { auto: 'auto', off: 'off', asks: 'asks' } as const
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether a proposal starts without a question (`task-parallel-auto.ts`): `auto` with the switch's last day, `off`
|
|
57
|
+
* without a switch, `asks` with the limit that held it back.
|
|
58
|
+
*/
|
|
59
|
+
export type AutoVerdict =
|
|
60
|
+
| { kind: typeof AUTO_VERDICT.auto; until: string }
|
|
61
|
+
| { kind: typeof AUTO_VERDICT.off }
|
|
62
|
+
| { kind: typeof AUTO_VERDICT.asks; reason: string }
|
|
63
|
+
|
|
48
64
|
const MIN_TASKS_BESIDE_AGENT = 1
|
|
49
65
|
const MIN_TASKS_WITHOUT_AGENT = 2
|
|
50
66
|
const TEXT_LIMIT = 60
|
|
51
67
|
const TOKENS_PER_K = 1000
|
|
52
|
-
const GLOB_CHARACTER = /[*?[{]/
|
|
68
|
+
export const GLOB_CHARACTER = /[*?[{]/
|
|
53
69
|
const LINE_SUFFIX = /(?::\d+(?:[-:]\d+)*|#L\d+.*)$/
|
|
54
70
|
const PATH_EXTENSIONS = 'ts|tsx|js|mjs|json|md|sh|yml|yaml|css|html|toml|sql'
|
|
55
71
|
const BARE_FILE = new RegExp(`(?<![\\w./\`-])[\\w./-]+\\.(?:${PATH_EXTENSIONS})(?::\\d+(?:[-:]\\d+)*)?(?![\\w/])`, 'g')
|
|
@@ -194,13 +210,26 @@ export const taskParallelReport = (
|
|
|
194
210
|
}
|
|
195
211
|
|
|
196
212
|
const CHECKBOX = /^\s*(?:[-*]\s*)?\[[ xX]\]\s*/
|
|
213
|
+
/** The separators of the line itself, so a task's text cannot fake a mark such as `· auto until`. */
|
|
214
|
+
const SEPARATORS = /[·∥]/g
|
|
197
215
|
|
|
198
216
|
const proposalTaskText = (text: string): string =>
|
|
199
|
-
ellipsize(
|
|
217
|
+
ellipsize(
|
|
218
|
+
terminalSafe(text.replace(/\s+/g, ' ')).replace(CHECKBOX, '').replaceAll('"', "'").replace(SEPARATORS, '-').trim(),
|
|
219
|
+
TEXT_LIMIT,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
const autoSuffix = (verdict: AutoVerdict): string => {
|
|
223
|
+
if (verdict.kind === AUTO_VERDICT.auto) return ` · auto until ${verdict.until}`
|
|
224
|
+
return verdict.kind === AUTO_VERDICT.asks ? ` · asks: ${terminalSafe(verdict.reason)}` : ''
|
|
225
|
+
}
|
|
200
226
|
|
|
201
|
-
/**
|
|
202
|
-
|
|
227
|
+
/**
|
|
228
|
+
* `parallel tasks <lane>: "<a>" ∥ "<b>" · ~<n>k tokens for 1 extra agent`, one line; under a set switch it ends in
|
|
229
|
+
* `· auto until <day>` or `· asks: <the limit that held>`.
|
|
230
|
+
*/
|
|
231
|
+
export const formatTaskParallelLine = (lane: string, proposal: TaskParallelProposal, verdict: AutoVerdict): string => {
|
|
203
232
|
const tasks = proposal.tasks.map((task) => `"${proposalTaskText(task.text)}"`).join(' ∥ ')
|
|
204
233
|
const agents = proposal.extraAgents === 1 ? 'agent' : 'agents'
|
|
205
|
-
return `parallel tasks ${proposalTaskText(lane)}: ${tasks} · ~${kilo(proposal.estimateTokens)} tokens for ${proposal.extraAgents} extra ${agents}`
|
|
234
|
+
return `parallel tasks ${proposalTaskText(lane)}: ${tasks} · ~${kilo(proposal.estimateTokens)} tokens for ${proposal.extraAgents} extra ${agents}${autoSuffix(verdict)}`
|
|
206
235
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { MS_PER_MINUTE } from '../plan-format/clock'
|
|
2
|
+
import { isRecord } from '../shared/is-record'
|
|
3
|
+
|
|
4
|
+
export const NO_TASK_SIGNAL_EVENT = 'no task signal'
|
|
5
|
+
|
|
6
|
+
/** The key a lane without a current phase is reported under, so it too is reported once. */
|
|
7
|
+
const NO_PHASE = ''
|
|
8
|
+
|
|
9
|
+
/** What the heartbeat read of a lane: whether its agent works, whether a task signal is open, its current phase. */
|
|
10
|
+
export type NoSignalReading = { working: boolean; signalled: boolean; phaseId: string | null }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Per lane: since when it works without a signal (null while it does not), and the phase it was last reported for
|
|
14
|
+
* (`''` for a lane without a phase, null before any report).
|
|
15
|
+
*/
|
|
16
|
+
export type NoSignalState = Record<string, { since: number | null; reported: string | null }>
|
|
17
|
+
|
|
18
|
+
export type NoTaskSignalEvent = { kind: typeof NO_TASK_SIGNAL_EVENT; lane: string; phaseId: string | null }
|
|
19
|
+
|
|
20
|
+
type LaneEntry = NoSignalState[string]
|
|
21
|
+
|
|
22
|
+
const nextEntry = (
|
|
23
|
+
previous: LaneEntry | undefined,
|
|
24
|
+
{ working, signalled }: NoSignalReading,
|
|
25
|
+
now: number,
|
|
26
|
+
): LaneEntry => ({
|
|
27
|
+
since: working && !signalled ? (previous?.since ?? now) : null,
|
|
28
|
+
reported: previous?.reported ?? null,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const isDue = (entry: LaneEntry, phaseKey: string, now: number, limitMinutes: number): boolean =>
|
|
32
|
+
entry.since !== null && now - entry.since >= limitMinutes * MS_PER_MINUTE && entry.reported !== phaseKey
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The lanes that worked `limitMinutes` or longer without a task signal (`jh task start` or an entry of their own task
|
|
36
|
+
* list), one event per lane and phase (D112): a signal or a lane that stops working starts the clock again, a phase
|
|
37
|
+
* already reported is not reported again, a new one is. A lane without a reading keeps what it had.
|
|
38
|
+
*/
|
|
39
|
+
export const noTaskSignalEvents = (
|
|
40
|
+
previous: NoSignalState,
|
|
41
|
+
readings: Readonly<Record<string, NoSignalReading>>,
|
|
42
|
+
now: number,
|
|
43
|
+
limitMinutes: number,
|
|
44
|
+
): { events: NoTaskSignalEvent[]; state: NoSignalState } => {
|
|
45
|
+
const events: NoTaskSignalEvent[] = []
|
|
46
|
+
const state: NoSignalState = { ...previous }
|
|
47
|
+
for (const [lane, reading] of Object.entries(readings)) {
|
|
48
|
+
const entry = nextEntry(previous[lane], reading, now)
|
|
49
|
+
const phaseKey = reading.phaseId ?? NO_PHASE
|
|
50
|
+
if (isDue(entry, phaseKey, now, limitMinutes)) {
|
|
51
|
+
events.push({ kind: NO_TASK_SIGNAL_EVENT, lane, phaseId: reading.phaseId })
|
|
52
|
+
entry.reported = phaseKey
|
|
53
|
+
}
|
|
54
|
+
state[lane] = entry
|
|
55
|
+
}
|
|
56
|
+
return { events, state }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The line the coordinator reads: `lane <name> no task signal` with the phase when one is known. */
|
|
60
|
+
export const noTaskSignalLine = ({ lane, phaseId }: NoTaskSignalEvent): string =>
|
|
61
|
+
`lane ${lane} ${NO_TASK_SIGNAL_EVENT}${phaseId === null ? '' : ` (P${phaseId})`}`
|
|
62
|
+
|
|
63
|
+
const isEntry = (value: unknown): boolean =>
|
|
64
|
+
isRecord(value) &&
|
|
65
|
+
(value.since === null || typeof value.since === 'number') &&
|
|
66
|
+
(value.reported === null || typeof value.reported === 'string')
|
|
67
|
+
|
|
68
|
+
export const isNoSignalState = (value: unknown): value is NoSignalState =>
|
|
69
|
+
isRecord(value) && Object.values(value).every(isEntry)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { defaultProjectsDirectory } from '../context/session-log'
|
|
3
|
+
import { laneOfDirectory } from '../plans/active-tasks'
|
|
4
|
+
import { readAllSignals } from '../plans/signals-read'
|
|
5
|
+
|
|
6
|
+
export type SignalSources = {
|
|
7
|
+
/** The main checkout, which holds `.jackherd/tasks/`. */
|
|
8
|
+
root: string
|
|
9
|
+
/** The lanes' worktree directory, relative to `root`. */
|
|
10
|
+
worktreeDirectory: string
|
|
11
|
+
now: number
|
|
12
|
+
/** Where Claude Code keeps its session logs; the user's by default. */
|
|
13
|
+
projectsDirectory?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The lanes with an open task signal, read as the herd reads them (`readAllSignals`, the lane by the signal's
|
|
18
|
+
* directory): a task in progress in a recent session of the lane's worktree, a subagent's among them, or an open
|
|
19
|
+
* `jh task` record whose directory lies in that worktree.
|
|
20
|
+
*/
|
|
21
|
+
export const signalledLanes = (lanes: readonly string[], sources: SignalSources): Set<string> => {
|
|
22
|
+
const worktrees = join(sources.root, sources.worktreeDirectory)
|
|
23
|
+
const signals = readAllSignals(
|
|
24
|
+
{ projects: sources.projectsDirectory ?? defaultProjectsDirectory(), root: sources.root, worktrees },
|
|
25
|
+
sources.now,
|
|
26
|
+
)
|
|
27
|
+
const signalled = new Set(signals.flatMap(({ cwd }) => laneOfDirectory(cwd, worktrees) ?? []))
|
|
28
|
+
return new Set(lanes.filter((lane) => signalled.has(lane)))
|
|
29
|
+
}
|
package/src/watch/texts.ts
CHANGED
|
@@ -26,8 +26,14 @@ export type WatchTexts = {
|
|
|
26
26
|
laneModelDuty: string
|
|
27
27
|
/** What phases of one lane that can run together ask of the coordinator, with the phases as `P19 ∥ P20`. */
|
|
28
28
|
parallelDuty: (phases: string) => string
|
|
29
|
+
/** What a lane that works without a task signal asks of the coordinator. */
|
|
30
|
+
noTaskSignalDuty: string
|
|
29
31
|
/** What a lane's call waiting for approval asks of the coordinator, with the mark (request id and call hash) the question must carry. */
|
|
30
32
|
approvalDuty: (id: string, hash: string) => string
|
|
33
|
+
/** The standing duty while the main checkout is behind its base branch on the remote (`origin/main`), with the count of commits. */
|
|
34
|
+
mainCheckoutBehind: (remoteBase: string, count: number) => string
|
|
35
|
+
/** The standing duty while the main checkout has another branch than the base checked out (null for a detached head). */
|
|
36
|
+
mainCheckoutOffBase: (branch: string | null, baseBranch: string) => string
|
|
31
37
|
/** The reminder after the coordinator line: `<term>: <count> rows only in the checkout`. */
|
|
32
38
|
checkoutRows: (term: string, count: number) => string
|
|
33
39
|
/** The head of the status line of the user's own open pull requests without a lane. */
|
|
@@ -63,8 +69,12 @@ export const watchTexts = (dialogLanguage: PlanLanguage): WatchTexts => ({
|
|
|
63
69
|
laneAlertDuty: 'request the hand-over at the next phase boundary (§ 8)',
|
|
64
70
|
parallelDuty: (phases) =>
|
|
65
71
|
`${phases} can run in parallel: propose it to the user, check content and release files yourself (the table covers paths and markers only)`,
|
|
72
|
+
noTaskSignalDuty: 'remind the lane to report its task with jh task start; do not stop it',
|
|
66
73
|
laneModelDuty:
|
|
67
74
|
'switch it while idle: /exit, then claude --resume <session> --model <id> in the same pane (§ 2 step 6)',
|
|
75
|
+
mainCheckoutBehind: (remoteBase, count) =>
|
|
76
|
+
`main checkout behind ${remoteBase} by ${count} (git merge --ff-only ${remoteBase})`,
|
|
77
|
+
mainCheckoutOffBase: (branch, baseBranch) => `main checkout on ${branch ?? 'a detached head'}, not ${baseBranch}`,
|
|
68
78
|
checkoutRows: (term, count) => `${term}: ${count} ${count === 1 ? 'row' : 'rows'} only in the checkout`,
|
|
69
79
|
otherPullRequests: 'other PRs',
|
|
70
80
|
approvalDuty: (id, hash) =>
|
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
import { latestVersionIfDue, type UpdateCheck } from '../sync/if-changed'
|
|
2
2
|
import { isNewerVersion } from '../version/release'
|
|
3
|
+
import { UPDATE_AVAILABLE_EVENT } from './watch-events'
|
|
3
4
|
|
|
4
5
|
/** The one line that wakes the coordinator when npm has a newer release; the orchestrate skill asks the user. */
|
|
5
6
|
export const updateAvailableLine = (installed: string, latest: string): string =>
|
|
6
|
-
`===
|
|
7
|
+
`=== ${UPDATE_AVAILABLE_EVENT} ${installed} → ${latest}`
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* The watcher's daily look at npm, on the hook's timestamp and check, so a day holds one registry question across
|
|
10
11
|
* both. Prints a line only for a newer plain release it has not announced yet; otherwise nothing, so a quiet
|
|
11
|
-
* day costs no tokens. Call it on every poll: the timestamp decides whether npm is asked at all.
|
|
12
|
+
* day costs no tokens. Call it on every poll: the timestamp decides whether npm is asked at all. `hint` is what the line
|
|
13
|
+
* carries after it, the path of the reference that holds the handling (`referenceHint`).
|
|
12
14
|
*/
|
|
13
15
|
export const updateAnnouncer = (
|
|
14
16
|
home: string,
|
|
15
17
|
installed: string,
|
|
16
18
|
check: UpdateCheck,
|
|
17
19
|
log: (line: string) => void,
|
|
20
|
+
hint: () => string = () => '',
|
|
18
21
|
): (() => Promise<void>) => {
|
|
19
22
|
let announced: string | null = null
|
|
20
23
|
return async () => {
|
|
21
24
|
const latest = await latestVersionIfDue(home, check)
|
|
22
25
|
if (latest === null || latest === announced || !isNewerVersion(latest, installed)) return
|
|
23
26
|
announced = latest
|
|
24
|
-
log(updateAvailableLine(installed, latest))
|
|
27
|
+
log(`${updateAvailableLine(installed, latest)}${hint()}`)
|
|
25
28
|
}
|
|
26
29
|
}
|
|
@@ -24,8 +24,17 @@ import {
|
|
|
24
24
|
type TaskParallelEvent,
|
|
25
25
|
type TaskParallelFingerprints,
|
|
26
26
|
} from './task-parallel'
|
|
27
|
+
import {
|
|
28
|
+
isNoSignalState,
|
|
29
|
+
NO_TASK_SIGNAL_EVENT,
|
|
30
|
+
type NoSignalState,
|
|
31
|
+
type NoTaskSignalEvent,
|
|
32
|
+
} from './task-signal-missing'
|
|
27
33
|
import { RUN_EVENT, RUN_STATUS, type WorkflowRun } from './workflow-runs'
|
|
28
34
|
|
|
35
|
+
/** The event `updateAvailableLine` prints; it is no `WatchEvent`, the announcer logs it on its own. */
|
|
36
|
+
export const UPDATE_AVAILABLE_EVENT = 'update available'
|
|
37
|
+
|
|
29
38
|
/** What the coordinator is told besides a lane that stopped. The values are the words of the report. */
|
|
30
39
|
export const WATCH_EVENT = {
|
|
31
40
|
merged: 'merged',
|
|
@@ -41,6 +50,7 @@ export const WATCH_EVENT = {
|
|
|
41
50
|
laneModel: LANE_MODEL_EVENT,
|
|
42
51
|
laneParallel: LANE_PARALLEL_EVENT,
|
|
43
52
|
taskParallel: TASK_PARALLEL_EVENT,
|
|
53
|
+
noTaskSignal: NO_TASK_SIGNAL_EVENT,
|
|
44
54
|
approval: APPROVAL_EVENT,
|
|
45
55
|
source: SOURCE_EVENT,
|
|
46
56
|
due: APPOINTMENT_EVENT.due,
|
|
@@ -95,12 +105,18 @@ export type WatchState = {
|
|
|
95
105
|
laneParallels: LaneParallels
|
|
96
106
|
/** The fingerprint of the parallel-task situation of each watched lane at the last heartbeat; a proposal is made only when it moved. */
|
|
97
107
|
taskParallels: TaskParallelFingerprints
|
|
108
|
+
/** The last day of `watch.parallelTasks.auto` whose end was reported, so it is said once; null before any. */
|
|
109
|
+
parallelAutoEnded: string | null
|
|
110
|
+
/** Per watched lane: since when it works without a task signal and the phase last reported as `no task signal`. */
|
|
111
|
+
noTaskSignals: NoSignalState
|
|
98
112
|
/** The ids of the approval requests on disk when they were last read, each reported once while it is open. */
|
|
99
113
|
approvals: string[]
|
|
100
114
|
/** Per configured source of `watch.sources`: the entries seen (`[id, fingerprint]`) and the start of a failure streak. */
|
|
101
115
|
sources: SourceState
|
|
102
116
|
/** Lane → change time of the report file last shown for it, null when it had none; a lane without an entry is unseen. */
|
|
103
117
|
reportMtimes: Record<string, number | null>
|
|
118
|
+
/** Lane → the questions of its report last shown or seen (`questionsFingerprint`); a lane without an entry is unseen. */
|
|
119
|
+
reportQuestions: Record<string, string>
|
|
104
120
|
/** The lane lines of the status block last printed; null before the first report. */
|
|
105
121
|
statusLines: string[] | null
|
|
106
122
|
/** The board's appointments reported as due and its unreadable appointment rows reported, each once (`appointments.ts`). */
|
|
@@ -149,6 +165,7 @@ export type WatchEvent =
|
|
|
149
165
|
| LaneModelEvent
|
|
150
166
|
| LaneParallelEvent
|
|
151
167
|
| TaskParallelEvent
|
|
168
|
+
| NoTaskSignalEvent
|
|
152
169
|
| ApprovalEvent
|
|
153
170
|
| SourceEvent
|
|
154
171
|
| AppointmentEvent
|
|
@@ -184,9 +201,12 @@ export const emptyWatchState = (): WatchState => ({
|
|
|
184
201
|
laneModels: {},
|
|
185
202
|
laneParallels: {},
|
|
186
203
|
taskParallels: {},
|
|
204
|
+
parallelAutoEnded: null,
|
|
205
|
+
noTaskSignals: {},
|
|
187
206
|
approvals: [],
|
|
188
207
|
sources: emptySourceState(),
|
|
189
208
|
reportMtimes: {},
|
|
209
|
+
reportQuestions: {},
|
|
190
210
|
statusLines: null,
|
|
191
211
|
appointments: emptyAppointmentState(),
|
|
192
212
|
})
|
|
@@ -230,10 +250,13 @@ const isWatchState = (value: Record<string, unknown>): value is WatchState =>
|
|
|
230
250
|
isLaneModels(value.laneModels) &&
|
|
231
251
|
isLaneParallels(value.laneParallels) &&
|
|
232
252
|
isTaskParallelFingerprints(value.taskParallels) &&
|
|
253
|
+
(value.parallelAutoEnded === null || typeof value.parallelAutoEnded === 'string') &&
|
|
254
|
+
isNoSignalState(value.noTaskSignals) &&
|
|
233
255
|
Array.isArray(value.approvals) &&
|
|
234
256
|
value.approvals.every((id) => typeof id === 'string') &&
|
|
235
257
|
isSourceState(value.sources) &&
|
|
236
258
|
everyValue(value.reportMtimes, isSeq) &&
|
|
259
|
+
everyValue(value.reportQuestions, (entry) => typeof entry === 'string') &&
|
|
237
260
|
(value.statusLines === null ||
|
|
238
261
|
(Array.isArray(value.statusLines) && value.statusLines.every((line) => typeof line === 'string'))) &&
|
|
239
262
|
isAppointmentState(value.appointments)
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { rmSync } from 'node:fs'
|
|
3
|
+
import { basename, dirname, join } from 'node:path'
|
|
4
|
+
import { writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
|
|
5
|
+
import {
|
|
6
|
+
ensurePrivateLevels,
|
|
7
|
+
type LocationSystem,
|
|
8
|
+
readPrivateText,
|
|
9
|
+
runtimeBaseOf,
|
|
10
|
+
systemLocation,
|
|
11
|
+
userDirectoryOf,
|
|
12
|
+
} from '../shared/private-location'
|
|
5
13
|
import { CLI_NAME, PACKAGE_NAME } from '../version/release'
|
|
6
14
|
|
|
7
15
|
/** Looks up the command line of a process; null when there is no such process. */
|
|
@@ -17,8 +25,6 @@ const RUNTIME = 'bun'
|
|
|
17
25
|
const DIRECTORY_PREFIX = 'jackherd-watch'
|
|
18
26
|
const KEY_HASH_LENGTH = 12
|
|
19
27
|
const NOT_A_SEGMENT = /[^A-Za-z0-9._-]+/g
|
|
20
|
-
const PRIVATE_DIRECTORY = 0o700
|
|
21
|
-
const PRIVATE_FILE = 0o600
|
|
22
28
|
const STOP_WAIT_MS = 5_000
|
|
23
29
|
const STOP_POLL_MS = 50
|
|
24
30
|
|
|
@@ -73,14 +79,6 @@ export const repositoryKey = (root: string): string => {
|
|
|
73
79
|
return `${name}-${hash}`
|
|
74
80
|
}
|
|
75
81
|
|
|
76
|
-
const readText = (file: string): string | null => {
|
|
77
|
-
try {
|
|
78
|
-
return readFileSync(file, 'utf8')
|
|
79
|
-
} catch {
|
|
80
|
-
return null
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
82
|
const commandLineOf: CommandLineOf = (pid) => {
|
|
85
83
|
const result = Bun.spawnSync(['ps', '-o', 'command=', '-p', String(pid)], { stdout: 'pipe', stderr: 'ignore' })
|
|
86
84
|
return result.exitCode === 0 ? result.stdout.toString().trim() || null : null
|
|
@@ -96,36 +94,34 @@ const isAlive = (pid: number): boolean => {
|
|
|
96
94
|
}
|
|
97
95
|
|
|
98
96
|
/** Where `ensureWatcherDirectory` puts the watcher's files, without creating anything: for readers such as the herd. */
|
|
99
|
-
export const watcherDirectoryOf = (root: string): string =>
|
|
100
|
-
join(
|
|
97
|
+
export const watcherDirectoryOf = (root: string, system: LocationSystem = systemLocation()): string =>
|
|
98
|
+
join(userDirectoryOf(runtimeBaseOf(system), DIRECTORY_PREFIX, system.uid), repositoryKey(root))
|
|
101
99
|
|
|
102
100
|
/**
|
|
103
101
|
* The directory for the lock and state of the watcher of the repository at `root`, one per user and repository,
|
|
104
|
-
* readable by that user only:
|
|
105
|
-
*
|
|
102
|
+
* readable by that user only: without a private runtime directory the base is the temporary directory, which may be
|
|
103
|
+
* the shared `/tmp`, where another account could plant the files, so both levels are verified (`ensurePrivateLevels`)
|
|
104
|
+
* and this throws for a level that is a link or someone else's. A watcher in another repository has its own lock and
|
|
105
|
+
* does not supersede this one.
|
|
106
106
|
*/
|
|
107
|
-
export const ensureWatcherDirectory = (root: string): string => {
|
|
108
|
-
const directory = watcherDirectoryOf(root)
|
|
109
|
-
|
|
110
|
-
return directory
|
|
107
|
+
export const ensureWatcherDirectory = (root: string, system: LocationSystem = systemLocation()): string => {
|
|
108
|
+
const directory = watcherDirectoryOf(root, system)
|
|
109
|
+
return ensurePrivateLevels(dirname(directory), directory)
|
|
111
110
|
}
|
|
112
111
|
|
|
113
112
|
/** Whether the lock names a live process that is a watcher, so its saved state is being kept up to date. */
|
|
114
113
|
export const isWatcherRunning = (lockFile: string, lookUp: CommandLineOf = commandLineOf): boolean => {
|
|
115
|
-
const pid = lockedPid(
|
|
114
|
+
const pid = lockedPid(readPrivateText(lockFile))
|
|
116
115
|
return pid !== null && isAlive(pid) && isWatcherCommand(lookUp(pid) ?? '')
|
|
117
116
|
}
|
|
118
117
|
|
|
119
|
-
/** Writes a file only the user can read. */
|
|
120
|
-
export const writePrivate = (file: string, content: string) => writeFileSync(file, content, { mode: PRIVATE_FILE })
|
|
121
|
-
|
|
122
118
|
/**
|
|
123
119
|
* Takes the lock for this process. A watcher that held it gets SIGTERM and is waited for, so the state
|
|
124
120
|
* file it writes on the way out is complete before this one reads it.
|
|
125
121
|
*/
|
|
126
122
|
export const takeOverLock = async (lockFile: string): Promise<TakeOver> => {
|
|
127
|
-
const previous = supersedeTarget(
|
|
128
|
-
|
|
123
|
+
const previous = supersedeTarget(readPrivateText(lockFile), process.pid, commandLineOf)
|
|
124
|
+
writePrivateFile(lockFile, String(process.pid))
|
|
129
125
|
if (previous === null || !isAlive(previous)) return { replaced: null, stillRunning: null }
|
|
130
126
|
try {
|
|
131
127
|
process.kill(previous, 'SIGTERM')
|
|
@@ -137,9 +133,9 @@ export const takeOverLock = async (lockFile: string): Promise<TakeOver> => {
|
|
|
137
133
|
return isAlive(previous) ? { replaced: null, stillRunning: previous } : { replaced: previous, stillRunning: null }
|
|
138
134
|
}
|
|
139
135
|
|
|
140
|
-
export const stopNoticeOf = (lockFile: string): string => stopNotice(
|
|
136
|
+
export const stopNoticeOf = (lockFile: string): string => stopNotice(readPrivateText(lockFile), process.pid)
|
|
141
137
|
|
|
142
138
|
/** Removes the lock only while it still names this process; a successor's lock stays. */
|
|
143
139
|
export const releaseLock = (lockFile: string) => {
|
|
144
|
-
if (lockedPid(
|
|
140
|
+
if (lockedPid(readPrivateText(lockFile)) === process.pid) rmSync(lockFile, { force: true })
|
|
145
141
|
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
// PostToolUse hook for AskUserQuestion, shipped by jackherd. When the call asked about a plan (``Plan `<path>` ``) and
|
|
2
|
-
// offered the label that approves it or the one that revokes it, it leaves a pointer to that dialog in the main
|
|
3
|
-
// checkout's `.jackherd/approvals/plans/`, so the merge guard finds the answer again after the session ended. It writes
|
|
4
|
-
// only the pointer; the merge guard reads the answer from the transcript itself. argv[2] and argv[3] are the two labels,
|
|
5
|
-
// rendered into the hook entry by `sync`. It never blocks and prints nothing: a pointer it fails to write costs a question.
|
|
6
|
-
// The other way round it costs a revocation: when no pointer is written for a "revoke" answer, the merge guard never
|
|
7
|
-
// sees that answer, and an older approval keeps holding until the next plan question that is recorded.
|
|
8
|
-
import { mainCheckoutOf } from '../shared/checkout'
|
|
9
|
-
import { planPointersDirectory, pointerOf, writePointer } from './plan-pointers'
|
|
10
|
-
|
|
11
|
-
const record = async (): Promise<void> => {
|
|
12
|
-
const pointer = pointerOf(JSON.parse(await Bun.stdin.text()), process.argv.slice(2, 4), Date.now())
|
|
13
|
-
if (pointer === undefined) return
|
|
14
|
-
const projectDirectory = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()
|
|
15
|
-
writePointer(planPointersDirectory(mainCheckoutOf(projectDirectory)), pointer)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
try {
|
|
19
|
-
await record()
|
|
20
|
-
} catch {
|
|
21
|
-
// Nothing to do: without a pointer the merge asks.
|
|
22
|
-
}
|
|
23
|
-
process.exit(0)
|
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Where plan approval reads its facts: git in the main checkout for the base branch, the worktrees and the boards, the
|
|
3
|
-
* pointer directory and the transcripts for the answers. Every git call runs without a shell and with a timeout; any
|
|
4
|
-
* failure answers nothing, so the guard asks.
|
|
5
|
-
*/
|
|
6
|
-
import { existsSync } from 'node:fs'
|
|
7
|
-
import { join, posix, relative, resolve } from 'node:path'
|
|
8
|
-
import { lanePlansOf } from '../shared/board-lanes'
|
|
9
|
-
import { laneOf } from '../shared/lane'
|
|
10
|
-
import type { PlanApprovalSources } from './plan-approval'
|
|
11
|
-
import { FULL_COMMIT } from '../shared/commit'
|
|
12
|
-
import { isPlainPath } from './plain-path'
|
|
13
|
-
import { planAnswers, planPointersDirectory } from './plan-pointers'
|
|
14
|
-
import { checksPassed } from './pull-request'
|
|
15
|
-
import { baseBranchOf } from '../shared/base-branch'
|
|
16
|
-
import { type PlanApprovalSettings, PROJECT_CONFIG, planApprovalSettingsOf } from './plan-approval-settings'
|
|
17
|
-
import { readConfigOrNull } from './settings'
|
|
18
|
-
|
|
19
|
-
/** The base commit plan approval reads, just fetched, and the settings its project config holds. */
|
|
20
|
-
export type PlanBase = { commit: string; settings: PlanApprovalSettings }
|
|
21
|
-
|
|
22
|
-
export type PlanSourceContext = {
|
|
23
|
-
/** The main checkout, where the worktrees, the base branch and the pointers are. */
|
|
24
|
-
main: string
|
|
25
|
-
base: PlanBase
|
|
26
|
-
/** The transcript of the session that merges. */
|
|
27
|
-
transcript: string | undefined
|
|
28
|
-
/** The label that approves a plan. */
|
|
29
|
-
label: string
|
|
30
|
-
files: () => readonly string[] | undefined
|
|
31
|
-
/** The `gh pr view` answer for the pull request, which carries its checks. */
|
|
32
|
-
view: string
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const GIT_TIMEOUT_MS = 3000
|
|
36
|
-
const BASE_REMOTE = 'origin'
|
|
37
|
-
const HEADER = /^[0-9a-f]+ blob (\d+)$/
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* The output of `git cat-file --batch`, one entry per requested path in order: the text of a blob, null for a path the
|
|
41
|
-
* commit lacks or that is no blob. Undefined when the output does not hold one entry per path.
|
|
42
|
-
*/
|
|
43
|
-
export const parseCatFileBatch = (output: Buffer, count: number): (string | null)[] | undefined => {
|
|
44
|
-
const texts: (string | null)[] = []
|
|
45
|
-
let at = 0
|
|
46
|
-
while (texts.length < count) {
|
|
47
|
-
const newline = output.indexOf('\n', at)
|
|
48
|
-
if (newline === -1) return undefined
|
|
49
|
-
const size = HEADER.exec(output.subarray(at, newline).toString('utf8'))?.[1]
|
|
50
|
-
if (size === undefined) {
|
|
51
|
-
texts.push(null)
|
|
52
|
-
at = newline + 1
|
|
53
|
-
continue
|
|
54
|
-
}
|
|
55
|
-
const end = newline + 1 + Number(size)
|
|
56
|
-
if (end > output.length) return undefined
|
|
57
|
-
texts.push(output.subarray(newline + 1, end).toString('utf8'))
|
|
58
|
-
at = end + 1
|
|
59
|
-
}
|
|
60
|
-
return texts
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** The lane whose worktree, directly below the worktree directory, has `branch` checked out. */
|
|
64
|
-
export const laneInWorktreeList = (
|
|
65
|
-
worktreeList: string,
|
|
66
|
-
main: string,
|
|
67
|
-
worktreeDirectory: string,
|
|
68
|
-
branch: string,
|
|
69
|
-
): string | undefined => {
|
|
70
|
-
const blocks = worktreeList.split('\n\n').map((block) => block.split('\n'))
|
|
71
|
-
const block = blocks.find((lines) => lines.includes(`branch refs/heads/${branch}`))
|
|
72
|
-
const path = block?.find((line) => line.startsWith('worktree '))?.slice('worktree '.length)
|
|
73
|
-
if (path === undefined) return undefined
|
|
74
|
-
const lane = laneOf({ cwd: path, main, worktreeDirectory })
|
|
75
|
-
return lane !== null && relative(resolve(main, worktreeDirectory), resolve(path)) === lane ? lane : undefined
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Whether `name` is the directory of a lane's worktree below the worktree directory. */
|
|
79
|
-
export const isLaneIn =
|
|
80
|
-
(main: string, worktreeDirectory: string) =>
|
|
81
|
-
(name: string): boolean => {
|
|
82
|
-
const worktree = join(resolve(main, worktreeDirectory), name)
|
|
83
|
-
return laneOf({ cwd: worktree, main, worktreeDirectory }) === name && existsSync(worktree)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Only the base branch into its remote ref: no tags, no FETCH_HEAD written, no maintenance started. */
|
|
87
|
-
export const baseFetchArguments = (baseBranch: string): string[] => [
|
|
88
|
-
'fetch',
|
|
89
|
-
'--quiet',
|
|
90
|
-
'--no-tags',
|
|
91
|
-
'--no-write-fetch-head',
|
|
92
|
-
'--no-auto-maintenance',
|
|
93
|
-
BASE_REMOTE,
|
|
94
|
-
`+refs/heads/${baseBranch}:refs/remotes/${BASE_REMOTE}/${baseBranch}`,
|
|
95
|
-
]
|
|
96
|
-
|
|
97
|
-
/** No prompt for a password or a host key: a fetch that would wait for one fails at once instead. */
|
|
98
|
-
export const NON_INTERACTIVE = { GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }
|
|
99
|
-
|
|
100
|
-
const git = (main: string, args: readonly string[], stdin?: string): Buffer | undefined => {
|
|
101
|
-
const result = Bun.spawnSync(['git', '-C', main, ...args], {
|
|
102
|
-
stdin: stdin === undefined ? 'ignore' : new TextEncoder().encode(stdin),
|
|
103
|
-
stdout: 'pipe',
|
|
104
|
-
stderr: 'ignore',
|
|
105
|
-
timeout: GIT_TIMEOUT_MS,
|
|
106
|
-
env: { ...process.env, ...NON_INTERACTIVE },
|
|
107
|
-
})
|
|
108
|
-
return result.exitCode === 0 ? result.stdout : undefined
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const baseCommitOf = (main: string, baseBranch: string): string | undefined => {
|
|
112
|
-
const commit = git(main, ['rev-parse', '--verify', '--quiet', `${BASE_REMOTE}/${baseBranch}^{commit}`])
|
|
113
|
-
?.toString()
|
|
114
|
-
.trim()
|
|
115
|
-
return commit !== undefined && FULL_COMMIT.test(commit) ? commit : undefined
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** The texts of the paths at the commit, in one `git cat-file --batch`; null for each one it lacks or cannot read. */
|
|
119
|
-
const readAtCommit = (main: string, commit: string, paths: readonly string[]): Map<string, string | null> => {
|
|
120
|
-
const asked = paths.filter(isPlainPath)
|
|
121
|
-
const output =
|
|
122
|
-
asked.length === 0
|
|
123
|
-
? undefined
|
|
124
|
-
: git(main, ['cat-file', '--batch'], asked.map((path) => `${commit}:${path}\n`).join(''))
|
|
125
|
-
const texts = output === undefined ? undefined : parseCatFileBatch(output, asked.length)
|
|
126
|
-
return new Map(paths.map((path) => [path, texts?.[asked.indexOf(path)] ?? null]))
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** The board directories under the plan directory at the commit whose name matches the board pattern. */
|
|
130
|
-
const boardDirectories = (main: string, commit: string, settings: PlanApprovalSettings): string[] => {
|
|
131
|
-
const listing = git(main, ['ls-tree', '-d', '--name-only', commit, '--', `${settings.planDirectory}/`])
|
|
132
|
-
const pattern = new Bun.Glob(settings.board.pattern)
|
|
133
|
-
return (listing?.toString() ?? '')
|
|
134
|
-
.split('\n')
|
|
135
|
-
.filter((path) => path !== '' && pattern.match(posix.basename(path)))
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Brings `origin/<baseBranch>` up to date, so a stale ref cannot hold an approval GitHub's base no longer does. */
|
|
139
|
-
const fetchBase = (main: string, baseBranch: string): boolean => git(main, baseFetchArguments(baseBranch)) !== undefined
|
|
140
|
-
|
|
141
|
-
/** The newest first-parent commit of the base from before the time; undefined when there is none. */
|
|
142
|
-
const commitBefore = (main: string, commit: string, time: number): string | undefined => {
|
|
143
|
-
const found = git(main, ['rev-list', '-1', '--first-parent', `--before=@${Math.floor(time / 1000)}`, commit])
|
|
144
|
-
?.toString()
|
|
145
|
-
.trim()
|
|
146
|
-
return found !== undefined && FULL_COMMIT.test(found) ? found : undefined
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* The plan as the base held it at the time of the answer; undefined when the base did not hold it then, or not
|
|
151
|
-
* readably. A plan that was only in a pull request when the user approved it could have grown before it landed, and
|
|
152
|
-
* the state the user saw is nowhere on the base, so the guard asks once more after it landed.
|
|
153
|
-
*/
|
|
154
|
-
const planAtApproval = (main: string, commit: string, plan: string, answeredAt: number): string | undefined => {
|
|
155
|
-
const before = commitBefore(main, commit, answeredAt)
|
|
156
|
-
return (before === undefined ? undefined : readAtCommit(main, before, [plan]).get(plan)) ?? undefined
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* The base plan approval reads: `origin/<baseBranch>` right after a fetch, and the settings of the project config at
|
|
161
|
-
* that commit, never of the checkout. The base branch comes from the checkout's config (the base cannot name itself)
|
|
162
|
-
* and must agree with the one the base's config names. Undefined, so the guard asks, when any step fails.
|
|
163
|
-
*/
|
|
164
|
-
export const planBaseOf = (main: string): PlanBase | undefined => {
|
|
165
|
-
const baseBranch = baseBranchOf(readConfigOrNull([main]))
|
|
166
|
-
if (baseBranch === undefined || !fetchBase(main, baseBranch)) return undefined
|
|
167
|
-
const commit = baseCommitOf(main, baseBranch)
|
|
168
|
-
if (commit === undefined) return undefined
|
|
169
|
-
const settings = planApprovalSettingsOf(readAtCommit(main, commit, [PROJECT_CONFIG]).get(PROJECT_CONFIG) ?? null)
|
|
170
|
-
return settings !== null && settings.baseBranch === baseBranch ? { commit, settings } : undefined
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
export const planApprovalSources = ({ main, base, transcript, label, files, view }: PlanSourceContext): PlanApprovalSources => {
|
|
174
|
-
const { commit, settings } = base
|
|
175
|
-
return {
|
|
176
|
-
checksPassed: (requiredChecks) => checksPassed(view, requiredChecks),
|
|
177
|
-
containsBase: (headCommit) =>
|
|
178
|
-
FULL_COMMIT.test(headCommit) && git(main, ['merge-base', '--is-ancestor', commit, headCommit]) !== undefined,
|
|
179
|
-
files,
|
|
180
|
-
readAtBase: (paths) => readAtCommit(main, commit, paths),
|
|
181
|
-
laneOfBranch: (branch) => {
|
|
182
|
-
const list = git(main, ['worktree', 'list', '--porcelain'])?.toString()
|
|
183
|
-
return list === undefined ? undefined : laneInWorktreeList(list, main, settings.worktreeDirectory, branch)
|
|
184
|
-
},
|
|
185
|
-
lanePlans: (lane) => {
|
|
186
|
-
const boards = boardDirectories(main, commit, settings)
|
|
187
|
-
const texts = readAtCommit(main, commit, boards.map((directory) => `${directory}/${settings.board.file}`))
|
|
188
|
-
return boards.flatMap((directory) => {
|
|
189
|
-
const text = texts.get(`${directory}/${settings.board.file}`)
|
|
190
|
-
return (text ? lanePlansOf(text, directory, lane, settings.planLanguage) : undefined) ?? []
|
|
191
|
-
})
|
|
192
|
-
},
|
|
193
|
-
answers: (plan) => planAnswers({ directory: planPointersDirectory(main), currentTranscript: transcript, plan, label }),
|
|
194
|
-
readAtCommitBefore: (plan, answeredAt) => (isPlainPath(plan) ? planAtApproval(main, commit, plan, answeredAt) : undefined),
|
|
195
|
-
}
|
|
196
|
-
}
|