jackherd 1.16.0 → 1.18.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 +28 -0
- package/INSTALL.md +2 -1
- package/README.md +29 -33
- package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
- package/dist/config.d.ts +33 -1
- package/dist/config.js +57 -5
- package/dist/plan-format.d.ts +18 -6
- package/dist/plan-format.js +8 -1
- package/docs/plan-contract.md +1 -0
- package/herdr-plugin/herdr-plugin.toml +4 -1
- package/herdr-plugin/restore-plans.ts +60 -26
- package/herdr-plugin/resume.ts +18 -0
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +11 -0
- package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
- package/kit/hooks/hooks/approval-gate/index.ts +36 -7
- package/kit/hooks/hooks/approval-gate/run.ts +72 -16
- package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
- package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
- package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
- package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
- package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
- package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
- package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
- package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +423 -35
- package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
- package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
- package/kit/hooks/hooks/merge-guard/run.ts +110 -28
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
- package/kit/hooks/hooks/plain-question/form.ts +89 -0
- package/kit/hooks/hooks/plain-question/index.ts +44 -4
- package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
- package/kit/hooks/hooks/plain-question/run.ts +14 -7
- package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
- package/kit/hooks/hooks.json +11 -2
- package/kit/review/agents/skill-review.md +3 -2
- package/kit/review/skills/pre-commit/SKILL.md +16 -22
- package/kit/rules/rules/kit/code-style.md +26 -54
- package/kit/rules/rules/kit/pull-requests.md +17 -40
- package/kit/rules/rules/kit/testing.md +6 -12
- package/kit/rules/rules/kit/writing-for-agents.md +3 -0
- package/kit/workflow/skills/follow/SKILL.md +2 -3
- package/kit/workflow/skills/implement/SKILL.md +28 -43
- package/kit/workflow/skills/orchestrate/SKILL.md +201 -458
- package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
- package/kit/workflow/skills/orchestrate/references/approvals.md +23 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
- package/kit/workflow/skills/orchestrate/references/events.md +46 -1
- package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
- package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +10 -2
- package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
- package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
- package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
- package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
- package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
- package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
- package/kit/workflow/skills/plan/SKILL.md +29 -48
- package/package.json +2 -1
- package/recipes/README.md +1 -0
- package/recipes/jira-cli.md +1 -1
- package/recipes/sources/google-calendar-today.ts +8 -2
- package/recipes/sources/jira.ts +10 -2
- package/recipes/sources/linear.ts +9 -3
- package/recipes/sources/sentry.ts +8 -2
- package/recipes/sources.md +4 -2
- package/schemas/jackherd.schema.json +67 -0
- package/schemas/sources.schema.json +9 -0
- package/src/approval/checked.ts +60 -0
- package/src/approval/command.ts +16 -0
- package/src/check/command.ts +51 -6
- package/src/check/lint-pointers.ts +79 -0
- package/src/check/lint-shaped.ts +196 -0
- package/src/check/plan-structure-check.ts +116 -4
- package/src/check/report.ts +9 -3
- package/src/check/schema.ts +37 -0
- package/src/check/settings.ts +4 -0
- package/src/check/skill-budget-defaults.ts +90 -32
- package/src/check/skill-budgets.ts +96 -18
- package/src/cli.ts +2 -0
- package/src/config/personal-sources.ts +46 -10
- package/src/config/repository-identity.ts +40 -0
- package/src/config/schema.ts +53 -1
- package/src/context/command.ts +33 -2
- package/src/context/options.ts +33 -8
- package/src/context/usage-logs.ts +53 -0
- package/src/context/usage.ts +110 -0
- package/src/lane/base-command.ts +35 -0
- package/src/lane/base.ts +43 -0
- package/src/lane/close-check.ts +49 -15
- package/src/lane/close-command.ts +71 -11
- package/src/lane/close-options.ts +29 -8
- package/src/lane/close-survey.ts +13 -1
- package/src/lane/close.ts +51 -12
- package/src/lane/command.ts +21 -3
- package/src/lane/send-command.ts +32 -0
- package/src/lane/send-herdr.ts +77 -0
- package/src/lane/send-options.ts +23 -0
- package/src/lane/send.ts +90 -0
- package/src/lane/skill-hint.ts +27 -0
- package/src/lane/space-command.ts +30 -0
- package/src/lane/space.ts +49 -0
- package/src/lane/start-lane-hint.ts +18 -0
- package/src/plan-contract/base-check-git.ts +33 -0
- package/src/plan-contract/base-check.ts +17 -0
- package/src/plan-contract/command.ts +25 -4
- package/src/plan-format/plan-structure.ts +17 -5
- package/src/plans/ask-coordinator.ts +23 -13
- package/src/plans/board-rows.ts +9 -4
- package/src/plans/click-hint.ts +4 -0
- package/src/plans/command.ts +6 -5
- package/src/plans/ensure-panes.ts +14 -3
- package/src/plans/git.ts +14 -0
- package/src/plans/github.ts +19 -10
- package/src/plans/layout.ts +82 -33
- package/src/plans/live.ts +8 -12
- package/src/plans/plan-panes.ts +21 -4
- package/src/plans/selection.ts +29 -0
- package/src/plans/settings.ts +4 -0
- package/src/resume/command.ts +213 -0
- package/src/resume/decision.ts +102 -0
- package/src/resume/keeper.ts +65 -0
- package/src/resume/location.ts +22 -0
- package/src/resume/log.ts +27 -0
- package/src/resume/ports.ts +102 -0
- package/src/resume/record.ts +116 -0
- package/src/resume/targets.ts +84 -0
- package/src/resume/texts.ts +27 -0
- package/src/secrets/command.ts +15 -5
- package/src/sync/herdr-plugin.ts +2 -11
- package/src/sync/plan-ask-rules.ts +11 -0
- package/src/sync/render.ts +3 -1
- package/src/vocabulary/index.ts +13 -0
- package/src/waiting/command.ts +13 -4
- package/src/watch/command.ts +177 -78
- package/src/watch/config-reload.ts +119 -0
- package/src/watch/lane-model.ts +11 -1
- package/src/watch/lane-progress.ts +70 -8
- package/src/watch/lane-report.ts +66 -11
- package/src/watch/main-checkout.ts +5 -1
- package/src/watch/merge-duties.ts +33 -0
- package/src/watch/merge-overlap-read.ts +69 -0
- package/src/watch/plan-views.ts +16 -4
- package/src/watch/reference-paths.ts +46 -5
- package/src/watch/report-questions.ts +5 -2
- package/src/watch/single-report.ts +3 -2
- package/src/watch/source-dry-run-command.ts +22 -4
- package/src/watch/source-reason.ts +58 -0
- package/src/watch/source-reload.ts +21 -17
- package/src/watch/source-run.ts +24 -7
- package/src/watch/sources.ts +9 -2
- package/src/watch/texts.ts +14 -3
- package/src/watch/waiting-line.ts +4 -3
|
@@ -16,16 +16,13 @@ You create a structured concept/plan document in `{{kit.planDirectory}}/`.
|
|
|
16
16
|
|
|
17
17
|
### 0. Are the decisions actually made?
|
|
18
18
|
|
|
19
|
-
A plan records decisions; it is not where they get taken. If the `## {{kit.heading.decisions}}` table would
|
|
20
|
-
|
|
21
|
-
Skip this when the request already carries its decisions (a plan for a bug fix with one obvious approach, a change the user has already specified).
|
|
19
|
+
A plan records decisions; it is not where they get taken. If the `## {{kit.heading.decisions}}` table would hold your guesses rather than the user's answers, **stop and recommend `/grill`**: name the open questions and leave the invocation to the user. It hands back rows in this table's format. Skip this when the request already carries its decisions.
|
|
22
20
|
|
|
23
21
|
### 1. Pre-Scan (before a single word of the plan is written)
|
|
24
22
|
|
|
25
23
|
- [ ] Read the relevant files in their **current** state, from disk
|
|
26
24
|
- [ ] Search the project's shared components and utilities for what covers 80%+ of the need. 80%+ match → the plan specifies reuse or an override; 60–80% → the plan extends the shared piece; below 60% → new code, with the rationale under the rejected alternatives
|
|
27
|
-
- [ ] Read related plans in `{{kit.planDirectory}}/`
|
|
28
|
-
- [ ] Read the architecture documentation the change touches — understand what is already documented before you plan
|
|
25
|
+
- [ ] Read related plans in `{{kit.planDirectory}}/` (dependencies, open TODOs) and the architecture documentation the change touches
|
|
29
26
|
|
|
30
27
|
### 2. Create the Document
|
|
31
28
|
|
|
@@ -37,52 +34,43 @@ The closing documentation section reads `## N — …` with rows `N.1` to `N.5`:
|
|
|
37
34
|
|
|
38
35
|
A checkbox moved on purpose to a later phase stays where it is, unticked, and ends in the target: `- [ ] Update the documentation → P9`. The herd counts it neither as open nor as done in its own phase and lists it as open in phase 9. The arrow stands only at the end of the line. Once a phase's tasks are ticked on the base branch, every box still open in it, gate boxes included, is ticked or moved on this way; `jh check` checks both.
|
|
39
36
|
|
|
40
|
-
A task starts with its short name: `- [ ] **Name**: …` (a review-gate item keeps its own form). The name has 40 characters at most and names the subject and the result
|
|
37
|
+
A task starts with its short name: `- [ ] **Name**: …` (a review-gate item keeps its own form). The name has 40 characters at most and names the subject and the result (`Heartbeat shows progress without ticks`); the how, the conditions, the origin and the evidence follow the colon. A phase's title is its name; its context goes into its origin line, a quote under the heading (`> Origin: …`, in the plan's language). A task the user adds later: `- [ ] **Name**: … (User <DD.MM.> <HH:MM>)`.
|
|
41
38
|
|
|
42
|
-
An Artifact that belongs to the plan (a mockup, a draft for review, a findings page) stands as a Markdown link with its title in the phase it concerns, in the same move as its first publish: `**Mockup:** [Title](url)` or in the origin line. The herd shows
|
|
39
|
+
An Artifact that belongs to the plan (a mockup, a draft for review, a findings page) stands as a Markdown link with its title in the phase it concerns, in the same move as its first publish: `**Mockup:** [Title](url)` or in the origin line. The herd shows them as chips; nothing else ties an Artifact published from the main checkout to the lane.
|
|
43
40
|
|
|
44
41
|
What the template's tables mean:
|
|
45
42
|
|
|
46
|
-
- **Model recommendations per phase** — one row per phase, judged by **error cost × complexity**.
|
|
47
|
-
- **Dependencies and parallelization** (from three phases) — `/implement`
|
|
48
|
-
- **{{kit.heading.dependsOn}}:** phases of this plan (numbers) or external gates (`User: mockups`).
|
|
49
|
-
- **{{kit.heading.writes}}:** the directories or files the phase creates or changes, as narrow as possible
|
|
43
|
+
- **Model recommendations per phase** — one row per phase, judged by **error cost × complexity**. `{{kit.model.implement}}` is the default of every row. A phase moves up to `{{kit.model.plan}}` when its rationale names a concrete error cost: a breakable invariant, possible data loss, security or tenant isolation, a migration without a way back, distributed state across systems. "Complex" or "many files" alone keeps the default. Review gates with `/security-audit` always get the strongest model.
|
|
44
|
+
- **Dependencies and parallelization** (from three phases) — `/implement` starts every phase whose dependencies are done and that shares no written path with another, at the same time.
|
|
45
|
+
- **{{kit.heading.dependsOn}}:** phases of this plan (numbers) or external gates (`User: mockups`).
|
|
46
|
+
- **{{kit.heading.writes}}:** the directories or files the phase creates or changes, as narrow as possible, shared files (barrels, translation files, `package.json`, generated types, architecture docs) listed explicitly. Write each path in full from the repository root: paths are compared as written.
|
|
50
47
|
- **{{kit.heading.serialMarker}}:** `Migration`, `<collection> schema bump`, `Event <type>`, a generator run. Two phases with a migration never run at the same time; neither do two that bump the same schema.
|
|
51
|
-
- **Cutting phases:** many small phases that ship in one release are bundled into a few phases now, not appended one by one later: every phase costs a hand-over. Shared text files (skills, README, changelog, architecture docs) go into a small phase of their own or into the closing tasks of a phase, so
|
|
48
|
+
- **Cutting phases:** many small phases that ship in one release are bundled into a few phases now, not appended one by one later: every phase costs a hand-over. Shared text files (skills, README, changelog, architecture docs) go into a small phase of their own or into the closing tasks of a phase, so no phase's code waits behind them.
|
|
52
49
|
|
|
53
50
|
### 3. Architecture Review
|
|
54
51
|
|
|
55
|
-
Apply `/uncle-bob` to the created plan
|
|
56
|
-
|
|
57
|
-
- **Interfaces:** Are the boundaries between modules/services clear? Who consumes what?
|
|
58
|
-
- **Blast radius:** Which existing consumers are affected? Are tests needed in all consuming packages?
|
|
52
|
+
Apply `/uncle-bob` to the created plan, and check the **interfaces** (who consumes what) and the **blast radius** (which consumers are affected, which consuming packages need tests).
|
|
59
53
|
|
|
60
54
|
### 4. Anchor the Review Gate in the Plan
|
|
61
55
|
|
|
62
|
-
Every phase gets the review gate block from the template at its end
|
|
56
|
+
Every phase gets the review gate block from the template at its end, plus the reviewers the project supplement names for its areas. A phase with tasks that genuinely needs no gate (documentation only, a draft) says so in a line of its own, in the plan's language: `**No review gate:** <reason>` (`en`), `**Kein Review-Gate:** <Grund>` (`de`). `jh check` refuses a phase the change adds with tasks and neither line.
|
|
63
57
|
|
|
64
58
|
### 5. Quality Checks
|
|
65
59
|
|
|
66
|
-
- **Focus
|
|
67
|
-
- **
|
|
68
|
-
- **
|
|
69
|
-
- **
|
|
70
|
-
- **
|
|
71
|
-
- **
|
|
72
|
-
- **
|
|
73
|
-
- **
|
|
74
|
-
- **Parallelism defined (3+ phases):** the dependencies table follows it, a row for every phase, one added later too. Every migration, schema bump and new event of a phase is a serial marker, every shared file stands under {{kit.heading.writes}}; otherwise `/implement` starts two phases that collide.
|
|
75
|
-
- **arc42 check (after the plan exists):** apply the filter in `.claude/rules/kit/arc42.md` to the finished plan and **ask the user concretely** which sections should be extended. On "yes", add a concrete task to `## {{kit.heading.documentation}}`.
|
|
76
|
-
- **Architecture check (after the plan exists):** apply the filter in `.claude/rules/kit/architecture-files.md` — new ownership, a new invariant or an accepted risk each trigger a concrete question about the unit's `ARCHITECTURE.md`.
|
|
77
|
-
- **arc42 closing phase (3+ phases or a new service/package):** end the plan with the architecture documentation phase from the template. Add arc42 sections beyond it only when they deliver real value.
|
|
78
|
-
- **Documentation planned:** `## {{kit.heading.documentation}}` names concretely what will be created or updated after completion.
|
|
60
|
+
- **Focus and scope:** a concrete problem, clearly bounded, phases that split into individual PRs, related plans in `{{kit.planDirectory}}/` linked.
|
|
61
|
+
- **Current state is fact-based:** every claim has a source (code read, files counted).
|
|
62
|
+
- **Shared code checked:** for every planned component it is documented why it is built new instead of reused or extended.
|
|
63
|
+
- **Tests defined:** the tests section is filled in or says why none are needed (decision tree in `.claude/rules/kit/testing.md`).
|
|
64
|
+
- **Lint before prose:** a rule the plan writes or changes in an agent text that the project's linter can check becomes a lint task (a case where it fires, one where it stays silent); the prose shrinks to the sentence naming it with `lint:<rule id>`.
|
|
65
|
+
- **Model recommendations and parallelism:** `## {{kit.heading.implementation}}` starts with the model table; from three phases the dependencies table follows, a row for every phase, every migration, schema bump and new event a serial marker, every shared file under {{kit.heading.writes}}.
|
|
66
|
+
- **arc42 and architecture (after the plan exists):** apply the filters in `.claude/rules/kit/arc42.md` and `.claude/rules/kit/architecture-files.md` to the finished plan and **ask the user concretely**; on "yes", add a concrete task to `## {{kit.heading.documentation}}`. From three phases or a new service/package, the plan ends with the template's architecture documentation phase.
|
|
67
|
+
- **Documentation planned:** `## {{kit.heading.documentation}}` names concretely what will be created or updated.
|
|
79
68
|
|
|
80
69
|
### 6. Propose the board rows
|
|
81
70
|
|
|
82
71
|
A **board** gathers the lanes one coordinator runs with `/orchestrate`: `{{kit.board.file}}` in a directory
|
|
83
|
-
matching `{{kit.planDirectory}}/{{kit.board.pattern}}` (this project calls it a "{{kit.board.term}}").
|
|
84
|
-
|
|
85
|
-
Skip this step for a plan of one phase that nobody runs as a lane, and say so.
|
|
72
|
+
matching `{{kit.planDirectory}}/{{kit.board.pattern}}` (this project calls it a "{{kit.board.term}}"). End by
|
|
73
|
+
proposing its rows; skip this for a plan of one phase that nobody runs as a lane, and say so.
|
|
86
74
|
|
|
87
75
|
Derive the rows from the dependencies table and the model table, nothing else:
|
|
88
76
|
|
|
@@ -92,33 +80,26 @@ Derive the rows from the dependencies table and the model table, nothing else:
|
|
|
92
80
|
Otherwise it is a `↳` row under a fitting lane, or a subagent there (`/implement` Step 0b).
|
|
93
81
|
- **Continuation:** a phase that shares a written path or a serial marker with an earlier phase, or depends on it,
|
|
94
82
|
is a `↳` row under that phase's lane, in the same worktree.
|
|
95
|
-
- **Starts after:** from the phase's {{kit.heading.dependsOn}} cell
|
|
96
|
-
|
|
97
|
-
- **
|
|
98
|
-
- **Plan:** a Markdown link to the plan, its path relative to the board's directory (`../<file>.md` for a plan
|
|
99
|
-
directly under `{{kit.planDirectory}}`).
|
|
100
|
-
Plans stay where they are; the row only links them.
|
|
83
|
+
- **Starts after:** from the phase's {{kit.heading.dependsOn}} cell (`P2 merged`, `User: mockups`); without one, at once.
|
|
84
|
+
- **Model:** the full model ID from the model table, never an alias.
|
|
85
|
+
- **Plan:** a Markdown link relative to the board's directory (`../<file>.md`). Plans stay where they are; the row only links them.
|
|
101
86
|
- **Lane name:** short kebab case, unique on the board; the worktree directory and the herdr agent carry the same name
|
|
102
|
-
(`/orchestrate` § 2).
|
|
103
|
-
- **Port:** `–`; the coordinator sets it
|
|
87
|
+
(`/orchestrate` § 2), else the herd lists the lane as a worktree without an agent.
|
|
88
|
+
- **Port:** `–`; the coordinator sets it, unless the project supplement assigns ports here.
|
|
104
89
|
|
|
105
90
|
Show the rows as the table they become: `## {{kit.heading.lanes}}` with the columns lane, port, plan, phases, starts
|
|
106
91
|
after, model, in this order and headed in {{kit.planLanguage}} (the herd reads them by position). Then ask
|
|
107
92
|
where they go:
|
|
108
93
|
|
|
109
|
-
- **The newest board**, by name: the newest `{{kit.planDirectory}}/{{kit.board.pattern}}/{{kit.board.file}}
|
|
110
|
-
|
|
111
|
-
- **A new board.** Ask for its name and propose one: the year and month or the topic, in the pattern's shape. Check
|
|
112
|
-
that the directory name matches `{{kit.board.pattern}}` before writing; if it does not, say why and ask again.
|
|
113
|
-
Create `{{kit.planDirectory}}/<name>/{{kit.board.file}}` with a `#` title and the `## {{kit.heading.lanes}}`
|
|
114
|
-
table holding the proposed rows.
|
|
94
|
+
- **The newest board**, by name: the newest `{{kit.planDirectory}}/{{kit.board.pattern}}/{{kit.board.file}}`, appended to its `## {{kit.heading.lanes}}` table.
|
|
95
|
+
- **A new board:** ask for its name, proposing one (the year and month or the topic). Check that the directory name matches `{{kit.board.pattern}}` before writing; if it does not, say why and ask again. Create `{{kit.planDirectory}}/<name>/{{kit.board.file}}` with a `#` title and the table.
|
|
115
96
|
- **Not now:** the rows stay in your answer, for `/orchestrate` to write when it starts the lanes.
|
|
116
97
|
|
|
117
98
|
Nothing is written without that answer. The board is a file like the plan: it goes into the same commit, under the
|
|
118
99
|
same approval.
|
|
119
100
|
|
|
120
101
|
After writing the rows, name the next step in one line: `/orchestrate <board path>`, from a Claude Code session
|
|
121
|
-
inside herdr. A plan or board
|
|
102
|
+
inside herdr. A plan or board not yet on `origin/{{kit.baseBranch}}` needs no merge first: `/orchestrate`
|
|
122
103
|
asks to commit it on a documentation branch and starts the lanes from there.
|
|
123
104
|
|
|
124
105
|
Ask every question with a fixed set of answers through the `AskUserQuestion` tool, in {{kit.responseLanguage}}.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jackherd",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.0",
|
|
4
4
|
"keywords": [
|
|
5
5
|
"claude-code",
|
|
6
6
|
"coding-agents",
|
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
"zod": "^4.0.0"
|
|
79
79
|
},
|
|
80
80
|
"devDependencies": {
|
|
81
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.310",
|
|
81
82
|
"@types/bun": "^1.4.3",
|
|
82
83
|
"tsup": "^8.5.1",
|
|
83
84
|
"typescript": "^5.8.3"
|
package/recipes/README.md
CHANGED
|
@@ -18,6 +18,7 @@ Each recipe is one `watch.sources` entry for the jackherd watcher: put in your k
|
|
|
18
18
|
- **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. The token reaches the script through the entry's `secrets` (the keychain, for a personal source only; see [sources.md](sources.md)) or through the watcher's environment; either way the script reads `process.env`.
|
|
19
19
|
- **A script prints one JSON value on stdout and nothing else.**
|
|
20
20
|
- **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. A 200 that carries an `errors` list, as GraphQL answers, is a failure too. Every request and every tool call is bounded with `AbortSignal.timeout(20_000)`, well under the 30 seconds a source may run.
|
|
21
|
+
- **The reason of a failure comes only through one stdout line.** Before it exits, a script may print `jackherd-reason: <reason>` on stdout (`HTTP 401`, `LINEAR_API_KEY not set`); the watcher shows the last such line as `exit 1: <reason>`, cut to 160 characters; a line over 1024 characters gives no reason. stderr stays unread. The line names a status or a missing variable, never a token and never a URL with access in it: the watcher masks the values of the entry's `secrets`, credentials and query values of URLs, but a token the script took from elsewhere it cannot recognise.
|
|
21
22
|
- **The scripts are files of their own** under [`sources/`](sources/). Copy one to `scripts/sources/<name>.ts` in your repository (a personal source: `~/.jackherd/sources/<name>.ts`), 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.
|
|
22
23
|
- **A recipe that needs no script** (`gh …`, `curl` on a public page, `curl --netrc`) stays a direct command.
|
|
23
24
|
- **`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).
|
package/recipes/jira-cli.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
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
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.
|
|
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. Before it exits, the script prints one stdout line `jackherd-reason: <reason>`, so the report reads `exit 1: <reason>`.
|
|
8
8
|
|
|
9
9
|
**Entry:**
|
|
10
10
|
|
|
@@ -7,8 +7,14 @@ import { addressesOf, civilOf, occurrencesOn, parseIcs, toItems } from './google
|
|
|
7
7
|
|
|
8
8
|
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
9
9
|
|
|
10
|
+
// The watcher shows this line with the exit code; it never names an address, the address is the credential.
|
|
11
|
+
const fail: (reason: string) => never = (reason) => {
|
|
12
|
+
console.log(`jackherd-reason: ${reason}`)
|
|
13
|
+
return process.exit(1)
|
|
14
|
+
}
|
|
15
|
+
|
|
10
16
|
const addresses = addressesOf(process.env)
|
|
11
|
-
if (addresses.length === 0)
|
|
17
|
+
if (addresses.length === 0) fail('GOOGLE_CALENDAR_ICS_URL not set')
|
|
12
18
|
|
|
13
19
|
// The address is the credential: nothing from a failed request (error text, URL) may reach stderr.
|
|
14
20
|
const readCalendar = async (address: string): Promise<string | undefined> => {
|
|
@@ -23,7 +29,7 @@ const readCalendar = async (address: string): Promise<string | undefined> => {
|
|
|
23
29
|
|
|
24
30
|
const answers = await Promise.all(addresses.map(readCalendar))
|
|
25
31
|
const texts = answers.filter((text) => text !== undefined)
|
|
26
|
-
if (texts.length < answers.length)
|
|
32
|
+
if (texts.length < answers.length) fail(`${answers.length - texts.length} of ${answers.length} calendars unreadable`)
|
|
27
33
|
|
|
28
34
|
const today = civilOf(Date.now(), zone)
|
|
29
35
|
const events = texts.flatMap((text) => parseIcs(text, zone))
|
package/recipes/sources/jira.ts
CHANGED
|
@@ -12,14 +12,22 @@ const plainText = (node: DocumentNode): string =>
|
|
|
12
12
|
? (node.text ?? node.attrs?.text ?? '')
|
|
13
13
|
: (node.content ?? []).map(plainText).join('')
|
|
14
14
|
|
|
15
|
+
// The watcher shows this line with the exit code; it never carries acli's output.
|
|
16
|
+
const fail: (reason: string) => never = (reason) => {
|
|
17
|
+
console.log(`jackherd-reason: ${reason}`)
|
|
18
|
+
return process.exit(1)
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
const acli = async (args: string[]): Promise<unknown> => {
|
|
16
22
|
const child = Bun.spawn(['acli', ...args], { stdout: 'pipe', stderr: 'ignore', signal: AbortSignal.timeout(20_000) })
|
|
17
23
|
const output = await new Response(child.stdout).text()
|
|
18
|
-
|
|
24
|
+
const code = await child.exited
|
|
25
|
+
const call = `acli ${args.slice(0, 2).join(' ')}`
|
|
26
|
+
if (code !== 0) fail(`${call} exited ${code}`)
|
|
19
27
|
try {
|
|
20
28
|
return JSON.parse(output)
|
|
21
29
|
} catch {
|
|
22
|
-
return
|
|
30
|
+
return fail(`${call} answered no JSON`)
|
|
23
31
|
}
|
|
24
32
|
}
|
|
25
33
|
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
// Copy to scripts/sources/linear.ts in your repository; the recipe recipes/linear.md says what to fill in.
|
|
2
|
+
// The watcher shows this line with the exit code; it never carries the token or a URL.
|
|
3
|
+
const fail: (reason: string) => never = (reason) => {
|
|
4
|
+
console.log(`jackherd-reason: ${reason}`)
|
|
5
|
+
return process.exit(1)
|
|
6
|
+
}
|
|
7
|
+
|
|
2
8
|
const token = process.env.LINEAR_API_KEY
|
|
3
|
-
if (!token)
|
|
9
|
+
if (!token) fail('LINEAR_API_KEY not set')
|
|
4
10
|
|
|
5
11
|
const query = `{ issues(filter: { team: { key: { eq: "ENG" } }, number: { eq: 123 } }) {
|
|
6
12
|
nodes { identifier title url updatedAt comments { nodes { body user { name } } } } } }`
|
|
@@ -11,9 +17,9 @@ const response = await fetch('https://<host>/graphql', {
|
|
|
11
17
|
body: JSON.stringify({ query }),
|
|
12
18
|
signal: AbortSignal.timeout(20_000),
|
|
13
19
|
})
|
|
14
|
-
if (!response.ok)
|
|
20
|
+
if (!response.ok) fail(`HTTP ${response.status}`)
|
|
15
21
|
const answer = (await response.json()) as { errors?: unknown }
|
|
16
|
-
if (answer.errors)
|
|
22
|
+
if (answer.errors) fail('GraphQL errors in the answer')
|
|
17
23
|
console.log(JSON.stringify(answer))
|
|
18
24
|
|
|
19
25
|
export {}
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
// Copy to scripts/sources/sentry.ts in your repository; the recipe recipes/sentry.md says what to fill in.
|
|
2
|
+
// The watcher shows this line with the exit code; it never carries the token or a URL.
|
|
3
|
+
const fail: (reason: string) => never = (reason) => {
|
|
4
|
+
console.log(`jackherd-reason: ${reason}`)
|
|
5
|
+
return process.exit(1)
|
|
6
|
+
}
|
|
7
|
+
|
|
2
8
|
const token = process.env.SENTRY_AUTH_TOKEN
|
|
3
|
-
if (!token)
|
|
9
|
+
if (!token) fail('SENTRY_AUTH_TOKEN not set')
|
|
4
10
|
|
|
5
11
|
const response = await fetch(
|
|
6
12
|
'https://<host>/api/0/projects/<org>/<project>/issues/?query=is:unresolved&statsPeriod=24h',
|
|
7
13
|
{ headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(20_000) },
|
|
8
14
|
)
|
|
9
|
-
if (!response.ok)
|
|
15
|
+
if (!response.ok) fail(`HTTP ${response.status}`)
|
|
10
16
|
console.log(JSON.stringify(await response.json()))
|
|
11
17
|
|
|
12
18
|
export {}
|
package/recipes/sources.md
CHANGED
|
@@ -79,7 +79,7 @@ first run of this source: baseline, nothing reported; later a new or changed ent
|
|
|
79
79
|
watcher state not read or changed; a running watcher takes a new source over at its next cycle
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
**Keep a personal source.** A source can live in `~/.jackherd/sources.json` instead of the repository: `{ "$schema": "…/schemas/sources.schema.json", "sources": [ … ] }`, entries as above. It runs in every repository where the watcher starts, after the repository's `watch.sources
|
|
82
|
+
**Keep a personal source.** A source can live in `~/.jackherd/sources.json` instead of the repository: `{ "$schema": "…/schemas/sources.schema.json", "sources": [ … ] }`, entries as above. It runs in every repository where the watcher starts, after the repository's `watch.sources`, unless it carries `"repos": ["owner/name"]` (one to twenty, unique without regard to case): then it runs only where the main checkout's `origin` names one of them, and elsewhere the watcher notes `<name>: not for this repo` (`<name>: no origin` when the checkout has none). `repos` belongs in the personal file only; the repository's config refuses it. `jh watch source <name> --dry-run` of a left-out source prints the note and exits 2 without running anything. On a name clash the repository's source wins and the personal one is skipped with a note. A personal source runs with `~/.jackherd/` as its working directory, so its script is `~/.jackherd/sources/<name>.ts` and its command `["bun", "sources/<name>.ts"]`. `jh watch source <name> --dry-run` finds it too and prints `origin: personal, <path>`; a draft file runs as a repository source, in the main checkout and without the keychain.
|
|
83
83
|
|
|
84
84
|
**Keep a secret for a source.** `"secrets": ["NAME"]` (up to 10 names, `A-Z`, digits and `_`, starting with a letter) puts those variables into the environment of the source's command, and nowhere else. For a personal source each value comes from the keychain (service `jackherd`), else from the watcher's own environment; a repository source gets them from the watcher's environment only, never from the keychain. A value that is found nowhere fails the run with `secret missing: NAME`, an unreachable keychain with `secret store unavailable`; other sources go on. The script reads the value from `process.env` and never prints it.
|
|
85
85
|
|
|
@@ -91,6 +91,8 @@ jh secret delete NAME
|
|
|
91
91
|
|
|
92
92
|
**Show entries as content.** `"show": "entries"` opens the source's entries in a herd block of their own under "Your turn", headed by its `label`, instead of the folded row in `Sources`. For a source whose entries are the day's content, such as a calendar; not together with `ticket`.
|
|
93
93
|
|
|
94
|
-
The watcher and the herd read the repository's config and the personal file again each cycle (about a minute): a new, changed or removed source counts without a restart, a new one runs at once. A changed file that is invalid keeps the previous sources and prints `sources not reloaded: <file>: <reason>`.
|
|
94
|
+
The watcher and the herd read the repository's config and the personal file again each cycle (about a minute): a new, changed or removed source counts without a restart, a new one runs at once. A changed file that is invalid keeps the previous sources and prints `sources not reloaded: <file>: <reason>`. `deploy`, `feedback`, `previews` and `checks` follow the same way; a change that applies prints `config reloaded: <parts>`, an invalid one keeps every value and prints `config not reloaded: <file>: <reason>`. The board and the language are read when the watcher starts.
|
|
95
|
+
|
|
96
|
+
**Say why a source failed.** A failing command may print one stdout line `jackherd-reason: <reason>` (a status, a missing variable); the watcher shows the last such line as `exit 1: <reason>`, with the values of the source's `secrets` and URL credentials masked, cut to 160 characters. stderr is never read. Name no token in it.
|
|
95
97
|
|
|
96
98
|
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.
|
|
@@ -143,6 +143,10 @@
|
|
|
143
143
|
"lockFile": {
|
|
144
144
|
"type": "string",
|
|
145
145
|
"pattern": "^(?![/-])(?!(?:.*\\/)?\\.\\.(?:\\/|$))[A-Za-z0-9._/-]+$"
|
|
146
|
+
},
|
|
147
|
+
"minFreeGb": {
|
|
148
|
+
"type": "number",
|
|
149
|
+
"exclusiveMinimum": 0
|
|
146
150
|
}
|
|
147
151
|
},
|
|
148
152
|
"additionalProperties": false
|
|
@@ -645,6 +649,15 @@
|
|
|
645
649
|
"show": {
|
|
646
650
|
"type": "string",
|
|
647
651
|
"const": "entries"
|
|
652
|
+
},
|
|
653
|
+
"repos": {
|
|
654
|
+
"minItems": 1,
|
|
655
|
+
"maxItems": 20,
|
|
656
|
+
"type": "array",
|
|
657
|
+
"items": {
|
|
658
|
+
"type": "string",
|
|
659
|
+
"pattern": "^[\\w.-]+\\/[\\w.-]+$"
|
|
660
|
+
}
|
|
648
661
|
}
|
|
649
662
|
},
|
|
650
663
|
"required": [
|
|
@@ -699,6 +712,28 @@
|
|
|
699
712
|
"type": "integer",
|
|
700
713
|
"minimum": 1,
|
|
701
714
|
"maximum": 9
|
|
715
|
+
},
|
|
716
|
+
"standing": {
|
|
717
|
+
"type": "object",
|
|
718
|
+
"properties": {
|
|
719
|
+
"tools": {
|
|
720
|
+
"type": "object",
|
|
721
|
+
"propertyNames": {
|
|
722
|
+
"type": "string",
|
|
723
|
+
"pattern": "^mcp__[A-Za-z0-9_-]+__execute_sql$"
|
|
724
|
+
},
|
|
725
|
+
"additionalProperties": {
|
|
726
|
+
"type": "string",
|
|
727
|
+
"enum": [
|
|
728
|
+
"stage"
|
|
729
|
+
]
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
},
|
|
733
|
+
"required": [
|
|
734
|
+
"tools"
|
|
735
|
+
],
|
|
736
|
+
"additionalProperties": false
|
|
702
737
|
}
|
|
703
738
|
},
|
|
704
739
|
"additionalProperties": false
|
|
@@ -929,6 +964,38 @@
|
|
|
929
964
|
"maximum": 9007199254740991
|
|
930
965
|
}
|
|
931
966
|
},
|
|
967
|
+
"openBoxes": {
|
|
968
|
+
"default": "fail",
|
|
969
|
+
"type": "string",
|
|
970
|
+
"enum": [
|
|
971
|
+
"fail",
|
|
972
|
+
"warn",
|
|
973
|
+
"off"
|
|
974
|
+
]
|
|
975
|
+
},
|
|
976
|
+
"lintPointers": {
|
|
977
|
+
"type": "object",
|
|
978
|
+
"properties": {
|
|
979
|
+
"level": {
|
|
980
|
+
"default": "warn",
|
|
981
|
+
"type": "string",
|
|
982
|
+
"enum": [
|
|
983
|
+
"warn",
|
|
984
|
+
"fail",
|
|
985
|
+
"off"
|
|
986
|
+
]
|
|
987
|
+
},
|
|
988
|
+
"exclude": {
|
|
989
|
+
"default": [],
|
|
990
|
+
"type": "array",
|
|
991
|
+
"items": {
|
|
992
|
+
"type": "string",
|
|
993
|
+
"minLength": 1
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
},
|
|
997
|
+
"additionalProperties": false
|
|
998
|
+
},
|
|
932
999
|
"looseTests": {
|
|
933
1000
|
"default": false,
|
|
934
1001
|
"type": "boolean"
|
|
@@ -126,6 +126,15 @@
|
|
|
126
126
|
"show": {
|
|
127
127
|
"type": "string",
|
|
128
128
|
"const": "entries"
|
|
129
|
+
},
|
|
130
|
+
"repos": {
|
|
131
|
+
"minItems": 1,
|
|
132
|
+
"maxItems": 20,
|
|
133
|
+
"type": "array",
|
|
134
|
+
"items": {
|
|
135
|
+
"type": "string",
|
|
136
|
+
"pattern": "^[\\w.-]+\\/[\\w.-]+$"
|
|
137
|
+
}
|
|
129
138
|
}
|
|
130
139
|
},
|
|
131
140
|
"required": [
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
3
|
+
import { isRecord } from '../shared/is-record'
|
|
4
|
+
import type { ApprovalRequestFile } from '../watch/approvals'
|
|
5
|
+
|
|
6
|
+
/** What `jh approval show|checked` prints and how it ends: 0 shown, 1 wrong arguments, 2 no such open request. */
|
|
7
|
+
export type ApprovalReport = { exitCode: number; stdout: string[]; stderr: string[] }
|
|
8
|
+
|
|
9
|
+
type RequestCall = Pick<ApprovalRequestFile, 'lane' | 'cwd' | 'tool' | 'input'>
|
|
10
|
+
|
|
11
|
+
const VERBS = new Set(['show', 'checked'])
|
|
12
|
+
const USAGE = 'Usage: jh approval show|checked <id> <hash>'
|
|
13
|
+
const ID = /^[0-9a-f]{12}$/
|
|
14
|
+
const HASH = /^[0-9a-f]{16}$/
|
|
15
|
+
|
|
16
|
+
/** JSON with the keys of every object sorted, as the approval gate hook hashes a call. */
|
|
17
|
+
const canonicalJson = (value: unknown): string => {
|
|
18
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
|
19
|
+
if (isRecord(value))
|
|
20
|
+
return `{${Object.keys(value)
|
|
21
|
+
.sort()
|
|
22
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
23
|
+
.join(',')}}`
|
|
24
|
+
return JSON.stringify(value) ?? 'null'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The hash of a call as the approval gate hook writes it (`callHash`, which this package does not import; contract test). */
|
|
28
|
+
export const requestHash = ({ lane, cwd, tool, input }: RequestCall): string =>
|
|
29
|
+
createHash('sha256').update(canonicalJson({ lane, cwd, tool, input })).digest('hex').slice(0, 16)
|
|
30
|
+
|
|
31
|
+
const refused = (exitCode: number, line: string): ApprovalReport => ({ exitCode, stdout: [], stderr: [line] })
|
|
32
|
+
|
|
33
|
+
/** The request as the coordinator reads it before it marks it: every field the lane wrote, made safe to print. */
|
|
34
|
+
const shown = ({ id, hash, lane, tool, cwd, input }: ApprovalRequestFile): string[] => [
|
|
35
|
+
`approval ${id} ${hash}`,
|
|
36
|
+
`lane: ${terminalSafe(lane)}`,
|
|
37
|
+
`tool: ${terminalSafe(tool)}`,
|
|
38
|
+
`cwd: ${terminalSafe(cwd)}`,
|
|
39
|
+
'input:',
|
|
40
|
+
...(JSON.stringify(input, null, 2) ?? 'null').split('\n').map(terminalSafe),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `jh approval show <id> <hash>` and `jh approval checked <id> <hash>`: print the open request whose id and hash these
|
|
45
|
+
* are, and whose content its hash covers, and end with 0; otherwise say why and end with 2. Neither writes anything.
|
|
46
|
+
* `show` is for reading; a successful `checked` in the coordinator's transcript is the mark the approval gate hook
|
|
47
|
+
* reads as "the coordinator checked exactly this call", so it comes after the check, never before.
|
|
48
|
+
*/
|
|
49
|
+
export const approvalReport = (args: readonly string[], requests: readonly ApprovalRequestFile[]): ApprovalReport => {
|
|
50
|
+
const [verb, id, hash, ...rest] = args
|
|
51
|
+
if (!VERBS.has(verb ?? '') || !ID.test(id ?? '') || !HASH.test(hash ?? '') || rest.length > 0)
|
|
52
|
+
return refused(1, USAGE)
|
|
53
|
+
const request = requests.find((candidate) => candidate.id === id && candidate.usedAt === null)
|
|
54
|
+
if (request === undefined) return refused(2, `no open request ${id}`)
|
|
55
|
+
if (request.hash !== hash)
|
|
56
|
+
return refused(2, `request ${id} has hash ${request.hash}, not ${hash}: it changed, look at it again`)
|
|
57
|
+
if (requestHash(request) !== hash)
|
|
58
|
+
return refused(2, `request ${id} does not match its hash: it was changed by hand, ask the user`)
|
|
59
|
+
return { exitCode: 0, stdout: shown(request), stderr: [] }
|
|
60
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { mainCheckout } from '../plans/git'
|
|
2
|
+
import { readApprovalRequests } from '../watch/approvals'
|
|
3
|
+
import { approvalReport } from './checked'
|
|
4
|
+
|
|
5
|
+
/** `jh approval show|checked <id> <hash>`: read a held call, and the coordinator's mark that it checked it (`approvalReport`). */
|
|
6
|
+
export const run = (args: string[]): number => {
|
|
7
|
+
try {
|
|
8
|
+
const report = approvalReport(args, readApprovalRequests(mainCheckout(process.cwd())))
|
|
9
|
+
for (const line of report.stdout) console.log(line)
|
|
10
|
+
for (const line of report.stderr) console.error(line)
|
|
11
|
+
return report.exitCode
|
|
12
|
+
} catch (error) {
|
|
13
|
+
console.error(`jh approval: ${error instanceof Error ? error.message : String(error)}`)
|
|
14
|
+
return 1
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/check/command.ts
CHANGED
|
@@ -3,17 +3,21 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import { join, resolve } from 'node:path'
|
|
5
5
|
import { planRootsIn } from '../../kit/hooks/hooks/shared/boards'
|
|
6
|
+
import { laneOf } from '../../kit/hooks/hooks/shared/lane'
|
|
6
7
|
import { findProjectRoot, globalConfigPath, locateConfigFile, MANIFEST_FILE, TOOL_DIRECTORY } from '../config/location'
|
|
7
8
|
import { readRepositoryFacts } from '../config/repository-facts'
|
|
9
|
+
import { installedReferenceHint } from '../lane/skill-hint'
|
|
8
10
|
import { mainCheckout } from '../plans/git'
|
|
9
11
|
import { baseRefOf } from '../plans/settings'
|
|
12
|
+
import { REFERENCE } from '../watch/reference-paths'
|
|
10
13
|
import { type ChangedFile, parsePorcelain, STATUS_COMMAND } from './changes'
|
|
11
14
|
import { deferralArrowCheck } from './deferral-arrows'
|
|
12
15
|
import { runCheck, runProcess } from './execute'
|
|
13
16
|
import { matchesAnyGlob } from './glob'
|
|
17
|
+
import { lintPointerCheck } from './lint-pointers'
|
|
14
18
|
import { parallelArgument, parallelReportLines } from './parallel-report'
|
|
15
19
|
import { type CheckPlan, type CheckStack, checkStackOf, type Filesystem, isSlow, planChecks } from './plan'
|
|
16
|
-
import { planStructureChecks } from './plan-structure-check'
|
|
20
|
+
import { laneOpenBoxesCheck, planStructureChecks } from './plan-structure-check'
|
|
17
21
|
import { publicContentCheck } from './public-content-check'
|
|
18
22
|
import {
|
|
19
23
|
artifactResult,
|
|
@@ -29,6 +33,7 @@ import {
|
|
|
29
33
|
} from './report'
|
|
30
34
|
import { type CheckResult, RUN_MODE, type RunMode, runModeOf } from './result'
|
|
31
35
|
import { isWorkspaceManifest, kitCopyPaths, WORKSPACE_LIST_FILE, WORKSPACE_MANIFEST } from './roots'
|
|
36
|
+
import { DEFAULT_LINT_POINTERS } from './schema'
|
|
32
37
|
import { type CheckSettings, CONFIG_PATH, noStackFallbackOf, parseCheckSettings, stackOf } from './settings'
|
|
33
38
|
import { skillBudgetCheck } from './skill-budgets'
|
|
34
39
|
|
|
@@ -199,11 +204,50 @@ const planRootsOf = (root: string, worktreeDirectory: string): string[] => {
|
|
|
199
204
|
}
|
|
200
205
|
}
|
|
201
206
|
|
|
202
|
-
/** The
|
|
203
|
-
const
|
|
207
|
+
/** The lane a checkout is for: the first directory below the main checkout's worktree directory; null outside one. */
|
|
208
|
+
const laneOfCheckout = (root: string, worktreeDirectory: string): string | null => {
|
|
209
|
+
try {
|
|
210
|
+
return laneOf({ cwd: root, main: mainCheckout(root), worktreeDirectory })
|
|
211
|
+
} catch {
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The open boxes of the lane's phase, left out for a checkout that is no lane's. */
|
|
217
|
+
const openBoxesCheckOf = (root: string, settings: CheckSettings): CheckResult | null => {
|
|
218
|
+
const lane = laneOfCheckout(root, settings.worktreeDirectory)
|
|
219
|
+
return lane === null
|
|
220
|
+
? null
|
|
221
|
+
: laneOpenBoxesCheck({
|
|
222
|
+
root,
|
|
223
|
+
lane,
|
|
224
|
+
planDirectory: settings.planDirectory,
|
|
225
|
+
board: settings.board,
|
|
226
|
+
language: settings.planLanguage,
|
|
227
|
+
level: settings.check.openBoxes,
|
|
228
|
+
baseOf: contentAt(root, baseRefOf(settings)),
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The checks jackherd runs itself beside the configured ones: public content, deferral arrows, skill budgets, lint pointers, plan structure. */
|
|
233
|
+
const ownChecksOf = (
|
|
234
|
+
root: string,
|
|
235
|
+
paths: readonly string[],
|
|
236
|
+
settings: CheckSettings,
|
|
237
|
+
home: string,
|
|
238
|
+
kitCopies: readonly string[],
|
|
239
|
+
) => [
|
|
240
|
+
openBoxesCheckOf(root, settings),
|
|
204
241
|
publicContentCheck(root, paths, settings.check.publicContent, home),
|
|
205
242
|
deferralArrowCheck(root, paths, settings.planDirectory),
|
|
206
243
|
skillBudgetCheck(root, settings.check.skillBudgets, settings.skillPrefix),
|
|
244
|
+
lintPointerCheck(
|
|
245
|
+
root,
|
|
246
|
+
paths,
|
|
247
|
+
settings.check.lintPointers ?? DEFAULT_LINT_POINTERS,
|
|
248
|
+
kitCopies,
|
|
249
|
+
contentAt(root, 'HEAD'),
|
|
250
|
+
),
|
|
207
251
|
...planStructureChecks({
|
|
208
252
|
root,
|
|
209
253
|
paths,
|
|
@@ -228,8 +272,9 @@ const checkChanges = async (
|
|
|
228
272
|
): Promise<CheckResult[] | string> => {
|
|
229
273
|
const stack = checkStackOf(stackOf(settings.stack))
|
|
230
274
|
let plan: CheckPlan
|
|
275
|
+
let kitCopies: string[]
|
|
231
276
|
try {
|
|
232
|
-
|
|
277
|
+
kitCopies = kitCopyPaths(readJsonIfPresent(join(root, TOOL_DIRECTORY, MANIFEST_FILE)))
|
|
233
278
|
plan = planChecks(
|
|
234
279
|
files,
|
|
235
280
|
{
|
|
@@ -253,7 +298,7 @@ const checkChanges = async (
|
|
|
253
298
|
testCommandResult(settings.commands.test, settings.stack),
|
|
254
299
|
missingTestCommandResult(settings.commands.test, settings.stack),
|
|
255
300
|
namedTestResult(plan.sourcesWithoutNamedTest),
|
|
256
|
-
...ownChecksOf(root, plan.paths, settings, home),
|
|
301
|
+
...ownChecksOf(root, plan.paths, settings, home, kitCopies),
|
|
257
302
|
])
|
|
258
303
|
}
|
|
259
304
|
|
|
@@ -282,7 +327,7 @@ export const run = async (args: string[], home: string = homedir()): Promise<num
|
|
|
282
327
|
}
|
|
283
328
|
print(settings.notes)
|
|
284
329
|
if (files.length === 0) {
|
|
285
|
-
console.log(
|
|
330
|
+
console.log(`Nothing changed.${installedReferenceHint(root, REFERENCE.events, home)}`)
|
|
286
331
|
return 0
|
|
287
332
|
}
|
|
288
333
|
const results = await checkChanges(files, root, settings, runModeOf(args), home)
|