jackherd 1.7.0 → 1.8.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 +43 -0
- package/INSTALL.md +4 -2
- package/{NOTICE → NOTICE.txt} +1 -1
- package/README.md +94 -1033
- package/assets/icons/bark-dark.svg +1 -1
- package/assets/icons/bark.svg +1 -1
- package/assets/icons/bowl-dark.svg +1 -1
- package/assets/icons/bowl.svg +1 -1
- package/assets/icons/decide-dark.svg +1 -1
- package/assets/icons/decide.svg +1 -1
- package/assets/icons/ear-dark.svg +1 -1
- package/assets/icons/ear.svg +1 -1
- package/assets/icons/lanes-dark.svg +1 -1
- package/assets/icons/lanes.svg +1 -1
- package/assets/icons/plan-dark.svg +1 -1
- package/assets/icons/plan.svg +1 -1
- package/dist/{chunk-OBUWTQ6Z.js → chunk-M6DDZEW4.js} +24 -10
- package/dist/config.d.ts +11 -5
- package/dist/config.js +53 -21
- package/dist/plan-format.d.ts +73 -7
- package/dist/plan-format.js +180 -100
- package/docs/workflow-dark.svg +1 -1
- package/docs/workflow.svg +1 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/herdr-panes.ts +2 -12
- package/kit/hooks/hooks/approval-gate/index.ts +10 -8
- package/kit/hooks/hooks/approval-gate/run.ts +21 -12
- package/kit/hooks/hooks/ask-hint/decision.ts +3 -3
- package/kit/hooks/hooks/git-guard/decision.ts +189 -15
- package/kit/hooks/hooks/git-guard/index.ts +13 -3
- package/kit/hooks/hooks/git-guard/push-target.ts +85 -0
- package/kit/hooks/hooks/git-guard/repository.ts +63 -0
- package/kit/hooks/hooks/merge-guard/check-free.ts +1 -13
- package/kit/hooks/hooks/merge-guard/decision.ts +342 -102
- package/kit/hooks/hooks/merge-guard/index.ts +6 -4
- package/kit/hooks/hooks/merge-guard/plain-path.ts +15 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +23 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-settings.ts +142 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +196 -0
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +168 -0
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +40 -0
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +194 -0
- package/kit/hooks/hooks/merge-guard/pull-request.ts +67 -21
- package/kit/hooks/hooks/merge-guard/run.ts +118 -29
- package/kit/hooks/hooks/merge-guard/settings.ts +8 -8
- package/kit/hooks/hooks/merge-guard/worker.ts +3 -3
- package/kit/hooks/hooks/shared/base-branch.ts +23 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +93 -0
- package/kit/hooks/hooks/shared/command-text-runners.ts +71 -0
- package/kit/hooks/hooks/shared/command.ts +14 -2
- package/kit/hooks/hooks/shared/commit.ts +2 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +4 -1
- package/kit/hooks/hooks/shared/json.ts +10 -0
- package/kit/hooks/hooks/shared/lane.ts +57 -0
- package/kit/hooks/hooks/shared/once.ts +8 -0
- package/kit/hooks/hooks/shared/own-pane.ts +31 -0
- package/kit/hooks/hooks/shared/pane-agent.ts +24 -0
- package/kit/hooks/hooks/shared/plan-open-phases.ts +114 -0
- package/kit/hooks/hooks/shared/program-position.ts +148 -5
- package/kit/hooks/hooks/shared/shell-aliases.ts +258 -0
- package/kit/hooks/hooks/shared/shell-snapshots.ts +68 -0
- package/kit/hooks/hooks/shared/shell.ts +167 -20
- package/kit/hooks/hooks.json +11 -2
- package/kit/hooks/statusline.sh +29 -16
- package/kit/review/skills/diagnose/SKILL.md +1 -1
- package/kit/review/skills/review/SKILL.md +2 -0
- package/kit/rules/rules/kit/testing.md +2 -5
- package/kit/rules/rules/kit/writing-for-agents.md +3 -1
- package/kit/workflow/agents/spec-review.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +3 -3
- package/kit/workflow/skills/grill/SKILL.md +1 -1
- package/kit/workflow/skills/implement/SKILL.md +10 -11
- package/kit/workflow/skills/orchestrate/SKILL.md +155 -117
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +17 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +27 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -0
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +23 -0
- package/kit/workflow/skills/plan/SKILL.md +5 -2
- package/kit/workflow/skills/plan/references/template-English.md +6 -0
- package/kit/workflow/skills/plan/references/template-German.md +6 -0
- package/package.json +3 -2
- package/recipes/README.md +21 -0
- package/recipes/github-issues.md +23 -0
- package/recipes/github-projects.md +22 -0
- package/recipes/jira-cli.md +26 -0
- package/recipes/jira-rest.md +24 -0
- package/recipes/linear.md +25 -0
- package/recipes/sentry.md +18 -0
- package/recipes/sources/jira.ts +51 -0
- package/recipes/sources/linear.ts +19 -0
- package/recipes/sources/sentry.ts +12 -0
- package/recipes/sources.md +80 -0
- package/recipes/status-page.md +19 -0
- package/schemas/jackherd.schema.json +42 -18
- package/src/check/command.ts +2 -2
- package/src/check/parallel-report.ts +19 -9
- package/src/cli.ts +1 -0
- package/src/config/context-shares.ts +130 -0
- package/src/config/legacy-names.ts +8 -0
- package/src/config/read-config.ts +12 -4
- package/src/config/schema.ts +75 -20
- package/src/context/auto-compact.ts +68 -0
- package/src/context/command.ts +68 -17
- package/src/context/coordinator-name.ts +27 -9
- package/src/context/index.ts +0 -1
- package/src/context/level.ts +18 -11
- package/src/context/report.ts +6 -3
- package/src/plan-format/dependencies.ts +144 -32
- package/src/plan-format/index.ts +6 -0
- package/src/plan-format/lanes.ts +6 -0
- package/src/plan-format/progress.ts +19 -8
- package/src/plans/command.ts +14 -5
- package/src/plans/current-phase.ts +63 -7
- package/src/plans/current-task.ts +51 -0
- package/src/plans/ensure-panes.ts +25 -8
- package/src/plans/frame.ts +99 -0
- package/src/plans/git.ts +8 -0
- package/src/plans/herd-memory.ts +107 -0
- package/src/plans/lane-plans.ts +5 -3
- package/src/plans/lane-summary.ts +222 -0
- package/src/plans/layout-phase.ts +148 -0
- package/src/plans/layout-summary.ts +140 -0
- package/src/plans/layout.ts +164 -420
- package/src/plans/live.ts +11 -6
- package/src/plans/plan-panes.ts +15 -0
- package/src/plans/planned.ts +9 -42
- package/src/plans/selection.ts +2 -6
- package/src/plans/settings.ts +2 -7
- package/src/plans/snapshot.ts +38 -39
- package/src/plans/symbols.ts +2 -2
- package/src/plans/texts.ts +32 -31
- package/src/plans/view.ts +0 -1
- package/src/plans/worked-phases.ts +2 -2
- package/src/settled/board-row.ts +133 -0
- package/src/settled/check.ts +129 -0
- package/src/settled/command.ts +65 -0
- package/src/settled/evidence-version.ts +14 -0
- package/src/settled/options.ts +69 -0
- package/src/settled/send.ts +27 -0
- package/src/settled/settle.ts +87 -0
- package/src/settled/topics.ts +56 -0
- package/src/sync/command.ts +16 -1
- package/src/sync/render.ts +13 -4
- package/src/sync/sync.ts +25 -5
- package/src/terminal/herdr.ts +46 -10
- package/src/terminal/none.ts +1 -0
- package/src/terminal/types.ts +15 -2
- package/src/update/command.ts +4 -2
- package/src/update/update.ts +33 -9
- package/src/vocabulary/index.ts +47 -11
- package/src/watch/appointments.ts +205 -0
- package/src/watch/command.ts +62 -19
- package/src/watch/lane-report.ts +11 -3
- package/src/watch/texts.ts +35 -59
- package/src/watch/watch-events.ts +21 -28
- package/kit/hooks/hooks/approval-gate/lane.ts +0 -14
|
@@ -69,6 +69,12 @@ What gets documented after completion?
|
|
|
69
69
|
|
|
70
70
|
- [ ] ...
|
|
71
71
|
|
|
72
|
+
## Follow-ups
|
|
73
|
+
|
|
74
|
+
> Ideas that come up during the work: noted here, not built in this plan, presented together once the release PR is merged.
|
|
75
|
+
|
|
76
|
+
- [ ] The idea in one sentence (User <DD.MM.> <HH:MM>, where it came up)
|
|
77
|
+
|
|
72
78
|
## Rejected Alternatives
|
|
73
79
|
|
|
74
80
|
| Idea | Reason |
|
|
@@ -69,6 +69,12 @@ Was wird nach Abschluss dokumentiert?
|
|
|
69
69
|
|
|
70
70
|
- [ ] ...
|
|
71
71
|
|
|
72
|
+
## Follow-ups
|
|
73
|
+
|
|
74
|
+
> Ideen, die während der Umsetzung aufkommen: hier vorgemerkt, nicht in diesem Plan gebaut, gesammelt vorgelegt, sobald der Release-PR gemergt ist.
|
|
75
|
+
|
|
76
|
+
- [ ] Idee in einem Satz (User <TT.MM.> <HH:MM>, wo sie aufkam)
|
|
77
|
+
|
|
72
78
|
## Verworfene Alternativen
|
|
73
79
|
|
|
74
80
|
| Idee | Grund |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jackherd",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"keywords": [
|
|
5
5
|
"claude-code",
|
|
6
6
|
"coding-agents",
|
|
@@ -48,11 +48,12 @@
|
|
|
48
48
|
"!docs/gen",
|
|
49
49
|
"assets",
|
|
50
50
|
"!assets/icons/build.ts",
|
|
51
|
+
"recipes",
|
|
51
52
|
"README.md",
|
|
52
53
|
"INSTALL.md",
|
|
53
54
|
"CHANGELOG.md",
|
|
54
55
|
"LICENSE",
|
|
55
|
-
"NOTICE",
|
|
56
|
+
"NOTICE.txt",
|
|
56
57
|
"install.sh"
|
|
57
58
|
],
|
|
58
59
|
"publishConfig": {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Source recipes
|
|
2
|
+
|
|
3
|
+
Each recipe is one `watch.sources` entry for the jackherd watcher: put in your key, project or query, then try it with `jh watch source <name> --dry-run` (and `--raw` where the shape differs from what is shown). `/jh-follow` picks a recipe and fills it for you. Where a recipe leaves a field open, [Rules every recipe follows](#rules-every-recipe-follows) decides; every field and limit of a source is in [sources.md](sources.md).
|
|
4
|
+
|
|
5
|
+
| Service | Recipe | Login | Script |
|
|
6
|
+
| --- | --- | --- | --- |
|
|
7
|
+
| Jira, with the Atlassian CLI | [jira-cli.md](jira-cli.md) | `acli jira auth login` | [sources/jira.ts](sources/jira.ts) |
|
|
8
|
+
| Jira, over REST with `curl` | [jira-rest.md](jira-rest.md) | API token in `~/.netrc` | none |
|
|
9
|
+
| Linear, over GraphQL | [linear.md](linear.md) | `LINEAR_API_KEY` | [sources/linear.ts](sources/linear.ts) |
|
|
10
|
+
| GitHub issues | [github-issues.md](github-issues.md) | `gh auth login` | none |
|
|
11
|
+
| GitHub Projects, as tickets | [github-projects.md](github-projects.md) | `gh auth refresh -s project` | none |
|
|
12
|
+
| Sentry | [sentry.md](sentry.md) | `SENTRY_AUTH_TOKEN` | [sources/sentry.ts](sources/sentry.ts) |
|
|
13
|
+
| A status page | [status-page.md](status-page.md) | none, public API | none |
|
|
14
|
+
|
|
15
|
+
## Rules every recipe follows
|
|
16
|
+
|
|
17
|
+
- **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.
|
|
18
|
+
- **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. Every request and every tool call is bounded with `AbortSignal.timeout(20_000)`, well under the 30 seconds a source may run.
|
|
19
|
+
- **The scripts are files of their own** under [`sources/`](sources/). Copy one to `scripts/sources/<name>.ts` in your repository, the path the entry's `command` names. The closing `export {}` makes the file a module, so the type check accepts top-level `await` and the names do not clash between scripts.
|
|
20
|
+
- **A recipe that needs no script** (`gh …`, `curl` on a public page, `curl --netrc`) stays a direct command.
|
|
21
|
+
- **`bun` as the first word, never a path to it.** The watcher starts a source without a shell and reads `bun` 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).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# GitHub issues
|
|
2
|
+
|
|
3
|
+
**Login:** `gh auth login`, kept by `gh`. No script.
|
|
4
|
+
|
|
5
|
+
**Entry:**
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{
|
|
9
|
+
"name": "decisions",
|
|
10
|
+
"command": ["gh", "issue", "list", "--repo", "<owner>/<repo>", "--label", "decision", "--json", "number,title,url,updatedAt,comments"],
|
|
11
|
+
"map": {
|
|
12
|
+
"id": ".number",
|
|
13
|
+
"updatedAt": ".updatedAt",
|
|
14
|
+
"title": ".title",
|
|
15
|
+
"url": ".url",
|
|
16
|
+
"body": ".comments[-1].body",
|
|
17
|
+
"author": ".comments[-1].author.login"
|
|
18
|
+
},
|
|
19
|
+
"ignoreAuthors": ["<your-login>"]
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Limits:** without `--limit`, `gh issue list` returns 30 issues. `ignoreAuthors` keeps your own comments from waking anybody.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# GitHub Projects
|
|
2
|
+
|
|
3
|
+
Reads the cards of a project board as tickets, without a script, so the herd shows each card as a badge beside its lane's plan.
|
|
4
|
+
|
|
5
|
+
**Login:** `gh` needs the `project` scope, `gh auth refresh -s project`.
|
|
6
|
+
|
|
7
|
+
`<n>` is the project's number, `<owner>` its organization or `@me`; without `--limit` `gh` returns only 30 cards. Each card is `items[]` with its `id`, `title`, `content` (the issue or pull request, `content.url`; a draft card has none, so its badge has no link) and one key per project field, the name with a lower-case first letter, so the `Status` field is `.status` and holds the option's name. A card carries no time of its own, so the entry maps no `updatedAt` and a change of the title or URL is the change. The cards belong to a lane through `lane`, since their ids (`PVTI_…`) are not what a plan names.
|
|
8
|
+
|
|
9
|
+
**Entry:**
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"name": "board",
|
|
14
|
+
"command": ["gh", "project", "item-list", "<n>", "--owner", "<owner>", "--format", "json", "--limit", "200"],
|
|
15
|
+
"items": ".items",
|
|
16
|
+
"ticket": true,
|
|
17
|
+
"lane": "<lane>",
|
|
18
|
+
"map": { "id": ".id", "title": ".title", "url": ".content.url", "status": ".status" }
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Limits:** jackherd only reads the board. The status moves by the project's own workflows (Settings → Workflows: "Item closed" and "Pull request merged" set Done, "Auto-add" brings new issues in), and a pull request moves its issue's card only when its description links it with `Closes #<n>`; otherwise the status stays where it was. Add `"reportOn": ["status"]` to be told of each move as well.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Jira with the Atlassian CLI
|
|
2
|
+
|
|
3
|
+
**Login:** `acli jira auth login`, kept by `acli`. No token passes through the source.
|
|
4
|
+
|
|
5
|
+
`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.
|
|
6
|
+
|
|
7
|
+
**Script:** copy [sources/jira.ts](sources/jira.ts) to `scripts/sources/jira.ts` in your repository, put your project into `jql`, and set `host` to your `<site>.atlassian.net`. 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.
|
|
8
|
+
|
|
9
|
+
**Entry:**
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"name": "tickets-decisions",
|
|
14
|
+
"command": ["bun", "scripts/sources/jira.ts"],
|
|
15
|
+
"map": {
|
|
16
|
+
"id": ".key",
|
|
17
|
+
"updatedAt": ".updated",
|
|
18
|
+
"title": ".summary",
|
|
19
|
+
"url": ".url",
|
|
20
|
+
"body": ".body",
|
|
21
|
+
"author": ".author"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**Limits:** one `view` call per item, six at a time; a query that returns many items takes longer. The search stops at 100 items (`--limit`). Before filling, read `acli jira workitem search --help` and `workitem view --help` of your installed version: the fields and options it names decide, not this recipe.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Jira over REST with `curl`
|
|
2
|
+
|
|
3
|
+
One call returns everything, where the CLI needs one call per item, but it needs an API token.
|
|
4
|
+
|
|
5
|
+
**Token:** an API token of your Atlassian account in `~/.netrc` (`machine <site>.atlassian.net login <email> password <api-token>`, mode 600), which `curl --netrc` reads without a shell; `<host>` is the same `<site>.atlassian.net`.
|
|
6
|
+
|
|
7
|
+
**Entry:** version 3 of the API returns a comment body as a document tree, so the path reaches into its first paragraph:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"name": "tickets-decisions",
|
|
12
|
+
"command": ["curl", "-fsS", "--max-time", "20", "--netrc", "-G", "https://<host>/rest/api/3/search/jql", "--data-urlencode", "jql=key in (PROJ-123)", "--data-urlencode", "fields=summary,updated,comment"],
|
|
13
|
+
"items": ".issues",
|
|
14
|
+
"map": {
|
|
15
|
+
"id": ".key",
|
|
16
|
+
"updatedAt": ".fields.updated",
|
|
17
|
+
"title": ".fields.summary",
|
|
18
|
+
"body": ".fields.comment.comments[-1].body.content[0].content[0].text",
|
|
19
|
+
"author": ".fields.comment.comments[-1].author.displayName"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Limits:** the body shows only the first text node of the comment's first paragraph; a comment that starts with a mention or a list shows less. For the whole text, use the [CLI recipe](jira-cli.md), whose script joins the nodes.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Linear over GraphQL
|
|
2
|
+
|
|
3
|
+
**Token:** a personal API key in `LINEAR_API_KEY`, sent as `Authorization: <key>` without `Bearer`.
|
|
4
|
+
|
|
5
|
+
**Script:** copy [sources/linear.ts](sources/linear.ts) to `scripts/sources/linear.ts` in your repository, put your team key and issue number into `query`, and set `<host>` to 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.
|
|
6
|
+
|
|
7
|
+
**Entry:**
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"name": "linear-eng-123",
|
|
12
|
+
"command": ["bun", "scripts/sources/linear.ts"],
|
|
13
|
+
"items": ".data.issues.nodes",
|
|
14
|
+
"map": {
|
|
15
|
+
"id": ".identifier",
|
|
16
|
+
"updatedAt": ".updatedAt",
|
|
17
|
+
"title": ".title",
|
|
18
|
+
"url": ".url",
|
|
19
|
+
"body": ".comments.nodes[-1].body",
|
|
20
|
+
"author": ".comments.nodes[-1].user.name"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Limits:** check with `--raw` which end of `comments.nodes` holds the newest comment; `[0]` instead of `[-1]` if it is the first.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Sentry
|
|
2
|
+
|
|
3
|
+
**Token:** an auth token with `event:read` in `SENTRY_AUTH_TOKEN`; `<host>` is `sentry.io` or your own.
|
|
4
|
+
|
|
5
|
+
**Script:** copy [sources/sentry.ts](sources/sentry.ts) to `scripts/sources/sentry.ts` in your repository and put your organization and project into the URL.
|
|
6
|
+
|
|
7
|
+
**Entry:**
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"name": "errors",
|
|
12
|
+
"every": 10,
|
|
13
|
+
"command": ["bun", "scripts/sources/sentry.ts"],
|
|
14
|
+
"map": { "id": ".id", "updatedAt": ".lastSeen", "title": ".title", "url": ".permalink", "body": ".culprit" }
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**Limits:** `lastSeen` moves with every event, but title, culprit and link do not, so only a new issue wakes the coordinator, not each occurrence.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Copy to scripts/sources/jira.ts in your repository; the recipe recipes/jira-cli.md says what to fill in.
|
|
2
|
+
const jql = 'project = PROJ AND assignee = currentUser() AND statusCategory != Done'
|
|
3
|
+
const host = '<host>'
|
|
4
|
+
const parallel = 6
|
|
5
|
+
|
|
6
|
+
type DocumentNode = { type?: string; text?: string; attrs?: { text?: string }; content?: DocumentNode[] }
|
|
7
|
+
type IssueComment = { author?: { displayName?: string }; body?: DocumentNode }
|
|
8
|
+
type Issue = { key: string; fields: { updated: string; summary: string; comment?: { comments: IssueComment[] } } }
|
|
9
|
+
|
|
10
|
+
const plainText = (node: DocumentNode): string =>
|
|
11
|
+
node.type === 'text' || node.type === 'mention'
|
|
12
|
+
? (node.text ?? node.attrs?.text ?? '')
|
|
13
|
+
: (node.content ?? []).map(plainText).join('')
|
|
14
|
+
|
|
15
|
+
const acli = async (args: string[]): Promise<unknown> => {
|
|
16
|
+
const child = Bun.spawn(['acli', ...args], { stdout: 'pipe', stderr: 'ignore', signal: AbortSignal.timeout(20_000) })
|
|
17
|
+
const output = await new Response(child.stdout).text()
|
|
18
|
+
if ((await child.exited) !== 0) process.exit(1)
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(output)
|
|
21
|
+
} catch {
|
|
22
|
+
return process.exit(1)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const entryOf = ({ key, fields }: Issue) => {
|
|
27
|
+
const latest = fields.comment?.comments.at(-1)
|
|
28
|
+
return {
|
|
29
|
+
key,
|
|
30
|
+
url: `https://${host}/browse/${key}`,
|
|
31
|
+
updated: fields.updated,
|
|
32
|
+
summary: fields.summary,
|
|
33
|
+
author: latest?.author?.displayName ?? null,
|
|
34
|
+
body: latest?.body ? plainText(latest.body) : null,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const keys = ((await acli(['jira', 'workitem', 'search', '--jql', jql, '--limit', '100', '--json'])) as { key: string }[]).map(
|
|
39
|
+
({ key }) => key,
|
|
40
|
+
)
|
|
41
|
+
const entries = []
|
|
42
|
+
for (let start = 0; start < keys.length; start += parallel) {
|
|
43
|
+
const batch = keys.slice(start, start + parallel)
|
|
44
|
+
const issues = await Promise.all(
|
|
45
|
+
batch.map((key) => acli(['jira', 'workitem', 'view', key, '--fields', 'summary,updated,comment', '--json'])),
|
|
46
|
+
)
|
|
47
|
+
entries.push(...(issues as Issue[]).map(entryOf))
|
|
48
|
+
}
|
|
49
|
+
console.log(JSON.stringify(entries))
|
|
50
|
+
|
|
51
|
+
export {}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Copy to scripts/sources/linear.ts in your repository; the recipe recipes/linear.md says what to fill in.
|
|
2
|
+
const token = process.env.LINEAR_API_KEY
|
|
3
|
+
if (!token) process.exit(1)
|
|
4
|
+
|
|
5
|
+
const query = `{ issues(filter: { team: { key: { eq: "ENG" } }, number: { eq: 123 } }) {
|
|
6
|
+
nodes { identifier title url updatedAt comments { nodes { body user { name } } } } } }`
|
|
7
|
+
|
|
8
|
+
const response = await fetch('https://<host>/graphql', {
|
|
9
|
+
method: 'POST',
|
|
10
|
+
headers: { Authorization: token, 'Content-Type': 'application/json' },
|
|
11
|
+
body: JSON.stringify({ query }),
|
|
12
|
+
signal: AbortSignal.timeout(20_000),
|
|
13
|
+
})
|
|
14
|
+
if (!response.ok) process.exit(1)
|
|
15
|
+
const answer = (await response.json()) as { errors?: unknown }
|
|
16
|
+
if (answer.errors) process.exit(1)
|
|
17
|
+
console.log(JSON.stringify(answer))
|
|
18
|
+
|
|
19
|
+
export {}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Copy to scripts/sources/sentry.ts in your repository; the recipe recipes/sentry.md says what to fill in.
|
|
2
|
+
const token = process.env.SENTRY_AUTH_TOKEN
|
|
3
|
+
if (!token) process.exit(1)
|
|
4
|
+
|
|
5
|
+
const response = await fetch(
|
|
6
|
+
'https://<host>/api/0/projects/<org>/<project>/issues/?query=is:unresolved&statsPeriod=24h',
|
|
7
|
+
{ headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(20_000) },
|
|
8
|
+
)
|
|
9
|
+
if (!response.ok) process.exit(1)
|
|
10
|
+
console.log(JSON.stringify(await response.json()))
|
|
11
|
+
|
|
12
|
+
export {}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# How a source works
|
|
2
|
+
|
|
3
|
+
The fields and limits of a `watch.sources` entry, for a source no [recipe](README.md) covers or a field a recipe leaves open.
|
|
4
|
+
|
|
5
|
+
Source reports are off unless the config lists `watch.sources`. The watcher cannot use the MCP servers of a Claude Code session: their connections and logins (often OAuth in a browser) belong to the session. A source is a command instead, which prints JSON on stdout. It runs without a shell, in the main checkout, with your environment, so its token stays where it is. `/jh-follow` fills one of the [recipes](README.md) for you.
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{
|
|
9
|
+
"watch": {
|
|
10
|
+
"sources": [
|
|
11
|
+
{
|
|
12
|
+
"name": "tickets",
|
|
13
|
+
"every": 5,
|
|
14
|
+
"command": ["tickets-cli", "search", "--query", "key in (T-123)", "--json"],
|
|
15
|
+
"items": ".issues",
|
|
16
|
+
"map": {
|
|
17
|
+
"id": ".key",
|
|
18
|
+
"updatedAt": ".fields.updated",
|
|
19
|
+
"title": ".fields.summary",
|
|
20
|
+
"body": ".fields.comment.comments[-1].body",
|
|
21
|
+
"author": ".fields.comment.comments[-1].author.name"
|
|
22
|
+
},
|
|
23
|
+
"ignoreAuthors": ["me"],
|
|
24
|
+
"lane": "search"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
=== source tickets (search) T-123 Decide the layout · updated
|
|
33
|
+
Yes, take variant B.
|
|
34
|
+
https://<host>/browse/T-123
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`items` is the path of the list in the output (without it the output is the list), `map` picks each entry's fields: `id` is required, `updatedAt` too unless a content field (`title`, `author`, `body`, `url`) or `reportOn` can tell a change, and `title`, `url`, `body`, `author` and `status` (a ticket's status, below) are optional; any further field you name (`priority`, `assignee`; a letter, then letters, digits and `_`) is read for `reportOn`. A path is `.` for the whole value or a chain of `.key`, `[n]` and `[-n]` (counted from the end), nothing more; for anything else, put a script in between.
|
|
38
|
+
|
|
39
|
+
What counts as news: an unknown `id` reports `new`; `updated` only when what you read changed, a short hash over `title`, `author`, `body` and `url`. A change of the time alone (a label, a sprint, a status) wakes nobody; where none of the four is mapped, `updatedAt` is the content, and then it is required. An entry whose `author` is in `ignoreAuthors` (case ignored) is recorded but wakes nobody, so your own comment does not. The first successful run of a source is a baseline, and the saved state holds per source only each id with its hash (with `reportOn` also the values of those fields, which may name people, such as an assignee), the 1000 seen most recently, so a restart repeats nothing and an entry that falls out of a time window and comes back is not new. Everything one GitHub read finds goes into one report, at most `reportLimit` entries per source (`5`), the rest as one line `=== source tickets +3 more`. The body shows its first line with text that is no Markdown heading (a bot's `## Code review` is skipped; a body of headings only shows the first), at most 160 characters, and the URL only as an `https:` URL without user name or password. `every` is in minutes, at least 1, `5` by default; the sources run with the GitHub read, so at most once a minute, and after a restart each runs at once.
|
|
40
|
+
|
|
41
|
+
**Report a status change.** `reportOn` names the mapped fields whose change reports, in place of the content hash: with `"reportOn": ["status"]` a ticket that moves from one column to the next wakes the coordinator, and a new comment alone does not. The report names each changed field with its old and new value, cut to 60 characters, `(empty)` for none, in one line; the body follows only when `body` is one of the changed fields. An unknown `id` still reports `new`, and `ignoreAuthors`, `reportLimit` and the one report per read stay as they are. `status` is just a field name: any mapped field but `id` may be listed, including `title` or `body`.
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
"map": { "id": ".key", "updatedAt": ".fields.updated", "title": ".fields.summary", "status": ".fields.status.name", "assignee": ".fields.assignee.displayName" },
|
|
45
|
+
"reportOn": ["status", "assignee"]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
=== source tickets (search) T-123 Decide the layout · updated: status In progress → Ready for Dev, assignee (empty) → alex
|
|
50
|
+
https://<host>/browse/T-123
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
At most 10 fields. For this the saved state keeps the value of each `reportOn` field on one line, as the report shows it (up to 200 characters, a longer one as its first 180, `…` and a hash of the whole, so any change shows). A field added to `reportOn` later, or a source that gains `reportOn`, starts from what it reads then and reports nothing for that first read; a state from an earlier version is read as it is, and dropping `reportOn` reports nothing either.
|
|
54
|
+
|
|
55
|
+
A run that ends with an exit code other than 0, runs past 30 seconds (then its whole process group is killed, a `curl` behind a script included), prints more than 1 MB, cannot be read, prints no JSON or not the configured shape reports once per streak, with the reason only and never stderr, which may carry a token. While it fails, the status block ends with `source tickets failing since 10:15:00`; the watcher and the other sources go on:
|
|
56
|
+
|
|
57
|
+
```text
|
|
58
|
+
=== source tickets failed: exit 1
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Show a ticket beside its plan.** `"ticket": true` marks a source's entries as tickets. The herd then shows each one as a badge in the 📎 line of its lane's plan, right after `📄 Plan`: `📡 T-123 · In Progress`, linked to the ticket. A ticket belongs to the source's `lane`, and also to any lane whose board row, name or plan names its id as a whole word (`T-12` is not found in `T-123`); so one source listing all your open tickets works without `lane`. `map.status` is the status the badge shows; without it the badge shows only the key. A ticket whose last reported change is less than a day old is amber, and a folded lane then shows `📡 <n>` in amber before its bar, linked to the first new ticket. A lane shows at most three tickets, new ones first, then `+<n>`; a link chip of the plan with the same URL gives way to the badge. For this the saved state keeps, of a marked source only, each entry of its last successful run with its URL, its status (stored like a `reportOn` value) and the time its last change was reported. The badges come from the watcher's state like the pull request badges: without a running watcher or with a state older than three minutes there are none, and the header says `watcher off` or `state 4 min old`, also without a pull request. The [GitHub Projects recipe](#user-content-recipes) is a ticket source with a status.
|
|
62
|
+
|
|
63
|
+
**How you hear about it.** The report goes to the coordinator, as a `=== source …` event of its watcher. It passes you one line with the entry, its gist and the link. A decision inside it, such as a product owner's answer, it takes over only after you confirmed it, since the text comes from outside. You hear of it only while the coordinator's session and its watcher run; what happened in between is reported at the next start. There is no desktop notification of its own: you get the one herdr shows for any lane that needs you. What a source reports is a change in the mapped content fields (`title`, `author`, `body`, `url`), or with `reportOn` a change of the fields it names, such as a status.
|
|
64
|
+
|
|
65
|
+
**Try a source before the watcher runs it.** `jh watch source <name> --dry-run` runs a configured source once; a draft not yet in the config comes as a file (`jh watch source draft.json --dry-run`, one source object) or on stdin (`-`). It lists the entries (`<id> · <author> · <title>`), says that the watcher's first run of the source is a baseline and reports nothing, and shows one entry as a later report would print it; it neither reads nor changes the watcher's state. A field of `map` that is empty in every entry is named with its path (`map.updatedAt: in 23 of 23 entries empty (.fields.updated)`), read from the raw output, so a run that failed on a missing `id` or `updatedAt` names it too; the dry run then ends with exit code 1, as for a failed run, and `/jh-follow` does not write the source. Without entries it says `0 entries: map not checked`. `--raw` adds the command's output, for finding the paths of `items` and `map`:
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
dry run source tickets: ["tickets-cli","search","--query","key in (T-123)","--json"]
|
|
69
|
+
2 entries
|
|
70
|
+
T-123 · alex · Decide the layout
|
|
71
|
+
T-124 · me · Pick a colour (ignoreAuthors)
|
|
72
|
+
first run of this source: baseline, nothing reported; later a new or changed entry reports like this:
|
|
73
|
+
=== source tickets (search) T-123 Decide the layout · new
|
|
74
|
+
Yes, take variant B.
|
|
75
|
+
watcher state not read or changed; the watcher takes a new source over at its next start
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The watcher reads `watch.sources` when it starts: a new or changed source counts from its next start.
|
|
79
|
+
|
|
80
|
+
Three limits stand: the watcher cannot make a command read only, so name only commands that read. A source in `.jackherd/config.json` runs for everyone who starts the watcher in the repository, at the same level of trust as `check.checks` and hooks. The watcher reads the config of the main checkout, never the one of a lane's worktree; a dry run started inside a worktree reads that worktree's config first. And `jh watch source … --dry-run` runs whatever command its draft names, so it never belongs in a permission allowlist.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# A status page
|
|
2
|
+
|
|
3
|
+
A page on the common status page service answers its public API without a token; `<host>` is the page's own.
|
|
4
|
+
|
|
5
|
+
**Login:** none. No script.
|
|
6
|
+
|
|
7
|
+
**Entry:**
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"name": "provider-status",
|
|
12
|
+
"every": 5,
|
|
13
|
+
"command": ["curl", "-fsS", "--max-time", "20", "https://<host>/api/v2/incidents/unresolved.json"],
|
|
14
|
+
"items": ".incidents",
|
|
15
|
+
"map": { "id": ".id", "updatedAt": ".updated_at", "title": ".name", "url": ".shortlink", "body": ".incident_updates[0].body" }
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**Limits:** the newest update stands first in `incident_updates`, so each new update of an open incident reports `updated` with its text. A resolved incident drops out of the list and reports nothing more.
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"baseBranch": {
|
|
68
68
|
"default": "main",
|
|
69
69
|
"type": "string",
|
|
70
|
-
"pattern": "^(?![-/.])(?!.*(?:\\.\\.|\\/\\/|@\\{|\\.lock(?:\\/|$)))[A-Za-z0-9._/-]+(?<![./])$"
|
|
70
|
+
"pattern": "^(?!HEAD$)(?![-/.])(?!.*(?:\\.\\.|\\/\\/|@\\{|\\.lock(?:\\/|$)))[A-Za-z0-9._/-]+(?<![./])$"
|
|
71
71
|
},
|
|
72
72
|
"skillPrefix": {
|
|
73
73
|
"default": "",
|
|
@@ -234,17 +234,17 @@
|
|
|
234
234
|
"default": {},
|
|
235
235
|
"type": "object",
|
|
236
236
|
"properties": {
|
|
237
|
-
"
|
|
238
|
-
"default":
|
|
239
|
-
"type": "
|
|
237
|
+
"warnTokens": {
|
|
238
|
+
"default": 250000,
|
|
239
|
+
"type": "integer",
|
|
240
240
|
"exclusiveMinimum": 0,
|
|
241
|
-
"
|
|
241
|
+
"maximum": 9007199254740991
|
|
242
242
|
},
|
|
243
|
-
"
|
|
244
|
-
"default":
|
|
245
|
-
"type": "
|
|
243
|
+
"alertTokens": {
|
|
244
|
+
"default": 350000,
|
|
245
|
+
"type": "integer",
|
|
246
246
|
"exclusiveMinimum": 0,
|
|
247
|
-
"
|
|
247
|
+
"maximum": 9007199254740991
|
|
248
248
|
},
|
|
249
249
|
"windows": {
|
|
250
250
|
"default": {},
|
|
@@ -269,17 +269,17 @@
|
|
|
269
269
|
"default": {},
|
|
270
270
|
"type": "object",
|
|
271
271
|
"properties": {
|
|
272
|
-
"
|
|
273
|
-
"default":
|
|
274
|
-
"type": "
|
|
272
|
+
"warnTokens": {
|
|
273
|
+
"default": 200000,
|
|
274
|
+
"type": "integer",
|
|
275
275
|
"exclusiveMinimum": 0,
|
|
276
|
-
"
|
|
276
|
+
"maximum": 9007199254740991
|
|
277
277
|
},
|
|
278
|
-
"
|
|
279
|
-
"default":
|
|
280
|
-
"type": "
|
|
278
|
+
"alertTokens": {
|
|
279
|
+
"default": 300000,
|
|
280
|
+
"type": "integer",
|
|
281
281
|
"exclusiveMinimum": 0,
|
|
282
|
-
"
|
|
282
|
+
"maximum": 9007199254740991
|
|
283
283
|
}
|
|
284
284
|
},
|
|
285
285
|
"additionalProperties": false
|
|
@@ -491,7 +491,9 @@
|
|
|
491
491
|
"commands": {
|
|
492
492
|
"default": [
|
|
493
493
|
"gh pr merge",
|
|
494
|
-
"gh workflow run"
|
|
494
|
+
"gh workflow run",
|
|
495
|
+
"gh run rerun",
|
|
496
|
+
"gh api"
|
|
495
497
|
],
|
|
496
498
|
"type": "array",
|
|
497
499
|
"items": {
|
|
@@ -528,6 +530,28 @@
|
|
|
528
530
|
}
|
|
529
531
|
},
|
|
530
532
|
"additionalProperties": false
|
|
533
|
+
},
|
|
534
|
+
"planApproval": {
|
|
535
|
+
"default": {},
|
|
536
|
+
"type": "object",
|
|
537
|
+
"properties": {
|
|
538
|
+
"exceptPaths": {
|
|
539
|
+
"default": [],
|
|
540
|
+
"type": "array",
|
|
541
|
+
"items": {
|
|
542
|
+
"type": "string",
|
|
543
|
+
"pattern": "^[A-Za-z0-9_.*/-]+$"
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
"requiredChecks": {
|
|
547
|
+
"type": "array",
|
|
548
|
+
"items": {
|
|
549
|
+
"type": "string",
|
|
550
|
+
"maxLength": 200
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
"additionalProperties": false
|
|
531
555
|
}
|
|
532
556
|
},
|
|
533
557
|
"additionalProperties": false
|
package/src/check/command.ts
CHANGED
|
@@ -78,7 +78,7 @@ const readPlanFile = (file: string): string | null => {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
/** `jh check --parallel <plan>`: the
|
|
81
|
+
/** `jh check --parallel <plan>`: the waves of phases that can run side by side and the critical path, read from the plan as it is on disk. */
|
|
82
82
|
const printParallel = (path: string, settings: CheckSettings): number => {
|
|
83
83
|
const file = resolve(process.cwd(), path)
|
|
84
84
|
const markdown = path === '' ? null : readPlanFile(file)
|
|
@@ -112,7 +112,7 @@ const executePlan = async (
|
|
|
112
112
|
|
|
113
113
|
/**
|
|
114
114
|
* Runs the configured checks over the files `git status` reports. Exit code 1 when any check fails
|
|
115
|
-
* or the configuration cannot be read. With `--parallel <plan>` it runs no check and prints the plan's
|
|
115
|
+
* or the configuration cannot be read. With `--parallel <plan>` it runs no check and prints the plan's waves and critical path.
|
|
116
116
|
*/
|
|
117
117
|
export const run = async (args: string[], home: string = homedir()): Promise<number> => {
|
|
118
118
|
const target = resolveCheckTarget(process.cwd(), home, existsSync, gitTopLevelOf)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type PhaseSchedule, phaseLabels, phasesText, wavesOfPlan } from '../plan-format'
|
|
2
2
|
import type { PlanLanguage } from '../vocabulary'
|
|
3
3
|
|
|
4
|
-
/** The flag by which `jh check` prints the
|
|
4
|
+
/** The flag by which `jh check` prints the waves of one plan instead of running the checks. */
|
|
5
5
|
export const PARALLEL_FLAG = '--parallel'
|
|
6
6
|
|
|
7
7
|
/** The plan path after `--parallel`: empty when it is missing, null when the flag is absent. */
|
|
@@ -10,14 +10,24 @@ export const parallelArgument = (args: readonly string[]): string | null => {
|
|
|
10
10
|
return index === -1 ? null : (args[index + 1] ?? '')
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** `critical path: P1 → P3 → P6 (3 phases)` */
|
|
14
|
+
const criticalPathLine = (path: readonly string[]): string =>
|
|
15
|
+
`critical path: ${phaseLabels(path, ' → ')} (${path.length} ${path.length === 1 ? 'phase' : 'phases'})`
|
|
16
|
+
|
|
17
|
+
const scheduleLines = ({ waves, criticalPath, unplaced }: PhaseSchedule): string[] => [
|
|
18
|
+
...(waves.length === 0
|
|
19
|
+
? ['no phase is ready']
|
|
20
|
+
: waves.map((wave, index) => `wave ${index + 1}: ${phasesText(wave)}`)),
|
|
21
|
+
...(criticalPath.length > 0 ? [criticalPathLine(criticalPath)] : []),
|
|
22
|
+
...(unplaced.length > 0 ? [`in no wave: ${phaseLabels(unplaced, ' ')}`] : []),
|
|
23
|
+
]
|
|
24
|
+
|
|
13
25
|
/**
|
|
14
|
-
* What `jh check --parallel <plan>` prints: one line per
|
|
15
|
-
* can start now
|
|
26
|
+
* What `jh check --parallel <plan>` prints: one line per wave of phases that can run side by side, in order (the
|
|
27
|
+
* first wave can start now, each later one once the waves before it are done), the critical path, and the open
|
|
28
|
+
* phases no wave holds; or the reason there is no wave.
|
|
16
29
|
*/
|
|
17
30
|
export const parallelReportLines = (markdown: string, language: PlanLanguage): string[] => {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
return groups.length === 0
|
|
21
|
-
? ['no phase is ready']
|
|
22
|
-
: groups.map((group, index) => `group ${index + 1}: ${phasesText(group)}`)
|
|
31
|
+
const schedule = wavesOfPlan(markdown, language)
|
|
32
|
+
return schedule === null ? ['no dependency table'] : scheduleLines(schedule)
|
|
23
33
|
}
|
package/src/cli.ts
CHANGED
|
@@ -24,6 +24,7 @@ export const COMMANDS: CommandTable = {
|
|
|
24
24
|
[HERD_COMMAND.name]: () => import('./plans/command'),
|
|
25
25
|
init: () => import('./sync/init-command'),
|
|
26
26
|
[HERD_COMMAND.alias]: () => import('./plans/command'),
|
|
27
|
+
settled: () => import('./settled/command'),
|
|
27
28
|
sync: () => import('./sync/command'),
|
|
28
29
|
update: () => import('./update/command'),
|
|
29
30
|
watch: () => import('./watch/command'),
|