jackherd 1.16.0 → 1.17.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 +22 -0
- package/INSTALL.md +2 -1
- package/README.md +22 -21
- 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 +1 -1
- package/kit/rules/rules/kit/code-style.md +6 -6
- 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 +1 -1
- package/kit/workflow/skills/orchestrate/SKILL.md +120 -299
- package/kit/workflow/skills/orchestrate/references/appointments.md +21 -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 +9 -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 +3 -2
- 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 +77 -26
- package/src/check/skill-budgets.ts +58 -10
- 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
|
@@ -4,40 +4,68 @@ import { errorMessage } from '../sync/error-message'
|
|
|
4
4
|
import { SOURCE_ORIGIN, type SourceOrigin, type SourceSettings } from '../watch/sources'
|
|
5
5
|
import { personalSourcesPath } from './location'
|
|
6
6
|
import { readResolvedConfig } from './read-config'
|
|
7
|
+
import { isRepositoryIn } from './repository-identity'
|
|
7
8
|
import { parsePersonalSourcesFile } from './schema'
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
/** Why a personal source with `repos` does not run in this repository. */
|
|
11
|
+
export const EXCLUDED_REASON = { otherRepository: 'not for this repo', noOrigin: 'no origin' } as const
|
|
12
|
+
|
|
13
|
+
export type ExcludedReason = (typeof EXCLUDED_REASON)[keyof typeof EXCLUDED_REASON]
|
|
14
|
+
|
|
15
|
+
/** A personal source left out because of its `repos`, kept so `jh secret list` and the dry run can still name it. */
|
|
16
|
+
export type ExcludedSource = { source: SourceSettings; reason: ExcludedReason }
|
|
17
|
+
|
|
18
|
+
export type MergedSources = { sources: SourceSettings[]; notes: string[]; excluded: ExcludedSource[] }
|
|
10
19
|
|
|
11
20
|
/** What a note starts with when an invalid personal file was left out. */
|
|
12
21
|
export const PERSONAL_IGNORED = 'personal sources ignored:'
|
|
13
22
|
|
|
14
23
|
const skippedPrefix = (name: string): string => `personal source ${name} in `
|
|
15
24
|
|
|
25
|
+
const excludedPrefix = (name: string): string => `${name}: `
|
|
26
|
+
|
|
16
27
|
const skippedNote = (name: string, path: string): string =>
|
|
17
28
|
`${skippedPrefix(name)}${path} skipped: the repository has a source of that name`
|
|
18
29
|
|
|
19
|
-
/**
|
|
30
|
+
/** The note for a source left out for its `repos`: `<name>: not for this repo` or `<name>: no origin`. */
|
|
31
|
+
export const excludedNote = ({ source, reason }: ExcludedSource): string => `${excludedPrefix(source.name)}${reason}`
|
|
32
|
+
|
|
33
|
+
/** Whether a merge note concerns the source `name`: the personal source of that name was skipped or left out for its `repos`, or the file was ignored. */
|
|
20
34
|
export const isNoteAbout = (note: string, name: string): boolean =>
|
|
21
|
-
note.startsWith(skippedPrefix(name)) || note.startsWith(PERSONAL_IGNORED)
|
|
35
|
+
note.startsWith(skippedPrefix(name)) || note.startsWith(excludedPrefix(name)) || note.startsWith(PERSONAL_IGNORED)
|
|
22
36
|
|
|
23
37
|
const withOrigin = (sources: readonly SourceSettings[], origin: SourceOrigin): SourceSettings[] =>
|
|
24
38
|
sources.map((source) => ({ ...source, origin }))
|
|
25
39
|
|
|
40
|
+
const exclusionOf = (source: SourceSettings, repository: string | null): ExcludedReason | null => {
|
|
41
|
+
if (source.repos === undefined || isRepositoryIn(repository, source.repos)) return null
|
|
42
|
+
return repository === null ? EXCLUDED_REASON.noOrigin : EXCLUDED_REASON.otherRepository
|
|
43
|
+
}
|
|
44
|
+
|
|
26
45
|
/**
|
|
27
46
|
* The repository's sources, then the personal ones, each marked with its origin. A personal source whose name a
|
|
28
|
-
* repository source has is left out and named in a note
|
|
47
|
+
* repository source has is left out and named in a note, and so is one whose `repos` do not list `repository`
|
|
48
|
+
* (`owner/name` of this checkout's origin, null without one); `path` is how the notes spell the personal file.
|
|
29
49
|
*/
|
|
30
50
|
export const mergedSources = (
|
|
31
51
|
repo: readonly SourceSettings[],
|
|
32
52
|
personal: readonly SourceSettings[],
|
|
33
53
|
path: string,
|
|
54
|
+
repository: string | null,
|
|
34
55
|
): MergedSources => {
|
|
35
56
|
const repoNames = new Set(repo.map(({ name }) => name))
|
|
36
|
-
const
|
|
57
|
+
const unclashed = personal.filter(({ name }) => !repoNames.has(name))
|
|
37
58
|
const skipped = personal.filter(({ name }) => repoNames.has(name))
|
|
59
|
+
const excluded = withOrigin(unclashed, SOURCE_ORIGIN.personal).flatMap((source): ExcludedSource[] => {
|
|
60
|
+
const reason = exclusionOf(source, repository)
|
|
61
|
+
return reason === null ? [] : [{ source, reason }]
|
|
62
|
+
})
|
|
63
|
+
const left = new Set(excluded.map(({ source }) => source.name))
|
|
64
|
+
const kept = unclashed.filter(({ name }) => !left.has(name))
|
|
38
65
|
return {
|
|
39
66
|
sources: [...withOrigin(repo, SOURCE_ORIGIN.repo), ...withOrigin(kept, SOURCE_ORIGIN.personal)],
|
|
40
|
-
notes: skipped.map(({ name }) => skippedNote(name, path)),
|
|
67
|
+
notes: [...skipped.map(({ name }) => skippedNote(name, path)), ...excluded.map(excludedNote)],
|
|
68
|
+
excluded,
|
|
41
69
|
}
|
|
42
70
|
}
|
|
43
71
|
|
|
@@ -64,14 +92,19 @@ export const readPersonalSources = (
|
|
|
64
92
|
*/
|
|
65
93
|
export const sourcesWithPersonal = (
|
|
66
94
|
repo: readonly SourceSettings[],
|
|
95
|
+
repository: string | null,
|
|
67
96
|
home: string = homedir(),
|
|
68
97
|
exists: (path: string) => boolean = existsSync,
|
|
69
98
|
readText?: (path: string) => string,
|
|
70
99
|
): MergedSources => {
|
|
71
100
|
try {
|
|
72
|
-
return mergedSources(repo, readPersonalSources(home, exists, readText), personalSourcesPath(home))
|
|
101
|
+
return mergedSources(repo, readPersonalSources(home, exists, readText), personalSourcesPath(home), repository)
|
|
73
102
|
} catch (error) {
|
|
74
|
-
return {
|
|
103
|
+
return {
|
|
104
|
+
sources: withOrigin(repo, SOURCE_ORIGIN.repo),
|
|
105
|
+
notes: [`${PERSONAL_IGNORED} ${errorMessage(error)}`],
|
|
106
|
+
excluded: [],
|
|
107
|
+
}
|
|
75
108
|
}
|
|
76
109
|
}
|
|
77
110
|
|
|
@@ -79,5 +112,8 @@ export const sourcesWithPersonal = (
|
|
|
79
112
|
* The sources of the project config of the first of `roots` that has one (`readResolvedConfig`), with the personal
|
|
80
113
|
* ones of `home` merged in; throws like `readResolvedConfig` when the project config is missing or invalid.
|
|
81
114
|
*/
|
|
82
|
-
export const configuredSources = (
|
|
83
|
-
|
|
115
|
+
export const configuredSources = (
|
|
116
|
+
roots: string[],
|
|
117
|
+
repository: string | null,
|
|
118
|
+
home: string = homedir(),
|
|
119
|
+
): MergedSources => sourcesWithPersonal(readResolvedConfig(roots, home).config.watch.sources, repository, home)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** `owner/name` as `repos` spells a repository. */
|
|
2
|
+
export const REPOSITORY_NAME = /^[\w.-]+\/[\w.-]+$/
|
|
3
|
+
|
|
4
|
+
const SCP_REMOTE = /^[\w.-]+@[\w.-]+:([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/
|
|
5
|
+
|
|
6
|
+
/** `owner/name` of a URL path with exactly those two segments; a deeper or shorter path names no repository of ours. */
|
|
7
|
+
const ownerAndName = (path: string): string | null => {
|
|
8
|
+
const segments = path
|
|
9
|
+
.replace(/\.git\/?$/, '')
|
|
10
|
+
.split('/')
|
|
11
|
+
.filter((segment) => segment !== '')
|
|
12
|
+
return segments.length === 2 ? segments.join('/') : null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `owner/name` of a git remote URL: `git@host:owner/name(.git)`, `https://host/owner/name(.git)` (credentials
|
|
17
|
+
* allowed) and `ssh://git@host(:port)/owner/name(.git)`. Null for anything else: a local path, a `file://` URL, a
|
|
18
|
+
* path with more or fewer than two segments.
|
|
19
|
+
*/
|
|
20
|
+
export const repositoryOf = (remoteUrl: string): string | null => {
|
|
21
|
+
const remote = remoteUrl.trim()
|
|
22
|
+
const scp = SCP_REMOTE.exec(remote)
|
|
23
|
+
if (scp) return `${scp[1]}/${scp[2]}`
|
|
24
|
+
try {
|
|
25
|
+
const url = new URL(remote)
|
|
26
|
+
return ['https:', 'http:', 'ssh:'].includes(url.protocol) ? ownerAndName(url.pathname) : null
|
|
27
|
+
} catch {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whether `repository` is one of `repos`; GitHub names compare without regard to case. A repository nobody could name is in none. */
|
|
33
|
+
export const isRepositoryIn = (repository: string | null, repos: readonly string[]): boolean =>
|
|
34
|
+
repository !== null && repos.some((repo) => repo.toLowerCase() === repository.toLowerCase())
|
|
35
|
+
|
|
36
|
+
/** The repository the checkout at `root` pushes to (`origin`), null without a git repository, an origin or a URL `repositoryOf` knows. */
|
|
37
|
+
export const readRepositoryIdentity = (root: string): string | null => {
|
|
38
|
+
const result = Bun.spawnSync(['git', '-C', root, 'remote', 'get-url', 'origin'], { stdout: 'pipe', stderr: 'ignore' })
|
|
39
|
+
return result.exitCode === 0 ? repositoryOf(result.stdout.toString()) : null
|
|
40
|
+
}
|
package/src/config/schema.ts
CHANGED
|
@@ -12,12 +12,16 @@ import { DEFAULT_BASE_BRANCH } from './base-branch'
|
|
|
12
12
|
import { DEFAULT_LINK_KINDS, isRegularExpression } from './link-kinds'
|
|
13
13
|
import { hasNestedQuantifier } from './nested-quantifier'
|
|
14
14
|
import { isRepositoryDirectory } from './repository-directory'
|
|
15
|
+
import { REPOSITORY_NAME } from './repository-identity'
|
|
15
16
|
import { DEFAULT_TONES, TONE_NAMES } from './tones'
|
|
16
17
|
|
|
17
18
|
export const TERMINAL_ADAPTER = { none: 'none', herdr: 'herdr' } as const
|
|
18
19
|
|
|
19
20
|
export type TerminalAdapter = (typeof TERMINAL_ADAPTER)[keyof typeof TERMINAL_ADAPTER]
|
|
20
21
|
|
|
22
|
+
/** The environments a standing approval may cover; production is never one. */
|
|
23
|
+
export const STANDING_ENVIRONMENT = { stage: 'stage' } as const
|
|
24
|
+
|
|
21
25
|
// TODO: draft role list taken from the agents the kit ships; confirm it before more kit files render model
|
|
22
26
|
// IDs per role.
|
|
23
27
|
export const MODEL_ROLE = {
|
|
@@ -302,6 +306,16 @@ const sourceSchema = z
|
|
|
302
306
|
.optional(),
|
|
303
307
|
/** `entries` shows the source's entries open, in a herd block of its own; not for a ticket source, whose entries are badges. */
|
|
304
308
|
show: z.literal(SOURCE_SHOW.entries).optional(),
|
|
309
|
+
/**
|
|
310
|
+
* `owner/name` of the repositories a personal source runs in (matched without regard to case); without it every
|
|
311
|
+
* repository. Only in the personal sources file: the repository's own config is for that repository already.
|
|
312
|
+
*/
|
|
313
|
+
repos: z
|
|
314
|
+
.array(z.string().regex(REPOSITORY_NAME, 'owner/name, such as acme/tool'))
|
|
315
|
+
.min(1)
|
|
316
|
+
.max(20)
|
|
317
|
+
.refine((repos) => new Set(repos.map((repo) => repo.toLowerCase())).size === repos.length, 'repos must be unique')
|
|
318
|
+
.optional(),
|
|
305
319
|
})
|
|
306
320
|
.refine(canChange, {
|
|
307
321
|
message: 'map needs updatedAt, a content field (title, author, body, url) or reportOn',
|
|
@@ -327,6 +341,22 @@ const sourceSchema = z
|
|
|
327
341
|
const hasUniqueNames = (sources: readonly { name: string }[]): boolean =>
|
|
328
342
|
new Set(sources.map(({ name }) => name)).size === sources.length
|
|
329
343
|
|
|
344
|
+
/** The sources of the repository's own config: none may name `repos`, which only a personal source has. */
|
|
345
|
+
const repositorySources = z
|
|
346
|
+
.array(sourceSchema)
|
|
347
|
+
.refine(hasUniqueNames, 'source names must be unique')
|
|
348
|
+
.superRefine((sources, context) => {
|
|
349
|
+
sources.forEach((source, index) => {
|
|
350
|
+
if (source.repos === undefined) return
|
|
351
|
+
context.addIssue({
|
|
352
|
+
code: 'custom',
|
|
353
|
+
path: [index, 'repos'],
|
|
354
|
+
message:
|
|
355
|
+
'repos belongs in the personal sources file (~/.jackherd/sources.json): this config is for one repository',
|
|
356
|
+
})
|
|
357
|
+
})
|
|
358
|
+
})
|
|
359
|
+
|
|
330
360
|
const coordinatorSchema = z
|
|
331
361
|
.strictObject({
|
|
332
362
|
/** Tokens in the coordinator's context from which the watcher reports `coordinator warn`. */
|
|
@@ -336,6 +366,18 @@ const coordinatorSchema = z
|
|
|
336
366
|
})
|
|
337
367
|
.refine(warnBelowAlert, WARN_BELOW_ALERT)
|
|
338
368
|
|
|
369
|
+
/**
|
|
370
|
+
* An SQL tool of an MCP server by its full name (`mcp__<server>__execute_sql`), never one whose name says `prod`. The
|
|
371
|
+
* approval gate hook applies the same rule (`kit/hooks/hooks/approval-gate/settings.ts`), which cannot import this.
|
|
372
|
+
*/
|
|
373
|
+
const standingTool = z
|
|
374
|
+
.string()
|
|
375
|
+
.regex(
|
|
376
|
+
/^mcp__[A-Za-z0-9_-]+__execute_sql$/,
|
|
377
|
+
'a full MCP tool name ending in execute_sql, such as mcp__<server>__execute_sql',
|
|
378
|
+
)
|
|
379
|
+
.refine((name) => !/prod/i.test(name), 'a tool whose name says prod never runs under a standing approval')
|
|
380
|
+
|
|
339
381
|
/** A guarded command as words: a program and one to three plain words after it, never an option, a quote or a shell character. */
|
|
340
382
|
const guardedCommand = z
|
|
341
383
|
.string()
|
|
@@ -420,6 +462,14 @@ const guardFields = z.strictObject({
|
|
|
420
462
|
.strictObject({
|
|
421
463
|
/** How long the approval gate hook holds a lane's call for the coordinator's dialog before it denies it; below the hook's 10 minutes. */
|
|
422
464
|
waitMinutes: z.number().int().min(1).max(9).default(8),
|
|
465
|
+
/**
|
|
466
|
+
* The calls a standing approval of the user may let through once the coordinator marked them checked: each tool
|
|
467
|
+
* by its full name and the environment it writes to, only `stage`. A tool whose name says `prod` is refused, as the
|
|
468
|
+
* hook refuses it under a broken config; which project a server reaches is the project's start script's to say.
|
|
469
|
+
*/
|
|
470
|
+
standing: z
|
|
471
|
+
.strictObject({ tools: z.record(standingTool, z.enum(Object.values(STANDING_ENVIRONMENT))) })
|
|
472
|
+
.optional(),
|
|
423
473
|
})
|
|
424
474
|
.prefault({}),
|
|
425
475
|
/**
|
|
@@ -531,6 +581,8 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
531
581
|
copy: z.array(laneCopyPath).default([]),
|
|
532
582
|
/** The dependency lock file a rebase takes from the base branch and regenerates, never merges by hand; detected from the repository when left out. */
|
|
533
583
|
lockFile: lockFilePath.optional(),
|
|
584
|
+
/** Free gigabytes the disk must hold before `jh lane space` lets a new lane install its dependencies; 5 when left out. */
|
|
585
|
+
minFreeGb: z.number().positive().optional(),
|
|
534
586
|
})
|
|
535
587
|
.prefault({}),
|
|
536
588
|
models: z.record(z.enum(Object.values(MODEL_ROLE)), modelId).default(DEFAULT_MODELS),
|
|
@@ -582,7 +634,7 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
582
634
|
parallelTasks: parallelTasksSchema.prefault({}),
|
|
583
635
|
taskSignalMinutes: z.number().int().positive().default(DEFAULT_TASK_SIGNAL_MINUTES),
|
|
584
636
|
previews: previewsSchema.optional(),
|
|
585
|
-
sources:
|
|
637
|
+
sources: repositorySources.default([]),
|
|
586
638
|
})
|
|
587
639
|
.prefault({}),
|
|
588
640
|
/** Read by the merge guard hook, which asks before the listed commands run, and by `/orchestrate`. */
|
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
|
+
}
|