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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 72eab06bb2287cadb7e9e2f525350b981571717088de593d9ad5225ca9aa324e
4
- data.tar.gz: 6a2fecf3083f015e9ecc90d658a4797439740e21985fecdd3515e98f179f16d1
3
+ metadata.gz: e36a59564be22ba55cda2815e3e72b59b887bb2a4e0417be776295ef1cb10b82
4
+ data.tar.gz: 0a9b1355a5bc153e4d6fdd6ef79df81f022c5b6a75f97de0a8f91ef52353e526
5
5
  SHA512:
6
- metadata.gz: e662b7bb01799d6a8faf1ff62efb1b4353dd6f684ab43f250bcf783981189709a2c60871a8cf8e3c55772e2d233131880ddd2324d8bbe47f2ac2766d5d09ef91
7
- data.tar.gz: 4de10a7bed0959e709c2f99df9dda5dac4ccdc79054919d4cade8780c712f3f2ad1a5b322586a01dadf43637de52ec731299ca8a874f13e156ff31480b02357b
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:
@@ -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
 
@@ -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,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
- 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
 
@@ -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], "created_at" => Lanes.now,
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["worktree"] && @lane["turns"].to_i.zero?
111
+ return unless @lane && @lane["turns"].to_i.zero?
109
112
 
110
- Lanes.remove_worktree(@lane, force: true)
111
- @lane["state"] = "discarded"
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))
@@ -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} " \
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
- FileUtils.rm_f(path(lane["repo_root"], lane["name"]))
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 = File.join(dir, "briefs", "#{Harnex.repo_key(lane['repo_root'])}--#{lane['name']}.md")
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
- lane["state"] = "failed"
296
- lane["last_error"] = stderr.strip.lines.last.to_s.strip
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"]]
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.29.0"
3
- RELEASE_DATE = "2026-10-22"
2
+ VERSION = "0.29.1"
3
+ RELEASE_DATE = "2026-10-23"
4
4
  end
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.0
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-22 00:00:00.000000000 Z
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,