harnex 0.29.0 → 0.29.1
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 +24 -0
- data/docs/configuration.md +4 -0
- data/docs/lanes.md +4 -0
- data/guides/01_dispatch.md +2 -1
- data/lib/harnex/commands/ask.rb +13 -6
- data/lib/harnex/commands/delegate.rb +13 -7
- data/lib/harnex/commands/run.rb +3 -2
- data/lib/harnex/lanes.rb +42 -7
- 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: e36a59564be22ba55cda2815e3e72b59b887bb2a4e0417be776295ef1cb10b82
|
|
4
|
+
data.tar.gz: 0a9b1355a5bc153e4d6fdd6ef79df81f022c5b6a75f97de0a8f91ef52353e526
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1794b1c8190bf40109732a821470d9a24772be9e68e46b9c49732ac46bd24fb73a88d07d0097dece3ec43e3b164932903d69ffda7fa6b4f18b244989990c8a52
|
|
7
|
+
data.tar.gz: 8163c5eb86dacb6174868459fcc9bba2fb87b66c503e61a96f38baefbe2ca93423df3a84fd07da626927402fe5432f4f541eec202a7b4cc4fde131e1b9ff7de4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.29.1] - 2026-10-23 | 10:31 AM | IST
|
|
4
|
+
|
|
5
|
+
Local-only fix release for #88: lanes in repos with a phase allowlist.
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- In a repo whose `.harnex/config.json` phase allowlist has
|
|
10
|
+
`"policy": "reject"`, harnex refused every `delegate`, `ask --write` and
|
|
11
|
+
`reply` (`meta.phase (none) is not allowlisted`). The run behind a lane
|
|
12
|
+
is now tagged with phase `delegate` or `ask`. These are harnex's own
|
|
13
|
+
phases, like `preflight`, so every allowlist accepts them.
|
|
14
|
+
- A lane that harnex refused before the worker started left a `failed`
|
|
15
|
+
record, so a retry under the same name failed with `lane NAME already
|
|
16
|
+
exists (failed)` until `discard`. Now a refused new lane leaves no record
|
|
17
|
+
and no brief. A refused `reply` keeps the state the lane's last turn left.
|
|
18
|
+
The next `delegate` under the same name also clears a record left this way
|
|
19
|
+
by 0.29.0.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- `harnex delegate --phase NAME` and `harnex ask --phase NAME` tag a lane
|
|
24
|
+
with a work phase such as `implement`. The repo allowlist checks a named
|
|
25
|
+
phase, and `reply` reuses it.
|
|
26
|
+
|
|
3
27
|
## [0.29.0] - 2026-10-22 | 06:08 PM | IST
|
|
4
28
|
|
|
5
29
|
**The agent release.** harnex becomes a tool your coding agents use directly:
|
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
|
@@ -74,6 +74,10 @@ cost, commits and the next commands.
|
|
|
74
74
|
expected behavior and how to verify it.
|
|
75
75
|
- **`--model provider/model`** and **`--effort low|medium|high`** pick the
|
|
76
76
|
model; the default is Pi's own default model.
|
|
77
|
+
- **`--phase NAME`** tags the lane's dispatch rows with a work phase (for
|
|
78
|
+
example `implement`). Without it the phase is `delegate` (`ask` for an ask),
|
|
79
|
+
which a repo's phase allowlist always accepts; a named phase is checked
|
|
80
|
+
against the allowlist (see [configuration](configuration.md)).
|
|
77
81
|
|
|
78
82
|
### Waiting, and long tasks
|
|
79
83
|
|
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,7 +101,12 @@ 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
|
|
|
@@ -108,8 +115,8 @@ module Harnex
|
|
|
108
115
|
Lanes.save(
|
|
109
116
|
"name" => id, "kind" => "ask", "repo_root" => Lanes.main_root(cwd) || cwd, "run_root" => run_root,
|
|
110
117
|
"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
|
|
118
|
+
"requested_model" => model, "effort" => @options[:effort], "phase" => @options[:phase],
|
|
119
|
+
"created_at" => Lanes.now, "launched_at" => Lanes.now, "state" => "running", "turns" => 0
|
|
113
120
|
)
|
|
114
121
|
end
|
|
115
122
|
|
|
@@ -126,7 +133,7 @@ module Harnex
|
|
|
126
133
|
else
|
|
127
134
|
argv += ["--context", question]
|
|
128
135
|
end
|
|
129
|
-
argv += ["--auto-stop", "--wait-done", "--intent", INTENT]
|
|
136
|
+
argv += ["--auto-stop", "--wait-done", "--intent", INTENT, "--phase", @options[:phase] || INTENT]
|
|
130
137
|
argv += ["--wait-timeout", @options[:timeout].to_s] if @options[:timeout]
|
|
131
138
|
argv += ["--model", model] if model
|
|
132
139
|
argv += ["--effort", @options[:effort]] if @options[:effort]
|
|
@@ -231,7 +238,7 @@ module Harnex
|
|
|
231
238
|
when "--write" then @options[:write] = true
|
|
232
239
|
when "--detach" then @options[:detach] = true
|
|
233
240
|
when "--json" then @options[:json] = true
|
|
234
|
-
when "--model", "--effort", "--cwd", "--file", "--section", "--id", "--fallback-model", "--resume", "--repo"
|
|
241
|
+
when "--model", "--effort", "--phase", "--cwd", "--file", "--section", "--id", "--fallback-model", "--resume", "--repo"
|
|
235
242
|
@options[arg.delete_prefix("--").tr("-", "_").to_sym] = value(index += 1, arg)
|
|
236
243
|
# Habits from `harnex run` (#87): an ask already runs in a tmux window
|
|
237
244
|
# named after its id and stops after answering.
|
|
@@ -23,6 +23,9 @@ module Harnex
|
|
|
23
23
|
--file PATH Read the task from PATH
|
|
24
24
|
--model NAME Pi model (provider/model); default: Pi's own default
|
|
25
25
|
--effort LEVEL Reasoning effort / Pi thinking level
|
|
26
|
+
--phase NAME Tag the run with a repo work phase (checked against the
|
|
27
|
+
repo's .harnex/config.json allowlist); default: delegate,
|
|
28
|
+
which every allowlist accepts
|
|
26
29
|
--no-wait Start the lane and return at once; collect with
|
|
27
30
|
`harnex result` (all your lanes) or `harnex result NAME`
|
|
28
31
|
--timeout DUR Wait at most DUR (default 9m, fits a 10-minute tool cap;
|
|
@@ -46,7 +49,7 @@ module Harnex
|
|
|
46
49
|
@out = out
|
|
47
50
|
@err = err
|
|
48
51
|
@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,
|
|
52
|
+
@options = { worktree: nil, name: nil, cwd: nil, file: nil, model: nil, effort: nil, phase: nil, wait: true,
|
|
50
53
|
timeout: Lanes::DEFAULT_TIMEOUT, detach: false, json: false, help: false }
|
|
51
54
|
end
|
|
52
55
|
|
|
@@ -90,7 +93,7 @@ module Harnex
|
|
|
90
93
|
lane = {
|
|
91
94
|
"name" => name, "kind" => "delegate", "repo_root" => root, "run_root" => root, "cwd" => workdir,
|
|
92
95
|
"owner" => Harnex.ambient_owner_id, "requested_model" => @options[:model], "effort" => @options[:effort],
|
|
93
|
-
"created_at" => Lanes.now, "turns" => 0
|
|
96
|
+
"phase" => @options[:phase], "created_at" => Lanes.now, "turns" => 0
|
|
94
97
|
}
|
|
95
98
|
if @options[:worktree]
|
|
96
99
|
warn_uncommitted(root)
|
|
@@ -105,15 +108,18 @@ module Harnex
|
|
|
105
108
|
|
|
106
109
|
# A lane that never started leaves nothing behind.
|
|
107
110
|
def discard_unstarted
|
|
108
|
-
return unless @lane && @lane["
|
|
111
|
+
return unless @lane && @lane["turns"].to_i.zero?
|
|
109
112
|
|
|
110
|
-
Lanes.remove_worktree(@lane, force: true)
|
|
111
|
-
@lane
|
|
112
|
-
Lanes.save(@lane)
|
|
113
|
+
Lanes.remove_worktree(@lane, force: true) if @lane["worktree"]
|
|
114
|
+
Lanes.forget(@lane)
|
|
113
115
|
end
|
|
114
116
|
|
|
115
117
|
def ensure_free!(root, name)
|
|
116
118
|
existing = Lanes.load(root, name)
|
|
119
|
+
if Lanes.never_started?(existing)
|
|
120
|
+
Lanes.forget(existing)
|
|
121
|
+
existing = nil
|
|
122
|
+
end
|
|
117
123
|
if Lanes.open?(existing)
|
|
118
124
|
raise OptionParser::InvalidArgument,
|
|
119
125
|
"lane #{name} already exists (#{existing['state']}): follow up with `harnex reply #{name} \"...\"`, " \
|
|
@@ -165,7 +171,7 @@ module Harnex
|
|
|
165
171
|
when "--no-wait" then @options[:wait] = false
|
|
166
172
|
when "--detach" then @options[:detach] = true
|
|
167
173
|
when "--json" then @options[:json] = true
|
|
168
|
-
when "--worktree", "--name", "--cwd", "--file", "--model", "--effort"
|
|
174
|
+
when "--worktree", "--name", "--cwd", "--file", "--model", "--effort", "--phase"
|
|
169
175
|
@options[arg.delete_prefix("--").to_sym] = value(index += 1, arg)
|
|
170
176
|
when "--timeout", "--wait-timeout"
|
|
171
177
|
@options[:timeout] = Lanes.parse_timeout(value(index += 1, arg))
|
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/lanes.rb
CHANGED
|
@@ -19,6 +19,9 @@ module Harnex
|
|
|
19
19
|
WORKTREES_DIR = File.join(".harnex", "worktrees").freeze
|
|
20
20
|
CLOSED_STATES = %w[landed discarded].freeze
|
|
21
21
|
DEFAULT_TIMEOUT = 540
|
|
22
|
+
# A lane's run is tagged meta.phase = its kind unless the caller names a
|
|
23
|
+
# phase; repo phase allowlists exempt these harnex-owned phases (#88).
|
|
24
|
+
PHASES = %w[delegate ask].freeze
|
|
22
25
|
|
|
23
26
|
module_function
|
|
24
27
|
|
|
@@ -60,6 +63,16 @@ module Harnex
|
|
|
60
63
|
lane && !CLOSED_STATES.include?(lane["state"])
|
|
61
64
|
end
|
|
62
65
|
|
|
66
|
+
# Older harnex kept the record of a lane refused before it started (#88).
|
|
67
|
+
def never_started?(lane)
|
|
68
|
+
lane && lane["state"] == "failed" && lane["turns"].to_i.zero? && !live?(lane)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def forget(lane)
|
|
72
|
+
FileUtils.rm_f(path(lane["repo_root"], lane["name"]))
|
|
73
|
+
FileUtils.rm_f(brief_path(lane["repo_root"], lane["name"]))
|
|
74
|
+
end
|
|
75
|
+
|
|
63
76
|
# A name resolves to this repo's lane first, then to the only lane
|
|
64
77
|
# anywhere with that name.
|
|
65
78
|
def resolve(name, from: Dir.pwd)
|
|
@@ -227,8 +240,7 @@ module Harnex
|
|
|
227
240
|
closed_at = Time.iso8601((lane["landed_at"] || lane["updated_at"]).to_s)
|
|
228
241
|
next if closed_at > cutoff
|
|
229
242
|
|
|
230
|
-
|
|
231
|
-
FileUtils.rm_f(File.join(dir, "briefs", "#{Harnex.repo_key(lane['repo_root'])}--#{lane['name']}.md"))
|
|
243
|
+
forget(lane)
|
|
232
244
|
rescue ArgumentError
|
|
233
245
|
next
|
|
234
246
|
end
|
|
@@ -253,8 +265,12 @@ module Harnex
|
|
|
253
265
|
|
|
254
266
|
# ---- briefs ------------------------------------------------------------
|
|
255
267
|
|
|
268
|
+
def brief_path(repo_root, name)
|
|
269
|
+
File.join(dir, "briefs", "#{Harnex.repo_key(repo_root)}--#{name}.md")
|
|
270
|
+
end
|
|
271
|
+
|
|
256
272
|
def write_brief(lane, task)
|
|
257
|
-
file =
|
|
273
|
+
file = brief_path(lane["repo_root"], lane["name"])
|
|
258
274
|
footer = if lane["branch"]
|
|
259
275
|
"You are working in a git worktree on branch #{lane['branch']}; the main checkout is not " \
|
|
260
276
|
"affected. When you are done, commit your changes on this branch, then reply with a short " \
|
|
@@ -275,6 +291,7 @@ module Harnex
|
|
|
275
291
|
# done); without, returns [launch_payload, 0]. A launch that fails before
|
|
276
292
|
# any result (bad flag, busy id, no tmux) raises with harnex's message.
|
|
277
293
|
def launch(lane, harnex_command, context_args:, child_args: [], wait: true, timeout: DEFAULT_TIMEOUT, detach: false)
|
|
294
|
+
before = lane.slice(*RELAUNCH_KEYS)
|
|
278
295
|
lane["launched_at"] = now
|
|
279
296
|
lane["state"] = "running"
|
|
280
297
|
lane.delete("after_seq")
|
|
@@ -283,7 +300,8 @@ module Harnex
|
|
|
283
300
|
|
|
284
301
|
argv = harnex_command + ["run", "pi", "--id", lane["name"]]
|
|
285
302
|
argv += detach ? ["--detach"] : ["--tmux", lane["name"]]
|
|
286
|
-
argv += ["--cwd", lane["cwd"], "--root", lane["run_root"], "--auto-stop", "--intent", lane["kind"]
|
|
303
|
+
argv += ["--cwd", lane["cwd"], "--root", lane["run_root"], "--auto-stop", "--intent", lane["kind"],
|
|
304
|
+
"--phase", lane["phase"] || lane["kind"]]
|
|
287
305
|
argv += context_args
|
|
288
306
|
argv += ["--model", lane["requested_model"]] if lane["requested_model"]
|
|
289
307
|
argv += ["--effort", lane["effort"]] if lane["effort"]
|
|
@@ -292,9 +310,8 @@ module Harnex
|
|
|
292
310
|
stdout, stderr, status = Open3.capture3(*argv)
|
|
293
311
|
payload = last_json(stdout)
|
|
294
312
|
if payload.empty?
|
|
295
|
-
|
|
296
|
-
lane
|
|
297
|
-
save(lane)
|
|
313
|
+
# 124: the worker did not register in time and may still come up.
|
|
314
|
+
status.exitstatus == Waiter::DONE_EXIT_TIMEOUT ? fail_launch(lane, stderr) : unlaunch(lane, before)
|
|
298
315
|
raise LaunchError, "harnex could not start lane #{lane['name']}: #{stderr.strip.lines.last(3).join.strip}"
|
|
299
316
|
end
|
|
300
317
|
return [payload, 0] unless wait
|
|
@@ -302,6 +319,24 @@ module Harnex
|
|
|
302
319
|
[payload["turn_result"] || payload, status.exitstatus]
|
|
303
320
|
end
|
|
304
321
|
|
|
322
|
+
RELAUNCH_KEYS = %w[state launched_at after_seq last_error].freeze
|
|
323
|
+
|
|
324
|
+
# A launch refused before the worker started (a bad flag, repo phase
|
|
325
|
+
# policy, a busy id) leaves no trace: a new lane's record goes, and a
|
|
326
|
+
# resumed lane keeps what its last turn left.
|
|
327
|
+
def unlaunch(lane, before)
|
|
328
|
+
return forget(lane) if lane["turns"].to_i.zero?
|
|
329
|
+
|
|
330
|
+
RELAUNCH_KEYS.each { |key| before.key?(key) ? lane[key] = before[key] : lane.delete(key) }
|
|
331
|
+
save(lane)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def fail_launch(lane, stderr)
|
|
335
|
+
lane["state"] = "failed"
|
|
336
|
+
lane["last_error"] = stderr.strip.lines.last.to_s.strip
|
|
337
|
+
save(lane)
|
|
338
|
+
end
|
|
339
|
+
|
|
305
340
|
def wait(lane, harnex_command, timeout: DEFAULT_TIMEOUT)
|
|
306
341
|
argv = harnex_command + ["wait", "--id", lane["name"], "--repo", lane["run_root"], "--until", "done",
|
|
307
342
|
"--since", lane["launched_at"]]
|
data/lib/harnex/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: harnex
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.29.
|
|
4
|
+
version: 0.29.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jikku Jose
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: 'Hand questions and tasks to Pi workers in visible tmux windows and get
|
|
14
14
|
the result back in one call: lanes in git worktrees, follow-ups that keep context,
|