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
package/src/context/command.ts
CHANGED
|
@@ -2,13 +2,15 @@ import { homedir } from 'node:os'
|
|
|
2
2
|
import { basename, join } from 'node:path'
|
|
3
3
|
import { CLAUDE_DIRECTORY } from '../claude/paths'
|
|
4
4
|
import { mainCheckout, worktreeRoot } from '../plans/git'
|
|
5
|
-
import { type PlansSettings, readSettings } from '../plans/settings'
|
|
5
|
+
import { type PlansSettings, readSettings, worktreeOf } from '../plans/settings'
|
|
6
6
|
import { type LaneAgent, laneStatusSourceFor } from '../terminal'
|
|
7
7
|
import { AUTO_COMPACT_ENV, autoCompactWindow, compactsBeforeAlertNote, readClaudeSettings } from './auto-compact'
|
|
8
8
|
import { resolveCoordinatorName } from './coordinator-name'
|
|
9
9
|
import { type ContextOptions, coordinatorSettings, parseContextOptions } from './options'
|
|
10
10
|
import { type ContextReport, contextReport, noValueNote, unnamedPaneNote } from './report'
|
|
11
|
-
import { agentLog } from './session-log'
|
|
11
|
+
import { agentLog, defaultProjectsDirectory } from './session-log'
|
|
12
|
+
import { parseSince, sumUsage, usageReport } from './usage'
|
|
13
|
+
import { usageEntriesOf } from './usage-logs'
|
|
12
14
|
|
|
13
15
|
type ResolvedAgents = { lane: string; named: LaneAgent[]; agents: LaneAgent[] }
|
|
14
16
|
|
|
@@ -56,6 +58,30 @@ const printNotes = ({ lane, named, agents }: ResolvedAgents, report: ContextRepo
|
|
|
56
58
|
if (report === null) console.error(noValueNote(agents, lane))
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
/**
|
|
62
|
+
* `--usage`: the tokens used by the lane's sessions and their subagents, as one line of JSON. The scope is the lane's
|
|
63
|
+
* worktree (the one the command runs in without a name), or with `--coordinator` the main checkout without its
|
|
64
|
+
* worktrees.
|
|
65
|
+
*/
|
|
66
|
+
const usageSubject = (
|
|
67
|
+
options: ContextOptions,
|
|
68
|
+
settings: PlansSettings,
|
|
69
|
+
root: string,
|
|
70
|
+
worktree: string,
|
|
71
|
+
): { label: string; directory: string } => {
|
|
72
|
+
if (options.coordinator) return { label: basename(root), directory: root }
|
|
73
|
+
if (options.lane) return { label: options.lane, directory: worktreeOf(root, settings, options.lane) }
|
|
74
|
+
return { label: basename(worktree), directory: worktree }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const printUsage = (options: ContextOptions, settings: PlansSettings, root: string, worktree: string): void => {
|
|
78
|
+
const { label, directory } = usageSubject(options, settings, root, worktree)
|
|
79
|
+
const since = options.since === null ? null : parseSince(options.since, Date.now())
|
|
80
|
+
const exclude = directory === root ? [join(root, settings.worktreeDirectory)] : []
|
|
81
|
+
const entries = usageEntriesOf(defaultProjectsDirectory(), directory, since)
|
|
82
|
+
console.log(JSON.stringify(usageReport(label, sumUsage(entries, { root: directory, exclude, since }), since)))
|
|
83
|
+
}
|
|
84
|
+
|
|
59
85
|
/**
|
|
60
86
|
* `jh context [<name>] [--coordinator]`: prints how full the context of an agent is as one line of JSON
|
|
61
87
|
* (`tokens`, `window`, `share`, `model`, `level`), or `null` when there is no value. The name defaults to the
|
|
@@ -63,6 +89,7 @@ const printNotes = ({ lane, named, agents }: ResolvedAgents, report: ContextRepo
|
|
|
63
89
|
* agent still carries that old name. `--coordinator` also takes
|
|
64
90
|
* the level from the coordinator's thresholds (`plans.context.coordinator`). Reads the session log, never writes it.
|
|
65
91
|
* One more line on stderr when Claude Code's auto-compact window is not above the alert threshold.
|
|
92
|
+
* `jh context --usage [--since 24h|7d|<date>] [<name>] [--coordinator]` prints the tokens used instead (`printUsage`).
|
|
66
93
|
*/
|
|
67
94
|
export const run = async (args: string[]): Promise<number> => {
|
|
68
95
|
try {
|
|
@@ -70,6 +97,10 @@ export const run = async (args: string[]): Promise<number> => {
|
|
|
70
97
|
const root = mainCheckout(process.cwd())
|
|
71
98
|
const worktree = worktreeRoot(process.cwd())
|
|
72
99
|
const settings = readSettings([worktree, root])
|
|
100
|
+
if (options.usage) {
|
|
101
|
+
printUsage(options, settings, root, worktree)
|
|
102
|
+
return 0
|
|
103
|
+
}
|
|
73
104
|
const resolved = resolveAgents(options, settings, root, worktree)
|
|
74
105
|
const context = options.coordinator ? coordinatorSettings(settings.context) : settings.context
|
|
75
106
|
const report = contextReport(resolved.agents, resolved.lane, agentLog, context)
|
package/src/context/options.ts
CHANGED
|
@@ -2,24 +2,49 @@ import { CLI_NAME } from '../version/release'
|
|
|
2
2
|
import type { ContextWindows } from './fill'
|
|
3
3
|
import type { ContextThresholds } from './level'
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* `lane` is null when the command names none; it then measures the agent of the worktree it runs in. `usage` asks for
|
|
7
|
+
* the tokens used instead of the fill, `since` (the text of `--since`) limits them to a period.
|
|
8
|
+
*/
|
|
9
|
+
export type ContextOptions = { lane: string | null; coordinator: boolean; usage: boolean; since: string | null }
|
|
7
10
|
|
|
8
11
|
export type ContextSettings = ContextWindows & ContextThresholds & { coordinator: ContextThresholds }
|
|
9
12
|
|
|
10
13
|
const COORDINATOR_OPTION = '--coordinator'
|
|
11
14
|
|
|
12
|
-
const
|
|
15
|
+
const USAGE_OPTION = '--usage'
|
|
16
|
+
const SINCE_OPTION = '--since'
|
|
13
17
|
|
|
14
|
-
const
|
|
18
|
+
const KNOWN_OPTIONS = [COORDINATOR_OPTION, USAGE_OPTION, SINCE_OPTION]
|
|
15
19
|
|
|
16
|
-
|
|
20
|
+
const USAGE = `usage: ${CLI_NAME} context [<name>] [${COORDINATOR_OPTION}] [${USAGE_OPTION} [${SINCE_OPTION} <24h|7d|date>]]`
|
|
21
|
+
|
|
22
|
+
const optionName = (arg: string): string => arg.split('=')[0] ?? arg
|
|
23
|
+
|
|
24
|
+
/** The value of `--since`, given as `--since <value>` or `--since=<value>`, and the arguments without it; null without the option. */
|
|
25
|
+
const takeSince = (args: readonly string[]): { since: string | null; rest: string[] } => {
|
|
26
|
+
const index = args.findIndex((arg) => optionName(arg) === SINCE_OPTION)
|
|
27
|
+
if (index === -1) return { since: null, rest: [...args] }
|
|
28
|
+
const arg = args[index] ?? ''
|
|
29
|
+
const inline = arg.includes('=')
|
|
30
|
+
const value = inline ? arg.slice(arg.indexOf('=') + 1) : args[index + 1]
|
|
31
|
+
if (!value || value.startsWith('--')) throw new Error(`${SINCE_OPTION} needs a value\n${USAGE}`)
|
|
32
|
+
return { since: value, rest: args.filter((_, position) => position !== index && (inline || position !== index + 1)) }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reads `[<name>] [--coordinator] [--usage [--since <value>]]` in any order; throws with the usage on an unknown
|
|
37
|
+
* option, a second name, a `--since` without a value or one without `--usage`.
|
|
38
|
+
*/
|
|
17
39
|
export const parseContextOptions = (args: readonly string[]): ContextOptions => {
|
|
18
|
-
const unknown = args.find((arg) => arg.startsWith('--') && !KNOWN_OPTIONS.includes(arg))
|
|
40
|
+
const unknown = args.find((arg) => arg.startsWith('--') && !KNOWN_OPTIONS.includes(optionName(arg)))
|
|
19
41
|
if (unknown) throw new Error(`unknown option ${unknown}\n${USAGE}`)
|
|
20
|
-
const
|
|
42
|
+
const { since, rest } = takeSince(args)
|
|
43
|
+
const usage = rest.includes(USAGE_OPTION)
|
|
44
|
+
if (since !== null && !usage) throw new Error(`${SINCE_OPTION} belongs to ${USAGE_OPTION}\n${USAGE}`)
|
|
45
|
+
const names = rest.filter((arg) => !arg.startsWith('--'))
|
|
21
46
|
if (names.length > 1) throw new Error(`one name at most, got ${names.join(' ')}\n${USAGE}`)
|
|
22
|
-
return { lane: names[0] ?? null, coordinator:
|
|
47
|
+
return { lane: names[0] ?? null, coordinator: rest.includes(COORDINATOR_OPTION), usage, since }
|
|
23
48
|
}
|
|
24
49
|
|
|
25
50
|
/** The settings with the coordinator's thresholds in place of a lane's; the windows stay the same. */
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { projectDirectoryOf } from './session-log'
|
|
4
|
+
import { parseUsageLine, type UsageEntry } from './usage'
|
|
5
|
+
|
|
6
|
+
const SUBAGENTS_DIRECTORY = 'subagents'
|
|
7
|
+
const LOG_EXTENSION = '.jsonl'
|
|
8
|
+
const USAGE_MARK = '"usage"'
|
|
9
|
+
|
|
10
|
+
const directoriesOf = (path: string): string[] =>
|
|
11
|
+
readdirSync(path, { withFileTypes: true })
|
|
12
|
+
.filter((each) => each.isDirectory())
|
|
13
|
+
.map((each) => each.name)
|
|
14
|
+
|
|
15
|
+
const logsIn = (directory: string): string[] =>
|
|
16
|
+
existsSync(directory)
|
|
17
|
+
? readdirSync(directory)
|
|
18
|
+
.filter((name) => name.endsWith(LOG_EXTENSION))
|
|
19
|
+
.map((name) => join(directory, name))
|
|
20
|
+
: []
|
|
21
|
+
|
|
22
|
+
/** The session logs of a project directory and the logs of the subagents of each session. */
|
|
23
|
+
const logsOfProject = (directory: string): string[] => [
|
|
24
|
+
...logsIn(directory),
|
|
25
|
+
...directoriesOf(directory).flatMap((session) => logsIn(join(directory, session, SUBAGENTS_DIRECTORY))),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/** The project directories of the working directory `root` and of every directory below it (Claude Code names one per working directory). */
|
|
29
|
+
const projectDirectoriesFor = (projectsDirectory: string, root: string): string[] => {
|
|
30
|
+
const prefix = projectDirectoryOf(root)
|
|
31
|
+
return existsSync(projectsDirectory)
|
|
32
|
+
? directoriesOf(projectsDirectory)
|
|
33
|
+
.filter((name) => name === prefix || name.startsWith(`${prefix}-`))
|
|
34
|
+
.map((name) => join(projectsDirectory, name))
|
|
35
|
+
: []
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const entriesOfLog = (path: string): UsageEntry[] =>
|
|
39
|
+
readFileSync(path, 'utf8')
|
|
40
|
+
.split('\n')
|
|
41
|
+
.filter((line) => line.includes(USAGE_MARK))
|
|
42
|
+
.flatMap((line) => parseUsageLine(line) ?? [])
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The usage entries of every session and subagent log whose project directory is `root`'s or lies below it; the
|
|
46
|
+
* caller sums them within a scope. A log last written before `since` (milliseconds) cannot hold a later request and
|
|
47
|
+
* is not read. The directory name is only a coarse filter: `cwd` in each line decides in `sumUsage`.
|
|
48
|
+
*/
|
|
49
|
+
export const usageEntriesOf = (projectsDirectory: string, root: string, since: number | null): UsageEntry[] =>
|
|
50
|
+
projectDirectoriesFor(projectsDirectory, root)
|
|
51
|
+
.flatMap(logsOfProject)
|
|
52
|
+
.filter((path) => since === null || statSync(path).mtimeMs >= since)
|
|
53
|
+
.flatMap(entriesOfLog)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { isRecord } from '../shared/is-record'
|
|
2
|
+
|
|
3
|
+
/** One answer's token counts out of a session log line, with the request it belongs to and where and when it ran. */
|
|
4
|
+
export type UsageEntry = {
|
|
5
|
+
requestId: string
|
|
6
|
+
cwd: string
|
|
7
|
+
/** The time of the line in milliseconds; null when the line carries none. */
|
|
8
|
+
at: number | null
|
|
9
|
+
input: number
|
|
10
|
+
cacheRead: number
|
|
11
|
+
cacheWrite: number
|
|
12
|
+
output: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type UsageTotals = { requests: number; input: number; cacheRead: number; cacheWrite: number; output: number }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What a sum counts: the entries run in `root` or below it, none below an `exclude` directory (the main checkout
|
|
19
|
+
* leaves its worktrees out), and, with `since` (milliseconds), none from before it and none without a time.
|
|
20
|
+
*/
|
|
21
|
+
export type UsageScope = { root: string; exclude: readonly string[]; since: number | null }
|
|
22
|
+
|
|
23
|
+
const HOUR = 3_600_000
|
|
24
|
+
const DAY = 24 * HOUR
|
|
25
|
+
const SINCE_FORM = /^(\d+)([hd])$/
|
|
26
|
+
const SINCE_FORMS = 'use 24h, 7d or an ISO date (YYYY-MM-DD)'
|
|
27
|
+
|
|
28
|
+
const count = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
|
|
29
|
+
|
|
30
|
+
const timeOf = (value: unknown): number | null => {
|
|
31
|
+
const time = typeof value === 'string' ? Date.parse(value) : Number.NaN
|
|
32
|
+
return Number.isNaN(time) ? null : time
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The usage of one log line; null for a line without usage, request or working directory, and for anything that is no JSON. */
|
|
36
|
+
export const parseUsageLine = (line: string): UsageEntry | null => {
|
|
37
|
+
let parsed: unknown
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(line)
|
|
40
|
+
} catch {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
if (!isRecord(parsed) || !isRecord(parsed.message) || !isRecord(parsed.message.usage)) return null
|
|
44
|
+
const { requestId, cwd, timestamp } = parsed
|
|
45
|
+
if (typeof requestId !== 'string' || typeof cwd !== 'string') return null
|
|
46
|
+
const usage = parsed.message.usage
|
|
47
|
+
return {
|
|
48
|
+
requestId,
|
|
49
|
+
cwd,
|
|
50
|
+
at: timeOf(timestamp),
|
|
51
|
+
input: count(usage.input_tokens),
|
|
52
|
+
cacheRead: count(usage.cache_read_input_tokens),
|
|
53
|
+
cacheWrite: count(usage.cache_creation_input_tokens),
|
|
54
|
+
output: count(usage.output_tokens),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const isBelow = (directory: string, parent: string): boolean =>
|
|
59
|
+
directory === parent || directory.startsWith(`${parent}/`)
|
|
60
|
+
|
|
61
|
+
/** Whether a working directory is the scope's root or below it, and not below an excluded directory. */
|
|
62
|
+
export const isWithinScope = (cwd: string, { root, exclude }: UsageScope): boolean =>
|
|
63
|
+
isBelow(cwd, root) && !exclude.some((excluded) => isBelow(cwd, excluded))
|
|
64
|
+
|
|
65
|
+
const addUsage = (totals: UsageTotals, entry: UsageEntry): UsageTotals => ({
|
|
66
|
+
requests: totals.requests + 1,
|
|
67
|
+
input: totals.input + entry.input,
|
|
68
|
+
cacheRead: totals.cacheRead + entry.cacheRead,
|
|
69
|
+
cacheWrite: totals.cacheWrite + entry.cacheWrite,
|
|
70
|
+
output: totals.output + entry.output,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const fromSince = (entry: UsageEntry, since: number | null): boolean =>
|
|
74
|
+
since === null || (entry.at !== null && entry.at >= since)
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The tokens of the requests in `scope`, each request once however many log lines or logs repeat it (Claude Code
|
|
78
|
+
* writes one answer as several lines with the same counts).
|
|
79
|
+
*/
|
|
80
|
+
export const sumUsage = (entries: readonly UsageEntry[], scope: UsageScope): UsageTotals => {
|
|
81
|
+
const seen = new Set<string>()
|
|
82
|
+
return entries.reduce<UsageTotals>(
|
|
83
|
+
(totals, entry) => {
|
|
84
|
+
if (seen.has(entry.requestId) || !isWithinScope(entry.cwd, scope) || !fromSince(entry, scope.since)) return totals
|
|
85
|
+
seen.add(entry.requestId)
|
|
86
|
+
return addUsage(totals, entry)
|
|
87
|
+
},
|
|
88
|
+
{ requests: 0, input: 0, cacheRead: 0, cacheWrite: 0, output: 0 },
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** What `jh context --usage` prints: the sums in tokens, no price, `total` their sum, `since` the start as an ISO time or null. */
|
|
93
|
+
export const usageReport = (scope: string, totals: UsageTotals, since: number | null) => ({
|
|
94
|
+
scope,
|
|
95
|
+
since: since === null ? null : new Date(since).toISOString(),
|
|
96
|
+
...totals,
|
|
97
|
+
total: totals.input + totals.cacheRead + totals.cacheWrite + totals.output,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
/** The start time of `--since`: `<n>h` or `<n>d` before `now`, or an ISO date; throws with the accepted forms otherwise. */
|
|
101
|
+
export const parseSince = (text: string, now: number): number => {
|
|
102
|
+
const relative = SINCE_FORM.exec(text)
|
|
103
|
+
if (relative) {
|
|
104
|
+
const amount = Number(relative[1])
|
|
105
|
+
if (amount > 0) return now - amount * (relative[2] === 'h' ? HOUR : DAY)
|
|
106
|
+
}
|
|
107
|
+
const date = /^\d{4}-\d{2}-\d{2}/.test(text) ? Date.parse(text) : Number.NaN
|
|
108
|
+
if (Number.isNaN(date)) throw new Error(`cannot read --since ${text}: ${SINCE_FORMS}`)
|
|
109
|
+
return date
|
|
110
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsOnRef, mainCheckout } from '../plans/git'
|
|
2
|
+
import { baseRefOf, readSettings } from '../plans/settings'
|
|
3
|
+
import { asksForHelp } from '../preview/options'
|
|
4
|
+
import { type BaseFacts, baseOf, LANE_BASE_USAGE, parseBaseOptions } from './base'
|
|
5
|
+
|
|
6
|
+
const gitSucceeds = (root: string, args: string[]): boolean =>
|
|
7
|
+
Bun.spawnSync(['git', '-C', root, ...args], { stdout: 'ignore', stderr: 'ignore' }).exitCode === 0
|
|
8
|
+
|
|
9
|
+
/** What git says about `plan` against `baseRef` in the main checkout, whose copy of the plan is the one that counts. */
|
|
10
|
+
const factsOf = (root: string, baseRef: string, plan: string): BaseFacts => ({
|
|
11
|
+
onBase: existsOnRef(root, baseRef, plan),
|
|
12
|
+
unchanged: gitSucceeds(root, ['diff', '--quiet', baseRef, '--', plan]),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `jh lane base <plan>`: prints the ref a lane's worktree starts from, `origin/<base branch>` or
|
|
17
|
+
* `origin/docs/plan-<topic>` (orchestrate § 2 step 2). Run `git fetch origin <base branch>` first. Exit 0 printed, 1
|
|
18
|
+
* on a wrong line.
|
|
19
|
+
*/
|
|
20
|
+
export const run = (args: string[], cwd: string = process.cwd()): number => {
|
|
21
|
+
if (asksForHelp(args)) {
|
|
22
|
+
console.log(LANE_BASE_USAGE)
|
|
23
|
+
return 0
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const { plan } = parseBaseOptions(args)
|
|
27
|
+
const root = mainCheckout(cwd)
|
|
28
|
+
const baseRef = baseRefOf(readSettings([root]))
|
|
29
|
+
console.log(baseOf(plan, baseRef, factsOf(root, baseRef, plan)))
|
|
30
|
+
return 0
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
33
|
+
return 1
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/lane/base.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
import { BASE_REMOTE } from '../plans/settings'
|
|
3
|
+
import { CLI_NAME } from '../version/release'
|
|
4
|
+
|
|
5
|
+
/** What git says about a plan against the base branch. */
|
|
6
|
+
export type BaseFacts = {
|
|
7
|
+
/** The plan exists on the base branch (`git cat-file -e <base>:<plan>`). */
|
|
8
|
+
onBase: boolean
|
|
9
|
+
/** The working copy equals the base branch's (`git diff --quiet <base> -- <plan>`). */
|
|
10
|
+
unchanged: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type BaseOptions = { plan: string }
|
|
14
|
+
|
|
15
|
+
const DOCS_BRANCH_PREFIX = 'docs/plan-'
|
|
16
|
+
const DATE_PREFIX = /^\d{4}-\d{2}-\d{2}-/
|
|
17
|
+
const MARKDOWN_EXTENSION = /\.md$/
|
|
18
|
+
|
|
19
|
+
export const LANE_BASE_USAGE = `usage: ${CLI_NAME} lane base <plan>
|
|
20
|
+
|
|
21
|
+
Prints the ref a lane's worktree starts from: the base branch when the plan is on it and equals the working copy,
|
|
22
|
+
else origin/${DOCS_BRANCH_PREFIX}<topic> with the plan's topic. Where the documentation commit of § 2 step 0 covered
|
|
23
|
+
several files and was named after the board's directory, use that branch instead. <plan> is the plan's path from the
|
|
24
|
+
repository root, without "..". The file decides, not the state of any pull request. Run \`git fetch origin <base
|
|
25
|
+
branch>\` first.`
|
|
26
|
+
|
|
27
|
+
/** The topic of a plan: its file name without date and extension, as the documentation branch names it. */
|
|
28
|
+
export const planTopicOf = (plan: string): string =>
|
|
29
|
+
basename(plan).replace(MARKDOWN_EXTENSION, '').replace(DATE_PREFIX, '')
|
|
30
|
+
|
|
31
|
+
/** The base of the lane that implements `plan`: `baseRef` when the plan is there and unchanged, else the documentation branch. */
|
|
32
|
+
export const baseOf = (plan: string, baseRef: string, { onBase, unchanged }: BaseFacts): string => {
|
|
33
|
+
if (onBase && unchanged) return baseRef
|
|
34
|
+
return `${BASE_REMOTE}/${DOCS_BRANCH_PREFIX}${planTopicOf(plan)}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Reads `<plan>`; throws with the usage on none, a second word, an option or a path that leaves the tree. */
|
|
38
|
+
export const parseBaseOptions = (args: readonly string[]): BaseOptions => {
|
|
39
|
+
const [plan, ...rest] = args
|
|
40
|
+
const leaves = plan?.split('/').includes('..') === true
|
|
41
|
+
if (plan === undefined || plan.startsWith('-') || rest.length > 0 || leaves) throw new Error(LANE_BASE_USAGE)
|
|
42
|
+
return { plan }
|
|
43
|
+
}
|
package/src/lane/close-check.ts
CHANGED
|
@@ -9,11 +9,17 @@ export const BOARD_LANE = { open: 'open', closed: 'closed', missing: 'missing' }
|
|
|
9
9
|
|
|
10
10
|
export type BoardLane = (typeof BOARD_LANE)[keyof typeof BOARD_LANE]
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* What the check proved once nothing stands against closing: a lane on a branch (its branch, the head the check
|
|
14
|
+
* compared, null when the branch is gone already, and its merged pull request), or a worktree on no branch whose head
|
|
15
|
+
* stands on the base branch.
|
|
16
|
+
*/
|
|
17
|
+
export type ClosableLane =
|
|
18
|
+
| { kind: 'branch'; branch: string; head: string | null; pullRequest: MergedPullRequest }
|
|
19
|
+
| { kind: 'detached'; head: string }
|
|
14
20
|
|
|
15
21
|
/** Every reason against closing, or the lane ready to close; the second only when the first is empty. */
|
|
16
|
-
export type CloseVerdict = { kind: 'refused'; refusals: string[] } |
|
|
22
|
+
export type CloseVerdict = { kind: 'refused'; refusals: string[] } | { kind: 'closable'; lane: ClosableLane }
|
|
17
23
|
|
|
18
24
|
/** One entry of `git status --porcelain`: its two status letters (`!!` ignored, `??` untracked) and its path. */
|
|
19
25
|
export type StatusEntry = { code: string; path: string }
|
|
@@ -28,14 +34,19 @@ export type CloseFacts = {
|
|
|
28
34
|
lane: string
|
|
29
35
|
boardLane: BoardLane
|
|
30
36
|
worktreeExists: boolean
|
|
37
|
+
/** The worktree's branch; without a worktree the branch `--branch` names, null when none was named. */
|
|
31
38
|
branch: string | null
|
|
32
|
-
/** The commit the worktree
|
|
39
|
+
/** The commit the worktree or, without one, the named branch points to; null when neither exists. */
|
|
33
40
|
head: string | null
|
|
34
41
|
pullRequest: MergedPullRequest | null
|
|
35
42
|
treeDiff: readonly string[]
|
|
36
43
|
status: readonly StatusEntry[]
|
|
37
44
|
setupFiles: readonly string[]
|
|
38
45
|
handoverEntries: readonly string[]
|
|
46
|
+
/** Whether the head of a worktree on no branch is an ancestor of the base branch on the remote. */
|
|
47
|
+
headOnBase: boolean
|
|
48
|
+
/** The staged paths of a worktree on no branch that the base branch on the remote holds with the same content. */
|
|
49
|
+
baseHeldPaths: readonly string[]
|
|
39
50
|
/** The agent named after the lane whose directory lies in the worktree, with its pane. */
|
|
40
51
|
agent: { pane: string; status: string } | null
|
|
41
52
|
/** The panes of other agents whose directory lies in the worktree: a helper, or a session without the lane's name. */
|
|
@@ -64,6 +75,10 @@ const BUILD_OUTPUT = [
|
|
|
64
75
|
const BUILD_OUTPUT_DIRECTORY_ENTRIES = [new Bun.Glob('packages/*/bin')]
|
|
65
76
|
const HANDOVER = claudePath(HANDOVER_DIRECTORY)
|
|
66
77
|
|
|
78
|
+
/** A change that is staged and nothing more: the worktree file equals the index. */
|
|
79
|
+
const isStagedOnly = (code: string): boolean =>
|
|
80
|
+
code[0] !== ' ' && code[0] !== '?' && code[0] !== IGNORED_CODE[0] && code[1] === ' '
|
|
81
|
+
|
|
67
82
|
const withoutSlashes = (path: string): string => path.replace(/^\/+|\/+$/g, '')
|
|
68
83
|
|
|
69
84
|
const isHandoverDirectory = (path: string): boolean => withoutSlashes(path) === HANDOVER
|
|
@@ -121,23 +136,33 @@ export const isBuildOutput = (path: string): boolean => {
|
|
|
121
136
|
* The paths removing the worktree would delete for good: every tracked change and untracked file, and every ignored
|
|
122
137
|
* path except dependencies (`node_modules` at any depth), the setup files, build output and the handover directory.
|
|
123
138
|
*/
|
|
124
|
-
export const lostPathsOf = (
|
|
139
|
+
export const lostPathsOf = (
|
|
140
|
+
status: readonly StatusEntry[],
|
|
141
|
+
setupFiles: readonly string[],
|
|
142
|
+
baseHeldPaths: readonly string[] = [],
|
|
143
|
+
): string[] => {
|
|
125
144
|
const kept = (path: string) =>
|
|
126
145
|
isDependencyPath(path) || isSetupFile(path, setupFiles) || isBuildOutput(path) || isHandoverDirectory(path)
|
|
127
|
-
|
|
146
|
+
const held = ({ code, path }: StatusEntry) => isStagedOnly(code) && baseHeldPaths.includes(path)
|
|
147
|
+
return status
|
|
148
|
+
.filter((entry) => (entry.code !== IGNORED_CODE || !kept(entry.path)) && !held(entry))
|
|
149
|
+
.map(({ path }) => path)
|
|
128
150
|
}
|
|
129
151
|
|
|
130
152
|
/** The lost paths and the entries of the handover directory, each once: the directory stands for all of them. */
|
|
131
|
-
const deletedPathsOf = ({ status, setupFiles, handoverEntries }: CloseFacts): string[] => [
|
|
132
|
-
...new Set([...lostPathsOf(status, setupFiles), ...handoverEntries]),
|
|
153
|
+
const deletedPathsOf = ({ status, setupFiles, handoverEntries, baseHeldPaths }: CloseFacts): string[] => [
|
|
154
|
+
...new Set([...lostPathsOf(status, setupFiles, baseHeldPaths), ...handoverEntries]),
|
|
133
155
|
]
|
|
134
156
|
|
|
135
157
|
/** A lane the board marks cleaned up already may still have its worktree left over; only a lane it never named is refused. */
|
|
136
158
|
const boardRefusals = ({ lane, boardLane }: CloseFacts): string[] =>
|
|
137
159
|
boardLane === BOARD_LANE.missing ? [`the board has no row for lane ${lane}`] : []
|
|
138
160
|
|
|
139
|
-
const
|
|
140
|
-
|
|
161
|
+
const detachedRefusals = ({ lane, headOnBase }: CloseFacts): string[] =>
|
|
162
|
+
headOnBase ? [] : [`the worktree of ${lane} is on no branch and its head is not on the base branch`]
|
|
163
|
+
|
|
164
|
+
const mergeRefusals = ({ branch, pullRequest, treeDiff }: CloseFacts): string[] => {
|
|
165
|
+
if (branch === null) return []
|
|
141
166
|
if (pullRequest === null) return [`branch ${branch} has no merged pull request`]
|
|
142
167
|
return treeDiff.map(
|
|
143
168
|
(path) => `branch ${branch} differs from the merge of #${pullRequest.number} (${pullRequest.mergeCommit}): ${path}`,
|
|
@@ -162,11 +187,17 @@ const lockRefusals = ({ lane, locked }: CloseFacts): string[] =>
|
|
|
162
187
|
* on the worktree (a Claude Code session may still hold it).
|
|
163
188
|
*/
|
|
164
189
|
export const closeRefusals = (facts: CloseFacts): string[] => {
|
|
165
|
-
if (!facts.worktreeExists)
|
|
190
|
+
if (!facts.worktreeExists)
|
|
191
|
+
return [
|
|
192
|
+
...boardRefusals(facts),
|
|
193
|
+
...(facts.branch === null
|
|
194
|
+
? [`lane ${facts.lane} has no worktree; name its branch with --branch to finish closing it`]
|
|
195
|
+
: mergeRefusals(facts)),
|
|
196
|
+
]
|
|
166
197
|
if (facts.runsInside) return [`run it outside the worktree of ${facts.lane}, which it would remove`]
|
|
167
198
|
return [
|
|
168
199
|
...boardRefusals(facts),
|
|
169
|
-
...mergeRefusals(facts),
|
|
200
|
+
...(facts.branch === null ? detachedRefusals(facts) : mergeRefusals(facts)),
|
|
170
201
|
...deletedPathsOf(facts).map((path) => `removing the worktree deletes ${path}`),
|
|
171
202
|
...agentRefusals(facts),
|
|
172
203
|
...lockRefusals(facts),
|
|
@@ -176,8 +207,11 @@ export const closeRefusals = (facts: CloseFacts): string[] => {
|
|
|
176
207
|
/** `closeRefusals` as a verdict that carries the branch and pull request it proved, so a caller needs no second check. */
|
|
177
208
|
export const closeVerdict = (facts: CloseFacts): CloseVerdict => {
|
|
178
209
|
const refusals = closeRefusals(facts)
|
|
210
|
+
if (refusals.length > 0) return { kind: 'refused', refusals }
|
|
179
211
|
const { branch, head, pullRequest } = facts
|
|
180
|
-
if (
|
|
181
|
-
return { kind: '
|
|
182
|
-
return
|
|
212
|
+
if (branch !== null && pullRequest !== null)
|
|
213
|
+
return { kind: 'closable', lane: { kind: 'branch', branch, head, pullRequest } }
|
|
214
|
+
return head === null
|
|
215
|
+
? { kind: 'refused', refusals: [`the worktree of ${facts.lane} has no head to compare`] }
|
|
216
|
+
: { kind: 'closable', lane: { kind: 'detached', head } }
|
|
183
217
|
}
|
|
@@ -13,9 +13,17 @@ import { runGh } from '../watch/gh'
|
|
|
13
13
|
import { parseWatchedPullRequests, WATCHED_PULL_REQUEST_FIELDS } from '../watch/pull-requests'
|
|
14
14
|
import { type ClosePorts, type CloseRequest, type CloseSurvey, closeLane } from './close'
|
|
15
15
|
import { boardLaneOf } from './close-board'
|
|
16
|
-
import { type CloseFacts, type MergedPullRequest, statusEntriesOf } from './close-check'
|
|
16
|
+
import { type CloseFacts, type MergedPullRequest, type StatusEntry, statusEntriesOf } from './close-check'
|
|
17
17
|
import { type CloseOptions, LANE_CLOSE_USAGE, parseCloseOptions } from './close-options'
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
agentFactsOf,
|
|
20
|
+
baseHeldPathsOf,
|
|
21
|
+
mergedPullRequestOf,
|
|
22
|
+
openWorkspaceOf,
|
|
23
|
+
stagedPlanPathsOf,
|
|
24
|
+
type WorktreeEntry,
|
|
25
|
+
worktreeEntryOf,
|
|
26
|
+
} from './close-survey'
|
|
19
27
|
|
|
20
28
|
/** Where the command runs and what it acts on, located once. */
|
|
21
29
|
type CloseContext = { root: string; cwd: string; settings: PlansSettings; worktree: string; boardFile: string }
|
|
@@ -23,7 +31,16 @@ type CloseContext = { root: string; cwd: string; settings: PlansSettings; worktr
|
|
|
23
31
|
/** The facts that only a worktree git lists has. */
|
|
24
32
|
type WorktreeFacts = Pick<
|
|
25
33
|
CloseFacts,
|
|
26
|
-
|
|
34
|
+
| 'worktreeExists'
|
|
35
|
+
| 'branch'
|
|
36
|
+
| 'head'
|
|
37
|
+
| 'pullRequest'
|
|
38
|
+
| 'treeDiff'
|
|
39
|
+
| 'status'
|
|
40
|
+
| 'handoverEntries'
|
|
41
|
+
| 'headOnBase'
|
|
42
|
+
| 'baseHeldPaths'
|
|
43
|
+
| 'locked'
|
|
27
44
|
>
|
|
28
45
|
|
|
29
46
|
/** The file the lane setup copies into every new worktree, in `.gitignore` syntax. */
|
|
@@ -51,6 +68,8 @@ const ABSENT_WORKTREE: WorktreeFacts = {
|
|
|
51
68
|
treeDiff: [],
|
|
52
69
|
status: [],
|
|
53
70
|
handoverEntries: [],
|
|
71
|
+
headOnBase: false,
|
|
72
|
+
baseHeldPaths: [],
|
|
54
73
|
locked: null,
|
|
55
74
|
}
|
|
56
75
|
|
|
@@ -97,30 +116,71 @@ const mergedPullRequestOn = async (context: CloseContext, branch: string): Promi
|
|
|
97
116
|
return mergedPullRequestOf(parseWatchedPullRequests(answer.stdout), branch)
|
|
98
117
|
}
|
|
99
118
|
|
|
119
|
+
const remoteBase = (context: CloseContext): string => `origin/${context.settings.baseBranch}`
|
|
120
|
+
|
|
121
|
+
/** Whether `head` is an ancestor of the base branch on the remote; false for an answer other than yes. */
|
|
122
|
+
const isOnBase = (context: CloseContext, head: string): boolean =>
|
|
123
|
+
Bun.spawnSync(['git', '-C', context.root, 'merge-base', '--is-ancestor', head, remoteBase(context)]).exitCode === 0
|
|
124
|
+
|
|
125
|
+
/** The staged plan changes of the worktree that the base branch on the remote holds as they are staged. */
|
|
126
|
+
const baseHeldPathsIn = (context: CloseContext, status: readonly StatusEntry[]): string[] => {
|
|
127
|
+
const candidates = stagedPlanPathsOf(status, context.settings.planDirectory)
|
|
128
|
+
if (candidates.length === 0) return []
|
|
129
|
+
const differing = linesOf(
|
|
130
|
+
runGit(context.worktree, ['diff', '--cached', '--name-only', remoteBase(context), '--', ...candidates]),
|
|
131
|
+
)
|
|
132
|
+
return baseHeldPathsOf(candidates, differing)
|
|
133
|
+
}
|
|
134
|
+
|
|
100
135
|
/** Branch, merged pull request, tree difference, status, handover files and lock of the worktree git lists for the lane. */
|
|
101
136
|
const worktreeFactsOf = async (context: CloseContext, entry: WorktreeEntry): Promise<WorktreeFacts> => {
|
|
102
137
|
const pullRequest = entry.branch === null ? null : await mergedPullRequestOn(context, entry.branch)
|
|
138
|
+
const status = statusEntriesOf(
|
|
139
|
+
runGit(context.worktree, ['status', '--porcelain=v1', '-z', IGNORED_MATCHING, ALL_UNTRACKED]),
|
|
140
|
+
)
|
|
141
|
+
const detached = entry.branch === null
|
|
103
142
|
return {
|
|
104
143
|
worktreeExists: true,
|
|
105
144
|
branch: entry.branch,
|
|
106
145
|
head: entry.head,
|
|
107
146
|
pullRequest,
|
|
108
147
|
treeDiff: pullRequest === null ? [] : treeDiffOf(context, entry.head, pullRequest.mergeCommit),
|
|
109
|
-
status
|
|
110
|
-
runGit(context.worktree, ['status', '--porcelain=v1', '-z', IGNORED_MATCHING, ALL_UNTRACKED]),
|
|
111
|
-
),
|
|
148
|
+
status,
|
|
112
149
|
handoverEntries: handoverEntriesOf(context.worktree),
|
|
150
|
+
headOnBase: detached && isOnBase(context, entry.head),
|
|
151
|
+
baseHeldPaths: detached ? baseHeldPathsIn(context, status) : [],
|
|
113
152
|
locked: entry.locked,
|
|
114
153
|
}
|
|
115
154
|
}
|
|
116
155
|
|
|
156
|
+
/** The commit the local branch points to; null when there is no such branch. */
|
|
157
|
+
const branchHeadOf = (context: CloseContext, branch: string): string | null => {
|
|
158
|
+
const result = Bun.spawnSync(
|
|
159
|
+
['git', '-C', context.root, 'rev-parse', '--verify', '--quiet', `${BRANCH_REF}${branch}^{commit}`],
|
|
160
|
+
{ stdout: 'pipe' },
|
|
161
|
+
)
|
|
162
|
+
return result.exitCode === 0 ? result.stdout.toString().trim() : null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** What survives of a lane whose worktree is gone: the branch `--branch` names, if it is still there, and its merged pull request. */
|
|
166
|
+
const branchFactsOf = async (context: CloseContext, branch: string): Promise<WorktreeFacts> => {
|
|
167
|
+
const head = branchHeadOf(context, branch)
|
|
168
|
+
const pullRequest = await mergedPullRequestOn(context, branch)
|
|
169
|
+
const treeDiff = head === null || pullRequest === null ? [] : treeDiffOf(context, head, pullRequest.mergeCommit)
|
|
170
|
+
return { ...ABSENT_WORKTREE, branch, head, pullRequest, treeDiff }
|
|
171
|
+
}
|
|
172
|
+
|
|
117
173
|
const workspaceOf = (context: CloseContext): string | null =>
|
|
118
174
|
context.settings.adapter === TERMINAL_ADAPTER.herdr
|
|
119
175
|
? openWorkspaceOf(runHerdr(['worktree', 'list', '--cwd', context.root]), context.worktree)
|
|
120
176
|
: null
|
|
121
177
|
|
|
178
|
+
/** The facts of a lane without a worktree: only what survives in the branch `--branch` names, if one was named. */
|
|
179
|
+
const absentWorktreeFactsOf = (context: CloseContext, branch: string | null): Promise<WorktreeFacts> | WorktreeFacts =>
|
|
180
|
+
branch === null ? ABSENT_WORKTREE : branchFactsOf(context, branch)
|
|
181
|
+
|
|
122
182
|
/** What git, GitHub and the terminal say about the lane now; throws when one of them cannot be asked. */
|
|
123
|
-
const surveyOf = async (lane: string, context: CloseContext): Promise<CloseSurvey> => {
|
|
183
|
+
const surveyOf = async (lane: string, branch: string | null, context: CloseContext): Promise<CloseSurvey> => {
|
|
124
184
|
const entry = worktreeEntryOf(runGit(context.root, ['worktree', 'list', '--porcelain']), context.worktree)
|
|
125
185
|
const present = entry !== null && existsSync(context.worktree)
|
|
126
186
|
const facts: CloseFacts = {
|
|
@@ -129,7 +189,7 @@ const surveyOf = async (lane: string, context: CloseContext): Promise<CloseSurve
|
|
|
129
189
|
setupFiles: setupFilesOf(context.root),
|
|
130
190
|
...agentFactsOf(laneStatusSourceFor(context.settings.adapter).listAgents(), lane, context.worktree),
|
|
131
191
|
runsInside: realPath(worktreeRoot(context.cwd)) === realPath(context.worktree),
|
|
132
|
-
...(present ? await worktreeFactsOf(context, entry) :
|
|
192
|
+
...(present ? await worktreeFactsOf(context, entry) : await absentWorktreeFactsOf(context, branch)),
|
|
133
193
|
}
|
|
134
194
|
return { facts, workspace: present ? workspaceOf(context) : null }
|
|
135
195
|
}
|
|
@@ -166,8 +226,8 @@ const deleteBranchIn =
|
|
|
166
226
|
runGit(context.root, ['update-ref', '-d', `${BRANCH_REF}${branch}`, head])
|
|
167
227
|
}
|
|
168
228
|
|
|
169
|
-
const portsFor = (lane: string, context: CloseContext): ClosePorts => ({
|
|
170
|
-
survey: () => surveyOf(lane, context),
|
|
229
|
+
const portsFor = (lane: string, branch: string | null, context: CloseContext): ClosePorts => ({
|
|
230
|
+
survey: () => surveyOf(lane, branch, context),
|
|
171
231
|
exitAgent: exitAgentIn(context),
|
|
172
232
|
removeWorktree: removeWorktreeIn(context),
|
|
173
233
|
deleteBranch: deleteBranchIn(context),
|
|
@@ -209,7 +269,7 @@ export const run = async (args: string[], cwd: string = process.cwd()): Promise<
|
|
|
209
269
|
now: new Date(),
|
|
210
270
|
language: context.settings.planLanguage,
|
|
211
271
|
}
|
|
212
|
-
return await closeLane(request, portsFor(options.lane, context))
|
|
272
|
+
return await closeLane(request, portsFor(options.lane, options.branch, context))
|
|
213
273
|
} catch (error) {
|
|
214
274
|
console.error(terminalSafe(error instanceof Error ? error.message : String(error)))
|
|
215
275
|
return 1
|