lux-hammer 0.3.24 → 0.3.26

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: 68c2cd8d90138ab3eaf6a68a23aeabc273b31d26122224ba3966d235a9841657
4
- data.tar.gz: ae13bf1ca96c396b51a1db3801ce4aa4179ad67f5bb8e4b04467715b9744164e
3
+ metadata.gz: acc942b0818d0cc60a8cbe9a55fbf092665d6efcb46df209b2a8e9721aa1e190
4
+ data.tar.gz: 94baed7a0025611360d0315d084fd7432a1eb68d84302bfeaf0960ee0326aee7
5
5
  SHA512:
6
- metadata.gz: 222754053a8ca203a8f0344fd0007174e1a333f92e69935fd908567e7e6a8a1d1c8675d5bd8a443a905fd9140ba54ed4afc2fdbd7abe817c8cc90d3643e97fa1
7
- data.tar.gz: 345ebe94f0d5eb3383fb4a0df98a1cc95d611fa330d2174c2ed6b3b25de724ee0acc51100682cd93cce813c446298cd0e19d004998e3fe95e8f5b01434368faa
6
+ metadata.gz: 486bb304e25a56faada26757651998d75e589fabca7402f499dc9cdd3228a90b30cc3fc7cda765d2cd34fc90eecc906208681c031e38628e0087f59eb5250af7
7
+ data.tar.gz: 87115686cf4bbdd6a47ed9a25e048cf811fe1c8071966e5ee3143b7fcafc286ff1480d00c5ba494e407fa9032b8e1b6dda43e8623c011ae9ffdbdf70e8a33612
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.24
1
+ 0.3.26
data/lib/hammer/parser.rb CHANGED
@@ -50,6 +50,17 @@ class Hammer
50
50
  next
51
51
  end
52
52
 
53
+ # Bundled boolean short flags: `-cf` is `-c -f`. Only when every letter
54
+ # is a boolean switch, so `-pVALUE` below keeps its meaning.
55
+ if token =~ /\A-[^-]{2,}\z/
56
+ bundle = token[1..-1].chars.map { |ch| @by_switch["-#{ch}"] }
57
+ if bundle.all? { |o| o&.boolean? }
58
+ bundle.each { |o| values[o.name] = true }
59
+ i += 1
60
+ next
61
+ end
62
+ end
63
+
53
64
  # Glued short flag with value: `-pVALUE` (non-boolean short opts only).
54
65
  if token.start_with?('-') && !token.start_with?('--') && token.length > 2
55
66
  if (opt = @by_switch[token[0, 2]]) && !opt.boolean?
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'pathname'
5
+
6
+ # Builds the command `llm wrap:run` hands to LlmWrap: picks the agent CLI by
7
+ # prefix and maps the generic -c / -f / -a switches to what that CLI takes.
8
+ module LlmLaunch
9
+ Launch = Struct.new(:argv, :env, :files)
10
+
11
+ # :agents gets the combined AGENTS.md text and the file paths, and answers
12
+ # with the argv to add and/or env to set - opencode has no flag for
13
+ # instructions, only inline config, and that one takes paths.
14
+ TOOLS ||= {
15
+ 'claude' => { continue: %w[-c], full: %w[--dangerously-skip-permissions],
16
+ agents: ->(text, _files) { { args: ['--append-system-prompt', text] } } },
17
+ 'codex' => { continue: %w[resume --last], full: %w[--dangerously-bypass-approvals-and-sandbox],
18
+ agents: ->(text, _files) { { args: ['-c', "developer_instructions=#{text}"] } } },
19
+ 'grok' => { continue: %w[-c], full: %w[--always-approve],
20
+ agents: ->(text, _files) { { args: ['--rules', text] } } },
21
+ 'opencode' => { continue: %w[-c], full: %w[--auto],
22
+ agents: ->(_text, files) { { env: { 'OPENCODE_CONFIG_CONTENT' => { instructions: files }.to_json } } } },
23
+ }.freeze
24
+
25
+ # Read AGENTS.md from the git root down on their own; -a only adds the
26
+ # files above it for these. Claude reads CLAUDE.md, so it gets the chain.
27
+ NATIVE_AGENTS ||= %w[codex grok opencode].freeze
28
+
29
+ # First tool whose name starts with the prefix, in TOOLS order: `c` is
30
+ # claude, `co` codex. No prefix means claude.
31
+ def self.tool(prefix)
32
+ return 'claude' if prefix.nil? || prefix.empty?
33
+ TOOLS.keys.find { |name| name.start_with?(prefix) }
34
+ end
35
+
36
+ def self.build(tool, extra = [], continue: false, full: false, agents: false, cwd: Dir.pwd, home: Dir.home)
37
+ spec = TOOLS.fetch(tool)
38
+ argv = [tool]
39
+ argv.concat(spec[:continue]) if continue
40
+ argv.concat(spec[:full]) if full
41
+
42
+ files = agents ? agents_files(tool, cwd: cwd, home: home) : []
43
+ env = {}
44
+ if files.any?
45
+ added = spec[:agents].call(agents_text(files), files.map(&:to_s))
46
+ argv.concat(added[:args] || [])
47
+ env.update(added[:env] || {})
48
+ end
49
+
50
+ argv.concat(extra)
51
+ Launch.new(argv, env, files)
52
+ end
53
+
54
+ # AGENTS.md from home down to cwd, outermost first. For NATIVE_AGENTS tools
55
+ # the walk stops above the git root - or above cwd outside git, since that is
56
+ # what they take as the project then.
57
+ def self.agents_files(tool, cwd: Dir.pwd, home: Dir.home)
58
+ stop = nil
59
+ if NATIVE_AGENTS.include?(tool)
60
+ stop = IO.popen(['git', '-C', cwd, 'rev-parse', '--show-toplevel'], err: File::NULL, &:read).to_s.strip
61
+ stop = cwd if stop.empty?
62
+ end
63
+
64
+ Pathname(cwd).ascend
65
+ .select { |dir| dir.to_s.start_with?(home) }
66
+ .reject { |dir| stop && dir.to_s.start_with?(stop) }
67
+ .reverse
68
+ .map { |dir| dir + 'AGENTS.md' }
69
+ .select(&:file?)
70
+ end
71
+
72
+ def self.agents_text(files)
73
+ body = files.map { |f| "## #{f}\n\n#{f.read.strip}\n" }.join("\n")
74
+ "Project instructions (AGENTS.md), broad to specific:\n\n#{body}"
75
+ end
76
+ end
data/recipes/llm.rb CHANGED
@@ -9,11 +9,11 @@ desc <<~TXT
9
9
  memory persistent memory store (backs the Claude Code memory plugin)
10
10
  plan apply a /plan bundle - sha1 checked, drift aware
11
11
  prompt token-prefix prompt expander (UserPromptSubmit hook + CLI)
12
- todo per-project task queue for agents (./LLM_TODO.local.md)
13
12
 
14
13
  Commands:
15
14
  usage subscription limits and per-model token usage
16
15
  wrap run a command with your last two prompts pinned to the screen
16
+ wrap:run start claude / codex / grok / opencode in wrap, same -c -f -a switches for all
17
17
  TXT
18
18
 
19
19
  require 'fileutils'
@@ -186,6 +186,58 @@ task :wrap do
186
186
  end
187
187
  end
188
188
 
189
+ namespace :wrap do
190
+ task :run do
191
+ desc <<~D
192
+ Start an agent CLI inside `llm wrap`, with the same switches whichever one it is.
193
+
194
+ TOOL is a prefix of claude, codex, grok or opencode - first match wins, so `c` is
195
+ claude and `co` is codex. No tool means claude. The switches map to what each
196
+ CLI actually takes:
197
+
198
+ -c continue the last session here claude/grok/opencode -c, codex resume --last
199
+ -f full permissions, no prompts --dangerously-skip-permissions, --dangerously-bypass-
200
+ approvals-and-sandbox, --always-approve, --auto
201
+ -a AGENTS.md from ~ down to cwd claude --append-system-prompt, codex -c
202
+ developer_instructions, grok --rules, opencode
203
+ inline config
204
+
205
+ codex, grok and opencode read AGENTS.md from the git root down by themselves, so
206
+ -a only adds the files above the repo for them; claude gets the whole chain.
207
+ Anything after `--` is handed to the tool untouched. `~/bin/ai` is this command.
208
+ D
209
+ example 'wrap:run'
210
+ example 'wrap:run g -cf'
211
+ example 'wrap:run cod -ca'
212
+ example 'wrap:run c -- --model opus'
213
+
214
+ opt :continue, type: :boolean, desc: 'continue the last session in this folder'
215
+ opt :full, type: :boolean, desc: 'full permissions, no approval prompts'
216
+ opt :agents, type: :boolean, desc: 'inject AGENTS.md found from ~ down to cwd'
217
+
218
+ proc do |opts|
219
+ require File.join(_llm_root, 'lib/llm/wrap')
220
+ require File.join(_llm_root, 'lib/llm/launch')
221
+
222
+ args = Array(opts[:args])
223
+ prefix = args.first unless args.first.nil? || args.first.start_with?('-')
224
+ tool = LlmLaunch.tool(prefix) ||
225
+ error("unknown tool #{prefix.inspect} - one of #{LlmLaunch::TOOLS.keys.join(', ')}")
226
+ extra = prefix ? args.drop(1) : args
227
+
228
+ launch = LlmLaunch.build(tool, extra, continue: opts[:continue], full: opts[:full], agents: opts[:agents])
229
+ ENV.update(launch.env)
230
+ say.gray "agents: #{launch.files.map { |f| f.to_s.sub(Dir.home, '~') }.join(', ')}" if launch.files.any?
231
+
232
+ flags = %i[continue full agents].select { |k| opts[k] }.map { |k| "--#{k}" }
233
+ origin = ['llm', 'wrap:run', tool, *flags, '--', *extra]
234
+ # Piped stdout is block-buffered and exec drops the buffer with the banner in it.
235
+ $stdout.flush
236
+ exit LlmWrap.run(launch.argv, origin: origin)
237
+ end
238
+ end
239
+ end
240
+
189
241
  namespace :memory do
190
242
  # Helpers are defined inside the namespace block (class_eval'd on the
191
243
  # namespace's anonymous Hammer subclass) so the task procs reach them.
@@ -333,292 +385,6 @@ namespace :memory do
333
385
  end
334
386
  end
335
387
 
336
- TODO_GUIDE ||= <<~TXT
337
- Per-project task queue in ./LLM_TODO.local.md - workable by humans and any agent.
338
-
339
- Human usage:
340
- llm todo:add "fix login redirect" queue a task; pipe stdin for bulk:
341
- `* ` bullets delimit tasks (multi-line ok),
342
- no bullets at all = one task per line
343
- llm todo:list tasks by id, counts, format warnings
344
- llm todo:pop + llm todo:done take / finish a single task by hand
345
-
346
- Edit the file freely: only the `# todo`, `# doing` and `# done` sections are
347
- managed. `# plan`, `# idea` and any prose stay untouched. A task starts with
348
- `*` at the line start and runs until the next `*` or header; when finished it
349
- moves to `# done` verbatim.
350
-
351
- LLM usage - one of these lines is the whole prompt:
352
- "Run `llm todo go` and follow its output." work through every task
353
- "Run `llm todo pop`, do the task, run `llm todo done`." do a single task
354
-
355
- Task text is printed raw on stdout. `todo go` repeats the protocol after each
356
- task and says when the queue is empty; `todo pop` re-prints the task in
357
- progress, so an interrupted agent resumes instead of skipping ahead.
358
- TXT
359
-
360
- task :todo do
361
- desc <<~D
362
- How the todo queue works, for humans and LLMs.
363
-
364
- #{TODO_GUIDE}
365
- D
366
- example 'todo'
367
-
368
- proc do
369
- say TODO_GUIDE
370
- say ''
371
- self.class.print_help 'todo:'
372
- end
373
- end
374
-
375
- namespace :todo do
376
- # Same story as :memory - helpers must live inside the namespace block.
377
- private
378
-
379
- TODO_FILE ||= 'LLM_TODO.local.md'
380
- TODO_SECTIONS ||= { todo: 'todo', doing: 'doing', done: 'done' }.freeze
381
-
382
- def todo_path
383
- File.join(Dir.pwd, TODO_FILE)
384
- end
385
-
386
- # Exact title match on purpose - "# done ideas" must stay an unmanaged section.
387
- def todo_section_key(title)
388
- case title.strip.downcase
389
- when 'todo', 'pending' then :todo
390
- when 'doing', 'in progress', 'in-progress' then :doing
391
- when 'done', 'completed' then :done
392
- end
393
- end
394
-
395
- # The tool manages only the `# todo`, `# doing` and `# done` sections (any
396
- # heading level). Inside them a task starts at a `*` or `-` bullet in the
397
- # first column and runs until the next bullet or header, so tasks can span
398
- # multiple lines and move between sections verbatim. Every other section
399
- # (`# plan`, `# idea`, ...) and any preamble is kept as-is and never touched.
400
- # Returns [tasks, nodes]; nodes reproduce the document layout for todo_save.
401
- def todo_parse
402
- tasks = { todo: [], doing: [], done: [] }
403
- nodes = []
404
- return [tasks, nodes] unless File.file?(todo_path)
405
-
406
- section = nil # managed section key, nil while inside a raw chunk
407
- current = nil # lines of the task being collected
408
-
409
- flush_task = lambda do
410
- tasks[section] << current.join("\n").rstrip if current
411
- current = nil
412
- end
413
-
414
- File.foreach(todo_path) do |raw|
415
- line = raw.chomp
416
- if (m = line.match(/^#+\s*(\S.*?)\s*$/))
417
- flush_task.call
418
- section = todo_section_key(m[1])
419
- if section
420
- nodes << [:section, section] unless nodes.include?([:section, section])
421
- else
422
- nodes << [:raw, [line]]
423
- end
424
- elsif section
425
- if line =~ /^[-*]\s+(.*)$/
426
- flush_task.call
427
- current = [$1.strip]
428
- elsif current
429
- current << line.rstrip
430
- end
431
- else
432
- nodes << [:raw, []] unless nodes.last && nodes.last[0] == :raw
433
- nodes.last[1] << line.rstrip
434
- end
435
- end
436
- flush_task.call
437
- [tasks, nodes]
438
- end
439
-
440
- # Rewrite: managed sections in canonical form, raw chunks verbatim, original
441
- # order kept. Managed sections missing from the file are appended at the end.
442
- def todo_save(tasks, nodes)
443
- order = nodes + (TODO_SECTIONS.keys - nodes.map { |t, v| v if t == :section }).map { |k| [:section, k] }
444
-
445
- File.open(todo_path, 'w') do |io|
446
- order.each_with_index do |(type, value), i|
447
- io.puts unless i.zero?
448
- if type == :section
449
- io.puts "# #{TODO_SECTIONS[value]}"
450
- tasks[value].each do |text|
451
- io.puts
452
- io.puts "* #{text}"
453
- end
454
- else
455
- lines = value.dup
456
- lines.pop while lines.any? && lines.last.empty?
457
- lines.each { |l| io.puts l }
458
- end
459
- end
460
- end
461
- end
462
-
463
- # One-line label for confirmations - multi-line tasks show their first line.
464
- def todo_label(text)
465
- first, rest = text.split("\n", 2)
466
- rest ? "#{first} ..." : first
467
- end
468
-
469
- # Format sanity check for hand-edited files. Returns warning strings.
470
- def todo_lint(tasks)
471
- warns = []
472
- seen = Hash.new(0)
473
- section = :preamble
474
- File.foreach(todo_path) do |raw|
475
- line = raw.chomp
476
- if (m = line.match(/^#+\s*(\S.*?)\s*$/))
477
- key = todo_section_key(m[1])
478
- section = key || :other
479
- seen[key] += 1 if key
480
- elsif section == :preamble && line =~ /^[-*]\s/
481
- warns << "ignored bullet before any section header: #{line}"
482
- end
483
- end
484
- TODO_SECTIONS.each_key do |key|
485
- warns << "duplicate '# #{key}' sections - their tasks are merged" if seen[key] > 1
486
- warns << "missing '# #{key}' section - will be added on next write" if seen[key].zero?
487
- end
488
- warns << "#{tasks[:doing].length} tasks in doing - expected at most 1" if tasks[:doing].length > 1
489
- warns
490
- end
491
-
492
- task :add do
493
- desc <<~DESC
494
- Add task(s) to ./#{TODO_FILE} (created on first add).
495
-
496
- Text comes from the argument (one task), or from stdin. Piped input
497
- that has `*` / `-` bullets is a list: a bullet begins a task and the
498
- lines under it belong to it, so multi-line tasks and whole lists can be
499
- piped in at once. Input with no bullets at all is one task per line.
500
- DESC
501
- example 'todo add "migrate the user model to Sequel"'
502
- example 'cat tasks.md | llm todo add'
503
- opt :text, desc: 'task text (or pipe tasks on stdin)'
504
-
505
- proc do |opts|
506
- # unquoted multi-word input lands in :text + :args - join it back
507
- arg = [opts[:text], *opts[:args]].compact.join(' ').strip
508
- if arg.empty?
509
- lines = opts[:stdin].to_s.split("\n")
510
- bulleted = lines.any? { |l| l =~ /^[-*]\s+/ }
511
- new_tasks = []
512
- lines.each do |line|
513
- if line =~ /^[-*]\s+(.*)$/
514
- new_tasks << [$1.strip]
515
- elsif bulleted
516
- new_tasks.last << line.rstrip if new_tasks.any?
517
- elsif line.strip != ''
518
- new_tasks << [line.rstrip]
519
- end
520
- end
521
- new_tasks = new_tasks.map { |ls| ls.join("\n").rstrip }.reject(&:empty?)
522
- else
523
- new_tasks = [arg]
524
- end
525
- error 'usage: llm todo add <text> (or pipe tasks on stdin)' if new_tasks.empty?
526
-
527
- tasks, nodes = todo_parse
528
- tasks[:todo].concat new_tasks
529
- todo_save tasks, nodes
530
- new_tasks.each { |t| say "added: #{todo_label(t)}", :green }
531
- end
532
- end
533
-
534
- task :pop do
535
- desc <<~DESC
536
- Print the current task and stop there - one task, no loop.
537
-
538
- Moves the first todo task to doing if nothing is doing yet. Prints only
539
- the raw task text (agent-friendly stdout). Re-running without `todo done`
540
- prints the same task again, so an interrupted agent resumes instead of
541
- starting the next one. Exits 1 when the list is empty.
542
- DESC
543
- example 'todo pop'
544
-
545
- proc do
546
- tasks, nodes = todo_parse
547
- if tasks[:doing].empty?
548
- error 'todo list is empty' if tasks[:todo].empty?
549
- tasks[:doing] << tasks[:todo].shift
550
- todo_save tasks, nodes
551
- end
552
- puts tasks[:doing].first
553
- end
554
- end
555
-
556
- task :go do
557
- desc <<~DESC
558
- Agent entry point: work through the whole todo list.
559
-
560
- Prints the current task (pop) followed by the loop protocol, so telling
561
- any agent "run llm todo go" is enough to drain the queue. When the list
562
- is empty it says so and tells the agent to stop and summarize.
563
- DESC
564
- example 'todo go'
565
-
566
- proc do
567
- tasks, nodes = todo_parse
568
- if tasks[:doing].empty? && tasks[:todo].empty?
569
- say 'All tasks done - stop the loop and summarize what was done.'
570
- next
571
- end
572
- if tasks[:doing].empty?
573
- tasks[:doing] << tasks[:todo].shift
574
- todo_save tasks, nodes
575
- end
576
- say "TASK: #{tasks[:doing].first}"
577
- say ''
578
- say 'Do this task fully and verify it. Then run `llm todo done`, and `llm todo go` for the next one.'
579
- end
580
- end
581
-
582
- task :done do
583
- desc 'Mark the in-progress task as done'
584
- example 'todo done'
585
-
586
- proc do
587
- tasks, nodes = todo_parse
588
- error 'no task in progress (run: llm todo pop)' if tasks[:doing].empty?
589
- text = tasks[:doing].shift
590
- tasks[:done] << text
591
- todo_save tasks, nodes
592
- say "done: #{todo_label(text)}", :green
593
- end
594
- end
595
-
596
- task :list do
597
- alt :inspect
598
- desc 'Inspect the todo file: tasks by id, counts, format validity warnings'
599
- example 'todo list'
600
-
601
- proc do
602
- error "no #{TODO_FILE} in #{Dir.pwd} (run: llm todo add <text>)" unless File.file?(todo_path)
603
- tasks, = todo_parse
604
- id = 0
605
- TODO_SECTIONS.each do |key, title|
606
- say "#{title}:", :cyan
607
- color = { todo: nil, doing: :yellow, done: :gray }[key]
608
- tasks[key].each do |text|
609
- first, *rest = text.split("\n")
610
- say "#{(id += 1).to_s.rjust(3)}. #{first}", color
611
- rest.each { |l| say " #{l}", color }
612
- end
613
- say ' (none)', :gray if tasks[key].empty?
614
- end
615
- say ''
616
- say "#{tasks[:todo].length} todo, #{tasks[:doing].length} doing, #{tasks[:done].length} done"
617
- todo_lint(tasks).each { |w| say "warning: #{w}", :yellow }
618
- end
619
- end
620
- end
621
-
622
388
  namespace :prompt do
623
389
  TOKEN_PATTERN ||= /[a-z0-9_-]+/.freeze
624
390
  TOKEN_LINE_RE ||= /\A(?:\s*:[a-z0-9_-]+)+\s*\z/.freeze
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lux-hammer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.24
4
+ version: 0.3.26
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dino Reic
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-24 00:00:00.000000000 Z
11
+ date: 2026-08-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -55,6 +55,7 @@ files:
55
55
  - "./lib/lux-hammer.rb"
56
56
  - "./recipes/deploy.rb"
57
57
  - "./recipes/git-helper.rb"
58
+ - "./recipes/lib/llm/launch.rb"
58
59
  - "./recipes/lib/llm/plan.rb"
59
60
  - "./recipes/lib/llm/usage.rb"
60
61
  - "./recipes/lib/llm/wrap.rb"