jackherd 0.0.1 → 1.0.1
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 +65 -0
- package/LICENSE +21 -0
- package/NOTICE +38 -0
- package/README.md +1122 -3
- package/assets/branding/README.md +62 -0
- package/assets/branding/jackherd-icon-256.png +0 -0
- package/assets/branding/jackherd-icon-512.png +0 -0
- package/assets/branding/jackherd-icon-64.png +0 -0
- package/assets/branding/jackherd-icon-small-32.png +0 -0
- package/assets/branding/jackherd-icon-small-64.png +0 -0
- package/assets/branding/jackherd-icon-small.svg +1 -0
- package/assets/branding/jackherd-icon.svg +1 -0
- package/assets/branding/jackherd-logo-1024.png +0 -0
- package/assets/branding/jackherd-logo-512.png +0 -0
- package/assets/branding/jackherd-logo-dark-1024.png +0 -0
- package/assets/branding/jackherd-logo-dark-512.png +0 -0
- package/assets/branding/jackherd-logo-dark.svg +1 -0
- package/assets/branding/jackherd-logo-mark-1024.png +0 -0
- package/assets/branding/jackherd-logo-mark-512.png +0 -0
- package/assets/branding/jackherd-logo-mark.svg +1 -0
- package/assets/branding/jackherd-logo.svg +1 -0
- package/dist/chunk-R6MGHCD7.js +93 -0
- package/dist/config.d.ts +167 -0
- package/dist/config.js +401 -0
- package/dist/plan-format.d.ts +325 -0
- package/dist/plan-format.js +636 -0
- package/docs/lanes.mmd +9 -0
- package/docs/lanes.svg +1 -0
- package/docs/skills.mmd +10 -0
- package/docs/skills.svg +1 -0
- package/docs/workflow.svg +172 -0
- package/herdr-plugin/herdr-plugin.toml +22 -0
- package/herdr-plugin/open-plans.ts +43 -0
- package/herdr-plugin/restore-plans.ts +107 -0
- package/install.sh +157 -0
- package/kit/hooks/hooks/git-guard/decision.ts +438 -0
- package/kit/hooks/hooks/git-guard/index.ts +38 -0
- package/kit/hooks/hooks/git-guard/shell.ts +407 -0
- package/kit/hooks/hooks/watch-guard/background.ts +36 -0
- package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
- package/kit/hooks/hooks/watch-guard/index.ts +11 -0
- package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
- package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
- package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
- package/kit/hooks/hooks.json +30 -0
- package/kit/hooks/statusline.sh +72 -0
- package/kit/review/agents/devops.md +11 -0
- package/kit/review/agents/public-content.md +39 -0
- package/kit/review/agents/security-audit.md +11 -0
- package/kit/review/agents/uncle-bob.md +11 -0
- package/kit/review/skills/devops/SKILL.md +50 -0
- package/kit/review/skills/diagnose/SKILL.md +140 -0
- package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
- package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
- package/kit/review/skills/pre-commit/SKILL.md +100 -0
- package/kit/review/skills/review/SKILL.md +43 -0
- package/kit/review/skills/security-audit/SKILL.md +65 -0
- package/kit/review/skills/uncle-bob/SKILL.md +43 -0
- package/kit/rules/rules/kit/arc42.md +89 -0
- package/kit/rules/rules/kit/architecture-files.md +87 -0
- package/kit/rules/rules/kit/code-style.md +159 -0
- package/kit/rules/rules/kit/pull-requests.md +59 -0
- package/kit/rules/rules/kit/testing.md +91 -0
- package/kit/rules/rules/kit/writing-for-agents.md +110 -0
- package/kit/workflow/agents/explore.md +25 -0
- package/kit/workflow/agents/implement.md +16 -0
- package/kit/workflow/agents/plan.md +13 -0
- package/kit/workflow/agents/spec-review.md +96 -0
- package/kit/workflow/skills/follow/SKILL.md +48 -0
- package/kit/workflow/skills/grill/SKILL.md +60 -0
- package/kit/workflow/skills/implement/SKILL.md +107 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
- package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
- package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
- package/kit/workflow/skills/plan/SKILL.md +118 -0
- package/kit/workflow/skills/plan/references/template-English.md +92 -0
- package/kit/workflow/skills/plan/references/template-German.md +92 -0
- package/kit/workflow/skills/start/SKILL.md +47 -0
- package/package.json +63 -5
- package/schemas/jackherd.schema.json +601 -0
- package/src/check/changes.ts +45 -0
- package/src/check/command-line.ts +12 -0
- package/src/check/command.ts +136 -0
- package/src/check/deferral-arrows.ts +41 -0
- package/src/check/execute.ts +29 -0
- package/src/check/glob.ts +15 -0
- package/src/check/plan-structure-check.ts +135 -0
- package/src/check/plan.ts +106 -0
- package/src/check/public-content-check.ts +32 -0
- package/src/check/public-content-facts.ts +177 -0
- package/src/check/public-content.ts +216 -0
- package/src/check/report.ts +55 -0
- package/src/check/result.ts +42 -0
- package/src/check/roots.ts +36 -0
- package/src/check/routing.ts +22 -0
- package/src/check/schema.ts +83 -0
- package/src/check/settings.ts +44 -0
- package/src/claude/paths.ts +14 -0
- package/src/cli.ts +46 -0
- package/src/config/command.ts +45 -0
- package/src/config/detect.ts +196 -0
- package/src/config/index.ts +2 -0
- package/src/config/json-schema.ts +5 -0
- package/src/config/legacy-names.ts +56 -0
- package/src/config/link-kinds.ts +25 -0
- package/src/config/location.ts +46 -0
- package/src/config/read-config.ts +42 -0
- package/src/config/repository-facts.ts +19 -0
- package/src/config/repository-values.ts +46 -0
- package/src/config/schema.ts +302 -0
- package/src/context/command.ts +39 -0
- package/src/context/coordinator-name.ts +16 -0
- package/src/context/fill.ts +69 -0
- package/src/context/index.ts +10 -0
- package/src/context/level.ts +22 -0
- package/src/context/options.ts +29 -0
- package/src/context/report.ts +42 -0
- package/src/context/session-log.ts +91 -0
- package/src/plan-format/drift.ts +69 -0
- package/src/plan-format/header.ts +27 -0
- package/src/plan-format/index.ts +9 -0
- package/src/plan-format/lanes.ts +128 -0
- package/src/plan-format/markdown.ts +98 -0
- package/src/plan-format/phase-tally.ts +55 -0
- package/src/plan-format/phases.ts +224 -0
- package/src/plan-format/plan-structure.ts +150 -0
- package/src/plan-format/progress.ts +202 -0
- package/src/plan-format/sprint-document.ts +69 -0
- package/src/plan-format/terminal-text.ts +28 -0
- package/src/plans/board-choice.ts +60 -0
- package/src/plans/board-pick.ts +72 -0
- package/src/plans/board-rows.ts +62 -0
- package/src/plans/command.ts +60 -0
- package/src/plans/ensure-panes.ts +71 -0
- package/src/plans/error-text.ts +5 -0
- package/src/plans/fetch.ts +96 -0
- package/src/plans/git.ts +145 -0
- package/src/plans/github.ts +35 -0
- package/src/plans/lane-discovery.ts +34 -0
- package/src/plans/lane-plans.ts +80 -0
- package/src/plans/layout.ts +881 -0
- package/src/plans/link-chips.ts +74 -0
- package/src/plans/live.ts +336 -0
- package/src/plans/markdown-html.ts +140 -0
- package/src/plans/options.ts +15 -0
- package/src/plans/phase-source.ts +71 -0
- package/src/plans/plan-chips.ts +75 -0
- package/src/plans/plan-panes.ts +206 -0
- package/src/plans/planned.ts +117 -0
- package/src/plans/pull-requests.ts +107 -0
- package/src/plans/restart-decision.ts +42 -0
- package/src/plans/selection.ts +352 -0
- package/src/plans/settings.ts +68 -0
- package/src/plans/single-flight.ts +25 -0
- package/src/plans/snapshot.ts +300 -0
- package/src/plans/sprint-export.ts +103 -0
- package/src/plans/sprint-html.ts +301 -0
- package/src/plans/sprint-page.ts +287 -0
- package/src/plans/supervisor.ts +124 -0
- package/src/plans/symbols.ts +54 -0
- package/src/plans/texts.ts +103 -0
- package/src/plans/unlisted-lanes.ts +45 -0
- package/src/plans/view.ts +68 -0
- package/src/plans/worked-phases.ts +53 -0
- package/src/sync/checksum.ts +13 -0
- package/src/sync/choice.ts +14 -0
- package/src/sync/command-context.ts +113 -0
- package/src/sync/command.ts +94 -0
- package/src/sync/error-message.ts +1 -0
- package/src/sync/gitignore.ts +45 -0
- package/src/sync/global-kit.ts +176 -0
- package/src/sync/herdr-plugin.ts +175 -0
- package/src/sync/if-changed.ts +109 -0
- package/src/sync/init-command.ts +239 -0
- package/src/sync/init.ts +136 -0
- package/src/sync/kit-hooks.ts +18 -0
- package/src/sync/kit-path.ts +77 -0
- package/src/sync/kit-source.ts +169 -0
- package/src/sync/kit.ts +9 -0
- package/src/sync/legacy-files.ts +116 -0
- package/src/sync/manifest.ts +44 -0
- package/src/sync/next-steps.ts +42 -0
- package/src/sync/plan-file.ts +154 -0
- package/src/sync/plan-hooks.ts +165 -0
- package/src/sync/project-fs.ts +132 -0
- package/src/sync/render.ts +60 -0
- package/src/sync/report.ts +96 -0
- package/src/sync/settings-hooks.ts +90 -0
- package/src/sync/settings-migration.ts +94 -0
- package/src/sync/settings-offer.ts +168 -0
- package/src/sync/setup-questions.ts +70 -0
- package/src/sync/setup.ts +52 -0
- package/src/sync/sync-plan.ts +120 -0
- package/src/sync/sync-result.ts +9 -0
- package/src/sync/sync.ts +307 -0
- package/src/sync/tools.ts +52 -0
- package/src/terminal/herdr.ts +91 -0
- package/src/terminal/index.ts +13 -0
- package/src/terminal/none.ts +7 -0
- package/src/terminal/open.ts +39 -0
- package/src/terminal/text.ts +159 -0
- package/src/terminal/types.ts +30 -0
- package/src/terminal/width.ts +90 -0
- package/src/update/command.ts +65 -0
- package/src/update/package-manager.ts +56 -0
- package/src/update/update.ts +105 -0
- package/src/version/installed-version.ts +32 -0
- package/src/version/release.ts +22 -0
- package/src/version/skill-diff.ts +63 -0
- package/src/version/started-kit.ts +14 -0
- package/src/vocabulary/index.ts +132 -0
- package/src/watch/base-tip.ts +18 -0
- package/src/watch/command.ts +919 -0
- package/src/watch/coordinator-fill.ts +76 -0
- package/src/watch/feedback-read.ts +73 -0
- package/src/watch/feedback.ts +308 -0
- package/src/watch/gh.ts +22 -0
- package/src/watch/github-read.ts +175 -0
- package/src/watch/herdr.ts +11 -0
- package/src/watch/json-guards.ts +9 -0
- package/src/watch/kit-update.ts +18 -0
- package/src/watch/lane-fill.ts +36 -0
- package/src/watch/lane-message.ts +99 -0
- package/src/watch/lane-progress.ts +44 -0
- package/src/watch/lane-report-file.ts +53 -0
- package/src/watch/lane-report.ts +270 -0
- package/src/watch/lane-search.ts +41 -0
- package/src/watch/lane-sources.ts +46 -0
- package/src/watch/lane-watch.ts +121 -0
- package/src/watch/monitor-tool.ts +19 -0
- package/src/watch/options.ts +79 -0
- package/src/watch/plan-badges-read.ts +30 -0
- package/src/watch/plan-badges.ts +66 -0
- package/src/watch/plan-views.ts +59 -0
- package/src/watch/preview-read.ts +60 -0
- package/src/watch/previews.ts +234 -0
- package/src/watch/pull-requests.ts +117 -0
- package/src/watch/single-report.ts +15 -0
- package/src/watch/skill-change.ts +61 -0
- package/src/watch/source-dry-run-command.ts +35 -0
- package/src/watch/source-dry-run.ts +124 -0
- package/src/watch/source-fields.ts +9 -0
- package/src/watch/source-path.ts +40 -0
- package/src/watch/source-run.ts +90 -0
- package/src/watch/sources.ts +344 -0
- package/src/watch/stream-output.ts +14 -0
- package/src/watch/texts.ts +76 -0
- package/src/watch/watch-events.ts +399 -0
- package/src/watch/watcher-lock.ts +145 -0
- package/src/watch/workflow-runs.ts +96 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { checkConfigSchema } from '../check/schema'
|
|
3
|
+
import { claudePath, WORKTREES_DIRECTORY } from '../claude/paths'
|
|
4
|
+
import { PLAN_LANGUAGE } from '../vocabulary'
|
|
5
|
+
import { SOURCE_FIELD } from '../watch/source-fields'
|
|
6
|
+
import { isSourcePath } from '../watch/source-path'
|
|
7
|
+
import { BUN_COMMANDS, DEFAULT_BASE_BRANCH } from './detect'
|
|
8
|
+
import { DEFAULT_LINK_KINDS, isRegularExpression } from './link-kinds'
|
|
9
|
+
|
|
10
|
+
export const TERMINAL_ADAPTER = { none: 'none', herdr: 'herdr' } as const
|
|
11
|
+
|
|
12
|
+
export type TerminalAdapter = (typeof TERMINAL_ADAPTER)[keyof typeof TERMINAL_ADAPTER]
|
|
13
|
+
|
|
14
|
+
// TODO: draft role list taken from the agents the kit ships; confirm it before more kit files render model
|
|
15
|
+
// IDs per role.
|
|
16
|
+
export const MODEL_ROLE = {
|
|
17
|
+
plan: 'plan',
|
|
18
|
+
implement: 'implement',
|
|
19
|
+
review: 'review',
|
|
20
|
+
explore: 'explore',
|
|
21
|
+
orchestrate: 'orchestrate',
|
|
22
|
+
} as const
|
|
23
|
+
|
|
24
|
+
export type ModelRole = (typeof MODEL_ROLE)[keyof typeof MODEL_ROLE]
|
|
25
|
+
|
|
26
|
+
export const isTerminalAdapter = (value: string): value is TerminalAdapter =>
|
|
27
|
+
(Object.values(TERMINAL_ADAPTER) as string[]).includes(value)
|
|
28
|
+
|
|
29
|
+
export const isModelRole = (value: string): value is ModelRole =>
|
|
30
|
+
(Object.values(MODEL_ROLE) as string[]).includes(value)
|
|
31
|
+
|
|
32
|
+
/** Lower-case letters and digits ending in a dash, so a prefixed name stays one plain path segment. */
|
|
33
|
+
export const SKILL_PREFIX = /^[a-z0-9]+-$/
|
|
34
|
+
|
|
35
|
+
const FULL_MODEL_ID = /^claude-[a-z0-9][a-z0-9.-]*$/
|
|
36
|
+
|
|
37
|
+
const language = z.enum(Object.values(PLAN_LANGUAGE))
|
|
38
|
+
|
|
39
|
+
const modelId = z
|
|
40
|
+
.string()
|
|
41
|
+
.regex(FULL_MODEL_ID, 'Use a full model ID such as claude-opus-5-5, not an alias like opus or sonnet')
|
|
42
|
+
|
|
43
|
+
const command = z.string().min(1)
|
|
44
|
+
|
|
45
|
+
const share = z.number().gt(0).lt(1)
|
|
46
|
+
|
|
47
|
+
/** A file below the main checkout, written into shell commands: relative, never leaving it, never an option, no quote or control character. */
|
|
48
|
+
const laneCopyPath = z.string().regex(
|
|
49
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: the class exists to refuse control characters
|
|
50
|
+
/^(?![/-])(?!(?:.*\/)?\.\.(?:\/|$))[^'\x00-\x1f\x7f]+$/,
|
|
51
|
+
'a relative path inside the checkout: no leading / or -, no .. segment, no quote or control character',
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const warnBelowAlert = ({ warnAt, alertAt }: { warnAt: number; alertAt: number }): boolean => warnAt < alertAt
|
|
55
|
+
|
|
56
|
+
const WARN_BELOW_ALERT = { message: 'warnAt must be below alertAt', path: ['warnAt'] }
|
|
57
|
+
|
|
58
|
+
/** The lock file, written unquoted into git commands: a plain relative path, never an option. */
|
|
59
|
+
const lockFilePath = z
|
|
60
|
+
.string()
|
|
61
|
+
.regex(/^(?![/-])(?!(?:.*\/)?\.\.(?:\/|$))[A-Za-z0-9._/-]+$/, 'a plain relative file name such as yarn.lock')
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A branch name as git arguments and rendered commands take it: never an option, no whitespace, no `..`, in the
|
|
65
|
+
* shape `git check-ref-format --branch` allows.
|
|
66
|
+
*/
|
|
67
|
+
const branchName = z
|
|
68
|
+
.string()
|
|
69
|
+
.regex(
|
|
70
|
+
/^(?![-/.])(?!.*(?:\.\.|\/\/|@\{|\.lock(?:\/|$)))[A-Za-z0-9._/-]+(?<![./])$/,
|
|
71
|
+
'a branch name such as main: no leading - / or ., no .. and no spaces',
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
/** A workflow file name as `gh run list --workflow` takes it: no path, no option, a YAML file. */
|
|
75
|
+
const workflowFile = z.string().regex(/^(?![-.])[A-Za-z0-9._-]+\.ya?ml$/, 'a workflow file name such as deploy.yml')
|
|
76
|
+
|
|
77
|
+
/** One directory name, glob characters allowed: never a path, an option or a hidden directory. */
|
|
78
|
+
const boardPattern = z
|
|
79
|
+
.string()
|
|
80
|
+
.regex(/^(?![-.])[A-Za-z0-9._*?-]+$/, 'one directory name such as board-*: no / and no leading - or .')
|
|
81
|
+
|
|
82
|
+
/** One markdown file name inside a board's directory. */
|
|
83
|
+
const boardFile = z
|
|
84
|
+
.string()
|
|
85
|
+
.regex(/^(?![-.])[A-Za-z0-9._-]+\.md$/, 'one markdown file name such as board.md: no / and no leading - or .')
|
|
86
|
+
|
|
87
|
+
const boardSchema = z.strictObject({
|
|
88
|
+
/** The directories under `planDirectory` that hold a board; the newest board is the default of `plans` and `watch`. */
|
|
89
|
+
pattern: boardPattern.default('board-*'),
|
|
90
|
+
/** The board document inside each of those directories. */
|
|
91
|
+
file: boardFile.default('board.md'),
|
|
92
|
+
/** What the project calls a board: the plans view, its page and the watcher print it as written. */
|
|
93
|
+
term: z.string().min(1).default('board'),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const deploySchema = z.strictObject({
|
|
97
|
+
/** The workflow whose runs on the base branch the watcher reports as `deploy`. */
|
|
98
|
+
workflow: workflowFile,
|
|
99
|
+
/** The deployment environments the watcher names as a run's target; any other reads `unknown`. */
|
|
100
|
+
environments: z.array(z.string().min(1)).min(1),
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const feedbackSchema = z.strictObject({
|
|
104
|
+
/** GitHub logins whose feedback does not wake the coordinator, such as a status bot; case is ignored. */
|
|
105
|
+
ignore: z.array(z.string().min(1)).default([]),
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const previewsSchema = z.strictObject({
|
|
109
|
+
/** Reads the GitHub deployments of each head commit; a successful one outside production and `deploy.environments` names its `environment_url`. */
|
|
110
|
+
deployments: z.boolean().default(false),
|
|
111
|
+
/**
|
|
112
|
+
* GitHub logins whose pull request comments link a preview; nothing they write counts as feedback, so name a bot that
|
|
113
|
+
* only posts previews, not one that also reviews. Case is ignored.
|
|
114
|
+
*/
|
|
115
|
+
authors: z.array(z.string().min(1)).default([]),
|
|
116
|
+
/** Finds the URL in such a comment: its first group, else its match; without it the first https link. */
|
|
117
|
+
urlPattern: z.string().min(1).refine(isRegularExpression, 'not a valid regular expression').optional(),
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const sourcePath = z.string().refine(isSourcePath, 'not a source path: `.`, or `.key`, `[n]` and `[-n]` in a chain')
|
|
121
|
+
|
|
122
|
+
const SOURCE_FIELD_NAME = /^[A-Za-z][A-Za-z0-9_]*$/
|
|
123
|
+
|
|
124
|
+
/** The six known fields, and any further field (`status`, `priority`, …) whose name is a plain name, for `reportOn`. */
|
|
125
|
+
const sourceMapSchema = z
|
|
126
|
+
.object({
|
|
127
|
+
[SOURCE_FIELD.id]: sourcePath,
|
|
128
|
+
/** Changes whenever the entry changes; compared as text, so any stable form of a time or version works. */
|
|
129
|
+
[SOURCE_FIELD.updatedAt]: sourcePath,
|
|
130
|
+
[SOURCE_FIELD.title]: sourcePath.optional(),
|
|
131
|
+
/** Shown only as an `https:` URL without user name or password. */
|
|
132
|
+
[SOURCE_FIELD.url]: sourcePath.optional(),
|
|
133
|
+
/** The first line with text is shown. */
|
|
134
|
+
[SOURCE_FIELD.body]: sourcePath.optional(),
|
|
135
|
+
[SOURCE_FIELD.author]: sourcePath.optional(),
|
|
136
|
+
})
|
|
137
|
+
.catchall(sourcePath)
|
|
138
|
+
.refine((map) => Object.keys(map).every((field) => SOURCE_FIELD_NAME.test(field)), {
|
|
139
|
+
message: 'a map field is a letter, then letters, digits and `_`',
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const sourceSchema = z
|
|
143
|
+
.strictObject({
|
|
144
|
+
/** Printed in every report of the source and the key of its saved state. */
|
|
145
|
+
name: z.string().regex(/^[A-Za-z0-9][\w.-]*$/, 'letters, digits, `_`, `.` and `-`'),
|
|
146
|
+
/** Program and arguments, run without a shell in the main checkout; it prints JSON on stdout and only reads. */
|
|
147
|
+
command: z.array(z.string().min(1)).min(1),
|
|
148
|
+
/** Minutes between two runs, at least 1. */
|
|
149
|
+
every: z.number().int().min(1).default(5),
|
|
150
|
+
/** The path of the list in the output; without it the output is the list. */
|
|
151
|
+
items: sourcePath.optional(),
|
|
152
|
+
map: sourceMapSchema,
|
|
153
|
+
/** Values of `map.author` whose changes wake nobody, such as the account the command runs as; case is ignored. */
|
|
154
|
+
ignoreAuthors: z.array(z.string().min(1)).default([]),
|
|
155
|
+
/** The most new or changed entries one report shows of this source; the rest is one line `+<n> more`. */
|
|
156
|
+
reportLimit: z.number().int().min(1).default(5),
|
|
157
|
+
/** The lane the source's reports name. */
|
|
158
|
+
lane: z.string().min(1).optional(),
|
|
159
|
+
/**
|
|
160
|
+
* The mapped fields whose change reports, with the old and the new value, in place of the content of `title`,
|
|
161
|
+
* `author`, `body` and `url`; without it a change of that content reports.
|
|
162
|
+
*/
|
|
163
|
+
reportOn: z.array(z.string().min(1)).min(1).max(10).optional(),
|
|
164
|
+
})
|
|
165
|
+
.refine((source) => source.ignoreAuthors.length === 0 || source.map.author !== undefined, {
|
|
166
|
+
message: 'ignoreAuthors needs map.author',
|
|
167
|
+
path: ['ignoreAuthors'],
|
|
168
|
+
})
|
|
169
|
+
.refine((source) => !source.reportOn?.includes(SOURCE_FIELD.id), {
|
|
170
|
+
message: 'reportOn cannot name id',
|
|
171
|
+
path: ['reportOn'],
|
|
172
|
+
})
|
|
173
|
+
.refine((source) => (source.reportOn ?? []).every((field) => Object.hasOwn(source.map, field)), {
|
|
174
|
+
message: 'reportOn names a field map does not have',
|
|
175
|
+
path: ['reportOn'],
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const hasUniqueNames = (sources: readonly { name: string }[]): boolean =>
|
|
179
|
+
new Set(sources.map(({ name }) => name)).size === sources.length
|
|
180
|
+
|
|
181
|
+
const coordinatorSchema = z
|
|
182
|
+
.strictObject({
|
|
183
|
+
/** Share of the coordinator's context window from which the watcher reports `coordinator warn`. */
|
|
184
|
+
warnAt: share.default(0.5),
|
|
185
|
+
/** Share of the coordinator's context window from which the watcher reports `coordinator alert`. */
|
|
186
|
+
alertAt: share.default(0.7),
|
|
187
|
+
})
|
|
188
|
+
.refine(warnBelowAlert, WARN_BELOW_ALERT)
|
|
189
|
+
|
|
190
|
+
const contextSchema = z
|
|
191
|
+
.strictObject({
|
|
192
|
+
/** Share of the context window from which a lane's fill shows yellow and `/implement` offers a new chat. */
|
|
193
|
+
warnAt: share.default(0.6),
|
|
194
|
+
/** Share of the context window from which a lane's fill shows red. */
|
|
195
|
+
alertAt: share.default(0.8),
|
|
196
|
+
/** Context window in tokens by full model ID; the log does not say which window a session ran with. */
|
|
197
|
+
windows: z.record(modelId, z.number().int().positive()).default({}),
|
|
198
|
+
/** The window assumed for a model not listed in `windows`. */
|
|
199
|
+
defaultWindow: z.number().int().positive().default(200_000),
|
|
200
|
+
/** The coordinator's own thresholds, used by `jh context --coordinator`; the lanes keep `warnAt` and `alertAt`. */
|
|
201
|
+
coordinator: coordinatorSchema.prefault({}),
|
|
202
|
+
})
|
|
203
|
+
.refine(warnBelowAlert, WARN_BELOW_ALERT)
|
|
204
|
+
|
|
205
|
+
export const jackherdConfigSchema = z.strictObject({
|
|
206
|
+
$schema: z.string().optional(),
|
|
207
|
+
responseLanguage: language.default(PLAN_LANGUAGE.en),
|
|
208
|
+
planLanguage: language.default(PLAN_LANGUAGE.en),
|
|
209
|
+
planDirectory: z.string().min(1).default('docs/plans'),
|
|
210
|
+
/** Where the boards live and what the project calls them. */
|
|
211
|
+
board: boardSchema.prefault({}),
|
|
212
|
+
/** Replaced by `board.pattern` in 0.13.0; named here only so an old config is refused with that hint. */
|
|
213
|
+
sprintPattern: z
|
|
214
|
+
.never({ error: 'sprintPattern was replaced by board.pattern (with board.file and board.term)' })
|
|
215
|
+
.optional(),
|
|
216
|
+
worktreeDirectory: z.string().min(1).default(claudePath(WORKTREES_DIRECTORY)),
|
|
217
|
+
baseBranch: branchName.default(DEFAULT_BASE_BRANCH),
|
|
218
|
+
/**
|
|
219
|
+
* Put before every skill and agent name. `jh sync --global` refuses to run without one: a personal skill
|
|
220
|
+
* shadows a project skill of the same name. In a project it is optional and keeps the project's own skills of a
|
|
221
|
+
* kit name (`implement`, `plan`, …) apart from the kit's.
|
|
222
|
+
*/
|
|
223
|
+
skillPrefix: z
|
|
224
|
+
.union([z.literal(''), z.string().regex(SKILL_PREFIX, 'lower-case letters and digits ending in a dash, e.g. kit-')])
|
|
225
|
+
.default(''),
|
|
226
|
+
commands: z
|
|
227
|
+
.strictObject({
|
|
228
|
+
test: command.default(BUN_COMMANDS.test),
|
|
229
|
+
typecheck: command.default(BUN_COMMANDS.typecheck),
|
|
230
|
+
lint: command.default(BUN_COMMANDS.lint),
|
|
231
|
+
format: command.default(BUN_COMMANDS.format),
|
|
232
|
+
/** Installs a new lane's dependencies. */
|
|
233
|
+
install: command.default(BUN_COMMANDS.install),
|
|
234
|
+
/** Starts a lane's dev server, run as `PORT=<port> <dev>`; a project supplement may name another. */
|
|
235
|
+
dev: command.default(BUN_COMMANDS.dev),
|
|
236
|
+
})
|
|
237
|
+
.prefault({}),
|
|
238
|
+
/** How `/orchestrate` sets up a lane's worktree. */
|
|
239
|
+
lanes: z
|
|
240
|
+
.strictObject({
|
|
241
|
+
/** Untracked files copied into every new worktree; empty means the files `.worktreeinclude` lists. */
|
|
242
|
+
copy: z.array(laneCopyPath).default([]),
|
|
243
|
+
/** The dependency lock file a rebase takes from the base branch and regenerates, never merges by hand. */
|
|
244
|
+
lockFile: lockFilePath.default('bun.lock'),
|
|
245
|
+
})
|
|
246
|
+
.prefault({}),
|
|
247
|
+
models: z.record(z.enum(Object.values(MODEL_ROLE)), modelId),
|
|
248
|
+
terminal: z
|
|
249
|
+
.strictObject({ adapter: z.enum(Object.values(TERMINAL_ADAPTER)).default(TERMINAL_ADAPTER.none) })
|
|
250
|
+
.prefault({}),
|
|
251
|
+
plans: z
|
|
252
|
+
.strictObject({
|
|
253
|
+
/** Minutes between background fetches of the base branch while `jh plans` runs; 0 turns them off. */
|
|
254
|
+
fetchIntervalMinutes: z.number().min(0).default(5),
|
|
255
|
+
/** Links in a plan that the sprint page shows as chips; the list replaces the built-in kinds. */
|
|
256
|
+
linkKinds: z
|
|
257
|
+
.array(
|
|
258
|
+
z.strictObject({
|
|
259
|
+
pattern: z.string().min(1).refine(isRegularExpression, 'not a valid regular expression'),
|
|
260
|
+
label: z.string().min(1),
|
|
261
|
+
icon: z.string().min(1),
|
|
262
|
+
}),
|
|
263
|
+
)
|
|
264
|
+
.default(DEFAULT_LINK_KINDS.map((kind) => ({ ...kind }))),
|
|
265
|
+
context: contextSchema.prefault({}),
|
|
266
|
+
})
|
|
267
|
+
.prefault({}),
|
|
268
|
+
/**
|
|
269
|
+
* Read by `jh watch`; without `deploy` it reports no deploy runs. `feedback` is on unless set to `false`:
|
|
270
|
+
* it reports new feedback on the lanes' pull requests except from the account the watcher runs as and `ignore`.
|
|
271
|
+
* Without `previews` it reads no deploy preview; with it, one `preview` per new head commit. Each of `sources` is a
|
|
272
|
+
* command whose JSON output reports `source` for a new or changed entry.
|
|
273
|
+
*/
|
|
274
|
+
watch: z
|
|
275
|
+
.strictObject({
|
|
276
|
+
deploy: deploySchema.optional(),
|
|
277
|
+
feedback: z.union([z.literal(false), feedbackSchema]).default({ ignore: [] }),
|
|
278
|
+
previews: previewsSchema.optional(),
|
|
279
|
+
sources: z.array(sourceSchema).refine(hasUniqueNames, 'source names must be unique').default([]),
|
|
280
|
+
})
|
|
281
|
+
.prefault({}),
|
|
282
|
+
/** Read by `jh check`; validated here too, so a typo fails every command alike. */
|
|
283
|
+
check: checkConfigSchema.optional(),
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
export type JackherdConfig = z.infer<typeof jackherdConfigSchema>
|
|
287
|
+
|
|
288
|
+
export const parseJackherdConfig = (raw: unknown): JackherdConfig => jackherdConfigSchema.parse(raw)
|
|
289
|
+
|
|
290
|
+
const parseLabelled = <T extends z.ZodType>(schema: T, raw: unknown, label: string): z.infer<T> => {
|
|
291
|
+
const parsed = schema.safeParse(raw)
|
|
292
|
+
if (!parsed.success) throw new Error(`${label} is invalid:\n${z.prettifyError(parsed.error)}`)
|
|
293
|
+
return parsed.data
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** `parseJackherdConfig` for a file: an invalid one throws with `label` (its path) and the issues in words. */
|
|
297
|
+
export const parseConfigFile = (raw: unknown, label: string): JackherdConfig =>
|
|
298
|
+
parseLabelled(jackherdConfigSchema, raw, label)
|
|
299
|
+
|
|
300
|
+
/** One `watch.sources` entry outside the config, such as a draft to try; invalid throws with `label` and the issues. */
|
|
301
|
+
export const parseSourceDraft = (raw: unknown, label: string): z.infer<typeof sourceSchema> =>
|
|
302
|
+
parseLabelled(sourceSchema, raw, label)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
import { mainCheckout, worktreeRoot } from '../plans/git'
|
|
3
|
+
import { readSettings } from '../plans/settings'
|
|
4
|
+
import { laneStatusSourceFor } from '../terminal'
|
|
5
|
+
import { defaultCoordinatorName } from './coordinator-name'
|
|
6
|
+
import { coordinatorSettings, parseContextOptions } from './options'
|
|
7
|
+
import { contextReport, noValueNote, unnamedPaneNote } from './report'
|
|
8
|
+
import { agentLog } from './session-log'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `jh context [<name>] [--coordinator]`: prints how full the context of an agent is as one line of JSON
|
|
12
|
+
* (`tokens`, `window`, `share`, `model`, `level`), or `null` when there is no value. The name defaults to the
|
|
13
|
+
* worktree the command runs in, with `--coordinator` to `orchestrator-<repository>`. `--coordinator` also takes
|
|
14
|
+
* the level from the coordinator's thresholds (`plans.context.coordinator`). Reads the session log, never writes it.
|
|
15
|
+
*/
|
|
16
|
+
export const run = async (args: string[]): Promise<number> => {
|
|
17
|
+
try {
|
|
18
|
+
const options = parseContextOptions(args)
|
|
19
|
+
const root = mainCheckout(process.cwd())
|
|
20
|
+
const worktree = worktreeRoot(process.cwd())
|
|
21
|
+
const settings = readSettings([worktree, root])
|
|
22
|
+
const lane = options.lane ?? (options.coordinator ? defaultCoordinatorName(basename(root)) : basename(worktree))
|
|
23
|
+
const context = options.coordinator ? coordinatorSettings(settings.context) : settings.context
|
|
24
|
+
const source = laneStatusSourceFor(settings.adapter)
|
|
25
|
+
const named = source.listAgents()
|
|
26
|
+
const pane = process.env.HERDR_PANE_ID
|
|
27
|
+
const adopt =
|
|
28
|
+
options.coordinator && pane && !named.some((agent) => agent.name === lane) ? { pane, name: lane } : undefined
|
|
29
|
+
const agents = adopt ? source.listAgents(adopt) : named
|
|
30
|
+
const report = contextReport(agents, lane, agentLog, context)
|
|
31
|
+
console.log(JSON.stringify(report))
|
|
32
|
+
if (agents.length > named.length) console.error(unnamedPaneNote(lane))
|
|
33
|
+
if (report === null) console.error(noValueNote(agents, lane))
|
|
34
|
+
return 0
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
37
|
+
return 1
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const PREFIX = 'orchestrator'
|
|
2
|
+
const MAX_LENGTH = 32
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The herdr name a coordinator takes when none is given: `orchestrator-<repository>`. herdr names are unique
|
|
6
|
+
* across all workspaces, so two repositories cannot both run a coordinator called `orchestrator`. The name
|
|
7
|
+
* fits `[a-z][a-z0-9_-]{0,31}`; a repository name that leaves nothing usable gives plain `orchestrator`.
|
|
8
|
+
*/
|
|
9
|
+
export const defaultCoordinatorName = (repository: string): string => {
|
|
10
|
+
const slug = repository
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
13
|
+
.replace(/^-+|-+$/g, '')
|
|
14
|
+
if (!slug) return PREFIX
|
|
15
|
+
return `${PREFIX}-${slug}`.slice(0, MAX_LENGTH).replace(/-+$/, '')
|
|
16
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** How the context window of a model is assumed: by model ID, with a default for the models not listed. */
|
|
2
|
+
export type ContextWindows = { windows: Readonly<Record<string, number>>; defaultWindow: number }
|
|
3
|
+
|
|
4
|
+
export type ContextFill = {
|
|
5
|
+
/** Input tokens of the last main-chain answer: what the next request carries. */
|
|
6
|
+
tokens: number
|
|
7
|
+
window: number
|
|
8
|
+
/** `tokens / window`, at most 1. */
|
|
9
|
+
share: number
|
|
10
|
+
model: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type Answer = { tokens: number; model: string }
|
|
14
|
+
|
|
15
|
+
/** Window sizes that exist whatever the config lists, so a fill past the assumed window still lands on a real one. */
|
|
16
|
+
const COMMON_WINDOWS = [200_000, 1_000_000]
|
|
17
|
+
|
|
18
|
+
const SYNTHETIC_MODEL = '<synthetic>'
|
|
19
|
+
|
|
20
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
|
|
21
|
+
|
|
22
|
+
const count = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
|
|
23
|
+
|
|
24
|
+
const parseLine = (line: string): unknown => {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(line)
|
|
27
|
+
} catch {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The answer of a main-chain `assistant` line; null for every other line, a sub-agent's answer and a synthetic one. */
|
|
33
|
+
const answerOf = (line: string): Answer | null => {
|
|
34
|
+
const entry = parseLine(line)
|
|
35
|
+
if (!isRecord(entry) || entry.type !== 'assistant' || entry.isSidechain === true) return null
|
|
36
|
+
const { message } = entry
|
|
37
|
+
if (!isRecord(message) || typeof message.model !== 'string' || message.model === SYNTHETIC_MODEL) return null
|
|
38
|
+
const { usage } = message
|
|
39
|
+
if (!isRecord(usage)) return null
|
|
40
|
+
const tokens =
|
|
41
|
+
count(usage.input_tokens) + count(usage.cache_creation_input_tokens) + count(usage.cache_read_input_tokens)
|
|
42
|
+
return tokens > 0 ? { tokens, model: message.model } : null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The window the fill fits in: the one configured for the model, or if the highest fill of the session already
|
|
47
|
+
* passed it, the next larger known window, the configured ones and the common sizes (the log never says which window a
|
|
48
|
+
* session ran with). Beyond every known window the highest fill itself is the window.
|
|
49
|
+
*/
|
|
50
|
+
const windowFor = (model: string, highest: number, { windows, defaultWindow }: ContextWindows): number => {
|
|
51
|
+
const assumed = windows[model] ?? defaultWindow
|
|
52
|
+
if (highest <= assumed) return assumed
|
|
53
|
+
const larger = [...Object.values(windows), defaultWindow, ...COMMON_WINDOWS].filter((size) => size >= highest)
|
|
54
|
+
return larger.length > 0 ? Math.min(...larger) : highest
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* How full the context of a session is, read from the lines of its log. Null when no line is a usable answer,
|
|
59
|
+
* so a missing or empty log is no error; lines that do not parse (the last one while it is being written) are
|
|
60
|
+
* skipped. `lines` may be a tail of the log: the highest fill is then the highest within it.
|
|
61
|
+
*/
|
|
62
|
+
export const contextFill = (lines: readonly string[], windows: ContextWindows): ContextFill | null => {
|
|
63
|
+
const answers = lines.flatMap((line) => answerOf(line) ?? [])
|
|
64
|
+
const last = answers.at(-1)
|
|
65
|
+
if (!last) return null
|
|
66
|
+
const highest = Math.max(...answers.map(({ tokens }) => tokens))
|
|
67
|
+
const window = windowFor(last.model, highest, windows)
|
|
68
|
+
return { tokens: last.tokens, window, share: Math.min(last.tokens / window, 1), model: last.model }
|
|
69
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const CONTEXT_LEVEL = { ok: 'ok', warn: 'warn', alert: 'alert' } as const
|
|
2
|
+
|
|
3
|
+
export type ContextLevel = (typeof CONTEXT_LEVEL)[keyof typeof CONTEXT_LEVEL]
|
|
4
|
+
|
|
5
|
+
export type ContextThresholds = { warnAt: number; alertAt: number }
|
|
6
|
+
|
|
7
|
+
/** A fill as a view shows it: the share of the window and which threshold it has passed. */
|
|
8
|
+
export type ContextReading = { share: number; level: ContextLevel }
|
|
9
|
+
|
|
10
|
+
/** `ok` below `warnAt`, `warn` from there, `alert` from `alertAt` on. */
|
|
11
|
+
export const contextLevel = (share: number, { warnAt, alertAt }: ContextThresholds): ContextLevel => {
|
|
12
|
+
if (share >= alertAt) return CONTEXT_LEVEL.alert
|
|
13
|
+
return share >= warnAt ? CONTEXT_LEVEL.warn : CONTEXT_LEVEL.ok
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const contextReading = (share: number, thresholds: ContextThresholds): ContextReading => ({
|
|
17
|
+
share,
|
|
18
|
+
level: contextLevel(share, thresholds),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/** `62 %` */
|
|
22
|
+
export const contextLabel = (share: number): string => `${Math.round(share * 100)} %`
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { CLI_NAME } from '../version/release'
|
|
2
|
+
import type { ContextWindows } from './fill'
|
|
3
|
+
import type { ContextThresholds } from './level'
|
|
4
|
+
|
|
5
|
+
/** `lane` is null when the command names none; it then measures the agent of the worktree it runs in. */
|
|
6
|
+
export type ContextOptions = { lane: string | null; coordinator: boolean }
|
|
7
|
+
|
|
8
|
+
export type ContextSettings = ContextWindows & ContextThresholds & { coordinator: ContextThresholds }
|
|
9
|
+
|
|
10
|
+
const COORDINATOR_OPTION = '--coordinator'
|
|
11
|
+
|
|
12
|
+
const KNOWN_OPTIONS = [COORDINATOR_OPTION]
|
|
13
|
+
|
|
14
|
+
const USAGE = `usage: ${CLI_NAME} context [<name>] [${COORDINATOR_OPTION}]`
|
|
15
|
+
|
|
16
|
+
/** Reads `[<name>] [--coordinator]` in any order; throws with the usage on an unknown option or a second name. */
|
|
17
|
+
export const parseContextOptions = (args: readonly string[]): ContextOptions => {
|
|
18
|
+
const unknown = args.find((arg) => arg.startsWith('--') && !KNOWN_OPTIONS.includes(arg))
|
|
19
|
+
if (unknown) throw new Error(`unknown option ${unknown}\n${USAGE}`)
|
|
20
|
+
const names = args.filter((arg) => !arg.startsWith('--'))
|
|
21
|
+
if (names.length > 1) throw new Error(`one name at most, got ${names.join(' ')}\n${USAGE}`)
|
|
22
|
+
return { lane: names[0] ?? null, coordinator: args.includes(COORDINATOR_OPTION) }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The settings with the coordinator's thresholds in place of a lane's; the windows stay the same. */
|
|
26
|
+
export const coordinatorSettings = (settings: ContextSettings): ContextSettings => ({
|
|
27
|
+
...settings,
|
|
28
|
+
...settings.coordinator,
|
|
29
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { LaneAgent } from '../terminal'
|
|
2
|
+
import { type ContextFill, type ContextWindows, contextFill } from './fill'
|
|
3
|
+
import { type ContextLevel, type ContextThresholds, contextLevel } from './level'
|
|
4
|
+
|
|
5
|
+
export type ContextReport = ContextFill & { level: ContextLevel }
|
|
6
|
+
|
|
7
|
+
export type AgentLogReader = (agent: Pick<LaneAgent, 'session' | 'cwd'>) => string[] | null
|
|
8
|
+
|
|
9
|
+
/** The fill of one agent with its level; null without an agent, a log or an answer in it. */
|
|
10
|
+
export const contextOfAgent = (
|
|
11
|
+
agent: Pick<LaneAgent, 'session' | 'cwd'> | undefined,
|
|
12
|
+
readLog: AgentLogReader,
|
|
13
|
+
settings: ContextWindows & ContextThresholds,
|
|
14
|
+
): ContextReport | null => {
|
|
15
|
+
const lines = agent ? readLog(agent) : null
|
|
16
|
+
const fill = lines && contextFill(lines, settings)
|
|
17
|
+
return fill ? { ...fill, level: contextLevel(fill.share, settings) } : null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How full the context of the agent named `lane` is, with the level it reached. Null when no such agent is
|
|
22
|
+
* listed or its log is missing or holds no answer: no value, and no error.
|
|
23
|
+
*/
|
|
24
|
+
export const contextReport = (
|
|
25
|
+
agents: readonly LaneAgent[],
|
|
26
|
+
lane: string,
|
|
27
|
+
readLog: AgentLogReader,
|
|
28
|
+
settings: ContextWindows & ContextThresholds,
|
|
29
|
+
): ContextReport | null =>
|
|
30
|
+
contextOfAgent(
|
|
31
|
+
agents.find((agent) => agent.name === lane),
|
|
32
|
+
readLog,
|
|
33
|
+
settings,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
/** Why `contextReport` gave no value. */
|
|
37
|
+
export const noValueNote = (agents: readonly LaneAgent[], lane: string): string =>
|
|
38
|
+
agents.some((agent) => agent.name === lane) ? `${lane} has no answer in its session log` : `no agent named ${lane}`
|
|
39
|
+
|
|
40
|
+
/** What to run so the agent of an unnamed pane is found by its name next time. */
|
|
41
|
+
export const unnamedPaneNote = (lane: string): string =>
|
|
42
|
+
`pane without a name, herdr agent rename "$HERDR_PANE_ID" ${lane}`
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { closeSync, existsSync, fstatSync, openSync, readdirSync, readSync, statSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { CLAUDE_DIRECTORY, SESSION_LOGS_DIRECTORY } from '../claude/paths'
|
|
5
|
+
|
|
6
|
+
/** How much of the end of a log is read: logs grow to hundreds of megabytes, and the last answers are at the end. */
|
|
7
|
+
export const TAIL_BYTES = 2 * 1024 * 1024
|
|
8
|
+
|
|
9
|
+
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
10
|
+
|
|
11
|
+
export const defaultProjectsDirectory = (): string => join(homedir(), CLAUDE_DIRECTORY, SESSION_LOGS_DIRECTORY)
|
|
12
|
+
|
|
13
|
+
const foundPaths = new Map<string, string>()
|
|
14
|
+
|
|
15
|
+
/** The log of a session, in whichever project directory holds it (found once, then remembered, since the view asks every few seconds); null for an id that is not a UUID or has no log. */
|
|
16
|
+
export const sessionLogPath = (sessionId: string, projectsDirectory: string): string | null => {
|
|
17
|
+
if (!SESSION_ID.test(sessionId) || !existsSync(projectsDirectory)) return null
|
|
18
|
+
const cacheKey = `${projectsDirectory}\0${sessionId}`
|
|
19
|
+
const cached = foundPaths.get(cacheKey)
|
|
20
|
+
if (cached && existsSync(cached)) return cached
|
|
21
|
+
const match = readdirSync(projectsDirectory)
|
|
22
|
+
.map((directory) => join(projectsDirectory, directory, `${sessionId}.jsonl`))
|
|
23
|
+
.find((path) => existsSync(path))
|
|
24
|
+
if (match) foundPaths.set(cacheKey, match)
|
|
25
|
+
return match ?? null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The last `bytes` of a file as complete lines: a cut-off first line is dropped, the last one is kept as it is. */
|
|
29
|
+
export const readTailLines = (path: string, bytes: number): string[] => {
|
|
30
|
+
const descriptor = openSync(path, 'r')
|
|
31
|
+
try {
|
|
32
|
+
const { size } = fstatSync(descriptor)
|
|
33
|
+
const start = Math.max(0, size - bytes)
|
|
34
|
+
const buffer = Buffer.alloc(size - start)
|
|
35
|
+
readSync(descriptor, buffer, 0, buffer.length, start)
|
|
36
|
+
const lines = buffer.toString('utf8').split('\n')
|
|
37
|
+
return start > 0 ? lines.slice(1) : lines
|
|
38
|
+
} finally {
|
|
39
|
+
closeSync(descriptor)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The tail of a session's log, read only; null when the session has no log or it cannot be read. */
|
|
44
|
+
export const sessionLog = (
|
|
45
|
+
sessionId: string,
|
|
46
|
+
projectsDirectory: string = defaultProjectsDirectory(),
|
|
47
|
+
bytes: number = TAIL_BYTES,
|
|
48
|
+
): string[] | null => {
|
|
49
|
+
try {
|
|
50
|
+
const path = sessionLogPath(sessionId, projectsDirectory)
|
|
51
|
+
return path ? readTailLines(path, bytes) : null
|
|
52
|
+
} catch {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type AgentSession = { session: string | null; cwd: string | null }
|
|
58
|
+
|
|
59
|
+
const hasAnswer = (lines: readonly string[]): boolean => lines.some((line) => line.includes('"type":"assistant"'))
|
|
60
|
+
|
|
61
|
+
/** Claude Code names a project directory after the working directory, `/` and `.` turned into `-`. */
|
|
62
|
+
const projectDirectoryOf = (cwd: string): string => cwd.replace(/[/.]/g, '-')
|
|
63
|
+
|
|
64
|
+
const newestLogOf = (cwd: string, projectsDirectory: string): string | null => {
|
|
65
|
+
const directory = join(projectsDirectory, projectDirectoryOf(cwd))
|
|
66
|
+
if (!existsSync(directory)) return null
|
|
67
|
+
const newest = readdirSync(directory)
|
|
68
|
+
.filter((name) => name.endsWith('.jsonl'))
|
|
69
|
+
.map((name) => ({ path: join(directory, name), modified: statSync(join(directory, name)).mtimeMs }))
|
|
70
|
+
.sort((a, b) => b.modified - a.modified)[0]
|
|
71
|
+
return newest?.path ?? null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The log lines that show how full an agent's context is. The session id the terminal reports can be stale
|
|
76
|
+
* (a chat that was resumed or cleared keeps writing to another file), so a reported log without any answer
|
|
77
|
+
* gives way to the newest log of the agent's working directory. Null when neither exists.
|
|
78
|
+
*/
|
|
79
|
+
export const agentLog = (
|
|
80
|
+
{ session, cwd }: AgentSession,
|
|
81
|
+
projectsDirectory: string = defaultProjectsDirectory(),
|
|
82
|
+
): string[] | null => {
|
|
83
|
+
const reported = session ? sessionLog(session, projectsDirectory) : null
|
|
84
|
+
if (reported && hasAnswer(reported)) return reported
|
|
85
|
+
try {
|
|
86
|
+
const path = cwd ? newestLogOf(cwd, projectsDirectory) : null
|
|
87
|
+
return path ? readTailLines(path, TAIL_BYTES) : reported
|
|
88
|
+
} catch {
|
|
89
|
+
return reported
|
|
90
|
+
}
|
|
91
|
+
}
|