lux-hammer 0.3.23 → 0.3.25

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: 74dd44bca6cf689dcfe0396d90e05e51b0605821314fb6d5b742502662f4e8e8
4
- data.tar.gz: a84eaa4fcce83f951905bab3419d18adfcce409cd0f9d2f23db910569443a2ce
3
+ metadata.gz: 273ffb060fd16cb52423002e5e8e041481a37b0487395d966a8f9ba90cfa53fa
4
+ data.tar.gz: 9dc14e358bfafc0050379d671711ca28d6d6eb3aa1935f7440c93ea897267431
5
5
  SHA512:
6
- metadata.gz: 044a31c525aba212012888f39d938fa37dc2e69e59097fa10f1dda53723b0a267dae2dad0e77887c19ad801a64fcb47e03b8aa95d916b04474d5239628a9b8f0
7
- data.tar.gz: 3ec9940429c06e4bfb00669184fe3d4f78fff0ed693f857fcd37c682bf89b70a214f3a7e92e24826a1ab5889c62de26f05e0f9b78fe22c1c65b12227d4a18681
6
+ metadata.gz: 8c9e26f85ded5283505130548bcd1b64718d1a405013149d086881e166c59239cb1a4db3fbefcb2a990ed2a06b82d90ae54769e6650be6d1d6032a9fe48328d5
7
+ data.tar.gz: 700f84954436b3e606d2b5234ff682f19c27d651083037e0faaa0f9ca796b1df604704d6908d58f7fa6a133dc9fa314df71084a805c6219b1d46d963557fe22b
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.23
1
+ 0.3.25
data/README.md CHANGED
@@ -506,9 +506,11 @@ end
506
506
 
507
507
  ## Stdin, JSON input, and global options
508
508
 
509
- Every command receives **`opts[:stdin]`** automatically when stdin is a
510
- pipe (non-TTY) and non-empty; otherwise it is `nil`. Interactive TTYs are
511
- never consumed.
509
+ Every command can read **`opts[:stdin]`**: piped stdin (non-TTY, non-empty)
510
+ as a string, otherwise `nil`. It is read on the first lookup and kept, so a
511
+ command that never looks never waits on a pipe. Interactive TTYs are never
512
+ consumed, and a socket with nothing pending (agent harnesses hand one to
513
+ every command) reads as `nil` rather than blocking.
512
514
 
513
515
  ```ruby
514
516
  task :count do
data/lib/hammer/input.rb CHANGED
@@ -3,18 +3,23 @@
3
3
  require 'json'
4
4
 
5
5
  class Hammer
6
- # Stdin + JSON helpers for opts. Hammer always attaches piped stdin as
7
- # `opts[:stdin]`; recipes opt into JSON body handling via
8
- # `Hammer::Input.prepare_json!` (typically from a `before` hook) and/or
9
- # `opt :json, type: :json` / `global_opt :json, type: :json`.
6
+ # Stdin + JSON helpers for opts. Hammer exposes piped stdin as
7
+ # `opts[:stdin]`, read lazily on first access; recipes opt into JSON body
8
+ # handling via `Hammer::Input.prepare_json!` (typically from a `before`
9
+ # hook) and/or `opt :json, type: :json` / `global_opt :json, type: :json`.
10
10
  module Input
11
11
  module_function
12
12
 
13
13
  # Read piped stdin once. Returns nil when stdin is a TTY or empty.
14
- # Does not consume an interactive TTY.
14
+ # Does not consume an interactive TTY, and does not block on a socket
15
+ # that has nothing pending: agent harnesses (Claude Code's Bash tool)
16
+ # hand every command a unix socket as stdin that never reaches EOF, so
17
+ # a plain read would hang there for good. A shell pipe or a redirected
18
+ # file is a FIFO or a regular file and is still read in full.
15
19
  def read_stdin
16
20
  return nil if $stdin.closed?
17
21
  return nil if $stdin.tty?
22
+ return nil if idle_socket?($stdin)
18
23
 
19
24
  data = $stdin.read
20
25
  return nil if data.nil?
@@ -25,11 +30,26 @@ class Hammer
25
30
  nil
26
31
  end
27
32
 
28
- # Idempotent: sets opts[:stdin] if not already present.
33
+ def idle_socket?(io)
34
+ return false unless io.respond_to?(:stat) && io.stat.socket?
35
+
36
+ IO.select([io], nil, nil, 0).nil?
37
+ rescue StandardError
38
+ false
39
+ end
40
+
41
+ # Reads stdin into the hash on the first `opts[:stdin]` lookup and keeps
42
+ # the result, so a command that never asks for stdin never waits on it.
43
+ LAZY_STDIN = lambda do |hash, key|
44
+ key == :stdin ? (hash[:stdin] = Input.read_stdin) : nil
45
+ end
46
+
47
+ # Idempotent: leaves an explicit opts[:stdin] alone, otherwise installs
48
+ # the lazy reader. Nothing is read until a recipe looks at opts[:stdin].
29
49
  def attach_stdin!(opts)
30
- return opts[:stdin] if opts.key?(:stdin)
50
+ return if opts.key?(:stdin) || opts.default_proc.equal?(LAZY_STDIN)
31
51
 
32
- opts[:stdin] = read_stdin
52
+ opts.default_proc = LAZY_STDIN
33
53
  end
34
54
 
35
55
  # Parse a JSON source string into a Hash/Array with symbol keys.
data/lib/lux-hammer.rb CHANGED
@@ -690,8 +690,9 @@ class Hammer
690
690
  options = effective_options(cmd)
691
691
  positional, opts = Parser.new(options).parse(argv)
692
692
  opts[:args] = positional
693
- # Always attach piped stdin (nil when TTY / empty). Recipes that want
694
- # JSON body handling call Hammer::Input.prepare_json! in a before hook.
693
+ # opts[:stdin] reads piped stdin on first access (nil when TTY / empty).
694
+ # Recipes that want JSON body handling call Hammer::Input.prepare_json!
695
+ # in a before hook.
695
696
  Hammer::Input.attach_stdin!(opts)
696
697
  print_run_banner(cmd, full || cmd.name, positional, opts, options: options) unless quiet || ENV['HAMMER_QUIET']
697
698
  instance = new
data/recipes/llm.rb CHANGED
@@ -9,7 +9,6 @@ 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
@@ -333,287 +332,6 @@ namespace :memory do
333
332
  end
334
333
  end
335
334
 
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 / multi-line)
341
- llm todo:list tasks by id, counts, format warnings
342
- llm todo:pop + llm todo:done take / finish a single task by hand
343
-
344
- Edit the file freely: only the `# todo`, `# doing` and `# done` sections are
345
- managed. `# plan`, `# idea` and any prose stay untouched. A task starts with
346
- `*` at the line start and runs until the next `*` or header; when finished it
347
- moves to `# done` verbatim.
348
-
349
- LLM usage - one of these lines is the whole prompt:
350
- "Run `llm todo go` and follow its output." work through every task
351
- "Run `llm todo pop`, do the task, run `llm todo done`." do a single task
352
-
353
- Task text is printed raw on stdout. `todo go` repeats the protocol after each
354
- task and says when the queue is empty; `todo pop` re-prints the task in
355
- progress, so an interrupted agent resumes instead of skipping ahead.
356
- TXT
357
-
358
- task :todo do
359
- desc <<~D
360
- How the todo queue works, for humans and LLMs.
361
-
362
- #{TODO_GUIDE}
363
- D
364
- example 'todo'
365
-
366
- proc do
367
- say TODO_GUIDE
368
- say ''
369
- self.class.print_help 'todo:'
370
- end
371
- end
372
-
373
- namespace :todo do
374
- # Same story as :memory - helpers must live inside the namespace block.
375
- private
376
-
377
- TODO_FILE ||= 'LLM_TODO.local.md'
378
- TODO_SECTIONS ||= { todo: 'todo', doing: 'doing', done: 'done' }.freeze
379
-
380
- def todo_path
381
- File.join(Dir.pwd, TODO_FILE)
382
- end
383
-
384
- # Exact title match on purpose - "# done ideas" must stay an unmanaged section.
385
- def todo_section_key(title)
386
- case title.strip.downcase
387
- when 'todo', 'pending' then :todo
388
- when 'doing', 'in progress', 'in-progress' then :doing
389
- when 'done', 'completed' then :done
390
- end
391
- end
392
-
393
- # The tool manages only the `# todo`, `# doing` and `# done` sections (any
394
- # heading level). Inside them a task starts at a `*` or `-` bullet in the
395
- # first column and runs until the next bullet or header, so tasks can span
396
- # multiple lines and move between sections verbatim. Every other section
397
- # (`# plan`, `# idea`, ...) and any preamble is kept as-is and never touched.
398
- # Returns [tasks, nodes]; nodes reproduce the document layout for todo_save.
399
- def todo_parse
400
- tasks = { todo: [], doing: [], done: [] }
401
- nodes = []
402
- return [tasks, nodes] unless File.file?(todo_path)
403
-
404
- section = nil # managed section key, nil while inside a raw chunk
405
- current = nil # lines of the task being collected
406
-
407
- flush_task = lambda do
408
- tasks[section] << current.join("\n").rstrip if current
409
- current = nil
410
- end
411
-
412
- File.foreach(todo_path) do |raw|
413
- line = raw.chomp
414
- if (m = line.match(/^#+\s*(\S.*?)\s*$/))
415
- flush_task.call
416
- section = todo_section_key(m[1])
417
- if section
418
- nodes << [:section, section] unless nodes.include?([:section, section])
419
- else
420
- nodes << [:raw, [line]]
421
- end
422
- elsif section
423
- if line =~ /^[-*]\s+(.*)$/
424
- flush_task.call
425
- current = [$1.strip]
426
- elsif current
427
- current << line.rstrip
428
- end
429
- else
430
- nodes << [:raw, []] unless nodes.last && nodes.last[0] == :raw
431
- nodes.last[1] << line.rstrip
432
- end
433
- end
434
- flush_task.call
435
- [tasks, nodes]
436
- end
437
-
438
- # Rewrite: managed sections in canonical form, raw chunks verbatim, original
439
- # order kept. Managed sections missing from the file are appended at the end.
440
- def todo_save(tasks, nodes)
441
- order = nodes + (TODO_SECTIONS.keys - nodes.map { |t, v| v if t == :section }).map { |k| [:section, k] }
442
-
443
- File.open(todo_path, 'w') do |io|
444
- order.each_with_index do |(type, value), i|
445
- io.puts unless i.zero?
446
- if type == :section
447
- io.puts "# #{TODO_SECTIONS[value]}"
448
- tasks[value].each do |text|
449
- io.puts
450
- io.puts "* #{text}"
451
- end
452
- else
453
- lines = value.dup
454
- lines.pop while lines.any? && lines.last.empty?
455
- lines.each { |l| io.puts l }
456
- end
457
- end
458
- end
459
- end
460
-
461
- # One-line label for confirmations - multi-line tasks show their first line.
462
- def todo_label(text)
463
- first, rest = text.split("\n", 2)
464
- rest ? "#{first} ..." : first
465
- end
466
-
467
- # Format sanity check for hand-edited files. Returns warning strings.
468
- def todo_lint(tasks)
469
- warns = []
470
- seen = Hash.new(0)
471
- section = :preamble
472
- File.foreach(todo_path) do |raw|
473
- line = raw.chomp
474
- if (m = line.match(/^#+\s*(\S.*?)\s*$/))
475
- key = todo_section_key(m[1])
476
- section = key || :other
477
- seen[key] += 1 if key
478
- elsif section == :preamble && line =~ /^[-*]\s/
479
- warns << "ignored bullet before any section header: #{line}"
480
- end
481
- end
482
- TODO_SECTIONS.each_key do |key|
483
- warns << "duplicate '# #{key}' sections - their tasks are merged" if seen[key] > 1
484
- warns << "missing '# #{key}' section - will be added on next write" if seen[key].zero?
485
- end
486
- warns << "#{tasks[:doing].length} tasks in doing - expected at most 1" if tasks[:doing].length > 1
487
- warns
488
- end
489
-
490
- task :add do
491
- desc <<~DESC
492
- Add task(s) to ./#{TODO_FILE} (created on first add).
493
-
494
- Text comes from the argument (one task), or from stdin - there a `*` at
495
- the start of a line begins a new task and following lines belong to it,
496
- so multi-line tasks and whole lists can be piped in at once.
497
- DESC
498
- example 'todo add "migrate the user model to Sequel"'
499
- example 'cat tasks.md | llm todo add'
500
- opt :text, desc: 'task text (or pipe tasks on stdin)'
501
-
502
- proc do |opts|
503
- # unquoted multi-word input lands in :text + :args - join it back
504
- arg = [opts[:text], *opts[:args]].compact.join(' ').strip
505
- if arg.empty?
506
- new_tasks = []
507
- opts[:stdin].to_s.split("\n").each do |line|
508
- if line =~ /^[-*]\s+(.*)$/
509
- new_tasks << [$1.strip]
510
- elsif new_tasks.any?
511
- new_tasks.last << line.rstrip
512
- elsif line.strip != ''
513
- new_tasks << [line.rstrip]
514
- end
515
- end
516
- new_tasks = new_tasks.map { |lines| lines.join("\n").rstrip }.reject(&:empty?)
517
- else
518
- new_tasks = [arg]
519
- end
520
- error 'usage: llm todo add <text> (or pipe tasks on stdin)' if new_tasks.empty?
521
-
522
- tasks, nodes = todo_parse
523
- tasks[:todo].concat new_tasks
524
- todo_save tasks, nodes
525
- new_tasks.each { |t| say "added: #{todo_label(t)}", :green }
526
- end
527
- end
528
-
529
- task :pop do
530
- desc <<~DESC
531
- Print the current task and stop there - one task, no loop.
532
-
533
- Moves the first todo task to doing if nothing is doing yet. Prints only
534
- the raw task text (agent-friendly stdout). Re-running without `todo done`
535
- prints the same task again, so an interrupted agent resumes instead of
536
- starting the next one. Exits 1 when the list is empty.
537
- DESC
538
- example 'todo pop'
539
-
540
- proc do
541
- tasks, nodes = todo_parse
542
- if tasks[:doing].empty?
543
- error 'todo list is empty' if tasks[:todo].empty?
544
- tasks[:doing] << tasks[:todo].shift
545
- todo_save tasks, nodes
546
- end
547
- puts tasks[:doing].first
548
- end
549
- end
550
-
551
- task :go do
552
- desc <<~DESC
553
- Agent entry point: work through the whole todo list.
554
-
555
- Prints the current task (pop) followed by the loop protocol, so telling
556
- any agent "run llm todo go" is enough to drain the queue. When the list
557
- is empty it says so and tells the agent to stop and summarize.
558
- DESC
559
- example 'todo go'
560
-
561
- proc do
562
- tasks, nodes = todo_parse
563
- if tasks[:doing].empty? && tasks[:todo].empty?
564
- say 'All tasks done - stop the loop and summarize what was done.'
565
- next
566
- end
567
- if tasks[:doing].empty?
568
- tasks[:doing] << tasks[:todo].shift
569
- todo_save tasks, nodes
570
- end
571
- say "TASK: #{tasks[:doing].first}"
572
- say ''
573
- say 'Do this task fully and verify it. Then run `llm todo done`, and `llm todo go` for the next one.'
574
- end
575
- end
576
-
577
- task :done do
578
- desc 'Mark the in-progress task as done'
579
- example 'todo done'
580
-
581
- proc do
582
- tasks, nodes = todo_parse
583
- error 'no task in progress (run: llm todo pop)' if tasks[:doing].empty?
584
- text = tasks[:doing].shift
585
- tasks[:done] << text
586
- todo_save tasks, nodes
587
- say "done: #{todo_label(text)}", :green
588
- end
589
- end
590
-
591
- task :list do
592
- alt :inspect
593
- desc 'Inspect the todo file: tasks by id, counts, format validity warnings'
594
- example 'todo list'
595
-
596
- proc do
597
- error "no #{TODO_FILE} in #{Dir.pwd} (run: llm todo add <text>)" unless File.file?(todo_path)
598
- tasks, = todo_parse
599
- id = 0
600
- TODO_SECTIONS.each do |key, title|
601
- say "#{title}:", :cyan
602
- color = { todo: nil, doing: :yellow, done: :gray }[key]
603
- tasks[key].each do |text|
604
- first, *rest = text.split("\n")
605
- say "#{(id += 1).to_s.rjust(3)}. #{first}", color
606
- rest.each { |l| say " #{l}", color }
607
- end
608
- say ' (none)', :gray if tasks[key].empty?
609
- end
610
- say ''
611
- say "#{tasks[:todo].length} todo, #{tasks[:doing].length} doing, #{tasks[:done].length} done"
612
- todo_lint(tasks).each { |w| say "warning: #{w}", :yellow }
613
- end
614
- end
615
- end
616
-
617
335
  namespace :prompt do
618
336
  TOKEN_PATTERN ||= /[a-z0-9_-]+/.freeze
619
337
  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.23
4
+ version: 0.3.25
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-19 00:00:00.000000000 Z
11
+ date: 2026-08-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest