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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 72eab06bb2287cadb7e9e2f525350b981571717088de593d9ad5225ca9aa324e
4
- data.tar.gz: 6a2fecf3083f015e9ecc90d658a4797439740e21985fecdd3515e98f179f16d1
3
+ metadata.gz: b4b50e456b0b9be717e037a0596b942273e2e4c192b80d697fc502a66cc4c220
4
+ data.tar.gz: 77ed9e03656bfff5f68064bb4dcb64f898d1de6b5d599ad30450ac51f8a1938e
5
5
  SHA512:
6
- metadata.gz: e662b7bb01799d6a8faf1ff62efb1b4353dd6f684ab43f250bcf783981189709a2c60871a8cf8e3c55772e2d233131880ddd2324d8bbe47f2ac2766d5d09ef91
7
- data.tar.gz: 4de10a7bed0959e709c2f99df9dda5dac4ccdc79054919d4cade8780c712f3f2ad1a5b322586a01dadf43637de52ec731299ca8a874f13e156ff31480b02357b
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>`. Write a self-contained brief: the GPT worker has not seen this conversation."
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 ends at
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 given;
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]. Repeat while it says still working, up to 12
54
- times. Never start a second lane for the same brief.
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
@@ -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
- - **Without `--worktree`** the worker works in the current directory, which is
69
- right for tasks that only run or inspect things (tests, a benchmark, a long
70
- command). `--name NAME` names the lane; otherwise harnex derives a name from
71
- the task (`pi-slow-bench`).
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.
@@ -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`
@@ -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
- Lanes.settle(lane, result["turn_result"] || {}, status.exitstatus) unless result.empty?
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], "created_at" => Lanes.now,
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
- @out.puts answer.to_s
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
- --name NAME Lane name without a worktree (default: from the task)
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["worktree"] && @lane["turns"].to_i.zero?
116
+ return unless @lane && @lane["turns"].to_i.zero?
109
117
 
110
- Lanes.remove_worktree(@lane, force: true)
111
- @lane["state"] = "discarded"
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
- stdout, stderr, status = Open3.capture3(*argv)
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.final_message(turn)
74
+ Lanes.render_answer(lane, turn)
75
75
  else
76
76
  Lanes.render(lane, turn, exit_code)
77
77
  end
@@ -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
- # `harnex preflight` tags its own probe; repo phase policy governs work.
490
- return if allowlist.include?(phase) || phase == Preflight::PHASE
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} " \
@@ -141,7 +141,8 @@ module Harnex
141
141
  text: text,
142
142
  submit: @options[:submit],
143
143
  enter_only: @options[:submit_only],
144
- force: @options[:force]
144
+ force: @options[:force],
145
+ from: Harnex.sender_identity
145
146
  )
146
147
 
147
148
  Net::HTTP.start(
@@ -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].each do |key|
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