jackherd 1.4.0 → 1.5.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 +7 -0
- package/README.md +105 -36
- package/dist/{chunk-UI3AZNZF.js → chunk-HQAONP4W.js} +2 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +17 -1
- package/dist/plan-format.d.ts +2 -0
- package/dist/plan-format.js +1 -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/workflow/skills/follow/SKILL.md +10 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +17 -6
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +15 -0
- package/src/config/schema.ts +28 -0
- 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/render.ts +6 -1
- package/src/vocabulary/index.ts +4 -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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
What changed for people who use jackherd, newest first.
|
|
4
4
|
|
|
5
|
+
## 1.5.0
|
|
6
|
+
|
|
7
|
+
- **The plans view lists only the lanes that still have something to do.** Before, every lane without an agent was listed (56 of 61 on one board, nearly all of them finished). Now a lane marked cleaned up, and a lane without a worktree whose named phases are all ticked, are not shown. A lane without a worktree that still has an open phase, or no plan and a "starts after" other than "now", is listed under `· waiting` with what it waits for. A worktree without an agent stays under `· no agent`. The board needs no change. The watcher is unchanged.
|
|
8
|
+
- **Documents merge without waiting for checks.** The new setting `merge.withoutChecks` (default empty) lists path patterns such as `docs/**/*.md`. When every file of a pull request matches, `/orchestrate` merges at once instead of `gh pr checks --watch`, and the merge guard does not ask for a plain `gh pr merge <number>`: it checks the file list on GitHub itself and asks as before on any doubt. Pattern rules are in the README.
|
|
9
|
+
- **The heartbeat shows progress.** In a stream, a `working` lane whose phase count moved since the last heartbeat becomes `<lane> working: P12 7/12`; a lane that did not move adds nothing. The heartbeat line stays short.
|
|
10
|
+
- **Source scripts are TypeScript.** The `follow` skill now writes a source's script as `scripts/sources/<name>.ts`, started with `["bun", "scripts/sources/<name>.ts"]` (Bun must be installed). The token goes from `process.env` into the request header, and a failure is an exit code, not an empty list. The README recipes for Jira with `acli`, Linear and Sentry are replaced, and a test compiles each one. The watcher reads `bun` as a source's program to mean the Bun that runs it, so Bun need not be on the `PATH`. Existing `watch.sources` entries keep working; recipes without a script stay direct commands.
|
|
11
|
+
|
|
5
12
|
## 1.4.0
|
|
6
13
|
|
|
7
14
|
- **A question in running text gets a hint.** The new `Stop` hook `ask-hint` tells you when the last message of a turn ends in `?` and no `AskUserQuestion` dialog was used. It only hints; the turn ends as before. A lane session stays silent, because a lane asks its coordinator as a text block. Switch it off with `"hints": { "askUserQuestion": false }` in `.jackherd/config.json`. `jh sync` brings the hook to a project install; a global install ships none.
|
package/README.md
CHANGED
|
@@ -531,6 +531,16 @@ A merge or a release is the step nobody takes back. In a project install the `me
|
|
|
531
531
|
- `guard.selfMerge.offer` (default `true`) lets `/orchestrate` offer "approve, and merge it yourself after green checks" in its PR question. With `false` the question has two options and the hook honours no such answer.
|
|
532
532
|
- `guard.selfMerge.approvalMinutes` (default 180) is how long that answer lets `gh pr merge <number>` pass without a second dialog.
|
|
533
533
|
|
|
534
|
+
**Merging without checks.** A pull request that only changes documents rarely needs its checks. List the paths in `merge.withoutChecks` (default empty, so every merge waits for the checks as before):
|
|
535
|
+
|
|
536
|
+
```json
|
|
537
|
+
{
|
|
538
|
+
"merge": { "withoutChecks": ["docs/**/*.md"] }
|
|
539
|
+
}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
A pattern is made of path words with `*` and `**`: no `..`, not absolute, no braces or negation, and at least one whole path segment without a wildcard, so `docs/**/*.md` is fine and `**`, `**/*.md` and `**/*.*` are refused. Keep the folder in the pattern: a `.md` file elsewhere can be an agent instruction (`CLAUDE.md`, a skill) that ships with a package and deserves its checks. With the list set, `/orchestrate` lists a pull request's files with `gh pr diff <number> --name-only` before it waits for checks and merges at once when every file matches. The merge guard checks the same itself: for a plain `gh pr merge <number>` it asks GitHub for the pull request and then for its files in that repository (`gh api …/pulls/<number>/files`, which also names the old path of a renamed file) and does not ask you when the pull request is open, from this repository, GitHub lists as many files as the pull request changes (fewer than 3000) and every one matches a pattern. This works without a self-merge answer, also when `guard.selfMerge.offer` is `false`. No file, one file outside, a count that differs, a failed `gh` call or a pattern list it cannot read means it asks as before. Limit: a push between its look and the merge is not seen.
|
|
543
|
+
|
|
534
544
|
**What self-merge does.** You pick the second option of the PR question. The coordinator commits, pushes and opens the PR, waits for the checks in the background, merges with `gh pr merge <number> --squash --delete-branch` when they are green and tells you in one line; on a red check it does not merge and reports. The project supplement can block the option per approval tier.
|
|
535
545
|
|
|
536
546
|
**What the hook checks.** It does not take the agent's word for your answer. It reads the session's transcript, which Claude Code writes, and looks for a dialog result where you picked that exact option for a question naming one branch. Then it asks GitHub which branch pull request `<number>` carries and lets the merge pass if the two match, the PR is open and not from a fork, and the command is exactly the plain form: `gh pr merge <number>` with `--squash`, `--merge`, `--rebase` and `--delete-branch` as the only flags, run on its own in the directory you work in (no `-R`, no `--admin`, no chain, no wrapper). `gh workflow run` and every other guarded command ask every time.
|
|
@@ -580,7 +590,7 @@ coordinator coordinator no value (no agent named coordinator)
|
|
|
580
590
|
gh unavailable since 11:24:31
|
|
581
591
|
```
|
|
582
592
|
|
|
583
|
-
As a stream (`--stream`) it keeps running, and the heartbeat is one line:
|
|
593
|
+
As a stream (`--stream`) it keeps running, and the heartbeat is one line. A working lane shows its plan progress, `<lane> working: P12 7/12` (the lane's current phase and its ticked checkboxes, counted as the plans view counts them), but only when the count moved since the last heartbeat; a lane that stays on one number says nothing, and you look for yourself. The plans are read only when a lane works:
|
|
584
594
|
|
|
585
595
|
```text
|
|
586
596
|
armed: search=no agent board docs/plans/board-search/board.md
|
|
@@ -710,36 +720,70 @@ Three limits stand: the watcher cannot make a command read only, so name only co
|
|
|
710
720
|
|
|
711
721
|
##### Recipes
|
|
712
722
|
|
|
713
|
-
Each recipe is one `watch.sources` entry; put in your key, project or query, then try it with `--dry-run` (and `--raw` where the shape differs from what is shown). A token never goes into an argument: an argument shows in the process list. Where a tool keeps its own login, it is used; where a token sits in an environment variable, a small script
|
|
714
|
-
|
|
715
|
-
**Jira with the Atlassian CLI.** Login: `acli jira auth login`, kept by `acli`. `acli jira workitem search` cannot return `updated` or `comment`, so a script searches for the keys and reads each one with `view`, a few at a time to stay well under the 30 seconds a source may run.
|
|
723
|
+
Each recipe is one `watch.sources` entry; put in your key, project or query, then try it with `--dry-run` (and `--raw` where the shape differs from what is shown). A token never goes into an argument: an argument shows in the process list. Where a tool keeps its own login, it is used; where a token sits in an environment variable, a small TypeScript script run by Bun (which runs wherever `jh` does) puts it from `process.env` straight into the request header. A script fails with an exit code, never with an empty answer the watcher would take for a source with nothing new, and it prints neither the token nor the body of a failed answer. The closing `export {}` makes the file a module, so the type check accepts top-level `await` and the names do not clash between scripts. A recipe that needs no script (`gh …`, `curl` on a public page, `curl --netrc`) stays a direct command. The watcher starts a source without a shell and reads `bun` as the first word to mean the Bun that runs the watcher itself, so the entry needs no absolute path, which would differ from machine to machine, even when Bun is not on the `PATH` (installed in `~/.bun/bin` only, say).
|
|
724
|
+
|
|
725
|
+
**Jira with the Atlassian CLI.** Login: `acli jira auth login`, kept by `acli`. `acli jira workitem search` cannot return `updated` or `comment`, so a script searches for the keys and reads each one with `view`, a few at a time to stay well under the 30 seconds a source may run. `plainText` joins the text and mention nodes of the comment's document tree into plain text; it is a pure function, so you can test it on its own. The script, for example `scripts/sources/jira.ts`, with your project in the query and `<host>` your `<site>.atlassian.net`:
|
|
726
|
+
|
|
727
|
+
```ts
|
|
728
|
+
// scripts/sources/jira.ts
|
|
729
|
+
const jql = 'project = PROJ AND assignee = currentUser() AND statusCategory != Done'
|
|
730
|
+
const host = '<host>'
|
|
731
|
+
const parallel = 6
|
|
732
|
+
|
|
733
|
+
type DocumentNode = { type?: string; text?: string; attrs?: { text?: string }; content?: DocumentNode[] }
|
|
734
|
+
type IssueComment = { author?: { displayName?: string }; body?: DocumentNode }
|
|
735
|
+
type Issue = { key: string; fields: { updated: string; summary: string; comment?: { comments: IssueComment[] } } }
|
|
736
|
+
|
|
737
|
+
const plainText = (node: DocumentNode): string =>
|
|
738
|
+
node.type === 'text' || node.type === 'mention'
|
|
739
|
+
? (node.text ?? node.attrs?.text ?? '')
|
|
740
|
+
: (node.content ?? []).map(plainText).join('')
|
|
741
|
+
|
|
742
|
+
const acli = async (args: string[]): Promise<unknown> => {
|
|
743
|
+
const child = Bun.spawn(['acli', ...args], { stdout: 'pipe', stderr: 'ignore', signal: AbortSignal.timeout(20_000) })
|
|
744
|
+
const output = await new Response(child.stdout).text()
|
|
745
|
+
if ((await child.exited) !== 0) process.exit(1)
|
|
746
|
+
try {
|
|
747
|
+
return JSON.parse(output)
|
|
748
|
+
} catch {
|
|
749
|
+
return process.exit(1)
|
|
750
|
+
}
|
|
751
|
+
}
|
|
716
752
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
jql='project = PROJ AND assignee = currentUser() AND statusCategory != Done'
|
|
721
|
-
acli jira workitem search --jql "$jql" --limit 100 --json |
|
|
722
|
-
jq -r '.[].key' |
|
|
723
|
-
xargs -r -P 6 -I{} acli jira workitem view {} --fields summary,updated,comment --json |
|
|
724
|
-
jq -s 'map({
|
|
753
|
+
const entryOf = ({ key, fields }: Issue) => {
|
|
754
|
+
const latest = fields.comment?.comments.at(-1)
|
|
755
|
+
return {
|
|
725
756
|
key,
|
|
726
|
-
url:
|
|
727
|
-
updated:
|
|
728
|
-
summary:
|
|
729
|
-
author:
|
|
730
|
-
body: (.
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
757
|
+
url: `https://${host}/browse/${key}`,
|
|
758
|
+
updated: fields.updated,
|
|
759
|
+
summary: fields.summary,
|
|
760
|
+
author: latest?.author?.displayName ?? null,
|
|
761
|
+
body: latest?.body ? plainText(latest.body) : null,
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const keys = ((await acli(['jira', 'workitem', 'search', '--jql', jql, '--limit', '100', '--json'])) as { key: string }[]).map(
|
|
766
|
+
({ key }) => key,
|
|
767
|
+
)
|
|
768
|
+
const entries = []
|
|
769
|
+
for (let start = 0; start < keys.length; start += parallel) {
|
|
770
|
+
const batch = keys.slice(start, start + parallel)
|
|
771
|
+
const issues = await Promise.all(
|
|
772
|
+
batch.map((key) => acli(['jira', 'workitem', 'view', key, '--fields', 'summary,updated,comment', '--json'])),
|
|
773
|
+
)
|
|
774
|
+
entries.push(...(issues as Issue[]).map(entryOf))
|
|
775
|
+
}
|
|
776
|
+
console.log(JSON.stringify(entries))
|
|
777
|
+
|
|
778
|
+
export {}
|
|
735
779
|
```
|
|
736
780
|
|
|
737
|
-
|
|
781
|
+
A failed `acli` call ends the script with exit code 1, which the watcher reports as a failing source; nothing is printed that it could take for an empty list.
|
|
738
782
|
|
|
739
783
|
```json
|
|
740
784
|
{
|
|
741
785
|
"name": "tickets-decisions",
|
|
742
|
-
"command": ["
|
|
786
|
+
"command": ["bun", "scripts/sources/jira.ts"],
|
|
743
787
|
"map": {
|
|
744
788
|
"id": ".key",
|
|
745
789
|
"updatedAt": ".updated",
|
|
@@ -768,19 +812,34 @@ acli jira workitem search --jql "$jql" --limit 100 --json |
|
|
|
768
812
|
}
|
|
769
813
|
```
|
|
770
814
|
|
|
771
|
-
**Linear over GraphQL.** Token: a personal API key in `LINEAR_API_KEY`, sent as `Authorization: <key>` without `Bearer`. The script, for example `scripts/sources/linear.
|
|
815
|
+
**Linear over GraphQL.** Token: a personal API key in `LINEAR_API_KEY`, sent as `Authorization: <key>` without `Bearer`. The script, for example `scripts/sources/linear.ts`, with `<host>` the API host `api.linear.app`. Linear answers a bad key or query with HTTP 200 and an `errors` list, so the script checks for it as well:
|
|
772
816
|
|
|
773
|
-
```
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
817
|
+
```ts
|
|
818
|
+
// scripts/sources/linear.ts
|
|
819
|
+
const token = process.env.LINEAR_API_KEY
|
|
820
|
+
if (!token) process.exit(1)
|
|
821
|
+
|
|
822
|
+
const query = `{ issues(filter: { team: { key: { eq: "ENG" } }, number: { eq: 123 } }) {
|
|
823
|
+
nodes { identifier title url updatedAt comments { nodes { body user { name } } } } } }`
|
|
824
|
+
|
|
825
|
+
const response = await fetch('https://<host>/graphql', {
|
|
826
|
+
method: 'POST',
|
|
827
|
+
headers: { Authorization: token, 'Content-Type': 'application/json' },
|
|
828
|
+
body: JSON.stringify({ query }),
|
|
829
|
+
signal: AbortSignal.timeout(20_000),
|
|
830
|
+
})
|
|
831
|
+
if (!response.ok) process.exit(1)
|
|
832
|
+
const answer = (await response.json()) as { errors?: unknown }
|
|
833
|
+
if (answer.errors) process.exit(1)
|
|
834
|
+
console.log(JSON.stringify(answer))
|
|
835
|
+
|
|
836
|
+
export {}
|
|
778
837
|
```
|
|
779
838
|
|
|
780
839
|
```json
|
|
781
840
|
{
|
|
782
841
|
"name": "linear-eng-123",
|
|
783
|
-
"command": ["
|
|
842
|
+
"command": ["bun", "scripts/sources/linear.ts"],
|
|
784
843
|
"items": ".data.issues.nodes",
|
|
785
844
|
"map": {
|
|
786
845
|
"id": ".identifier",
|
|
@@ -813,19 +872,28 @@ Check with `--raw` which end of `comments.nodes` holds the newest comment; `[0]`
|
|
|
813
872
|
}
|
|
814
873
|
```
|
|
815
874
|
|
|
816
|
-
**Sentry.** Token: an auth token with `event:read` in `SENTRY_AUTH_TOKEN`; `<host>` is `sentry.io` or your own. The script, for example `scripts/sources/sentry.
|
|
875
|
+
**Sentry.** Token: an auth token with `event:read` in `SENTRY_AUTH_TOKEN`; `<host>` is `sentry.io` or your own. The script, for example `scripts/sources/sentry.ts`:
|
|
876
|
+
|
|
877
|
+
```ts
|
|
878
|
+
// scripts/sources/sentry.ts
|
|
879
|
+
const token = process.env.SENTRY_AUTH_TOKEN
|
|
880
|
+
if (!token) process.exit(1)
|
|
881
|
+
|
|
882
|
+
const response = await fetch(
|
|
883
|
+
'https://<host>/api/0/projects/<org>/<project>/issues/?query=is:unresolved&statsPeriod=24h',
|
|
884
|
+
{ headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(20_000) },
|
|
885
|
+
)
|
|
886
|
+
if (!response.ok) process.exit(1)
|
|
887
|
+
console.log(JSON.stringify(await response.json()))
|
|
817
888
|
|
|
818
|
-
|
|
819
|
-
#!/bin/sh
|
|
820
|
-
printf 'Authorization: Bearer %s\n' "$SENTRY_AUTH_TOKEN" |
|
|
821
|
-
curl -fsS --max-time 20 -H @- "https://<host>/api/0/projects/<org>/<project>/issues/?query=is:unresolved&statsPeriod=24h"
|
|
889
|
+
export {}
|
|
822
890
|
```
|
|
823
891
|
|
|
824
892
|
```json
|
|
825
893
|
{
|
|
826
894
|
"name": "errors",
|
|
827
895
|
"every": 10,
|
|
828
|
-
"command": ["
|
|
896
|
+
"command": ["bun", "scripts/sources/sentry.ts"],
|
|
829
897
|
"map": { "id": ".id", "updatedAt": ".lastSeen", "title": ".title", "url": ".permalink", "body": ".culprit" }
|
|
830
898
|
}
|
|
831
899
|
```
|
|
@@ -887,6 +955,7 @@ The herdr plugin `zerodice0.worktree-bootstrap`, when enabled and configured in
|
|
|
887
955
|
One block per lane of the chosen board, lanes that wait on you first:
|
|
888
956
|
|
|
889
957
|
- the agent's state: `❓ blocked` (waits for you), `📨 done` (the agent finished its answer, not the lane), `⏸ idle`, `⚡ working`, or `·` no agent
|
|
958
|
+
- which lanes are listed at all. A lane with an agent or an open pull request is drawn in full. A lane with a worktree but no agent is listed under `· no agent`. A lane without a worktree is listed under `· waiting`, with what it starts after, when a phase it names is still open in its plan or, if it names no plan, when its "starts after" cell says something other than "now" (`sofort`); otherwise it is finished and not shown. A lane whose name cell carries the cleaned-up mark (`abgeräumt`) is finished whatever its plan says. The board needs no extra column for this
|
|
890
959
|
- how full the agent's context is, and a bar with the lane's ticked tasks
|
|
891
960
|
- the branch, commits ahead and uncommitted files
|
|
892
961
|
- the lane's open pull request with its checks
|
|
@@ -35,6 +35,7 @@ var PLAN_VOCABULARY = {
|
|
|
35
35
|
phase: "Phase",
|
|
36
36
|
noReviewGate: "Kein Review-Gate",
|
|
37
37
|
laneClosed: "abger\xE4umt",
|
|
38
|
+
startsNow: ["sofort"],
|
|
38
39
|
phaseConnectors: ["danach", "und", "dann", "sowie"],
|
|
39
40
|
statusWords: {
|
|
40
41
|
done: { label: "Done", leading: ["done"], anywhere: ["abgeschlossen"] },
|
|
@@ -62,6 +63,7 @@ var PLAN_VOCABULARY = {
|
|
|
62
63
|
phase: "Phase",
|
|
63
64
|
noReviewGate: "No review gate",
|
|
64
65
|
laneClosed: "cleaned up",
|
|
66
|
+
startsNow: ["now", "immediately"],
|
|
65
67
|
phaseConnectors: ["then", "and", "after"],
|
|
66
68
|
statusWords: {
|
|
67
69
|
done: { label: "Done", leading: ["done"], anywhere: ["completed"] },
|
package/dist/config.d.ts
CHANGED
|
@@ -139,6 +139,9 @@ declare const jackherdConfigSchema: z.ZodObject<{
|
|
|
139
139
|
approvalMinutes: z.ZodDefault<z.ZodNumber>;
|
|
140
140
|
}, z.core.$strict>>;
|
|
141
141
|
}, z.core.$strict>>;
|
|
142
|
+
merge: z.ZodPrefault<z.ZodObject<{
|
|
143
|
+
withoutChecks: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
144
|
+
}, z.core.$strict>>;
|
|
142
145
|
hints: z.ZodPrefault<z.ZodObject<{
|
|
143
146
|
askUserQuestion: z.ZodDefault<z.ZodBoolean>;
|
|
144
147
|
}, z.core.$strict>>;
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
PLAN_LANGUAGE
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-HQAONP4W.js";
|
|
4
4
|
|
|
5
5
|
// src/config/json-schema.ts
|
|
6
6
|
import { z as z3 } from "zod";
|
|
@@ -299,6 +299,20 @@ var coordinatorSchema = z2.strictObject({
|
|
|
299
299
|
alertAt: share.default(0.7)
|
|
300
300
|
}).refine(warnBelowAlert, WARN_BELOW_ALERT);
|
|
301
301
|
var guardedCommand = z2.string().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");
|
|
302
|
+
var checkFreePattern = z2.string().regex(/^[A-Za-z0-9_.*/-]+$/, "path words with * and ** only, such as docs/**/*.md").refine(
|
|
303
|
+
(pattern) => !pattern.startsWith("/") && !pattern.split("/").includes(".."),
|
|
304
|
+
"a relative path that stays inside the repository"
|
|
305
|
+
).refine(
|
|
306
|
+
(pattern) => pattern.split("/").some((segment) => /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(segment)),
|
|
307
|
+
"a pattern with a whole path segment that is no wildcard, such as docs/**/*.md"
|
|
308
|
+
);
|
|
309
|
+
var mergeSchema = z2.strictObject({
|
|
310
|
+
/**
|
|
311
|
+
* Paths of pull requests that are merged without waiting for checks: when every file of the pull request matches one
|
|
312
|
+
* of them, `/orchestrate` merges at once and the merge guard does not ask. Empty means every merge waits for checks.
|
|
313
|
+
*/
|
|
314
|
+
withoutChecks: z2.array(checkFreePattern).default([])
|
|
315
|
+
});
|
|
302
316
|
var guardSchema = z2.strictObject({
|
|
303
317
|
/**
|
|
304
318
|
* The commands the merge guard hook asks the user about before they run, also behind options before the verb, a chain,
|
|
@@ -391,6 +405,8 @@ var jackherdConfigSchema = z2.strictObject({
|
|
|
391
405
|
}).prefault({}),
|
|
392
406
|
/** Read by the merge guard hook, which asks before the listed commands run, and by `/orchestrate`. */
|
|
393
407
|
guard: guardSchema.prefault({}),
|
|
408
|
+
/** Read by `/orchestrate` and by the merge guard hook. */
|
|
409
|
+
merge: mergeSchema.prefault({}),
|
|
394
410
|
/** Read by the `ask-hint` hook. */
|
|
395
411
|
hints: hintsSchema.prefault({}),
|
|
396
412
|
/** Read by `jh check`; validated here too, so a typo fails every command alike. */
|
package/dist/plan-format.d.ts
CHANGED
|
@@ -55,6 +55,8 @@ type PlanVocabulary = {
|
|
|
55
55
|
noReviewGate: string;
|
|
56
56
|
/** Marks a lane as closed when its name cell contains it. */
|
|
57
57
|
laneClosed: string;
|
|
58
|
+
/** Words a starts-after cell begins with when the lane may start at once. */
|
|
59
|
+
startsNow: readonly string[];
|
|
58
60
|
/** Words that may stand between two phases of one entry (`P1 Rule und P2 Monitor`). */
|
|
59
61
|
phaseConnectors: readonly string[];
|
|
60
62
|
statusWords: Record<PlanStatus, StatusWord>;
|
package/dist/plan-format.js
CHANGED
package/install.sh
CHANGED
|
@@ -9,7 +9,7 @@ set -eu
|
|
|
9
9
|
|
|
10
10
|
# The default is the version this file ships with. The release bump changes it together with
|
|
11
11
|
# package.json; install.test.ts fails while the two differ. Overridable, but only by a plain release.
|
|
12
|
-
JACKHERD_VERSION="${JACKHERD_VERSION:-1.
|
|
12
|
+
JACKHERD_VERSION="${JACKHERD_VERSION:-1.5.0}"
|
|
13
13
|
|
|
14
14
|
JACKHERD_PACKAGE='jackherd'
|
|
15
15
|
# Test seam: the terminal the wizard asks its questions on.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a pull request changes nothing but files the project merges without waiting for checks
|
|
3
|
+
* (`merge.withoutChecks`). The file list comes from GitHub, never from an agent's text. Every doubt answers
|
|
4
|
+
* "no", so the guard asks as before.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { runGh } from './gh'
|
|
8
|
+
import type { RepositoryFacts } from './pull-request'
|
|
9
|
+
|
|
10
|
+
/** What GitHub lists per file: the name, and the old name of a renamed file. */
|
|
11
|
+
const FILE_ENTRY_QUERY = '.[] | [.filename, .previous_filename] | @json'
|
|
12
|
+
|
|
13
|
+
/** GitHub lists at most this many files of a pull request and says nothing when it stops there. */
|
|
14
|
+
export const MAX_LISTED_FILES = 3000
|
|
15
|
+
|
|
16
|
+
const isControlCharacter = (character: string): boolean => {
|
|
17
|
+
const code = character.charCodeAt(0)
|
|
18
|
+
return code < 0x20 || code === 0x7f
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A repository path as GitHub lists it: relative, no empty, `.` or `..` segment, no backslash, no control character. */
|
|
22
|
+
const isPlainPath = (file: string): boolean =>
|
|
23
|
+
file !== '' &&
|
|
24
|
+
!file.startsWith('/') &&
|
|
25
|
+
!file.includes('\\') &&
|
|
26
|
+
![...file].some(isControlCharacter) &&
|
|
27
|
+
!file.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')
|
|
28
|
+
|
|
29
|
+
const isNameOrNull = (value: unknown): value is string | null => value === null || typeof value === 'string'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The names a listing carries, the old name of a rename too, from one `[filename, previous]` JSON pair per line.
|
|
33
|
+
* The pairs must be exactly as many as the pull request changes files, and fewer than GitHub's cap: a list that
|
|
34
|
+
* was cut short would hide the files that are not in it. Anything else answers nothing.
|
|
35
|
+
*/
|
|
36
|
+
export const parseFileEntries = (stdout: string, changedFiles: number): string[] | undefined => {
|
|
37
|
+
if (changedFiles >= MAX_LISTED_FILES) return undefined
|
|
38
|
+
try {
|
|
39
|
+
const entries = stdout
|
|
40
|
+
.split('\n')
|
|
41
|
+
.filter((line) => line !== '')
|
|
42
|
+
.map((line): unknown => JSON.parse(line))
|
|
43
|
+
const pairs = entries.filter(
|
|
44
|
+
(entry): entry is [string, string | null] =>
|
|
45
|
+
Array.isArray(entry) && entry.length === 2 && typeof entry[0] === 'string' && isNameOrNull(entry[1]),
|
|
46
|
+
)
|
|
47
|
+
if (pairs.length !== entries.length || pairs.length !== changedFiles) return undefined
|
|
48
|
+
return pairs.flatMap(([filename, previous]) => (previous === null ? [filename] : [filename, previous]))
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** True only for at least one file, at least one pattern, and every file a plain path that matches a pattern. */
|
|
55
|
+
export const allFilesMatch = (files: readonly string[], patterns: readonly string[]): boolean => {
|
|
56
|
+
if (files.length === 0 || patterns.length === 0) return false
|
|
57
|
+
const globs = patterns.map((pattern) => new Bun.Glob(pattern))
|
|
58
|
+
return files.every((file) => isPlainPath(file) && globs.some((glob) => glob.match(file)))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Asks `gh api` for the files of pull request <number> of `facts.repository`, named in full; any failure answers nothing.
|
|
63
|
+
*/
|
|
64
|
+
export const changedFilesOf = (pullRequest: number, directory: string, facts: RepositoryFacts): string[] | undefined => {
|
|
65
|
+
const listing = runGh(
|
|
66
|
+
['api', `repos/${facts.repository}/pulls/${pullRequest}/files?per_page=100`, '--paginate', '--jq', FILE_ENTRY_QUERY],
|
|
67
|
+
directory,
|
|
68
|
+
)
|
|
69
|
+
return listing === undefined ? undefined : parseFileEntries(listing, facts.changedFiles)
|
|
70
|
+
}
|
|
@@ -13,7 +13,8 @@ export type GuardPolicy = {
|
|
|
13
13
|
/** The guarded commands as words: a program and the plain words after it, such as `gh pr merge`. */
|
|
14
14
|
commands: readonly string[]
|
|
15
15
|
/**
|
|
16
|
-
* Whether the
|
|
16
|
+
* Whether the merge of this pull request may pass without a question: the user approved it, or every file of it
|
|
17
|
+
* matches `merge.withoutChecks`. Asked only for a plain `gh pr merge <number>`.
|
|
17
18
|
* A throw counts as not approved.
|
|
18
19
|
*/
|
|
19
20
|
isMergeApproved: (pullRequest: number) => boolean
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const LOOKUP_TIMEOUT_MS = 4000
|
|
2
|
+
|
|
3
|
+
/** Runs `gh` with these arguments in `directory`, without a shell; stdout on success, nothing on any failure or timeout. */
|
|
4
|
+
export const runGh = (args: readonly string[], directory: string): string | undefined => {
|
|
5
|
+
const result = Bun.spawnSync(['gh', ...args], {
|
|
6
|
+
cwd: directory,
|
|
7
|
+
timeout: LOOKUP_TIMEOUT_MS,
|
|
8
|
+
stdout: 'pipe',
|
|
9
|
+
stderr: 'ignore',
|
|
10
|
+
})
|
|
11
|
+
return result.exitCode === 0 ? result.stdout.toString() : undefined
|
|
12
|
+
}
|
|
@@ -4,10 +4,17 @@
|
|
|
4
4
|
* says which branch pull request <number> carries.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
import { runGh } from './gh'
|
|
8
8
|
|
|
9
9
|
type PullRequestView = { headRefName?: unknown; isCrossRepository?: unknown; state?: unknown }
|
|
10
10
|
|
|
11
|
+
/** Where a pull request lives and how many files it changes, as GitHub's `pr view` says. */
|
|
12
|
+
export type RepositoryFacts = { repository: string; changedFiles: number }
|
|
13
|
+
|
|
14
|
+
const VIEW_FIELDS = 'headRefName,isCrossRepository,state,url,changedFiles'
|
|
15
|
+
const PULL_REQUEST_URL = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/pull\/[1-9]\d*$/
|
|
16
|
+
|
|
17
|
+
|
|
11
18
|
/** The head branch of an open pull request from this repository; a fork's branch name proves nothing. */
|
|
12
19
|
export const parseHeadBranch = (json: string): string | undefined => {
|
|
13
20
|
try {
|
|
@@ -21,13 +28,23 @@ export const parseHeadBranch = (json: string): string | undefined => {
|
|
|
21
28
|
}
|
|
22
29
|
}
|
|
23
30
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The repository from the pull request's own url (host github.com, plain owner and name) and its file count. The
|
|
33
|
+
* file list is then asked of that repository by name, so it cannot come from another one than `gh pr merge` merges in.
|
|
34
|
+
*/
|
|
35
|
+
export const parseRepositoryFacts = (json: string): RepositoryFacts | undefined => {
|
|
36
|
+
try {
|
|
37
|
+
const view: unknown = JSON.parse(json)
|
|
38
|
+
if (typeof view !== 'object' || view === null) return undefined
|
|
39
|
+
const { url, changedFiles } = view as { url?: unknown; changedFiles?: unknown }
|
|
40
|
+
const repository = typeof url === 'string' ? url.match(PULL_REQUEST_URL)?.[1] : undefined
|
|
41
|
+
const counted = typeof changedFiles === 'number' && Number.isInteger(changedFiles) && changedFiles >= 0
|
|
42
|
+
return repository !== undefined && counted ? { repository, changedFiles } : undefined
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
33
46
|
}
|
|
47
|
+
|
|
48
|
+
/** Runs `gh pr view <number>` in `directory`, the directory the command itself runs in; any failure answers nothing. */
|
|
49
|
+
export const viewPullRequest = (pullRequest: number, directory: string): string | undefined =>
|
|
50
|
+
runGh(['pr', 'view', String(pullRequest), '--json', VIEW_FIELDS], directory)
|
|
@@ -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
|
}
|
|
@@ -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
|
|
@@ -287,7 +288,9 @@ has no value, and `--coordinator <name>` names you. Without either the watcher l
|
|
|
287
288
|
|
|
288
289
|
In a stream each report is printed in one piece, so it arrives as one message, and the watcher
|
|
289
290
|
keeps running. The heartbeat there is one line, `=== heartbeat <time> · <lane> <status> · …`, due
|
|
290
|
-
after that many minutes without a report.
|
|
291
|
+
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)
|
|
292
|
+
only when the count moved since the last heartbeat. Pass it on as it is. A working lane without a number
|
|
293
|
+
has not moved (or has no plan): read its screen yourself when that lasts. `lane added: <name>` and `lane removed: <name>` mark a
|
|
291
294
|
change of the found lanes. A lane's agent is watched from the state it shows when it first appears,
|
|
292
295
|
so an agent that starts out idle does not fire; a lane whose agent exits fires as `no longer listed`,
|
|
293
296
|
also when it leaves the found lanes with it.
|
|
@@ -518,16 +521,24 @@ When the user picked `"{{kit.guard.selfMergeLabel}}"` for a PR:
|
|
|
518
521
|
1. Commit, push and open the PR as in step 4. Enter in the board's `{{kit.heading.history}}` that the
|
|
519
522
|
user approved this PR with self-merge (time, lane, PR number), so the approval has a record outside
|
|
520
523
|
the chat.
|
|
521
|
-
2.
|
|
524
|
+
2. **Merging without checks.** The paths a PR may carry to be merged without waiting for checks are
|
|
525
|
+
`{{kit.merge.withoutChecks}}` (`merge.withoutChecks` in the config; `none` means every merge waits).
|
|
526
|
+
Unless `none`, list the PR's files with `gh pr diff <n> --name-only` and compare each one with the
|
|
527
|
+
patterns yourself. When **every** file matches, go straight to step 4: no checks wait, because
|
|
528
|
+
the config says these paths need none. One file outside, an empty list, or a failed `gh` call:
|
|
529
|
+
go on with step 3. The merge guard reads the file list from GitHub itself and lets that
|
|
530
|
+
merge pass without a question only when it agrees; if it asks, the dialog is the user's (step 5).
|
|
531
|
+
This is the rule for the documents of a plan commit as well (§ 2 step 0).
|
|
532
|
+
3. Wait for the checks in the background, never in the foreground (§ 4): a Bash call
|
|
522
533
|
`gh pr checks <n> --watch` with `run_in_background: true`, then work on until it reports.
|
|
523
|
-
|
|
534
|
+
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
535
|
`--repo` or `--admin`. The merge guard lets exactly this command through for the approved branch,
|
|
525
536
|
for `guard.selfMerge.approvalMinutes` after the answer. Red, or checks that never finish: do not
|
|
526
537
|
merge, report to the user with the failing check and the lane's next step.
|
|
527
|
-
|
|
538
|
+
5. When the guard asks anyway (the approval lapsed, the branch differs, the command carries an
|
|
528
539
|
option it does not cover), the dialog is the user's. Do not confirm it for them and do not look
|
|
529
540
|
for a way around it: tell them what the PR waits for.
|
|
530
|
-
|
|
541
|
+
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
542
|
|
|
532
543
|
Self-merge never covers `gh workflow run` (a release, a deploy) or another PR: each of those asks
|
|
533
544
|
the user every time.
|
package/package.json
CHANGED
|
@@ -486,6 +486,21 @@
|
|
|
486
486
|
},
|
|
487
487
|
"additionalProperties": false
|
|
488
488
|
},
|
|
489
|
+
"merge": {
|
|
490
|
+
"default": {},
|
|
491
|
+
"type": "object",
|
|
492
|
+
"properties": {
|
|
493
|
+
"withoutChecks": {
|
|
494
|
+
"default": [],
|
|
495
|
+
"type": "array",
|
|
496
|
+
"items": {
|
|
497
|
+
"type": "string",
|
|
498
|
+
"pattern": "^[A-Za-z0-9_.*/-]+$"
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
},
|
|
502
|
+
"additionalProperties": false
|
|
503
|
+
},
|
|
489
504
|
"hints": {
|
|
490
505
|
"default": {},
|
|
491
506
|
"type": "object",
|
package/src/config/schema.ts
CHANGED
|
@@ -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,
|
|
@@ -307,6 +333,8 @@ export const jackherdConfigSchema = z.strictObject({
|
|
|
307
333
|
.prefault({}),
|
|
308
334
|
/** Read by the merge guard hook, which asks before the listed commands run, and by `/orchestrate`. */
|
|
309
335
|
guard: guardSchema.prefault({}),
|
|
336
|
+
/** Read by `/orchestrate` and by the merge guard hook. */
|
|
337
|
+
merge: mergeSchema.prefault({}),
|
|
310
338
|
/** Read by the `ask-hint` hook. */
|
|
311
339
|
hints: hintsSchema.prefault({}),
|
|
312
340
|
/** 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
|
}
|
package/src/sync/render.ts
CHANGED
|
@@ -12,6 +12,10 @@ const laneCopyCommand = (copy: readonly string[]): string =>
|
|
|
12
12
|
? WORKTREE_INCLUDE_COPY
|
|
13
13
|
: `cd <main-checkout> && cp -p --parents -- ${copy.map((path) => `'${path}'`).join(' ')} <worktree>/`
|
|
14
14
|
|
|
15
|
+
/** The patterns a pull request may match to be merged without checks, as prose for a skill; `none` for an empty list. */
|
|
16
|
+
const checkFreeList = (patterns: readonly string[]): string =>
|
|
17
|
+
patterns.length === 0 ? 'none' : patterns.map((pattern) => `\`${pattern}\``).join(', ')
|
|
18
|
+
|
|
15
19
|
const prefixed = (prefix: string, values: Record<string, string>): [string, string][] =>
|
|
16
20
|
Object.entries(values).map(([key, value]) => [`kit.${prefix}.${key}`, value])
|
|
17
21
|
|
|
@@ -31,6 +35,7 @@ const placeholderValues = (config: JackherdConfig): Map<string, string> => {
|
|
|
31
35
|
...prefixed('board', config.board),
|
|
32
36
|
['kit.guard.selfMergeLabel', SELF_MERGE_LABEL[config.responseLanguage]],
|
|
33
37
|
['kit.guard.selfMerge', config.guard.selfMerge.offer ? 'offered' : 'not offered'],
|
|
38
|
+
['kit.merge.withoutChecks', checkFreeList(config.merge.withoutChecks)],
|
|
34
39
|
...prefixed('heading', { ...header, ...sections, ...columns, phase }),
|
|
35
40
|
])
|
|
36
41
|
}
|
|
@@ -42,7 +47,7 @@ export const listPlaceholders = (template: string): string[] =>
|
|
|
42
47
|
/**
|
|
43
48
|
* Fills the kit placeholders from the project config: `{{kit.model.<role>}}`,
|
|
44
49
|
* `{{kit.command.<test|typecheck|lint|format|install|dev>}}`, `{{kit.lanes.<copyCommand|lockFile>}}`,
|
|
45
|
-
* `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.heading.<key>}}` from the plan
|
|
50
|
+
* `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.merge.withoutChecks}}`, `{{kit.heading.<key>}}` from the plan
|
|
46
51
|
* vocabulary, the two languages, the plan and worktree directory and the base branch. Other
|
|
47
52
|
* `{{…}}` text is left alone; an unknown `kit.` placeholder throws rather than reaching a project
|
|
48
53
|
* file unrendered. `overrides` win over the config: a global kit renders what each repository decides as
|
package/src/vocabulary/index.ts
CHANGED
|
@@ -59,6 +59,8 @@ export type PlanVocabulary = {
|
|
|
59
59
|
noReviewGate: string
|
|
60
60
|
/** Marks a lane as closed when its name cell contains it. */
|
|
61
61
|
laneClosed: string
|
|
62
|
+
/** Words a starts-after cell begins with when the lane may start at once. */
|
|
63
|
+
startsNow: readonly string[]
|
|
62
64
|
/** Words that may stand between two phases of one entry (`P1 Rule und P2 Monitor`). */
|
|
63
65
|
phaseConnectors: readonly string[]
|
|
64
66
|
statusWords: Record<PlanStatus, StatusWord>
|
|
@@ -82,6 +84,7 @@ export const PLAN_VOCABULARY: Record<PlanLanguage, PlanVocabulary> = {
|
|
|
82
84
|
phase: 'Phase',
|
|
83
85
|
noReviewGate: 'Kein Review-Gate',
|
|
84
86
|
laneClosed: 'abgeräumt',
|
|
87
|
+
startsNow: ['sofort'],
|
|
85
88
|
phaseConnectors: ['danach', 'und', 'dann', 'sowie'],
|
|
86
89
|
statusWords: {
|
|
87
90
|
done: { label: 'Done', leading: ['done'], anywhere: ['abgeschlossen'] },
|
|
@@ -109,6 +112,7 @@ export const PLAN_VOCABULARY: Record<PlanLanguage, PlanVocabulary> = {
|
|
|
109
112
|
phase: 'Phase',
|
|
110
113
|
noReviewGate: 'No review gate',
|
|
111
114
|
laneClosed: 'cleaned up',
|
|
115
|
+
startsNow: ['now', 'immediately'],
|
|
112
116
|
phaseConnectors: ['then', 'and', 'after'],
|
|
113
117
|
statusWords: {
|
|
114
118
|
done: { label: 'Done', leading: ['done'], anywhere: ['completed'] },
|
package/src/watch/command.ts
CHANGED
|
@@ -46,6 +46,7 @@ import { clockLocaleOf, type PlansSettings, plansSettingsOf, worktreeOf } from '
|
|
|
46
46
|
import { boardTexts } from '../plans/texts'
|
|
47
47
|
import { defaultUpdateCheck } from '../sync/if-changed'
|
|
48
48
|
import { herdrLaneStatusSource } from '../terminal/herdr'
|
|
49
|
+
import { AGENT_STATUS } from '../terminal/types'
|
|
49
50
|
import { startedKit } from '../version/started-kit'
|
|
50
51
|
import {
|
|
51
52
|
type CoordinatorRead,
|
|
@@ -68,6 +69,7 @@ import {
|
|
|
68
69
|
} from './feedback'
|
|
69
70
|
import { type FeedbackItemsRead, readCommentItems, readFeedbackItems, readOwnLogin } from './feedback-read'
|
|
70
71
|
import { answered, deployTargetOf, type GithubRead, readGithub, targetText } from './github-read'
|
|
72
|
+
import { currentPhaseCountsOf, type PhaseCount, progressNotes } from './heartbeat-progress'
|
|
71
73
|
import { agentScreen, listAgents } from './herdr'
|
|
72
74
|
import { endedByKitUpdate } from './kit-update'
|
|
73
75
|
import { type LaneReadings, laneFillEvents, recordLaneLevels } from './lane-fill'
|
|
@@ -216,6 +218,8 @@ type Watching = {
|
|
|
216
218
|
state: WatchState
|
|
217
219
|
baseline: LaneSnapshot
|
|
218
220
|
current: LaneSnapshot
|
|
221
|
+
/** The phase count of each lane at the last heartbeat, so a heartbeat names only a count that moved. */
|
|
222
|
+
heartbeatCounts: Record<string, PhaseCount>
|
|
219
223
|
/** The last `herdr agent list` answer, kept for finding the coordinator by its pane. */
|
|
220
224
|
agents: HerdrAgent[]
|
|
221
225
|
github: GithubRead | null
|
|
@@ -762,6 +766,20 @@ const report = async (
|
|
|
762
766
|
return shown
|
|
763
767
|
}
|
|
764
768
|
|
|
769
|
+
/**
|
|
770
|
+
* The heartbeat of a stream: one line, a working lane with its phase count when that moved since the last heartbeat.
|
|
771
|
+
* The plans are read only when a lane works; a board that cannot be read leaves the lines without a count, and the
|
|
772
|
+
* next report names the failure.
|
|
773
|
+
*/
|
|
774
|
+
const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: number): Promise<void> => {
|
|
775
|
+
const working = watching.lanes.filter((lane) => watching.current[lane]?.status === AGENT_STATUS.working)
|
|
776
|
+
const { progress } = working.length > 0 ? await boardReadOrNote(watch, working) : { progress: {} }
|
|
777
|
+
const counts = currentPhaseCountsOf(progress)
|
|
778
|
+
const notes = progressNotes(watching.lanes, watching.current, counts, watching.heartbeatCounts)
|
|
779
|
+
watching.heartbeatCounts = { ...watching.heartbeatCounts, ...counts }
|
|
780
|
+
console.log(streamHeartbeatLine(clock(watch, now), watching.lanes, watching.current, notes))
|
|
781
|
+
}
|
|
782
|
+
|
|
765
783
|
/**
|
|
766
784
|
* One poll: reports what is due and tells whether to keep watching. Without `--stream` the first
|
|
767
785
|
* report ends the watch; in a stream every reported lane counts as seen and the heartbeat is one line.
|
|
@@ -784,7 +802,7 @@ const pollOnce = async (watch: Watch, watching: Watching): Promise<boolean> => {
|
|
|
784
802
|
} else if (heartbeat && !stream) {
|
|
785
803
|
next = await report(watch, watching, [], [{ kind: WATCH_EVENT.heartbeat }], { ...next, statusLines: null })
|
|
786
804
|
} else {
|
|
787
|
-
if (heartbeat)
|
|
805
|
+
if (heartbeat) await printStreamHeartbeat(watch, watching, now)
|
|
788
806
|
if (due) writePrivate(watch.files.state, JSON.stringify(next))
|
|
789
807
|
watching.baseline = advanceBaseline(watching.baseline, watching.current)
|
|
790
808
|
}
|
|
@@ -809,6 +827,7 @@ const initialWatching = (
|
|
|
809
827
|
agents: start.agents,
|
|
810
828
|
github: null,
|
|
811
829
|
laneBranches: {},
|
|
830
|
+
heartbeatCounts: {},
|
|
812
831
|
githubReadAt: 0,
|
|
813
832
|
coordinator: { name: watch.options.coordinator, read: null },
|
|
814
833
|
herdrDown: false,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { currentPhase, type LaneProgress, PHASE_ID_PREFIX, type PhaseProgress } from '../plan-format'
|
|
2
|
+
import { AGENT_STATUS } from '../terminal/types'
|
|
3
|
+
import type { LaneSnapshot } from './lane-watch'
|
|
4
|
+
|
|
5
|
+
/** The current phase of a lane and how many of its checkboxes are ticked, as the plans view counts them. */
|
|
6
|
+
export type PhaseCount = Pick<PhaseProgress, 'id' | 'done' | 'total'>
|
|
7
|
+
|
|
8
|
+
/** What a heartbeat adds to a working lane, by lane name: `P12 7/12`, without the middle dot of `shortTitle`, which the heartbeat's separator rewrite would turn into a slash. */
|
|
9
|
+
export type LaneNotes = Readonly<Record<string, string>>
|
|
10
|
+
|
|
11
|
+
/** The count of the lane's current phase; null without a plan, without a current phase or with nothing to tick. */
|
|
12
|
+
export const currentPhaseCountOf = (progress: LaneProgress | null): PhaseCount | null => {
|
|
13
|
+
const phase = progress ? currentPhase(progress) : null
|
|
14
|
+
return phase && phase.total > 0 ? { id: phase.id, done: phase.done, total: phase.total } : null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The counts of the lanes that have one. */
|
|
18
|
+
export const currentPhaseCountsOf = (progress: Readonly<Record<string, LaneProgress>>): Record<string, PhaseCount> =>
|
|
19
|
+
Object.fromEntries(
|
|
20
|
+
Object.entries(progress).flatMap(([lane, laneProgress]) => {
|
|
21
|
+
const count = currentPhaseCountOf(laneProgress)
|
|
22
|
+
return count ? [[lane, count]] : []
|
|
23
|
+
}),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const sameCount = (left: PhaseCount | undefined, right: PhaseCount): boolean =>
|
|
27
|
+
left?.id === right.id && left.done === right.done && left.total === right.total
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The note of each working lane whose count differs from the one at the last heartbeat (or has none before): a lane
|
|
31
|
+
* that has not moved says nothing, so a stretch on one number tells the coordinator to look for itself.
|
|
32
|
+
*/
|
|
33
|
+
export const progressNotes = (
|
|
34
|
+
lanes: readonly string[],
|
|
35
|
+
current: LaneSnapshot,
|
|
36
|
+
counts: Readonly<Record<string, PhaseCount>>,
|
|
37
|
+
previous: Readonly<Record<string, PhaseCount>>,
|
|
38
|
+
): LaneNotes =>
|
|
39
|
+
Object.fromEntries(
|
|
40
|
+
lanes.flatMap((lane) => {
|
|
41
|
+
const count = counts[lane]
|
|
42
|
+
const moved = count !== undefined && !sameCount(previous[lane], count)
|
|
43
|
+
return moved && current[lane]?.status === AGENT_STATUS.working
|
|
44
|
+
? [[lane, `${PHASE_ID_PREFIX}${count.id} ${count.done}/${count.total}`]]
|
|
45
|
+
: []
|
|
46
|
+
}),
|
|
47
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
|
|
3
|
+
const BUN_NAMES = new Set(['bun', 'bun.exe'])
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The command a source starts. The watcher starts it without a shell, so `bun` would be looked up on the PATH, where
|
|
7
|
+
* Bun may be missing although the watcher itself runs under it (installed in `~/.bun/bin` only). `bun` as the program
|
|
8
|
+
* therefore means the Bun that runs the watcher (`execPath`); a path written out, another program, or a watcher that
|
|
9
|
+
* runs under something else is left as written.
|
|
10
|
+
*/
|
|
11
|
+
export const resolveSourceCommand = (command: readonly string[], execPath: string): string[] => {
|
|
12
|
+
const [program, ...rest] = command
|
|
13
|
+
return program === 'bun' && BUN_NAMES.has(basename(execPath.replaceAll('\\', '/')))
|
|
14
|
+
? [execPath, ...rest]
|
|
15
|
+
: [...command]
|
|
16
|
+
}
|
package/src/watch/source-run.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveSourceCommand } from './source-command'
|
|
1
2
|
import { exitFailure, parseSourceOutput, SOURCE_FAILURE, type SourceRun, type SourceSettings } from './sources'
|
|
2
3
|
|
|
3
4
|
/** How long a source's command may run and how much it may print before it is stopped. */
|
|
@@ -39,7 +40,13 @@ const readCapped = async (child: Child, maxBytes: number): Promise<string | null
|
|
|
39
40
|
/** Started in a process group of its own, so a timeout reaches what the command started too (`curl` behind `sh`). */
|
|
40
41
|
const spawned = (command: readonly string[], cwd: string): Child | null => {
|
|
41
42
|
try {
|
|
42
|
-
return Bun.spawn(
|
|
43
|
+
return Bun.spawn(resolveSourceCommand(command, process.execPath), {
|
|
44
|
+
cwd,
|
|
45
|
+
stdin: 'ignore',
|
|
46
|
+
stdout: 'pipe',
|
|
47
|
+
stderr: 'ignore',
|
|
48
|
+
detached: true,
|
|
49
|
+
})
|
|
43
50
|
} catch {
|
|
44
51
|
return null
|
|
45
52
|
}
|
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
import { AGENT_STATUS } from '../terminal/types'
|
|
3
|
+
import type { LaneNotes } from './heartbeat-progress'
|
|
2
4
|
import { type LaneSnapshot, NO_AGENT } from './lane-watch'
|
|
3
5
|
import { WATCH_EVENT } from './watch-events'
|
|
4
6
|
|
|
7
|
+
const SEPARATOR = ' · '
|
|
8
|
+
|
|
9
|
+
/** `lane status`, a working lane with its note: `lane working: P12 7/12`. */
|
|
10
|
+
const laneState = (lane: string, current: LaneSnapshot, notes: LaneNotes): string => {
|
|
11
|
+
const status = current[lane]?.status ?? NO_AGENT
|
|
12
|
+
const note = status === AGENT_STATUS.working ? notes[lane] : undefined
|
|
13
|
+
const head = `${terminalSafe(lane)} ${terminalSafe(status)}`
|
|
14
|
+
return note === undefined ? head : `${head}: ${terminalSafe(note).replaceAll(SEPARATOR, ' / ')}`
|
|
15
|
+
}
|
|
16
|
+
|
|
5
17
|
/**
|
|
6
18
|
* The heartbeat of a stream, as one line, since every line of a stream reaches the coordinator as a
|
|
7
19
|
* message of its own: `=== heartbeat <time> · <lane> <status> · …`.
|
|
8
20
|
*/
|
|
9
|
-
export const streamHeartbeatLine = (
|
|
10
|
-
|
|
21
|
+
export const streamHeartbeatLine = (
|
|
22
|
+
time: string,
|
|
23
|
+
lanes: readonly string[],
|
|
24
|
+
current: LaneSnapshot,
|
|
25
|
+
notes: LaneNotes,
|
|
26
|
+
): string => {
|
|
27
|
+
const states = lanes.map((lane) => laneState(lane, current, notes))
|
|
11
28
|
return [`=== ${WATCH_EVENT.heartbeat} ${terminalSafe(time)}`, ...(states.length > 0 ? states : ['no lanes'])].join(
|
|
12
|
-
|
|
29
|
+
SEPARATOR,
|
|
13
30
|
)
|
|
14
31
|
}
|