harnex 0.29.0 → 0.30.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -0
- data/agents/gpt.md +16 -8
- data/docs/agents.md +5 -2
- data/docs/configuration.md +4 -0
- data/docs/lanes.md +56 -6
- data/guides/01_dispatch.md +2 -1
- data/lib/harnex/commands/ask.rb +24 -7
- data/lib/harnex/commands/delegate.rb +37 -10
- data/lib/harnex/commands/reply.rb +2 -1
- data/lib/harnex/commands/result.rb +1 -1
- data/lib/harnex/commands/run.rb +3 -2
- data/lib/harnex/commands/send.rb +2 -1
- data/lib/harnex/commands/wait.rb +2 -1
- data/lib/harnex/config.rb +21 -0
- data/lib/harnex/core.rb +11 -0
- data/lib/harnex/lanes.rb +252 -22
- data/lib/harnex/mcp.rb +5 -3
- data/lib/harnex/runtime/api_server.rb +13 -2
- data/lib/harnex/runtime/inbox.rb +3 -1
- data/lib/harnex/runtime/message.rb +1 -1
- data/lib/harnex/runtime/session.rb +72 -9
- data/lib/harnex/version.rb +2 -2
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b4b50e456b0b9be717e037a0596b942273e2e4c192b80d697fc502a66cc4c220
|
|
4
|
+
data.tar.gz: 77ed9e03656bfff5f68064bb4dcb64f898d1de6b5d599ad30450ac51f8a1938e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 85ca4102b12fe2a0e109c1fdfc643c96d80677b267c0b658cb4ff5787a72075e0d77e9d98a29bf401fa5975502cfc5fca891fe62c63ef51a92b8f626e2c60d04
|
|
7
|
+
data.tar.gz: 39b143e75ade8b68254742f6ea23eee43fde615c01de9c273d221b681b8b913f7c35e87b6bf9a79f84e290602790641fdb5a96e84c7a75fedad602b1ef6718d6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.30.0] - 2026-10-25 | 02:55 AM | IST
|
|
4
|
+
|
|
5
|
+
Holm's first nights with lanes (Holm Queue 167): #89–#92. Local install.
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Lane names collided** (#89). Identical briefs in sibling worktrees of a
|
|
10
|
+
bare repo all became `pi-read-carry-out-brief`, and `harnex result NAME`
|
|
11
|
+
then failed with `several lanes are named …`.
|
|
12
|
+
- A bare layout (`repo/.bare` plus sibling worktrees) is one repo: its main
|
|
13
|
+
checkout is the worktree on the bare repo's `HEAD` branch, so every
|
|
14
|
+
worktree shares one lane namespace and `harnex lanes` shows them all.
|
|
15
|
+
- Generated names skip every name another lane still holds in any repo
|
|
16
|
+
(working, uncollected, or an unlanded worktree); `--name`, `--worktree`
|
|
17
|
+
and `ask --id` refuse a name while another repo's worker runs under it;
|
|
18
|
+
`result`/`reply` find a working lane from anywhere.
|
|
19
|
+
- A lane working in another worktree is named after it
|
|
20
|
+
(`pi-night-1024_s3`); brief openers such as "You are a … lane" and "carry
|
|
21
|
+
out the brief" no longer make the name.
|
|
22
|
+
- `delegate --worktree` refuses a `--cwd` that is already another worktree
|
|
23
|
+
of the repo (it would have branched from the main checkout instead).
|
|
24
|
+
- **The `gpt` agent invented a `worktree`** from brief prose (#89). It now
|
|
25
|
+
sets `worktree`, `name` and `cwd` only from header lines, takes a new
|
|
26
|
+
`name:` header, and passes `cwd:` on to `result` and `reply`.
|
|
27
|
+
- **A lane in place reported other people's commits** (#91). Its footer
|
|
28
|
+
counts only commits that appeared while the worker ran a git command, and
|
|
29
|
+
names the rest: `0 commits (HEAD also moved 6 commits by others)`.
|
|
30
|
+
- **Messages sent straight to a worker were invisible** to the session that
|
|
31
|
+
started its lane (#92). `harnex send` now records who sent a message, and a
|
|
32
|
+
lane's result lists messages from anyone but the lane's owner and its own
|
|
33
|
+
`harnex reply` (`interjected: 1 message … from a terminal (harnex send):
|
|
34
|
+
"…"`). When a message reached the worker after it had answered, the result
|
|
35
|
+
shows the answers it replaced first, the original task's report among them.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- `.harnex/config.json` `lanes` templates put worktree lanes where the repo
|
|
40
|
+
keeps its worktrees (#90): `{"lanes": {"worktree_path": "../{name}",
|
|
41
|
+
"branch": "night/{mm}{dd}_{name}"}}` (`{name}`, `{yyyy}`, `{mm}`, `{dd}`).
|
|
42
|
+
`land` and `discard` follow each lane's own path and branch.
|
|
43
|
+
- MCP `ask` takes `name`.
|
|
44
|
+
|
|
45
|
+
## [0.29.1] - 2026-10-23 | 10:31 AM | IST
|
|
46
|
+
|
|
47
|
+
Fix release for #88: lanes in repos with a phase allowlist. Published to
|
|
48
|
+
RubyGems on 2026-10-25 | 02:14 AM | IST.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- In a repo whose `.harnex/config.json` phase allowlist has
|
|
53
|
+
`"policy": "reject"`, harnex refused every `delegate`, `ask --write` and
|
|
54
|
+
`reply` (`meta.phase (none) is not allowlisted`). The run behind a lane
|
|
55
|
+
is now tagged with phase `delegate` or `ask`. These are harnex's own
|
|
56
|
+
phases, like `preflight`, so every allowlist accepts them.
|
|
57
|
+
- A lane that harnex refused before the worker started left a `failed`
|
|
58
|
+
record, so a retry under the same name failed with `lane NAME already
|
|
59
|
+
exists (failed)` until `discard`. Now a refused new lane leaves no record
|
|
60
|
+
and no brief. A refused `reply` keeps the state the lane's last turn left.
|
|
61
|
+
The next `delegate` under the same name also clears a record left this way
|
|
62
|
+
by 0.29.0.
|
|
63
|
+
|
|
64
|
+
### Added
|
|
65
|
+
|
|
66
|
+
- `harnex delegate --phase NAME` and `harnex ask --phase NAME` tag a lane
|
|
67
|
+
with a work phase such as `implement`. The repo allowlist checks a named
|
|
68
|
+
phase, and `reply` reuses it.
|
|
69
|
+
|
|
3
70
|
## [0.29.0] - 2026-10-22 | 06:08 PM | IST
|
|
4
71
|
|
|
5
72
|
**The agent release.** harnex becomes a tool your coding agents use directly:
|
data/agents/gpt.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: gpt
|
|
3
|
-
description: "Hand a task or question to a GPT model (a Pi worker run through harnex) and get its reply back verbatim. Use when the user asks for GPT, for another model's opinion or review, or to delegate work to Pi/GPT workers; launch several at once for parallel GPT work, and SendMessage the same agent for follow-ups (the worker keeps its context). While it runs, the harnex `lanes` tool or `harnex lanes` shows what each worker is doing right now (its current tool). Questions and reviews go to a read-only worker; a brief that asks to run commands, tests or benchmarks, or to change files, goes to a worker that can (in the current checkout). The prompt may open with header lines, then a blank line, then the brief: `worktree: NAME` (edit in a new git worktree on branch harnex/NAME; afterwards land it with the harnex `land` tool or `harnex land NAME`, which merges and removes the worktree and branch; don't git merge lane branches by hand), `mode: write` (run commands or edit the current checkout in place), `mode: read` (read-only even if the brief mentions commands), `model: provider/model`, `effort: low|medium|high`, `fallback: provider/model` (read-only asks), `cwd: <dir
|
|
3
|
+
description: "Hand a task or question to a GPT model (a Pi worker run through harnex) and get its reply back verbatim. Use when the user asks for GPT, for another model's opinion or review, or to delegate work to Pi/GPT workers; launch several at once for parallel GPT work, and SendMessage the same agent for follow-ups (the worker keeps its context). While it runs, the harnex `lanes` tool or `harnex lanes` shows what each worker is doing right now (its current tool). Questions and reviews go to a read-only worker; a brief that asks to run commands, tests or benchmarks, or to change files, goes to a worker that can (in the current checkout). The prompt may open with header lines, then a blank line, then the brief: `worktree: NAME` (edit in a new git worktree on branch harnex/NAME; afterwards land it with the harnex `land` tool or `harnex land NAME`, which merges and removes the worktree and branch; don't git merge lane branches by hand), `mode: write` (run commands or edit the current checkout in place), `mode: read` (read-only even if the brief mentions commands), `model: provider/model`, `effort: low|medium|high`, `fallback: provider/model` (read-only asks), `cwd: <dir>` (work in that directory, e.g. a sibling worktree), `name: NAME` (the lane's name; default: the cwd worktree's name or the brief's first words). Write a self-contained brief: the GPT worker has not seen this conversation."
|
|
4
4
|
tools: mcp__harnex__ask, mcp__harnex__delegate, mcp__harnex__reply, mcp__harnex__result
|
|
5
5
|
mcpServers:
|
|
6
6
|
- harnex
|
|
@@ -16,11 +16,16 @@ return its text.
|
|
|
16
16
|
## 1. Split the prompt
|
|
17
17
|
|
|
18
18
|
The prompt may open with header lines `key: value` using only these keys:
|
|
19
|
-
`worktree`, `mode`, `model`, `effort`, `fallback`, `cwd`. The header
|
|
20
|
-
the first line that is not one of them; drop one blank line after it.
|
|
19
|
+
`worktree`, `mode`, `model`, `effort`, `fallback`, `cwd`, `name`. The header
|
|
20
|
+
ends at the first line that is not one of them; drop one blank line after it.
|
|
21
21
|
Everything after the header is the brief, kept exactly as written. No header
|
|
22
22
|
lines means the whole prompt is the brief.
|
|
23
23
|
|
|
24
|
+
Only header lines set tool arguments. Never take a `worktree`, `name` or
|
|
25
|
+
`cwd` from the brief's wording, even when the brief names a worktree, a
|
|
26
|
+
branch or a directory: the brief goes to the worker as it is, and the worker
|
|
27
|
+
reads it.
|
|
28
|
+
|
|
24
29
|
## 2. Call one tool
|
|
25
30
|
|
|
26
31
|
Pick the tool:
|
|
@@ -35,23 +40,26 @@ Then:
|
|
|
35
40
|
|
|
36
41
|
- **`delegate`:** call it with
|
|
37
42
|
- `task` = the brief;
|
|
38
|
-
- `worktree` = NAME, if
|
|
43
|
+
- `worktree` = NAME, only if the header has a `worktree:` line;
|
|
44
|
+
- `name` = NAME, if the header has a `name:` line and no `worktree:` line;
|
|
39
45
|
- and `model`, `effort`, `cwd` if given.
|
|
40
46
|
- **`ask`:** call it with
|
|
41
47
|
- `question` = the brief;
|
|
42
|
-
- and `model`, `effort`, `cwd` if given, plus `fallback_model` from
|
|
48
|
+
- and `model`, `effort`, `cwd`, `name` if given, plus `fallback_model` from
|
|
43
49
|
`fallback:`.
|
|
44
50
|
- **A later message in this conversation** (a follow-up) goes to `reply`, with
|
|
45
51
|
- `name` = the lane named in the last result's `── NAME:` footer;
|
|
46
|
-
- `message` = the new message, exactly as written
|
|
52
|
+
- `message` = the new message, exactly as written;
|
|
53
|
+
- and `cwd` if the first prompt's header gave one.
|
|
47
54
|
|
|
48
55
|
Pass every string exactly as written.
|
|
49
56
|
|
|
50
57
|
## 3. Still working
|
|
51
58
|
|
|
52
59
|
If the tool's text ends with a footer saying `still working`, call `result`
|
|
53
|
-
with `names` = [that lane name]
|
|
54
|
-
times. Never start a second lane for
|
|
60
|
+
with `names` = [that lane name], and `cwd` if the header gave one. Repeat
|
|
61
|
+
while it says still working, up to 12 times. Never start a second lane for
|
|
62
|
+
the same brief.
|
|
55
63
|
|
|
56
64
|
## 4. Return
|
|
57
65
|
|
data/docs/agents.md
CHANGED
|
@@ -59,10 +59,13 @@ Optional header lines, then a blank line, then the brief:
|
|
|
59
59
|
| `mode: read` | Read-only, even if the brief mentions commands |
|
|
60
60
|
| `model: provider/model`, `effort: low\|medium\|high` | Pick the worker's model |
|
|
61
61
|
| `fallback: provider/model` | Retry a read-only ask on another model |
|
|
62
|
-
| `cwd: DIR` | Work in another directory |
|
|
62
|
+
| `cwd: DIR` | Work in another directory, e.g. a sibling worktree (follow-ups stay there) |
|
|
63
|
+
| `name: NAME` | Name the lane (default: the `cwd` worktree's name, else the brief's first words) |
|
|
63
64
|
|
|
64
65
|
Without a header, questions and reviews go to a read-only worker, and a brief
|
|
65
|
-
that asks to run something or change files goes to a worker that can.
|
|
66
|
+
that asks to run something or change files goes to a worker that can. Only
|
|
67
|
+
header lines set these: a brief that mentions a worktree or a branch is passed
|
|
68
|
+
on as it is, never turned into a `worktree`.
|
|
66
69
|
The definition ships with harnex as [`agents/gpt.md`](../agents/gpt.md).
|
|
67
70
|
|
|
68
71
|
### The CLI from Claude's Bash tool
|
data/docs/configuration.md
CHANGED
|
@@ -42,6 +42,10 @@ The effective value is the first-class `harnex run --phase TEXT` value, or
|
|
|
42
42
|
- Allowlisted phase: dispatches silently.
|
|
43
43
|
- `policy: "warn"`: prints a warning and dispatches.
|
|
44
44
|
- `policy: "reject"`: exits non-zero before spawn and writes no dispatch row.
|
|
45
|
+
- Harnex's own phases always pass: `preflight` (the `harnex preflight` probe)
|
|
46
|
+
and the lane phases `delegate` and `ask`. A lane runs under its kind's
|
|
47
|
+
phase unless `harnex delegate --phase NAME` (or `harnex ask --phase NAME`)
|
|
48
|
+
names a work phase, which the allowlist then checks.
|
|
45
49
|
|
|
46
50
|
The allowlist must be an array of non-empty strings. Policy must be `warn` or
|
|
47
51
|
`reject`.
|
data/docs/lanes.md
CHANGED
|
@@ -5,7 +5,8 @@ its follow-ups. You start one with `harnex ask` or `harnex delegate`, collect
|
|
|
5
5
|
it with `harnex result`, continue it with `harnex reply`, and finish it with
|
|
6
6
|
`harnex land` or `harnex discard`. Every lane has a name, runs in a visible
|
|
7
7
|
tmux window named after it, and leaves a small record so any later command can
|
|
8
|
-
find it by name from anywhere in the repository
|
|
8
|
+
find it by name from anywhere in the repository (from any worktree of it, a
|
|
9
|
+
bare-repo layout included).
|
|
9
10
|
|
|
10
11
|
Lanes are the agent-facing layer of harnex. Everything here is also available
|
|
11
12
|
as native tools through [`harnex mcp`](mcp.md), and the low-level `run`, `send`,
|
|
@@ -65,15 +66,28 @@ cost, commits and the next commands.
|
|
|
65
66
|
`harnex/NAME` from `HEAD`. The worker is told to commit there. Parallel lanes
|
|
66
67
|
never collide, and your checkout is untouched until you land. If your checkout
|
|
67
68
|
has uncommitted changes, harnex warns that the worktree starts from `HEAD`.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
A repo that keeps its worktrees elsewhere says so in `.harnex/config.json`
|
|
70
|
+
(see [Where things live](#where-things-live)).
|
|
71
|
+
- **Without `--worktree`** the worker works in the current directory, or in
|
|
72
|
+
`--cwd DIR`, which is right for tasks that only run or inspect things (tests,
|
|
73
|
+
a benchmark, a long command) and for a worktree you made yourself.
|
|
74
|
+
- **Names.** `--name NAME` names the lane. Otherwise a lane working in another
|
|
75
|
+
worktree of the repo is named after it (`--cwd ../night-1024_s3` gives
|
|
76
|
+
`pi-night-1024_s3`), and any other lane after the task's first words
|
|
77
|
+
(`pi-slow-bench`, then `pi-slow-bench-2`). Generated names skip every name
|
|
78
|
+
another lane still holds anywhere (working, uncollected, or a worktree not
|
|
79
|
+
yet landed), and a name you choose is refused while another repo's worker
|
|
80
|
+
is running under it, so `harnex result NAME` and `reply NAME` find a
|
|
81
|
+
working lane from anywhere.
|
|
72
82
|
- **`--file PATH`** reads a long brief from a file. Briefs must be
|
|
73
83
|
self-contained: the worker has not seen your conversation. Name the files, the
|
|
74
84
|
expected behavior and how to verify it.
|
|
75
85
|
- **`--model provider/model`** and **`--effort low|medium|high`** pick the
|
|
76
86
|
model; the default is Pi's own default model.
|
|
87
|
+
- **`--phase NAME`** tags the lane's dispatch rows with a work phase (for
|
|
88
|
+
example `implement`). Without it the phase is `delegate` (`ask` for an ask),
|
|
89
|
+
which a repo's phase allowlist always accepts; a named phase is checked
|
|
90
|
+
against the allowlist (see [configuration](configuration.md)).
|
|
77
91
|
|
|
78
92
|
### Waiting, and long tasks
|
|
79
93
|
|
|
@@ -122,6 +136,11 @@ or bare `land`.
|
|
|
122
136
|
`harnex discard NAME` stops the lane if it is still working and deletes its
|
|
123
137
|
worktree and branch without merging.
|
|
124
138
|
|
|
139
|
+
**Lanes in place.** For a lane without a worktree, the footer counts only the
|
|
140
|
+
commits the worker made (harnex watches `HEAD` around each git command the
|
|
141
|
+
worker runs). Commits someone else made in the same checkout meanwhile are
|
|
142
|
+
named apart: `0 commits (HEAD also moved 3 commits by others)`.
|
|
143
|
+
|
|
125
144
|
## Parallel lanes
|
|
126
145
|
|
|
127
146
|
```bash
|
|
@@ -149,6 +168,26 @@ mid-task as a follow-up; a lane that has finished is relaunched on its own Pi
|
|
|
149
168
|
session (`pi --session`), so it remembers everything from the earlier turns.
|
|
150
169
|
The footer's cost is the lane's total so far.
|
|
151
170
|
|
|
171
|
+
**Messages from someone else.** Anyone can `harnex send` to a lane's worker
|
|
172
|
+
(the owner from another terminal, a buddy). The lane's result then says so,
|
|
173
|
+
so the session that started the lane is not left guessing:
|
|
174
|
+
|
|
175
|
+
```text
|
|
176
|
+
[earlier reply this turn, before another message reached the worker]
|
|
177
|
+
Review: PASS (0 P1, 2 P2) ...
|
|
178
|
+
|
|
179
|
+
[latest reply]
|
|
180
|
+
Understood; the report is now in /tmp/review.md.
|
|
181
|
+
|
|
182
|
+
── review: done in 6m10s · gpt-6.1-sol · $0.41 · 0 commits
|
|
183
|
+
interjected: 1 message reached the worker from outside this lane (the reply above may answer it; all: `harnex events --id review`):
|
|
184
|
+
23:26:04 from a terminal (harnex send): "Write the report to /tmp/review.md instead"
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The worker's earlier answers in the same turn (the original task's report
|
|
188
|
+
among them) come first; your own `harnex reply` follow-ups are never listed as
|
|
189
|
+
interjections.
|
|
190
|
+
|
|
152
191
|
## When a model fails
|
|
153
192
|
|
|
154
193
|
```text
|
|
@@ -184,7 +223,18 @@ worker's tmux window, and the window itself is always there to watch.
|
|
|
184
223
|
repo, worktree, branch, model, owner, Pi session, cost, last result). Closed
|
|
185
224
|
lanes are pruned after 30 days; open lanes never are.
|
|
186
225
|
- **Worktrees:** `.harnex/worktrees/NAME` inside your repository, on branch
|
|
187
|
-
`harnex/NAME`.
|
|
226
|
+
`harnex/NAME`. To keep them beside the main checkout instead, set templates
|
|
227
|
+
in the repo's `.harnex/config.json` (`{name}`, `{yyyy}`, `{mm}`, `{dd}`;
|
|
228
|
+
the path is relative to the main checkout). `land` and `discard` follow the
|
|
229
|
+
lane's own path and branch:
|
|
230
|
+
|
|
231
|
+
```json
|
|
232
|
+
{ "lanes": { "worktree_path": "../night-{mm}{dd}_{name}", "branch": "night/{mm}{dd}_{name}" } }
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
- **Bare-repo layouts** (`repo/.bare` plus sibling worktrees): the main
|
|
236
|
+
checkout is the worktree on the bare repo's `HEAD` branch (`master`), and
|
|
237
|
+
every worktree of the repo shares its lanes.
|
|
188
238
|
- **`.harnex/`** is added to the clone's `.git/info/exclude` the first time
|
|
189
239
|
harnex writes there, so lane worktrees and dispatch rows never show up as
|
|
190
240
|
untracked work. Nothing tracked changes.
|
data/guides/01_dispatch.md
CHANGED
|
@@ -213,7 +213,8 @@ are present.
|
|
|
213
213
|
Every dispatch writes one `dispatch_start` and one rich v2 `dispatch_end` row
|
|
214
214
|
to the canonical repo/global dispatch stream, which is the only telemetry
|
|
215
215
|
destination. Repo `.harnex/config.json` can warn on or reject non-canonical
|
|
216
|
-
phase names before spawn
|
|
216
|
+
phase names before spawn; lanes (`delegate`, `ask`) and `preflight` are
|
|
217
|
+
exempt unless a lane is given `--phase NAME`.
|
|
217
218
|
|
|
218
219
|
Pi runs use structured RPC (`pi --mode rpc`) and require Pi >= 0.80.4; gate
|
|
219
220
|
unattended work with `harnex doctor --adapter pi`. Harnex `--model` / `--effort`
|
data/lib/harnex/commands/ask.rb
CHANGED
|
@@ -33,6 +33,8 @@ module Harnex
|
|
|
33
33
|
Ask once more with NAME when the first model fails on
|
|
34
34
|
provider capacity or rate limits (provider_capacity)
|
|
35
35
|
--effort LEVEL Reasoning effort / Pi thinking level
|
|
36
|
+
--phase NAME Tag the run with a repo work phase (with --write, checked
|
|
37
|
+
against the repo's allowlist); default: ask
|
|
36
38
|
--cwd DIR Directory the worker works in (default: current directory)
|
|
37
39
|
--file PATH Read the question from PATH (see run --context-file)
|
|
38
40
|
--section TEXT With --file: send only that section
|
|
@@ -63,7 +65,7 @@ module Harnex
|
|
|
63
65
|
@out = out
|
|
64
66
|
@err = err
|
|
65
67
|
@harnex_command = harnex_command || [Gem.ruby, File.expand_path("../../../bin/harnex", __dir__)]
|
|
66
|
-
@options = { model: nil, fallback_model: nil, effort: nil, cwd: nil, file: nil, section: nil, write: false,
|
|
68
|
+
@options = { model: nil, fallback_model: nil, effort: nil, phase: nil, cwd: nil, file: nil, section: nil, write: false,
|
|
67
69
|
id: nil, timeout: Lanes::DEFAULT_TIMEOUT, detach: false, json: false, help: false, resume: nil,
|
|
68
70
|
repo: nil }
|
|
69
71
|
end
|
|
@@ -99,17 +101,25 @@ module Harnex
|
|
|
99
101
|
lane = register_lane(id, model)
|
|
100
102
|
stdout, status = launch(question, model, id)
|
|
101
103
|
result = parse_result(stdout)
|
|
102
|
-
|
|
104
|
+
if !result.empty?
|
|
105
|
+
Lanes.settle(lane, result["turn_result"] || {}, status.exitstatus)
|
|
106
|
+
elsif status.exitstatus != Waiter::DONE_EXIT_TIMEOUT
|
|
107
|
+
# Refused before the worker started (#88); 124 may still come up.
|
|
108
|
+
Lanes.forget(lane)
|
|
109
|
+
end
|
|
103
110
|
[result, status.exitstatus]
|
|
104
111
|
end
|
|
105
112
|
|
|
106
113
|
def register_lane(id, model)
|
|
107
114
|
cwd = File.expand_path(@options[:cwd] || Dir.pwd)
|
|
115
|
+
if (other = Lanes.live_holder(id))
|
|
116
|
+
raise OptionParser::InvalidArgument, "a worker named #{id} is working in #{other['repo_root']}; pick another --id"
|
|
117
|
+
end
|
|
108
118
|
Lanes.save(
|
|
109
119
|
"name" => id, "kind" => "ask", "repo_root" => Lanes.main_root(cwd) || cwd, "run_root" => run_root,
|
|
110
120
|
"cwd" => cwd, "read_only" => !@options[:write], "owner" => Harnex.ambient_owner_id,
|
|
111
|
-
"requested_model" => model, "effort" => @options[:effort], "
|
|
112
|
-
"launched_at" => Lanes.now, "state" => "running", "turns" => 0
|
|
121
|
+
"requested_model" => model, "effort" => @options[:effort], "phase" => @options[:phase],
|
|
122
|
+
"created_at" => Lanes.now, "launched_at" => Lanes.now, "state" => "running", "turns" => 0
|
|
113
123
|
)
|
|
114
124
|
end
|
|
115
125
|
|
|
@@ -126,7 +136,7 @@ module Harnex
|
|
|
126
136
|
else
|
|
127
137
|
argv += ["--context", question]
|
|
128
138
|
end
|
|
129
|
-
argv += ["--auto-stop", "--wait-done", "--intent", INTENT]
|
|
139
|
+
argv += ["--auto-stop", "--wait-done", "--intent", INTENT, "--phase", @options[:phase] || INTENT]
|
|
130
140
|
argv += ["--wait-timeout", @options[:timeout].to_s] if @options[:timeout]
|
|
131
141
|
argv += ["--model", model] if model
|
|
132
142
|
argv += ["--effort", @options[:effort]] if @options[:effort]
|
|
@@ -197,7 +207,8 @@ module Harnex
|
|
|
197
207
|
answer = full_answer(turn)
|
|
198
208
|
case exit_code
|
|
199
209
|
when 0
|
|
200
|
-
|
|
210
|
+
lane = answered_lane(result["id"])
|
|
211
|
+
@out.puts(lane ? Lanes.render_answer(lane, turn.merge("final_message" => answer.to_s, "final_message_truncated" => false)) : answer.to_s)
|
|
201
212
|
@err.puts("harnex ask: #{result['id']} answered in #{elapsed.round}s (model #{turn['model'] || 'Pi default'})")
|
|
202
213
|
when Waiter::DONE_EXIT_TIMEOUT
|
|
203
214
|
@err.puts("harnex ask: #{result['id']} is still working; keep waiting with: #{resume_command(result['id'])}")
|
|
@@ -209,6 +220,12 @@ module Harnex
|
|
|
209
220
|
exit_code
|
|
210
221
|
end
|
|
211
222
|
|
|
223
|
+
def answered_lane(id)
|
|
224
|
+
id && Lanes.resolve(id, from: File.expand_path(@options[:cwd] || Dir.pwd))
|
|
225
|
+
rescue OptionParser::InvalidArgument
|
|
226
|
+
nil
|
|
227
|
+
end
|
|
228
|
+
|
|
212
229
|
def full_answer(turn)
|
|
213
230
|
path = turn["final_message_path"].to_s
|
|
214
231
|
return File.read(path).chomp if turn["final_message_truncated"] && !path.empty? && File.file?(path)
|
|
@@ -231,7 +248,7 @@ module Harnex
|
|
|
231
248
|
when "--write" then @options[:write] = true
|
|
232
249
|
when "--detach" then @options[:detach] = true
|
|
233
250
|
when "--json" then @options[:json] = true
|
|
234
|
-
when "--model", "--effort", "--cwd", "--file", "--section", "--id", "--fallback-model", "--resume", "--repo"
|
|
251
|
+
when "--model", "--effort", "--phase", "--cwd", "--file", "--section", "--id", "--fallback-model", "--resume", "--repo"
|
|
235
252
|
@options[arg.delete_prefix("--").tr("-", "_").to_sym] = value(index += 1, arg)
|
|
236
253
|
# Habits from `harnex run` (#87): an ask already runs in a tmux window
|
|
237
254
|
# named after its id and stops after answering.
|
|
@@ -17,12 +17,19 @@ module Harnex
|
|
|
17
17
|
Options:
|
|
18
18
|
--worktree NAME Work in a new git worktree (.harnex/worktrees/NAME, branch
|
|
19
19
|
harnex/NAME); finish with `harnex land NAME` or
|
|
20
|
-
`harnex discard NAME
|
|
21
|
-
|
|
20
|
+
`harnex discard NAME`. The repo's .harnex/config.json
|
|
21
|
+
can move it: {"lanes": {"worktree_path": "../{name}",
|
|
22
|
+
"branch": "night/{mm}{dd}_{name}"}}
|
|
23
|
+
--name NAME Lane name without a worktree (default: the --cwd
|
|
24
|
+
worktree's name, else from the task). A working
|
|
25
|
+
lane's name is unique across every repo
|
|
22
26
|
--cwd DIR Directory the worker works in (default: current directory)
|
|
23
27
|
--file PATH Read the task from PATH
|
|
24
28
|
--model NAME Pi model (provider/model); default: Pi's own default
|
|
25
29
|
--effort LEVEL Reasoning effort / Pi thinking level
|
|
30
|
+
--phase NAME Tag the run with a repo work phase (checked against the
|
|
31
|
+
repo's .harnex/config.json allowlist); default: delegate,
|
|
32
|
+
which every allowlist accepts
|
|
26
33
|
--no-wait Start the lane and return at once; collect with
|
|
27
34
|
`harnex result` (all your lanes) or `harnex result NAME`
|
|
28
35
|
--timeout DUR Wait at most DUR (default 9m, fits a 10-minute tool cap;
|
|
@@ -46,7 +53,7 @@ module Harnex
|
|
|
46
53
|
@out = out
|
|
47
54
|
@err = err
|
|
48
55
|
@harnex_command = harnex_command || [Gem.ruby, File.expand_path("../../../bin/harnex", __dir__)]
|
|
49
|
-
@options = { worktree: nil, name: nil, cwd: nil, file: nil, model: nil, effort: nil, wait: true,
|
|
56
|
+
@options = { worktree: nil, name: nil, cwd: nil, file: nil, model: nil, effort: nil, phase: nil, wait: true,
|
|
50
57
|
timeout: Lanes::DEFAULT_TIMEOUT, detach: false, json: false, help: false }
|
|
51
58
|
end
|
|
52
59
|
|
|
@@ -83,14 +90,15 @@ module Harnex
|
|
|
83
90
|
root = Lanes.main_root(workdir)
|
|
84
91
|
raise OptionParser::InvalidArgument, "--worktree needs a git repository (#{workdir} is not in one)" if @options[:worktree] && root.nil?
|
|
85
92
|
|
|
93
|
+
refuse_nested_worktree!(root, workdir) if @options[:worktree]
|
|
86
94
|
Lanes.sweep_merged(root)
|
|
87
95
|
root ||= workdir
|
|
88
|
-
name = Lanes.validate_name!(@options[:worktree] || @options[:name] || Lanes.generate_name(root, task))
|
|
96
|
+
name = Lanes.validate_name!(@options[:worktree] || @options[:name] || Lanes.generate_name(root, task, cwd: workdir))
|
|
89
97
|
ensure_free!(root, name)
|
|
90
98
|
lane = {
|
|
91
99
|
"name" => name, "kind" => "delegate", "repo_root" => root, "run_root" => root, "cwd" => workdir,
|
|
92
100
|
"owner" => Harnex.ambient_owner_id, "requested_model" => @options[:model], "effort" => @options[:effort],
|
|
93
|
-
"created_at" => Lanes.now, "turns" => 0
|
|
101
|
+
"phase" => @options[:phase], "created_at" => Lanes.now, "turns" => 0
|
|
94
102
|
}
|
|
95
103
|
if @options[:worktree]
|
|
96
104
|
warn_uncommitted(root)
|
|
@@ -105,25 +113,44 @@ module Harnex
|
|
|
105
113
|
|
|
106
114
|
# A lane that never started leaves nothing behind.
|
|
107
115
|
def discard_unstarted
|
|
108
|
-
return unless @lane && @lane["
|
|
116
|
+
return unless @lane && @lane["turns"].to_i.zero?
|
|
109
117
|
|
|
110
|
-
Lanes.remove_worktree(@lane, force: true)
|
|
111
|
-
@lane
|
|
112
|
-
Lanes.save(@lane)
|
|
118
|
+
Lanes.remove_worktree(@lane, force: true) if @lane["worktree"]
|
|
119
|
+
Lanes.forget(@lane)
|
|
113
120
|
end
|
|
114
121
|
|
|
115
122
|
def ensure_free!(root, name)
|
|
116
123
|
existing = Lanes.load(root, name)
|
|
124
|
+
if Lanes.never_started?(existing)
|
|
125
|
+
Lanes.forget(existing)
|
|
126
|
+
existing = nil
|
|
127
|
+
end
|
|
117
128
|
if Lanes.open?(existing)
|
|
118
129
|
raise OptionParser::InvalidArgument,
|
|
119
130
|
"lane #{name} already exists (#{existing['state']}): follow up with `harnex reply #{name} \"...\"`, " \
|
|
120
131
|
"or `harnex land #{name}` / `harnex discard #{name}` first"
|
|
121
132
|
end
|
|
133
|
+
if (other = Lanes.live_holder(name)) && other["repo_root"] != root
|
|
134
|
+
raise OptionParser::InvalidArgument,
|
|
135
|
+
"a worker named #{name} is working in #{other['repo_root']}; working lanes' names are unique, " \
|
|
136
|
+
"so pick another name (or wait: `harnex result #{name}`)"
|
|
137
|
+
end
|
|
122
138
|
return unless Harnex.read_registry(root, name)
|
|
123
139
|
|
|
124
140
|
raise OptionParser::InvalidArgument, "a harnex session named #{name} is running; pick another name"
|
|
125
141
|
end
|
|
126
142
|
|
|
143
|
+
# #89: --cwd in a sibling worktree plus --worktree would branch a new
|
|
144
|
+
# worktree from the main checkout, not from --cwd; say so instead.
|
|
145
|
+
def refuse_nested_worktree!(root, workdir)
|
|
146
|
+
top = Lanes.toplevel(workdir)
|
|
147
|
+
return if top.nil? || root.nil? || Lanes.same_dir?(top, root)
|
|
148
|
+
|
|
149
|
+
raise OptionParser::InvalidArgument,
|
|
150
|
+
"--cwd #{top} is already a worktree of #{root}: drop --worktree to work there in place, " \
|
|
151
|
+
"or drop --cwd to start a new worktree from the main checkout"
|
|
152
|
+
end
|
|
153
|
+
|
|
127
154
|
def warn_uncommitted(root)
|
|
128
155
|
dirty = Lanes.dirty_paths(root)
|
|
129
156
|
return if dirty.empty?
|
|
@@ -165,7 +192,7 @@ module Harnex
|
|
|
165
192
|
when "--no-wait" then @options[:wait] = false
|
|
166
193
|
when "--detach" then @options[:detach] = true
|
|
167
194
|
when "--json" then @options[:json] = true
|
|
168
|
-
when "--worktree", "--name", "--cwd", "--file", "--model", "--effort"
|
|
195
|
+
when "--worktree", "--name", "--cwd", "--file", "--model", "--effort", "--phase"
|
|
169
196
|
@options[arg.delete_prefix("--").to_sym] = value(index += 1, arg)
|
|
170
197
|
when "--timeout", "--wait-timeout"
|
|
171
198
|
@options[:timeout] = Lanes.parse_timeout(value(index += 1, arg))
|
|
@@ -61,7 +61,8 @@ module Harnex
|
|
|
61
61
|
def follow_up(lane, message)
|
|
62
62
|
argv = @harnex_command + ["send", "--id", lane["name"], "--repo", lane["run_root"], "--message", message]
|
|
63
63
|
argv += ["--wait-done"] + (@options[:timeout] ? ["--timeout", @options[:timeout].to_s] : []) if @options[:wait]
|
|
64
|
-
|
|
64
|
+
# Marked as the lane's own follow-up, not an interjection (#92).
|
|
65
|
+
stdout, stderr, status = Open3.capture3({ "HARNEX_SEND_VIA" => "reply" }, *argv)
|
|
65
66
|
result = Lanes.last_json(stdout)
|
|
66
67
|
raise Lanes::LaunchError, "harnex could not reach lane #{lane['name']}: #{stderr.strip.lines.last}" if result.empty?
|
|
67
68
|
|
|
@@ -71,7 +71,7 @@ module Harnex
|
|
|
71
71
|
text = if @options[:json]
|
|
72
72
|
JSON.generate(lane: lane["name"], exit_code: exit_code, state: lane["state"], result: turn)
|
|
73
73
|
elsif lane["kind"] == "ask" && exit_code.zero?
|
|
74
|
-
Lanes.
|
|
74
|
+
Lanes.render_answer(lane, turn)
|
|
75
75
|
else
|
|
76
76
|
Lanes.render(lane, turn, exit_code)
|
|
77
77
|
end
|
data/lib/harnex/commands/run.rb
CHANGED
|
@@ -486,8 +486,9 @@ module Harnex
|
|
|
486
486
|
metadata = @options[:meta].is_a?(Hash) ? @options[:meta] : {}
|
|
487
487
|
phase = metadata["phase"].to_s
|
|
488
488
|
allowlist = phase_config.fetch("allowlist")
|
|
489
|
-
#
|
|
490
|
-
|
|
489
|
+
# Harnex tags its own runs (the `harnex preflight` probe, lanes); repo
|
|
490
|
+
# phase policy governs work phases.
|
|
491
|
+
return if allowlist.include?(phase) || phase == Preflight::PHASE || Lanes::PHASES.include?(phase)
|
|
491
492
|
|
|
492
493
|
policy = phase_config.fetch("policy")
|
|
493
494
|
message = "harnex run: meta.phase #{phase.empty? ? '(none)' : phase.inspect} " \
|
data/lib/harnex/commands/send.rb
CHANGED
data/lib/harnex/commands/wait.rb
CHANGED
|
@@ -488,7 +488,8 @@ module Harnex
|
|
|
488
488
|
}
|
|
489
489
|
payload[:turn] = event["turn"] if event["turn"]
|
|
490
490
|
payload[:follow_ups] = event["follow_ups"] if event["follow_ups"]
|
|
491
|
-
%w[end_sha commits turn_commits model final_message final_message_path final_message_truncated
|
|
491
|
+
%w[end_sha commits turn_commits model final_message final_message_path final_message_truncated
|
|
492
|
+
earlier_replies].each do |key|
|
|
492
493
|
payload[key.to_sym] = event[key] if event.key?(key)
|
|
493
494
|
end
|
|
494
495
|
exit_code = 0
|
data/lib/harnex/config.rb
CHANGED
|
@@ -6,6 +6,7 @@ module Harnex
|
|
|
6
6
|
PHASE_POLICIES = %w[warn reject].freeze
|
|
7
7
|
RETENTION_DIRS = %w[events output receipts].freeze
|
|
8
8
|
RETENTION_FIELDS = %w[max_age_days max_bytes].freeze
|
|
9
|
+
LANE_TEMPLATES = %w[worktree_path branch].freeze
|
|
9
10
|
RETENTION_ENV = {
|
|
10
11
|
"events" => {
|
|
11
12
|
"max_age_days" => "HARNEX_EVENTS_MAX_AGE_DAYS",
|
|
@@ -38,6 +39,10 @@ module Harnex
|
|
|
38
39
|
def retention
|
|
39
40
|
data.is_a?(Hash) ? data["retention"] : nil
|
|
40
41
|
end
|
|
42
|
+
|
|
43
|
+
def lanes
|
|
44
|
+
data.is_a?(Hash) ? data["lanes"] : nil
|
|
45
|
+
end
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
module_function
|
|
@@ -63,6 +68,22 @@ module Harnex
|
|
|
63
68
|
|
|
64
69
|
validate_phase!(document["phase"], path) if document.key?("phase")
|
|
65
70
|
validate_retention!(document["retention"], path) if document.key?("retention")
|
|
71
|
+
validate_lanes!(document["lanes"], path) if document.key?("lanes")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# #90: where worktree lanes live and what their branches are called,
|
|
75
|
+
# e.g. {"worktree_path": "../{name}", "branch": "night/{mm}{dd}_{name}"}.
|
|
76
|
+
def validate_lanes!(lanes, path)
|
|
77
|
+
raise ConfigError, "#{path}: $.lanes must be an object" unless lanes.is_a?(Hash)
|
|
78
|
+
|
|
79
|
+
lanes.each do |key, template|
|
|
80
|
+
unless LANE_TEMPLATES.include?(key)
|
|
81
|
+
raise ConfigError, "#{path}: $.lanes.#{key} is not supported (expected #{LANE_TEMPLATES.join(' or ')})"
|
|
82
|
+
end
|
|
83
|
+
next if template.is_a?(String) && template.include?("{name}")
|
|
84
|
+
|
|
85
|
+
raise ConfigError, "#{path}: $.lanes.#{key} must be a string containing {name}"
|
|
86
|
+
end
|
|
66
87
|
end
|
|
67
88
|
|
|
68
89
|
def validate_phase!(phase, path)
|
data/lib/harnex/core.rb
CHANGED
|
@@ -450,6 +450,17 @@ module Harnex
|
|
|
450
450
|
nil
|
|
451
451
|
end
|
|
452
452
|
|
|
453
|
+
# #92: who is sending a message, for the receiving lane's footer. `via`
|
|
454
|
+
# is "reply" when `harnex reply` sends on the lane's behalf
|
|
455
|
+
# (HARNEX_SEND_VIA), else "send".
|
|
456
|
+
def sender_identity(env = ENV)
|
|
457
|
+
{
|
|
458
|
+
"via" => env["HARNEX_SEND_VIA"].to_s.strip.empty? ? "send" : env["HARNEX_SEND_VIA"].strip,
|
|
459
|
+
"session" => env["HARNEX_ID"].to_s.strip,
|
|
460
|
+
"owner" => ambient_owner_id(env).to_s
|
|
461
|
+
}.reject { |_key, value| value.empty? }
|
|
462
|
+
end
|
|
463
|
+
|
|
453
464
|
# How to invoke this harnex in a printed hint: the checkout's bin when
|
|
454
465
|
# running from source, plain `harnex` for the installed gem.
|
|
455
466
|
def cli_command
|