jackherd 1.4.0 → 1.6.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 +15 -0
- package/README.md +109 -38
- package/dist/{chunk-UI3AZNZF.js → chunk-QJSXHHQ3.js} +15 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +27 -2
- package/dist/plan-format.d.ts +14 -1
- package/dist/plan-format.js +5 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/merge-guard/check-free.ts +70 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +2 -1
- package/kit/hooks/hooks/merge-guard/gh.ts +12 -0
- package/kit/hooks/hooks/merge-guard/pull-request.ts +27 -10
- package/kit/hooks/hooks/merge-guard/run.ts +22 -8
- package/kit/hooks/hooks/merge-guard/settings.ts +20 -4
- package/kit/rules/rules/kit/code-style.md +1 -0
- package/kit/workflow/skills/follow/SKILL.md +10 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +26 -9
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +23 -0
- package/src/cli.ts +15 -2
- package/src/config/detect.ts +15 -0
- package/src/config/schema.ts +31 -1
- package/src/plans/lane-standing.ts +59 -0
- package/src/plans/layout.ts +32 -12
- package/src/plans/texts.ts +5 -1
- package/src/sync/emptied-directories.ts +19 -0
- package/src/sync/project-fs.ts +28 -2
- package/src/sync/render.ts +14 -5
- package/src/sync/sync.ts +28 -10
- package/src/vocabulary/index.ts +24 -0
- package/src/watch/command.ts +20 -1
- package/src/watch/heartbeat-progress.ts +47 -0
- package/src/watch/source-command.ts +16 -0
- package/src/watch/source-run.ts +8 -1
- package/src/watch/stream-output.ts +20 -3
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
import { isAbsolute, relative } from 'node:path'
|
|
3
3
|
import { approvedBranches, parseTranscript } from './approval'
|
|
4
|
+
import { allFilesMatch, changedFilesOf } from './check-free'
|
|
4
5
|
import { decideBashCommand, type GuardPolicy, type MergeGuardDecision } from './decision'
|
|
5
|
-
import {
|
|
6
|
+
import { parseHeadBranch, parseRepositoryFacts, type RepositoryFacts, viewPullRequest } from './pull-request'
|
|
6
7
|
import { type GuardSettings, readGuardSettings } from './settings'
|
|
7
8
|
|
|
8
9
|
type HookInput = { tool_input?: { command?: unknown }; transcript_path?: unknown; cwd?: unknown }
|
|
@@ -25,13 +26,21 @@ const isInside = (directory: string, project: string): boolean => {
|
|
|
25
26
|
return path === '' || (!path.startsWith('..') && !isAbsolute(path))
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/** Every file of the pull request, as GitHub lists them, matches `merge.withoutChecks`. */
|
|
30
|
+
const isCheckFree = (
|
|
31
|
+
pullRequest: number,
|
|
32
|
+
facts: RepositoryFacts | undefined,
|
|
33
|
+
withoutChecks: readonly string[],
|
|
34
|
+
directory: string,
|
|
35
|
+
): boolean => facts !== undefined && allFilesMatch(changedFilesOf(pullRequest, directory, facts) ?? [], withoutChecks)
|
|
36
|
+
|
|
28
37
|
const policyFor = (input: HookInput, label: string, projectDirectory: string): GuardPolicy => {
|
|
29
38
|
const settings = readGuardSettings(projectDirectory)
|
|
30
39
|
const directory = typeof input.cwd === 'string' && input.cwd !== '' ? input.cwd : projectDirectory
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
if (!
|
|
34
|
-
return
|
|
40
|
+
const views = new Map<number, string | undefined>()
|
|
41
|
+
const viewOf = (pullRequest: number): string => {
|
|
42
|
+
if (!views.has(pullRequest)) views.set(pullRequest, viewPullRequest(pullRequest, directory))
|
|
43
|
+
return views.get(pullRequest) ?? ''
|
|
35
44
|
}
|
|
36
45
|
let approved: Set<string> | undefined
|
|
37
46
|
return {
|
|
@@ -39,9 +48,14 @@ const policyFor = (input: HookInput, label: string, projectDirectory: string): G
|
|
|
39
48
|
isMergeApproved: (pullRequest) => {
|
|
40
49
|
if (!isInside(directory, projectDirectory)) return false
|
|
41
50
|
const branches = (approved ??= approvedBranchesFrom(input.transcript_path, settings, label))
|
|
42
|
-
if (branches.size === 0) return false
|
|
43
|
-
|
|
44
|
-
|
|
51
|
+
if (branches.size === 0 && settings.withoutChecks.length === 0) return false
|
|
52
|
+
// The branch lookup also proves the pull request is open and from this repository; both paths need that.
|
|
53
|
+
const view = viewOf(pullRequest)
|
|
54
|
+
const branch = parseHeadBranch(view)
|
|
55
|
+
if (branch === undefined) return false
|
|
56
|
+
return (
|
|
57
|
+
branches.has(branch) || isCheckFree(pullRequest, parseRepositoryFacts(view), settings.withoutChecks, directory)
|
|
58
|
+
)
|
|
45
59
|
},
|
|
46
60
|
}
|
|
47
61
|
}
|
|
@@ -5,17 +5,30 @@ import { join } from 'node:path'
|
|
|
5
5
|
export type GuardSettings = {
|
|
6
6
|
commands: string[]
|
|
7
7
|
selfMerge: { offer: boolean; approvalMinutes: number }
|
|
8
|
+
/** Path patterns of a pull request that is merged without checks; empty means every merge waits for them. */
|
|
9
|
+
withoutChecks: string[]
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
export const DEFAULT_SETTINGS: GuardSettings = {
|
|
11
13
|
commands: ['gh pr merge', 'gh workflow run'],
|
|
12
14
|
selfMerge: { offer: true, approvalMinutes: 180 },
|
|
15
|
+
withoutChecks: [],
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
const CONFIG_PATH = join('.jackherd', 'config.json')
|
|
16
19
|
export const GUARDED_COMMAND = /^[a-z][a-z0-9-]*( [a-z][a-z0-9-]*){1,3}$/
|
|
17
20
|
export const MAX_MINUTES = 1440
|
|
18
21
|
|
|
22
|
+
/** The same rules as `checkFreePattern` in the config schema, which the hook cannot import. */
|
|
23
|
+
const isCheckFreePattern = (value: unknown): value is string =>
|
|
24
|
+
typeof value === 'string' &&
|
|
25
|
+
/^[A-Za-z0-9_.*/-]+$/.test(value) &&
|
|
26
|
+
!value.startsWith('/') &&
|
|
27
|
+
!value.split('/').includes('..') &&
|
|
28
|
+
value.split('/').some((segment) => /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(segment))
|
|
29
|
+
|
|
30
|
+
const isPatternList = (value: unknown): value is string[] => Array.isArray(value) && value.every(isCheckFreePattern)
|
|
31
|
+
|
|
19
32
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
20
33
|
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
21
34
|
|
|
@@ -25,13 +38,13 @@ const isCommandList = (value: unknown): value is string[] =>
|
|
|
25
38
|
const isMinutes = (value: unknown): value is number =>
|
|
26
39
|
typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= MAX_MINUTES
|
|
27
40
|
|
|
28
|
-
/** The
|
|
29
|
-
const
|
|
41
|
+
/** The first config that exists; the hook has no schema library, `jh` validates the file. */
|
|
42
|
+
const readConfig = (directories: string[]): Record<string, unknown> => {
|
|
30
43
|
const path = directories.map((directory) => join(directory, CONFIG_PATH)).find((candidate) => existsSync(candidate))
|
|
31
44
|
if (!path) return {}
|
|
32
45
|
try {
|
|
33
46
|
const config: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
34
|
-
return isRecord(config)
|
|
47
|
+
return isRecord(config) ? config : {}
|
|
35
48
|
} catch {
|
|
36
49
|
return {}
|
|
37
50
|
}
|
|
@@ -42,7 +55,9 @@ const readGuardSection = (directories: string[]): Record<string, unknown> => {
|
|
|
42
55
|
* malformed keeps its default, so a broken config guards more, never less; only an empty list switches the guard off.
|
|
43
56
|
*/
|
|
44
57
|
export const readGuardSettings = (projectDirectory: string, home: string = homedir()): GuardSettings => {
|
|
45
|
-
const
|
|
58
|
+
const config = readConfig([projectDirectory, home])
|
|
59
|
+
const section = isRecord(config.guard) ? config.guard : {}
|
|
60
|
+
const merge = isRecord(config.merge) ? config.merge : {}
|
|
46
61
|
const selfMerge = isRecord(section.selfMerge) ? section.selfMerge : {}
|
|
47
62
|
return {
|
|
48
63
|
commands: isCommandList(section.commands) ? section.commands : DEFAULT_SETTINGS.commands,
|
|
@@ -52,5 +67,6 @@ export const readGuardSettings = (projectDirectory: string, home: string = homed
|
|
|
52
67
|
? selfMerge.approvalMinutes
|
|
53
68
|
: DEFAULT_SETTINGS.selfMerge.approvalMinutes,
|
|
54
69
|
},
|
|
70
|
+
withoutChecks: isPatternList(merge.withoutChecks) ? merge.withoutChecks : DEFAULT_SETTINGS.withoutChecks,
|
|
55
71
|
}
|
|
56
72
|
}
|
|
@@ -17,6 +17,7 @@ The formatter and linter config is the authority; these are the conventions it e
|
|
|
17
17
|
- **All code in English**: file names, identifiers, type and component names, comments. User-facing strings go through the project's i18n system.
|
|
18
18
|
- **Commit messages in English**, subject and body. The subject is a Conventional Commit (`fix(scope): …`); the body explains cause and consequence in prose.
|
|
19
19
|
- **Plans and ADRs** are written in {{kit.planLanguage}}.
|
|
20
|
+
- **Replies to the user** are in {{kit.responseLanguage}}, addressing the user with {{kit.responseAddress}}. That holds for every answer, every question and every report to the user; it does not change what is written into code, commits or plans.
|
|
20
21
|
|
|
21
22
|
## Naming
|
|
22
23
|
|
|
@@ -6,7 +6,7 @@ argument-hint: 'What to follow, e.g. a ticket key, an issue or a query'
|
|
|
6
6
|
|
|
7
7
|
If `.claude/project/follow.md` exists, read it first. It extends this skill for this project and wins where the two disagree.
|
|
8
8
|
|
|
9
|
-
You add one entry to `watch.sources`, so that `jh watch` reports a new or changed entry of an external service as `=== source <name> …`. You run only reading commands; the one file you write is the project's `.jackherd/config.json` in the main checkout, and only after the user approved the entry. How sources work, their fields and limits: the
|
|
9
|
+
You add one entry to `watch.sources`, so that `jh watch` reports a new or changed entry of an external service as `=== source <name> …`. You run only reading commands; the one file you write is the project's `.jackherd/config.json` in the main checkout, and only after the user approved the entry. How sources work, their fields and limits: the jackherd README, section **Source reports** and **Recipes**, in the installed package (`"$(dirname "$(readlink -f "$(command -v jh)")")/../README.md"`). Read those two sections; do not copy a recipe from memory.
|
|
10
10
|
|
|
11
11
|
## 1. Ask
|
|
12
12
|
|
|
@@ -20,7 +20,7 @@ One `AskUserQuestion` round in {{kit.responseLanguage}}, only for what the argum
|
|
|
20
20
|
|
|
21
21
|
## 2. Check, read only
|
|
22
22
|
|
|
23
|
-
- The tool: `command -v <tool>` for the recipe's program (`curl`, the service's CLI).
|
|
23
|
+
- The tool: `command -v <tool>` for the recipe's program (`curl`, the service's CLI, and `bun` for a recipe with a script).
|
|
24
24
|
- The login: the tool's own status command where it has one. For a token in an environment variable only whether it is set: `test -n "$<VARIABLE>" && echo set || echo missing`. Never print, echo or pass on a token's value, and never read a file that holds one.
|
|
25
25
|
|
|
26
26
|
Anything missing goes to the user as the command to run themselves (`! <command>`); wait until they did, then check again.
|
|
@@ -29,7 +29,14 @@ Anything missing goes to the user as the command to run themselves (`! <command>
|
|
|
29
29
|
|
|
30
30
|
Before filling, read the help of the installed CLI version for every command the recipe calls (`acli jira workitem search --help` and `workitem view --help`, `gh issue list --help`) and confirm that the fields and options the entry needs exist there. Read the provider's web documentation only when the help leaves it open or no recipe fits. A recipe is a starting point; the installed version decides.
|
|
31
31
|
|
|
32
|
-
Fill the recipe with the item or query and the answers from step 1: a `name` of letters, digits, `.`, `_` and `-` that says what is followed, `lane` and `ignoreAuthors` where chosen. A recipe that needs a script (a token
|
|
32
|
+
Fill the recipe with the item or query and the answers from step 1: a `name` of letters, digits, `.`, `_` and `-` that says what is followed, `lane` and `ignoreAuthors` where chosen. A recipe that needs a script (a token from the environment, or one call per item) gets TypeScript run by Bun: write it to the scratchpad first as `<name>.ts`, never into the repository before step 4, and let the entry's `command` be `["bun", "scripts/sources/<name>.ts"]`. The script:
|
|
33
|
+
|
|
34
|
+
- bounds every request and every call of a tool with `AbortSignal.timeout(20_000)`, where a shell recipe would use `--max-time`;
|
|
35
|
+
- reads a token only from `process.env`, and never prints it, passes it as an argument or writes it into an output;
|
|
36
|
+
- ends with `process.exit(1)` when the token is missing, the answer is not 2xx (or carries an `errors` list, as GraphQL does with a 200) or a tool it calls fails, without printing the answer's body, so a failure is an exit code and never an empty list the watcher would take for a source with nothing new;
|
|
37
|
+
- prints one JSON value on stdout and nothing else.
|
|
38
|
+
|
|
39
|
+
A recipe that needs no script (`gh …`, `curl` on a public page, `curl --netrc`) stays a direct command. Write `bun` as the program, never an absolute path to it: the watcher starts a source without a shell and resolves `bun` to the Bun that runs the watcher, and a path in the config would differ from machine to machine.
|
|
33
40
|
|
|
34
41
|
Write the entry as one JSON object to a draft file in the scratchpad and run, from the main checkout:
|
|
35
42
|
|
|
@@ -122,7 +122,8 @@ stay approval-bound (§ 5, § 6).
|
|
|
122
122
|
`git add <paths>`, `git commit -m "docs(plans): add the <topic> plan"` (English, like every commit
|
|
123
123
|
message), `git push -u origin docs/plan-<topic>`, open the PR per `pull-requests.md`, `cd` back,
|
|
124
124
|
and `git worktree remove {{kit.worktreeDirectory}}/docs-plan-<topic>`. The copies in the main
|
|
125
|
-
checkout stay until the PR is merged (§ 6).
|
|
125
|
+
checkout stay until the PR is merged (§ 6). When this PR is merged by self-merge, it needs no check wait if it qualifies under
|
|
126
|
+
**Merging without checks** (§ 5, **Self-merge**, step 2). Answered no: no lane of these plans starts; say why,
|
|
126
127
|
and do not ask again in this run unless the user raises it.
|
|
127
128
|
1. **Collision check.** Compare the lane's `{{kit.heading.writes}}` set and serial markers with every
|
|
128
129
|
running lane. Shared paths run one after the other; migrations, database pushes and bumps of the
|
|
@@ -149,8 +150,12 @@ stay approval-bound (§ 5, § 6).
|
|
|
149
150
|
- **Plugin in place:** `herdr plugin list` shows `zerodice0.worktree-bootstrap` as `enabled`,
|
|
150
151
|
and the main checkout has `.herdr/worktree-copy.list` (for step 3) or
|
|
151
152
|
`.herdr/worktree-setup.json` (for step 4). Skip the step the plugin's file covers. Before
|
|
152
|
-
step 5, check the result: `cmp -s` per env file, and the dependencies
|
|
153
|
-
|
|
153
|
+
step 5, check the result: `cmp -s` per env file, and the dependencies are all there. Run
|
|
154
|
+
`{{kit.lanes.verifyInstall}}` in the worktree, as a pane command with the `-OK`/`-FAIL` marker of step 4.
|
|
155
|
+
It ends with an exit code, so there is nothing to poll; with bun, pnpm and yarn it returns quickly when nothing
|
|
156
|
+
is missing.
|
|
157
|
+
Never look for a directory such as `node_modules/<package>`: some package managers keep a workspace's
|
|
158
|
+
dependencies out of the root. If the command fails, do the step below yourself; if that fails too, report it.
|
|
154
159
|
- **Plugin missing, disabled, or its file absent:** do the step as written below.
|
|
155
160
|
|
|
156
161
|
Copy the untracked files every lane needs (`lanes.copy` in the jackherd config, else what
|
|
@@ -287,7 +292,9 @@ has no value, and `--coordinator <name>` names you. Without either the watcher l
|
|
|
287
292
|
|
|
288
293
|
In a stream each report is printed in one piece, so it arrives as one message, and the watcher
|
|
289
294
|
keeps running. The heartbeat there is one line, `=== heartbeat <time> · <lane> <status> · …`, due
|
|
290
|
-
after that many minutes without a report.
|
|
295
|
+
after that many minutes without a report. A lane that is `working` carries its plan progress after a colon (`<lane> working: P12 7/12`, the current phase and its ticks)
|
|
296
|
+
only when the count moved since the last heartbeat. Pass it on as it is. A working lane without a number
|
|
297
|
+
has not moved (or has no plan): read its screen yourself when that lasts. `lane added: <name>` and `lane removed: <name>` mark a
|
|
291
298
|
change of the found lanes. A lane's agent is watched from the state it shows when it first appears,
|
|
292
299
|
so an agent that starts out idle does not fire; a lane whose agent exits fires as `no longer listed`,
|
|
293
300
|
also when it leaves the found lanes with it.
|
|
@@ -518,16 +525,24 @@ When the user picked `"{{kit.guard.selfMergeLabel}}"` for a PR:
|
|
|
518
525
|
1. Commit, push and open the PR as in step 4. Enter in the board's `{{kit.heading.history}}` that the
|
|
519
526
|
user approved this PR with self-merge (time, lane, PR number), so the approval has a record outside
|
|
520
527
|
the chat.
|
|
521
|
-
2.
|
|
528
|
+
2. **Merging without checks.** The paths a PR may carry to be merged without waiting for checks are
|
|
529
|
+
`{{kit.merge.withoutChecks}}` (`merge.withoutChecks` in the config; `none` means every merge waits).
|
|
530
|
+
Unless `none`, list the PR's files with `gh pr diff <n> --name-only` and compare each one with the
|
|
531
|
+
patterns yourself. When **every** file matches, go straight to step 4: no checks wait, because
|
|
532
|
+
the config says these paths need none. One file outside, an empty list, or a failed `gh` call:
|
|
533
|
+
go on with step 3. The merge guard reads the file list from GitHub itself and lets that
|
|
534
|
+
merge pass without a question only when it agrees; if it asks, the dialog is the user's (step 5).
|
|
535
|
+
This is the rule for the documents of a plan commit as well (§ 2 step 0).
|
|
536
|
+
3. Wait for the checks in the background, never in the foreground (§ 4): a Bash call
|
|
522
537
|
`gh pr checks <n> --watch` with `run_in_background: true`, then work on until it reports.
|
|
523
|
-
|
|
538
|
+
4. Green (or a PR that qualified in step 2): `gh pr merge <n> --squash --delete-branch`, with the plain PR number and without `-R`,
|
|
524
539
|
`--repo` or `--admin`. The merge guard lets exactly this command through for the approved branch,
|
|
525
540
|
for `guard.selfMerge.approvalMinutes` after the answer. Red, or checks that never finish: do not
|
|
526
541
|
merge, report to the user with the failing check and the lane's next step.
|
|
527
|
-
|
|
542
|
+
5. When the guard asks anyway (the approval lapsed, the branch differs, the command carries an
|
|
528
543
|
option it does not cover), the dialog is the user's. Do not confirm it for them and do not look
|
|
529
544
|
for a way around it: tell them what the PR waits for.
|
|
530
|
-
|
|
545
|
+
6. After the merge, one line to the user (PR, merge commit, checks or "no checks: all files match merge.withoutChecks"), then § 6 as for any merge.
|
|
531
546
|
|
|
532
547
|
Self-merge never covers `gh workflow run` (a release, a deploy) or another PR: each of those asks
|
|
533
548
|
the user every time.
|
|
@@ -573,7 +588,9 @@ After its last PR is merged and the user agreed:
|
|
|
573
588
|
locked (`git worktree list --porcelain` shows `locked claude session`) needs `git worktree unlock`
|
|
574
589
|
first; check the session has ended.
|
|
575
590
|
4. Delete the local branch; the remote one is usually gone with the merge (`git ls-remote --heads`).
|
|
576
|
-
5. Record the lane's end in the board's `{{kit.heading.history}}
|
|
591
|
+
5. Record the lane's end in the board's `{{kit.heading.history}}`, and mark every row of the lane by appending
|
|
592
|
+
`, {{kit.word.laneClosed}} <date>` to its name cell, in the same documentation PR as the history entry. The plans view lists a lane without a worktree as waiting until its
|
|
593
|
+
name cell carries that word and its plan has open phases.
|
|
577
594
|
|
|
578
595
|
Close only lanes you started or the user named.
|
|
579
596
|
|
package/package.json
CHANGED
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
"en"
|
|
14
14
|
]
|
|
15
15
|
},
|
|
16
|
+
"responseAddress": {
|
|
17
|
+
"default": "informal",
|
|
18
|
+
"type": "string",
|
|
19
|
+
"enum": [
|
|
20
|
+
"informal",
|
|
21
|
+
"formal"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
16
24
|
"planLanguage": {
|
|
17
25
|
"default": "en",
|
|
18
26
|
"type": "string",
|
|
@@ -486,6 +494,21 @@
|
|
|
486
494
|
},
|
|
487
495
|
"additionalProperties": false
|
|
488
496
|
},
|
|
497
|
+
"merge": {
|
|
498
|
+
"default": {},
|
|
499
|
+
"type": "object",
|
|
500
|
+
"properties": {
|
|
501
|
+
"withoutChecks": {
|
|
502
|
+
"default": [],
|
|
503
|
+
"type": "array",
|
|
504
|
+
"items": {
|
|
505
|
+
"type": "string",
|
|
506
|
+
"pattern": "^[A-Za-z0-9_.*/-]+$"
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
"additionalProperties": false
|
|
511
|
+
},
|
|
489
512
|
"hints": {
|
|
490
513
|
"default": {},
|
|
491
514
|
"type": "object",
|
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { pathToFileURL } from 'node:url'
|
|
3
|
+
import { locateKit } from './sync/kit-source'
|
|
3
4
|
import { CLI_NAME } from './version/release'
|
|
4
5
|
|
|
5
6
|
export type CommandModule = { run: (args: string[]) => Promise<number> | number }
|
|
@@ -8,6 +9,7 @@ export type CommandTable = Record<string, () => Promise<CommandModule>>
|
|
|
8
9
|
|
|
9
10
|
export type Resolution =
|
|
10
11
|
| { kind: 'run'; name: string; args: string[] }
|
|
12
|
+
| { kind: 'version' }
|
|
11
13
|
| { kind: 'usage'; exitCode: number; message: string }
|
|
12
14
|
|
|
13
15
|
/** One line per command; each entry lazily imports the module that exports its `run`. */
|
|
@@ -25,14 +27,25 @@ export const COMMANDS: CommandTable = {
|
|
|
25
27
|
export const resolveCommand = (argv: string[], table: CommandTable): Resolution => {
|
|
26
28
|
const [name, ...args] = argv
|
|
27
29
|
const names = Object.keys(table)
|
|
28
|
-
const usage = `Usage: ${CLI_NAME} <command> [args]\nCommands: ${names.length > 0 ? names.join(', ') : '(none yet)'}`
|
|
30
|
+
const usage = `Usage: ${CLI_NAME} <command> [args]\n ${CLI_NAME} --version\nCommands: ${names.length > 0 ? names.join(', ') : '(none yet)'}`
|
|
31
|
+
if (name === '--version' || name === '-v') return { kind: 'version' }
|
|
29
32
|
if (!name || name === '--help' || name === '-h') return { kind: 'usage', exitCode: name ? 0 : 1, message: usage }
|
|
30
33
|
if (!names.includes(name)) return { kind: 'usage', exitCode: 1, message: `Unknown command: ${name}\n${usage}` }
|
|
31
34
|
return { kind: 'run', name, args }
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
const installedVersion = async (): Promise<string> => (await locateKit()).version
|
|
38
|
+
|
|
39
|
+
export const main = async (
|
|
40
|
+
argv: string[],
|
|
41
|
+
table: CommandTable = COMMANDS,
|
|
42
|
+
readVersion: () => Promise<string> = installedVersion,
|
|
43
|
+
): Promise<number> => {
|
|
35
44
|
const resolution = resolveCommand(argv, table)
|
|
45
|
+
if (resolution.kind === 'version') {
|
|
46
|
+
console.log(await readVersion())
|
|
47
|
+
return 0
|
|
48
|
+
}
|
|
36
49
|
if (resolution.kind === 'usage') {
|
|
37
50
|
console.log(resolution.message)
|
|
38
51
|
return resolution.exitCode
|
package/src/config/detect.ts
CHANGED
|
@@ -88,6 +88,21 @@ const DERIVED_COMMANDS: Record<PackageManager, DerivedCommands> = {
|
|
|
88
88
|
},
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/** The install command of each manager that stops at the lock file instead of changing it. */
|
|
92
|
+
const FROZEN_INSTALL: Record<string, string> = {
|
|
93
|
+
[DERIVED_COMMANDS.bun.install]: 'bun install --frozen-lockfile',
|
|
94
|
+
[DERIVED_COMMANDS.yarn.install]: 'yarn install --immutable',
|
|
95
|
+
[DERIVED_COMMANDS.pnpm.install]: 'pnpm install --frozen-lockfile',
|
|
96
|
+
[DERIVED_COMMANDS.npm.install]: 'npm ci',
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The check that a worktree's dependencies are all there: the manager's install with the lock file frozen, fast when
|
|
101
|
+
* nothing is missing, fetching what is, failing when the lock file and `package.json` disagree. An install command the
|
|
102
|
+
* project set itself is returned as it is, since its flags are not known.
|
|
103
|
+
*/
|
|
104
|
+
export const frozenInstallCommand = (install: string): string => FROZEN_INSTALL[install] ?? install
|
|
105
|
+
|
|
91
106
|
/** The bun defaults of the schema, so a repository Bun runs and one detection skips agree. */
|
|
92
107
|
export const BUN_COMMANDS = DERIVED_COMMANDS.bun
|
|
93
108
|
|
package/src/config/schema.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { checkConfigSchema } from '../check/schema'
|
|
3
3
|
import { claudePath, WORKTREES_DIRECTORY } from '../claude/paths'
|
|
4
|
-
import { PLAN_LANGUAGE } from '../vocabulary'
|
|
4
|
+
import { PLAN_LANGUAGE, RESPONSE_ADDRESS } from '../vocabulary'
|
|
5
5
|
import { SOURCE_FIELD } from '../watch/source-fields'
|
|
6
6
|
import { isSourcePath } from '../watch/source-path'
|
|
7
7
|
import { BUN_COMMANDS, DEFAULT_BASE_BRANCH } from './detect'
|
|
@@ -192,6 +192,32 @@ const guardedCommand = z
|
|
|
192
192
|
.string()
|
|
193
193
|
.regex(/^[a-z][a-z0-9-]*( [a-z][a-z0-9-]*){1,3}$/, 'a program and one to three plain words, such as gh pr merge')
|
|
194
194
|
|
|
195
|
+
/**
|
|
196
|
+
* A path pattern for a pull request that needs no checks: plain path words with wildcards, at least one whole path
|
|
197
|
+
* segment without a wildcard that starts with a letter or digit (a docs folder, then wildcards, then a file ending, but
|
|
198
|
+
* not wildcards alone nor any pattern whose only literal is a dot), no `..`, not absolute. Braces, classes and
|
|
199
|
+
* negation stay out, so the hook, which has no schema library, reads exactly what this accepts.
|
|
200
|
+
*/
|
|
201
|
+
const checkFreePattern = z
|
|
202
|
+
.string()
|
|
203
|
+
.regex(/^[A-Za-z0-9_.*/-]+$/, 'path words with * and ** only, such as docs/**/*.md')
|
|
204
|
+
.refine(
|
|
205
|
+
(pattern) => !pattern.startsWith('/') && !pattern.split('/').includes('..'),
|
|
206
|
+
'a relative path that stays inside the repository',
|
|
207
|
+
)
|
|
208
|
+
.refine(
|
|
209
|
+
(pattern) => pattern.split('/').some((segment) => /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(segment)),
|
|
210
|
+
'a pattern with a whole path segment that is no wildcard, such as docs/**/*.md',
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
const mergeSchema = z.strictObject({
|
|
214
|
+
/**
|
|
215
|
+
* Paths of pull requests that are merged without waiting for checks: when every file of the pull request matches one
|
|
216
|
+
* of them, `/orchestrate` merges at once and the merge guard does not ask. Empty means every merge waits for checks.
|
|
217
|
+
*/
|
|
218
|
+
withoutChecks: z.array(checkFreePattern).default([]),
|
|
219
|
+
})
|
|
220
|
+
|
|
195
221
|
const guardSchema = z.strictObject({
|
|
196
222
|
/**
|
|
197
223
|
* The commands the merge guard hook asks the user about before they run, also behind options before the verb, a chain,
|
|
@@ -231,6 +257,8 @@ const contextSchema = z
|
|
|
231
257
|
export const jackherdConfigSchema = z.strictObject({
|
|
232
258
|
$schema: z.string().optional(),
|
|
233
259
|
responseLanguage: language.default(PLAN_LANGUAGE.en),
|
|
260
|
+
/** Whether replies address the user with the familiar or the polite form where the language has both (German: du or Sie). */
|
|
261
|
+
responseAddress: z.enum(Object.values(RESPONSE_ADDRESS)).default(RESPONSE_ADDRESS.informal),
|
|
234
262
|
planLanguage: language.default(PLAN_LANGUAGE.en),
|
|
235
263
|
planDirectory: z.string().min(1).default('docs/plans'),
|
|
236
264
|
/** Where the boards live and what the project calls them. */
|
|
@@ -307,6 +335,8 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
307
335
|
.prefault({}),
|
|
308
336
|
/** Read by the merge guard hook, which asks before the listed commands run, and by `/orchestrate`. */
|
|
309
337
|
guard: guardSchema.prefault({}),
|
|
338
|
+
/** Read by `/orchestrate` and by the merge guard hook. */
|
|
339
|
+
merge: mergeSchema.prefault({}),
|
|
310
340
|
/** Read by the `ask-hint` hook. */
|
|
311
341
|
hints: hintsSchema.prefault({}),
|
|
312
342
|
/** Read by `jh check`; validated here too, so a typo fails every command alike. */
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { LaneProgress, SprintLane } from '../plan-format'
|
|
2
|
+
import { PLAN_VOCABULARY } from '../vocabulary'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `active` lanes are drawn in full; `noAgent` is a worktree nobody works in, still listed; `waiting` has no
|
|
6
|
+
* worktree and still something to do; `closed` has nothing left and is not drawn.
|
|
7
|
+
*/
|
|
8
|
+
export const LANE_STANDING = { active: 'active', noAgent: 'no-agent', waiting: 'waiting', closed: 'closed' } as const
|
|
9
|
+
export type LaneStanding = (typeof LANE_STANDING)[keyof typeof LANE_STANDING]
|
|
10
|
+
|
|
11
|
+
type StandingInput = {
|
|
12
|
+
progress: Pick<LaneProgress, 'lane' | 'done' | 'total'>
|
|
13
|
+
status: { agent: string | null; branch: string | null }
|
|
14
|
+
pullRequests: readonly unknown[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const NO_STARTS_AFTER = new Set(['', '-', '–', '—'])
|
|
18
|
+
|
|
19
|
+
/** The starts-after cell of the lane's newest row that has one, trimmed. */
|
|
20
|
+
const startsAfterOf = ({ steps }: SprintLane): string =>
|
|
21
|
+
steps
|
|
22
|
+
.map((step) => step.startsAfter.trim())
|
|
23
|
+
.reverse()
|
|
24
|
+
.find((cell) => !NO_STARTS_AFTER.has(cell)) ?? ''
|
|
25
|
+
|
|
26
|
+
const STARTS_NOW = Object.values(PLAN_VOCABULARY).flatMap(({ startsNow }) => startsNow)
|
|
27
|
+
|
|
28
|
+
/** Every language's word counts: the language a view answers in is not the language the board is written in. */
|
|
29
|
+
const startsNow = (cell: string): boolean => {
|
|
30
|
+
const lowered = cell.toLowerCase()
|
|
31
|
+
return STARTS_NOW.some(
|
|
32
|
+
(word) => lowered === word || (lowered.startsWith(word) && /^\W/.test(lowered.slice(word.length))),
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What a waiting lane waits for: its starts-after text as the board writes it; null when it may start at once. */
|
|
37
|
+
export const waitingNote = (lane: SprintLane): string | null => {
|
|
38
|
+
const cell = startsAfterOf(lane)
|
|
39
|
+
return cell === '' || startsNow(cell) ? null : cell
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const hasOpenWork = (progress: StandingInput['progress']): boolean => {
|
|
43
|
+
if (progress.total > 0) return progress.done < progress.total
|
|
44
|
+
return waitingNote(progress.lane) !== null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Where a lane stands on the board. The cleaned-up mark goes first: it closes a lane, unless an agent, a worktree
|
|
49
|
+
* or a pull request is still there, which keeps it listed. Then an agent or an open pull request makes a lane
|
|
50
|
+
* active and a worktree alone lists it. Without a worktree a lane waits while it has open work, else it is closed.
|
|
51
|
+
*/
|
|
52
|
+
export const laneStanding = (input: StandingInput): LaneStanding => {
|
|
53
|
+
const { progress, status, pullRequests } = input
|
|
54
|
+
const working = status.agent !== null || pullRequests.length > 0
|
|
55
|
+
if (progress.lane.closed) return working || status.branch !== null ? LANE_STANDING.noAgent : LANE_STANDING.closed
|
|
56
|
+
if (working) return LANE_STANDING.active
|
|
57
|
+
if (status.branch !== null) return LANE_STANDING.noAgent
|
|
58
|
+
return hasOpenWork(progress) ? LANE_STANDING.waiting : LANE_STANDING.closed
|
|
59
|
+
}
|
package/src/plans/layout.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
type WatchBadges,
|
|
29
29
|
type WatchHealth,
|
|
30
30
|
} from '../watch/plan-badges'
|
|
31
|
+
import { LANE_STANDING, type LaneStanding, laneStanding, waitingNote } from './lane-standing'
|
|
31
32
|
import { chipKey, type LinkChip } from './link-chips'
|
|
32
33
|
import { type PlannedPlan, phaseList, plannedPhaseCount } from './planned'
|
|
33
34
|
import { CHECKS, type ChecksState, type PullRequest, pullRequestKey } from './pull-requests'
|
|
@@ -753,18 +754,27 @@ const laneBlock = (
|
|
|
753
754
|
return joinFrames([row, ...view.pullRequests.map((pullRequest) => pullRequestFrame(pullRequest, context)), content])
|
|
754
755
|
}
|
|
755
756
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
progress.lane.closed || (status.agent === null && pullRequests.length === 0)
|
|
757
|
+
const laneTally = ({ progress }: Pick<LaneView, 'progress'>): string =>
|
|
758
|
+
progress.total > 0 ? `${progress.lane.name} ${progress.done}/${progress.total}` : progress.lane.name
|
|
759
759
|
|
|
760
|
-
const parkedItem = (
|
|
761
|
-
const
|
|
762
|
-
const
|
|
763
|
-
const label =
|
|
764
|
-
progress.total > 0 ? `${name} ${progress.done}/${progress.total}` : hasPlan ? name : `${name} (${TEXTS.noPlan})`
|
|
760
|
+
const parkedItem = (view: Pick<LaneView, 'progress'>): string => {
|
|
761
|
+
const hasPlan = view.progress.lane.steps.some((step) => step.planHref)
|
|
762
|
+
const label = view.progress.total > 0 || hasPlan ? laneTally(view) : `${laneTally(view)} (${TEXTS.noPlan})`
|
|
765
763
|
return label.replaceAll(' ', NON_BREAKING_SPACE)
|
|
766
764
|
}
|
|
767
765
|
|
|
766
|
+
/** ` name 2/5 after P1 gemergt`: one dimmed row per lane that waits, its wait cut at the pane's edge. */
|
|
767
|
+
const waitingBlock = (lanes: LaneView[], width: number): string[] => {
|
|
768
|
+
if (lanes.length === 0) return []
|
|
769
|
+
const head = paint.dim(`${NO_AGENT_SYMBOL} ${TEXTS.waiting(lanes.length)}`)
|
|
770
|
+
const rows = lanes.map((lane) => {
|
|
771
|
+
const note = waitingNote(lane.progress.lane)
|
|
772
|
+
const item = laneTally(lane)
|
|
773
|
+
return paint.dim(fit(note === null ? ` ${item}` : ` ${item} ${TEXTS.startsAfter(plain(note))}`, width))
|
|
774
|
+
})
|
|
775
|
+
return [head, ...rows]
|
|
776
|
+
}
|
|
777
|
+
|
|
768
778
|
/** `· no agent (6) name 3/9 · other 1/4`: one wrapping block for every parked lane, each with its progress. */
|
|
769
779
|
const parkedBlock = (lanes: LaneView[], width: number): string[] => {
|
|
770
780
|
if (lanes.length === 0) return []
|
|
@@ -837,19 +847,29 @@ const laneColumnsOf = (lanes: LaneView[]): LaneColumns => ({
|
|
|
837
847
|
right: Math.max(0, ...lanes.map((lane) => visibleLength(laneRight(lane)))),
|
|
838
848
|
})
|
|
839
849
|
|
|
850
|
+
/** The lanes by what the view does with them; closed lanes are in none of the three groups. */
|
|
851
|
+
const partitionLanes = (lanes: LaneView[]): Record<'active' | 'waiting' | 'noAgent', LaneView[]> => {
|
|
852
|
+
const standing = (wanted: LaneStanding) => lanes.filter((lane) => laneStanding(lane) === wanted)
|
|
853
|
+
return {
|
|
854
|
+
active: standing(LANE_STANDING.active),
|
|
855
|
+
waiting: standing(LANE_STANDING.waiting),
|
|
856
|
+
noAgent: standing(LANE_STANDING.noAgent),
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
840
860
|
/**
|
|
841
861
|
* Title and clock on top, the key hint at the bottom (with the legend after `?`), and in between one
|
|
842
862
|
* block per lane: the lane line with its total bar, and for an open lane the phase rows. Folded lanes are
|
|
843
|
-
* one line each
|
|
844
|
-
* agent share
|
|
863
|
+
* one line each and every lane block after the first is preceded by a dimmed rule. Lanes that wait and
|
|
864
|
+
* lanes with a worktree but no agent each share a closing block; finished lanes are not drawn.
|
|
845
865
|
*/
|
|
846
866
|
export const renderScreen = (
|
|
847
867
|
{ title, time, lanes, planned, watch, language = PLAN_LANGUAGE.en }: OverviewView,
|
|
848
868
|
state: Selection,
|
|
849
869
|
width: number,
|
|
850
870
|
): Screen => {
|
|
851
|
-
const active
|
|
852
|
-
const parked =
|
|
871
|
+
const { active, waiting, noAgent } = partitionLanes(lanes)
|
|
872
|
+
const parked = [...waitingBlock(waiting, width), ...parkedBlock(noAgent, width)]
|
|
853
873
|
const columns = laneColumnsOf(active)
|
|
854
874
|
const badgeTexts = BADGE_TEXTS[language]
|
|
855
875
|
const blocks = active.map((lane) => laneBlock(lane, state, width, columns, badgeTexts))
|
package/src/plans/texts.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { NO_AGENT_WORD, PHASE_COUNT } from './symbols'
|
|
|
8
8
|
*/
|
|
9
9
|
export type PlanTexts = {
|
|
10
10
|
noAgent: string
|
|
11
|
+
waiting: (lanes: number) => string
|
|
12
|
+
startsAfter: (wait: string) => string
|
|
11
13
|
noPlan: string
|
|
12
14
|
noWorktree: string
|
|
13
15
|
noRow: string
|
|
@@ -70,6 +72,8 @@ export const boardTexts = (term: string) => ({
|
|
|
70
72
|
|
|
71
73
|
export const TEXTS: PlanTexts = {
|
|
72
74
|
noAgent: NO_AGENT_WORD,
|
|
75
|
+
waiting: (lanes) => `waiting (${lanes})`,
|
|
76
|
+
startsAfter: (wait) => `after ${wait}`,
|
|
73
77
|
noPlan: 'no plan',
|
|
74
78
|
noWorktree: 'no worktree',
|
|
75
79
|
noRow: 'no row in lanes',
|
|
@@ -94,7 +98,7 @@ export const TEXTS: PlanTexts = {
|
|
|
94
98
|
hint: '↑↓ select →← open a all n notes s html q quit ? help',
|
|
95
99
|
legend: [
|
|
96
100
|
'enter/space or click toggles a lane or a phase, click or enter on a task opens its whole text, and opens a ↗ pull request · wheel PgUp PgDn scroll · j k h l like the arrows · click or ctrl+click on a 📎 chip opens its link · ✦ artifact · ⑂ pull request or issue · ◈ Figma · private = the plan says so',
|
|
97
|
-
'⚡ working · ❓ blocked, waits for you · 📨 done = the agent finished its answer, not the lane · ⏸ idle · · no agent · working and blocked lanes open by themselves, → ← overrule until the status changes',
|
|
101
|
+
'⚡ working · ❓ blocked, waits for you · 📨 done = the agent finished its answer, not the lane · ⏸ idle · · no agent = a worktree nobody works in · waiting = not started: no worktree yet, something left to do, and what it starts after; finished lanes (cleaned up, or every named phase ticked) are not shown · working and blocked lanes open by themselves, → ← overrule until the status changes',
|
|
98
102
|
`n/m = m checkboxes of the phase, n ticked (the phase being worked on is yellow; ticked, not merged = all ticked in the worktree, not on the base branch yet) · ${PHASE_COUNT.allTicked} n/n all ticked · ${PHASE_COUNT.empty} no checkboxes · ${PHASE_COUNT.missing} not in the plan · n shows or hides the notes of a lane · s opens every lane and plan as one HTML page · ⎇ branch · ↑ commits ahead · ± uncommitted · no worktree: plan read from main`,
|
|
99
103
|
],
|
|
100
104
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const ancestorsOf = (path: string): string[] => {
|
|
2
|
+
const segments = path.split('/').slice(0, -1)
|
|
3
|
+
return segments.map((_, index) => segments.slice(0, index + 1).join('/'))
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const depth = (path: string): number => path.split('/').length
|
|
7
|
+
|
|
8
|
+
const deepestFirst = (left: string, right: string): number => depth(right) - depth(left) || left.localeCompare(right)
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The directories a sync may clean up after it removed `removed`: every directory above those files, deepest first,
|
|
12
|
+
* except the ones a file in `kept` still lies in. The lock proves these directories were the kit's, since the kit
|
|
13
|
+
* wrote a file into each of them; whether one is really empty is for the file system to say.
|
|
14
|
+
*/
|
|
15
|
+
export const emptiedDirectories = (removed: readonly string[], kept: readonly string[]): string[] => {
|
|
16
|
+
const stillUsed = new Set(kept.flatMap(ancestorsOf))
|
|
17
|
+
const candidates = new Set(removed.flatMap(ancestorsOf))
|
|
18
|
+
return [...candidates].filter((directory) => !stillUsed.has(directory)).sort(deepestFirst)
|
|
19
|
+
}
|