]> git.mdlowis.com Git - proto/nlshell.git/commitdiff
added simple if clauses
authorMike Lowis <mike@mdlowis.com>
Fri, 2 Oct 2026 03:51:01 +0000 (23:51 -0400)
committerMike Lowis <mike@mdlowis.com>
Fri, 2 Oct 2026 03:51:01 +0000 (23:51 -0400)
nl-shell-harness-spec.md
nlshell.rb
test/nlshell_test.rb

index 330e478824b7fa8c3ce629692b4997e127b6de57..d5504cd2ac3911c51cc0a0cbdc42713d157dfdf2 100644 (file)
@@ -56,12 +56,7 @@ graph TD
     LEX --> VAL{Unbalanced quote?}
     VAL -->|yes| REJ[Reject, ask to close quote]
     VAL -->|no| SEG[Sentences then clauses - SS 5]
-    SEG --> CLS[Classifier - regex/keyword, per clause]
-    CLS -->|action/chain| AN[ActionRecorder path]
-    CLS -->|loop/conditional| LN[Tree node path]
-    CLS -->|query| QN[Query path]
-    CLS -->|teach| TN[Teach path]
-    AN & LN --> T1Try[Tier 1 lookup]
+    SEG --> T1Try[Tier 1 lookup]
     T1Try -->|miss| T2Try[Tier 2 fuzzy match]
     T2Try -->|miss| T3Call[Tier 3: schema-constrained call]
     T3Call --> Validate[JSON Schema validate]
@@ -243,11 +238,9 @@ Implemented as `Lexer.clauses`, driven by the ruleset rather than hardcoded:
 
 - Splits a sentence into clauses on connective token sequences declared via `connectives([...])` (builtins: `["after", "that"]`, `["and", "then"]`, `["then"]`, `["next"]`).
 - A bare `and` **without** a preceding comma is ambiguous ("copy a and b to c" is one command). The code only splits on it when the token right after `and` (or `, and`) is in the ruleset's `verb_patterns` set (a known verb word) **or** is a `:command` token (a recognized `$PATH` executable, e.g. "delete a.txt and ls") — not "post-canonicalization" since Tier 2 canonicalization doesn't exist yet; otherwise it's treated as a list argument within one clause.
-- Each resulting clause is classified independently (§7 node types) via `Classifier.classify`.
+- Each resulting clause is resolved independently via the same Tier 1→Tier 2(→Tier 3) pipeline as any other clause (§6.1) — there's no separate classification step; a `query`/`conditional`/`loop` phrase is just a tool whose rule happens to match it, same as any `action`. §7's node `type` comes from whichever tool resolves it, not from a pre-tagged clause kind.
 
-**[A] still open, not yet implemented:** the REPL currently classifies and executes each clause as it's reached, rather than resolving the whole Utterance first. The plan-then-execute policy below remains a v-next item:
-
-- For a multi-clause Utterance: resolve every clause first (may hit Tier 3 per clause), assemble the full plan, show it once, then execute. Query and confirmed-teach clauses may run immediately since they don't mutate state; action clauses wait for the full plan to validate. If any clause fails to resolve, execute nothing.
+**Partially implemented:** `Utterance.build` now resolves every clause first and executes nothing if any clause fails to resolve, closing the core gap this note used to describe. Still open: the query/confirmed-teach-clauses-may-run-immediately refinement, and Tier 3 escalation per clause (Tier 3 doesn't exist yet).
 
 ### 5.6 Fragments and the context register **[A]**
 
@@ -309,6 +302,9 @@ graph TD
 **Steps**
 
 1. **Normalize:** lowercase, strip filler words ("please", "the", "my"), split into tokens.
+
+   **Proposal (not yet implemented):** derive the filler list from a standard English stop-word list minus whatever words appear literally in the current rule set (`STOP_WORDS - vocabulary`), recomputed whenever rules are reindexed, instead of hand-maintaining a fixed list. This generalizes past the handful of words anyone's thought to add, and self-updates as rules change, but only protects words that collide with *current* rule vocabulary — it does not protect words reserved for future use. The seed `STOP_WORDS` list must still exclude pronouns ("it", "them") and negation ("not", "no") up front, since §5.6's planned pronoun/ellipsis resolution needs those to survive normalization rather than be discarded here.
+
 2. **Typo correction:** for each token not in the known vocabulary (rule words + synonym terms), replace it with the nearest vocabulary word if edit distance ≤ 2 (`text` gem, Jaro-Winkler or Levenshtein). Leave the token alone if nothing is close.
 3. **Synonym canonicalization:** rewrite each token to its canonical form using the `synonyms` table, e.g. `wipe → delete`. Both the input and the indexed rule patterns are canonicalized the same way, so they meet in the middle.
 4. **Score:** compare the canonicalized input against every rule's canonicalized trigger phrase.
@@ -333,6 +329,21 @@ Start conservative and tune from the audit log (`tier_used`, `confirmed`, and la
 
 ---
 
+### 6.5 Control-flow tools (proposal, not yet implemented)
+
+Collapses the former `Classifier` pre-filter for `conditional`/`loop` into the same `Ruleset` tool + Tier 1/2/3 mechanism every other action uses, instead of a separate node kind with its own bespoke matching. `query` already folds in this way with no further changes needed — a query is just a tool whose action looks something up instead of running a destructive command. Loops/conditionals need actual new capability, scoped below:
+
+1. **DSL:** `ToolBuilder` gains a way to mark a slot as a *body* rather than a leaf value — e.g. `rule "if :condition then :body"` declares `:body` as recursive (a `body_slots :body` call, or a naming convention) — so `build` stops requiring every slot to bind one flat keyword to one leaf token.
+2. **Extraction:** a body slot consumes *all* leftover tokens after the pattern's literal words are matched, not one leftover token per slot like today's `Tier1.match`/`Tier2.extract_slots` — a different extraction rule, specific to body slots.
+3. **Recursive resolution:** the body span's tokens are re-split via `Lexer.clauses` and each resulting clause re-resolved via the same `Utterance.resolve` (Tier1→Tier2→Tier3) used for top-level clauses, producing a nested list of nodes — reusing the one pipeline rather than standing up a parallel one.
+4. **Condition parsing:** `:condition` becomes a predicate struct (§7's `equals|contains|exists|greater_than|matches_pattern`, `left`/`right` as `$ref`/literal), not a leaf token — a parsing step beyond today's slot binding, needed regardless of the DSL change above.
+5. **Executor:** `Executor.call` currently flattens every arg via `.text`; it needs a branch so a `:body`/`:then`/`:else` arg executes its nested nodes recursively instead of being passed as a string.
+6. **Limits:** the depth cap (§7, 3) and loop iteration cap (§10, 20) must be enforced in this new recursive path, not just documented.
+
+Suggested first slice: a single `conditional` tool, a flat `:body` of one action, no `else`, no loops — validates the DSL/extraction/executor extensions before generalizing to full nesting and loops.
+
+---
+
 ## 7. Node Schemas (JSON Schema, abbreviated)
 
 ```jsonc
index 09137e83eb00c74576063c16abd7808eca98264a..8d8283b82a1d42541c8dc9bc9c5c08191bc2c875 100755 (executable)
@@ -211,37 +211,11 @@ module Fuzzy
   end
 end
 
-# Stage: CLS. Tags a clause as conditional/loop/query/action by matching its
-# leading words against configured phrases, checked in the configured order
-# (first match wins). teach/remember aren't a kind - they're builtin actions.
-module Classifier
-  class << self
-    attr_accessor :patterns
-  end
-
-  def self.configure(ruleset)
-    self.patterns = ruleset.classifier_patterns
-  end
-
-  # Returns the matched kind, or nil if no configured phrase matches.
-  # TODO: fall through to Tier 3 (LLM) instead of nil once that tier exists.
-  def self.classify(clause)
-    patterns.each do |kind, phrases|
-      return kind if phrases.any? { |phrase| leading_match?(clause, phrase) }
-    end
-    nil
-  end
-
-  def self.leading_match?(clause, phrase)
-    return false if clause.length < phrase.length
-
-    clause.first(phrase.length).each_with_index.all? { |t, i| t.word? && Fuzzy.close?(t.text, phrase[i]) }
-  end
-end
-
 # Stage: Tier 1. Exact/template match against configured rule patterns. A
 # pattern is a string of literal words and :slot placeholders, matched
 # word-for-word against a clause (same length, no fuzzing - that's Tier 2).
+# A trailing :slot the tool declared via body_slots is the one exception:
+# it claims every remaining token as a nested clause, not one token.
 module Tier1
   class << self
     attr_accessor :rules
@@ -254,15 +228,41 @@ module Tier1
   # Returns an action node (Hash) on an exact match, or nil on a miss.
   def self.lookup(clause)
     rules.each do |pattern, tool|
-      args = match(pattern.split, clause)
+      args = match(pattern.split, clause, tool.body_slots)
       return { type: "action", name: tool.name, args: args, tool: tool } if args
     end
     nil
   end
 
-  def self.match(words, clause)
-    return nil unless words.length == clause.length
+  def self.match(words, clause, body_slots = Set.new)
+    last = words.last
+    if last&.start_with?(":") && body_slots.include?(last.delete_prefix(":").to_sym)
+      match_with_body(words, clause, last)
+    else
+      return nil unless words.length == clause.length
+
+      match_literals(words, clause)
+    end
+  end
+
+  # Matches the pattern's fixed-length head normally, then hands every
+  # leftover token to the same Tier 1/2 resolution a top-level clause gets -
+  # so a conditional's body is just another resolved node, not a raw token.
+  def self.match_with_body(words, clause, body_word)
+    head = words[0..-2]
+    return nil if clause.length <= head.length # need at least one token for the body
 
+    args = match_literals(head, clause.first(head.length))
+    return nil unless args
+
+    body = Utterance.resolve(clause[head.length..])
+    return nil unless body.node
+
+    args[body_word.delete_prefix(":").to_sym] = body.node
+    args
+  end
+
+  def self.match_literals(words, clause)
     args = {}
     words.each_with_index do |word, i|
       token = clause[i]
@@ -281,8 +281,9 @@ module Tier1
   end
 end
 
-# Tier 2 canonicalization store (SS6.4 step 3): term -> canonical word. Tier 2
-# itself isn't built yet; "remember" populates this for when it is.
+# Tier 2 canonicalization store (SS6.4 step 3): term -> canonical word,
+# applied to both the query and rule vocabulary before Tier2 scores them.
+# "remember" populates this at runtime.
 module Synonyms
   class << self
     attr_accessor :terms
@@ -295,23 +296,183 @@ module Synonyms
   end
 end
 
+# Stage: Tier 2 (SS6.4). Bag-of-words + TF-IDF cosine fallback for a Tier 1
+# miss - tolerates reordered/extra/filler words that Tier 1's strict,
+# same-length positional match can't. Runs only after Tier1.lookup misses.
+module Tier2
+  # Carry no matching signal of their own; stripped before scoring.
+  FILLER_WORDS = %w[please the a an my your].freeze
+
+  # SS6.4 decision thresholds, collapsed to accept/reject: there's no Tier 3
+  # yet to escalate a medium-confidence score to.
+  ACCEPT_SCORE = 0.75
+  ACCEPT_LEAD  = 0.15
+
+  # Max edit distance for correcting a query word toward a known vocabulary
+  # word before scoring (SS6.4 step 2).
+  TYPO_MAX_DISTANCE = 2
+
+  class << self
+    attr_accessor :rules, :vocabulary, :idf, :vectors
+  end
+
+  def self.configure(ruleset)
+    self.rules = ruleset.rule_index
+    reindex
+  end
+
+  # Rebuilds vocabulary/idf/vectors from the current rule set. Call again
+  # after a rule is added at runtime (teach uses the same shared rules Hash).
+  def self.reindex
+    docs = rules.keys.to_h { |pattern| [pattern, literal_words(pattern)] }
+    self.vocabulary = docs.values.flatten.uniq
+    self.idf = compute_idf(docs.values)
+    self.vectors = docs.transform_values { |words| vectorize(words) }
+  end
+
+  # Returns an action node Hash (same shape as Tier1.lookup), or nil if
+  # nothing scores high enough, or slot extraction can't bind cleanly.
+  def self.lookup(clause)
+    words = normalize(clause)
+    return nil if words.empty?
+
+    query = vectorize(words)
+    scored = vectors.map { |pattern, vector| [pattern, cosine(query, vector)] }.sort_by { |_, score| -score }
+    return nil unless accept?(scored)
+
+    pattern, = scored.first
+    tool = rules[pattern]
+    args = extract_slots(pattern, clause)
+    return nil unless args
+
+    { type: "action", name: tool.name, args: args, tool: tool }
+  end
+
+  # Top score must clear the floor AND lead the runner-up by a margin - a
+  # close second choice means the match is too ambiguous to auto-accept.
+  def self.accept?(scored)
+    return false if scored.empty?
+
+    top = scored[0][1]
+    runner_up = scored[1]&.last || 0
+    top >= ACCEPT_SCORE && (top - runner_up) >= ACCEPT_LEAD
+  end
+
+  # Clause tokens -> normalized bag-of-words: literal tokens contribute their
+  # kind (Token#key, so different filenames score identically), filler words
+  # and punctuation are dropped, everything else is typo-corrected then
+  # synonym-canonicalized.
+  def self.normalize(clause)
+    clause.reject(&:punct?)
+          .map(&:key)
+          .reject { |w| FILLER_WORDS.include?(w) }
+          .map { |w| Synonyms.canonical(correct(w)) }
+  end
+
+  # Nearest known vocabulary word within TYPO_MAX_DISTANCE, or the word
+  # itself if nothing is close enough to count as a typo of it.
+  def self.correct(word)
+    return word if vocabulary.include?(word)
+
+    nearest = vocabulary.min_by { |v| Fuzzy.distance(word, v) }
+    return word unless nearest && Fuzzy.distance(word, nearest) <= TYPO_MAX_DISTANCE
+
+    nearest
+  end
+
+  def self.literal_words(pattern)
+    pattern.split.reject { |w| w.start_with?(":") }
+  end
+
+  # Smoothed IDF (as in scikit-learn): +1 inside the log avoids a zero/div-by-
+  # zero for a term in every document; +1 outside keeps every term's weight
+  # positive even if it appears in all of them.
+  def self.compute_idf(docs)
+    total = docs.length
+    vocabulary.to_h do |term|
+      doc_freq = docs.count { |words| words.include?(term) }
+      [term, Math.log((total + 1).fdiv(doc_freq + 1)) + 1]
+    end
+  end
+
+  def self.vectorize(words)
+    counts = words.each_with_object(Hash.new(0)) { |w, h| h[w] += 1 }
+    counts.each_with_object({}) { |(term, count), v| v[term] = count * idf.fetch(term, 0) }
+  end
+
+  def self.cosine(a, b)
+    dot = a.sum { |term, weight| weight * b.fetch(term, 0) }
+    mag_a = Math.sqrt(a.values.sum { |w| w**2 })
+    mag_b = Math.sqrt(b.values.sum { |w| w**2 })
+    return 0.0 if mag_a.zero? || mag_b.zero?
+
+    dot / (mag_a * mag_b)
+  end
+
+  # SS6.4 step 6: tokens not consumed matching a literal pattern word fill
+  # the pattern's :slots, in the order both appear. Returns nil if the
+  # leftover count doesn't match the slot count - a high-scoring pattern
+  # that can't bind cleanly isn't a usable match.
+  def self.extract_slots(pattern, clause)
+    literals = literal_words(pattern)
+    slots = pattern.split.select { |w| w.start_with?(":") }.map { |w| w.delete_prefix(":").to_sym }
+
+    leftovers = []
+    # Each non-filler clause token either consumes one remaining literal
+    # pattern word (fuzzy match, synonym-aware) or becomes a candidate slot
+    # value; filler words carry no matching signal and are dropped outright.
+    clause.reject(&:punct?).each do |token|
+      next if FILLER_WORDS.include?(token.key)
+
+      index = literals.find_index { |word| token.word? && Fuzzy.close?(Synonyms.canonical(token.text), word) }
+      if index
+        literals.delete_at(index)
+      else
+        leftovers << token
+      end
+    end
+
+    return nil unless leftovers.length == slots.length
+
+    slots.zip(leftovers).to_h
+  end
+end
+
 # Stage: EX. Runs a resolved tool's action, isolating the REPL from any
 # exception an action raises so one bad command can't crash the shell.
 module Executor
+  # Only a Token needs flattening to its raw text - a :body/:then/:else arg
+  # is already a resolved node Hash, meant for the action to execute itself.
   def self.call(tool, args)
-    tool.action.call(**args.transform_values(&:text))
+    tool.action.call(**args.transform_values { |v| v.is_a?(Lexer::Token) ? v.text : v })
     true
   rescue StandardError => e
     puts "   error running #{tool.name}: #{e.message}"
     false
   end
+
+  # Confirms first for an :ask tool, same policy a top-level clause gets - the
+  # one gate between "resolved" and "actually runs", so a conditional's body
+  # can't bypass permission checking.
+  def self.run(node)
+    tool = node[:tool]
+    if tool.permission == :ask
+      print "   run #{tool.name}? [y/N] "
+      return false unless gets&.chomp&.downcase == "y"
+    end
+
+    call(tool, node[:args])
+  end
 end
 
 
 # Stage 0 result (spec SS5.7). Everything downstream (Tier 1/2/3, executor)
-# operates on this, never on raw text or loose token/clause arrays.
+# operates on this, never on raw text or loose token/clause arrays. A clause's
+# kind is no longer tagged up front by a separate classifier - every clause
+# is resolved directly through the same Tier 1/2(/3) pipeline, so node/tier
+# are nil together on a miss.
 module Utterance
-  Clause = Struct.new(:tokens, :kind)
+  Clause = Struct.new(:tokens, :node, :tier)
   Result = Struct.new(:raw, :clauses)
 
   # SS5.8 limits.
@@ -335,17 +496,30 @@ module Utterance
     clauses = sentences.flat_map { |sentence| Lexer.clauses(sentence) }
     raise Rejected, "that's too many clauses in one request, please split it up" if clauses.length > MAX_CLAUSES
 
-    # Classification/Tier 1 tolerate small typos on their own (Fuzzy.close?
-    # inside Classifier.leading_match? and Tier1.match), so no separate
-    # correction pass is needed here.
-    Result.new(raw, clauses.map { |tokens| Clause.new(tokens, Classifier.classify(tokens)) })
+    # Tier 1/2 tolerate small typos on their own (Fuzzy.close? inside
+    # Tier1.match and Tier2's typo correction), so no separate pass is needed.
+    Result.new(raw, clauses.map { |tokens| resolve(tokens) })
+  end
+
+  # Tries Tier 1 first (strict positional match), falling back to Tier 2
+  # (fuzzy bag-of-words) only on a Tier 1 miss - tags which tier resolved it
+  # so the REPL can report that, or nil/nil if neither did.
+  def self.resolve(tokens)
+    if (node = Tier1.lookup(tokens))
+      Clause.new(tokens, node, :tier1)
+    elsif (node = Tier2.lookup(tokens))
+      Clause.new(tokens, node, :tier2)
+    else
+      Clause.new(tokens, nil, nil)
+    end
   end
 end
 
 module Ruleset
   # One tool: the rule patterns that trigger it, the keyword-arg action that
-  # runs it, and whether running it needs user confirmation first.
-  Tool = Struct.new(:name, :rules, :action, :permission)
+  # runs it, whether running it needs user confirmation first, and which
+  # slot names (if any) are bodies - see ToolBuilder#body_slots.
+  Tool = Struct.new(:name, :rules, :action, :permission, :body_slots)
 
   # A pattern's :slot names (the "target" in "copy :target to :destination").
   def self.slot_names(pattern)
@@ -378,6 +552,7 @@ module Ruleset
       @verb_synonyms = []
       @permission = :ask # fail-safe default: a tool must opt into :yolo
       @action = nil
+      @body_slots = Set.new
     end
 
     def rule(pattern)
@@ -404,6 +579,14 @@ module Ruleset
       @permission = level
     end
 
+    # Declares which of this tool's :slots are bodies: a nested clause
+    # (resolved through the same Tier 1/2 pipeline as any top-level clause),
+    # not a single leaf token. Must be the last slot in any rule that uses it
+    # - Tier1.match can only claim a trailing run of tokens, not a middle one.
+    def body_slots(names)
+      @body_slots.merge(names)
+    end
+
     def action(&block)
       @action = block
     end
@@ -417,7 +600,8 @@ module Ruleset
       raise ArgumentError, "tool #{@name.inspect}: rules disagree on slot names" unless slots.uniq.length == 1
       raise ArgumentError, "tool #{@name.inspect}: action params don't match rule slots" unless slots.first == required_keywords
 
-      Tool.new(@name, patterns, @action, @permission)
+      validate_body_slots(patterns)
+      Tool.new(@name, patterns, @action, @permission, @body_slots)
     end
 
     private
@@ -433,6 +617,19 @@ module Ruleset
     def required_keywords
       @action.parameters.select { |type, _| type == :keyreq }.map { |_, name| name }.to_set
     end
+
+    # A body slot not in trailing position would silently never match -
+    # Tier1.match_with_body only ever claims a trailing run of tokens.
+    def validate_body_slots(patterns)
+      patterns.each do |pattern|
+        words = pattern.split
+        @body_slots.each do |slot|
+          next unless words.include?(":#{slot}")
+
+          raise ArgumentError, "tool #{@name.inspect}: body slot :#{slot} must be last in #{pattern.inspect}" unless words.last == ":#{slot}"
+        end
+      end
+    end
   end
 
   class RulesModule
@@ -448,10 +645,6 @@ module Ruleset
       @connectives = list
     end
 
-    def classifiers(hash)
-      @classifiers = hash
-    end
-
     def tool(name, &block)
       builder = ToolBuilder.new(name)
       builder.instance_eval(&block)
@@ -469,10 +662,6 @@ module Ruleset
       Set.new(@rules.keys.map { |pattern| pattern.split.first })
     end
 
-    # :action phrases come from verbs, not the classifiers() call, so the
-    # two lists can't drift apart.
-    def classifier_patterns = @classifiers.merge(action: verb_patterns.to_a.map { |v| [v] })
-
     private
 
     # Last tool to claim a rule pattern wins; shadowing is never silent
@@ -511,22 +700,6 @@ builtins = Ruleset.create("builtins") do
     ["next"],
   ]
 
-  # Checked in this order; first matching kind wins. teach/remember are not a
-  # kind of their own - they're builtin tools, classified as :action like any
-  # other verb (see the "teach"/"remember" tools below).
-  classifiers(
-    conditional: [
-      ["if"], ["unless"], ["when"]
-    ],
-    loop: [
-      ["for", "each"], ["repeat"], ["while"], ["every"]
-    ],
-    query: [
-      ["what"], ["how"], ["why"], ["who"], ["where"], ["is"], ["are"], ["does"], ["list"], ["show"]
-    ],
-    # action: derived from verbs, below.
-  )
-
   tool("delete") do
     permission :ask
 
@@ -571,6 +744,23 @@ builtins = Ruleset.create("builtins") do
     end
   end
 
+  # "then" is already a clause-splitting connective (SS5.5), so it can't also
+  # be a literal word here - "do" joins condition and body instead.
+  tool("conditional") do
+    permission :yolo # never destructive itself; the body's own permission gates that, via Executor.run
+
+    rule "if :target exists do :body"
+    body_slots %i[body]
+
+    action do |target:, body:|
+      if File.exist?(target)
+        Executor.run(body)
+      else
+        puts "   condition false, skipping"
+      end
+    end
+  end
+
   tool("teach") do
     permission :ask
 
@@ -592,7 +782,7 @@ builtins = Ruleset.create("builtins") do
         next
       end
 
-      Classifier.configure(builtins) # refresh so the new leading word classifies as :action
+      Tier2.reindex # new pattern's literal words join Tier 2's vocabulary
       puts "   learned: #{trigger.inspect} -> #{tool.name}"
     end
   end
@@ -610,8 +800,8 @@ builtins = Ruleset.create("builtins") do
 end
 
 Lexer.configure(builtins)
-Classifier.configure(builtins)
 Tier1.configure(builtins)
+Tier2.configure(builtins)
 
 # Only run the REPL when executed directly, so tests can require this file
 # for its modules/ruleset without blocking on stdin.
@@ -629,27 +819,18 @@ if __FILE__ == $PROGRAM_NAME
     end
     next if utterance.nil? # empty/punctuation-only input: no-op
 
-    if utterance.clauses.any? { |clause| clause.kind.nil? }
+    # Every clause must resolve before any of them run - a later clause
+    # failing shouldn't leave an earlier one already executed.
+    if utterance.clauses.any? { |clause| clause.node.nil? }
       puts "couldn't tell what kind of request this is, please rephrase"
       next
     end
 
     utterance.clauses.each_with_index do |clause, j|
-      puts "clause #{j} [#{clause.kind}]: #{clause.tokens.map(&:to_s).join(' ')}"
-      next unless clause.kind == :action
-
-      node = Tier1.lookup(clause.tokens)
-      puts "   #{node ? "tier1 hit: #{node}" : 'tier1 miss'}"
-      next unless node
-
-      tool = node[:tool]
-      # :ask tools must be confirmed before running; :yolo tools run immediately.
-      if tool.permission == :ask
-        print "   run #{tool.name}? [y/N] "
-        next unless gets&.chomp&.downcase == "y"
-      end
+      puts "clause #{j}: #{clause.tokens.map(&:to_s).join(' ')}"
+      puts "   #{clause.tier} hit: #{clause.node}"
 
-      Executor.call(tool, node[:args])
+      Executor.run(clause.node)
     end
     puts "match: #{Lexer.match_text(utterance.clauses.flat_map(&:tokens))}"
   end
index 5102ce262a7d5c33c1b910bb115208879ec71f96..9f0fef0cf49382e063789017deb1f49b05453ccf 100644 (file)
@@ -64,20 +64,26 @@ class FuzzyTest < Minitest::Test
   end
 end
 
-class ClassifierTest < Minitest::Test
-  def test_classifies_known_verb_as_action
-    tokens = Lexer.downcase_words(Lexer.tokenize("delete old.log"))
-    assert_equal :action, Classifier.classify(tokens)
+class UtteranceTest < Minitest::Test
+  def test_resolves_a_known_verb_via_tier1
+    clause = Utterance.build("delete old.log").clauses.first
+    assert_equal :tier1, clause.tier
+    assert_equal "delete", clause.node[:name]
   end
 
-  def test_classifies_query_keyword
-    tokens = Lexer.downcase_words(Lexer.tokenize("what is the time"))
-    assert_equal :query, Classifier.classify(tokens)
+  # Regression: query/conditional/loop keywords no longer get a free pass
+  # through a separate classifier - with no tool registered for "what", this
+  # must fail to resolve exactly like any other unmatched verb.
+  def test_unmatched_leading_word_leaves_the_clause_unresolved
+    clause = Utterance.build("what is the time").clauses.first
+    assert_nil clause.node
+    assert_nil clause.tier
   end
 
-  def test_unknown_leading_word_is_unclassified
-    tokens = Lexer.downcase_words(Lexer.tokenize("bork the garble"))
-    assert_nil Classifier.classify(tokens)
+  def test_unknown_verb_leaves_the_clause_unresolved
+    clause = Utterance.build("bork the garble").clauses.first
+    assert_nil clause.node
+    assert_nil clause.tier
   end
 end
 
@@ -101,6 +107,56 @@ class Tier1Test < Minitest::Test
     node = Tier1.lookup(tokens)
     assert_equal "delete", node[:name]
   end
+
+  # A :body slot must claim every remaining token as a nested resolved node,
+  # not bind to exactly one token like an ordinary :slot.
+  def test_lookup_resolves_a_body_slot_as_a_nested_node
+    tokens = Lexer.downcase_words(Lexer.tokenize("if nlshell.rb exists do archive nlshell.rb"))
+    node = Tier1.lookup(tokens)
+    refute_nil node
+    assert_equal "conditional", node[:name]
+    assert_equal "archive", node[:args][:body][:name]
+  end
+
+  # Not enough tokens left for the body slot to bind anything - a miss, not
+  # a crash.
+  def test_lookup_misses_when_body_slot_has_nothing_to_bind
+    tokens = Lexer.downcase_words(Lexer.tokenize("if nlshell.rb exists do"))
+    assert_nil Tier1.lookup(tokens)
+  end
+end
+
+class Tier2Test < Minitest::Test
+  # A filler word breaks Tier 1's same-length positional match, but Tier 2's
+  # bag-of-words score should still resolve it.
+  def test_lookup_resolves_past_a_filler_word_tier1_misses_on
+    tokens = Lexer.downcase_words(Lexer.tokenize("delete the old.log"))
+    assert_nil Tier1.lookup(tokens)
+
+    node = Tier2.lookup(tokens)
+    refute_nil node
+    assert_equal "delete", node[:name]
+    assert_equal "old.log", node[:args][:target].text
+  end
+
+  def test_lookup_rejects_unrelated_input
+    tokens = Lexer.downcase_words(Lexer.tokenize("what time is it"))
+    assert_nil Tier2.lookup(tokens)
+  end
+
+  # Regression: extract_slots must drop filler words from the leftover count,
+  # not just from the score-time bag-of-words, or a real match's slot count
+  # comes out wrong and gets rejected.
+  def test_extract_slots_ignores_filler_words
+    tokens = Lexer.downcase_words(Lexer.tokenize("delete the old.log"))
+    args = Tier2.extract_slots("delete :target", tokens)
+    assert_equal({ target: tokens.last }, args)
+  end
+
+  def test_extract_slots_rejects_a_leftover_count_mismatch
+    tokens = Lexer.downcase_words(Lexer.tokenize("delete old.log now"))
+    assert_nil Tier2.extract_slots("delete :target", tokens)
+  end
 end
 
 class RulesetTest < Minitest::Test
@@ -176,7 +232,7 @@ class SharedRegistrationTest < Minitest::Test
 end
 
 class ExecutorTest < Minitest::Test
-  Tool = Struct.new(:name, :action)
+  Tool = Struct.new(:name, :action, :permission)
 
   def test_returns_true_on_success
     tool = Tool.new("noop", ->(target:) { target })
@@ -193,6 +249,25 @@ class ExecutorTest < Minitest::Test
 
     refute Executor.call(tool, { target: token })
   end
+
+  # Regression: a :body arg (a resolved node Hash, not a Token) must reach
+  # the action unchanged - only Token args get flattened to their raw text.
+  def test_call_passes_non_token_args_through_unchanged
+    captured = nil
+    tool = Tool.new("wrap", ->(body:) { captured = body })
+    node = { type: "action", name: "noop" }
+
+    Executor.call(tool, { body: node })
+    assert_same node, captured
+  end
+
+  # :yolo tools run immediately - no stdin prompt, so this can't hang a test.
+  def test_run_executes_a_yolo_tool_without_confirmation
+    tool = Tool.new("noop", ->(target:) { target }, :yolo)
+    token = Lexer::Token.new(:literal, "old.log", :file)
+
+    assert Executor.run({ tool: tool, args: { target: token } })
+  end
 end
 
 # End-to-end smoke tests driving the actual REPL process over stdin/stdout.
@@ -218,4 +293,26 @@ class ReplIntegrationTest < Minitest::Test
     out = run_repl(%(delete old.log\nn\n))
     refute_match(/Deleting old\.log/, out)
   end
+
+  def test_unresolvable_input_reports_couldnt_tell
+    out = run_repl(%(bork the garble\n))
+    assert_match(/couldn't tell what kind of request this is/, out)
+  end
+
+  def test_conditional_runs_body_when_condition_is_true
+    out = run_repl(%(if nlshell.rb exists do archive nlshell.rb\n))
+    assert_match(/Archiving nlshell\.rb/, out)
+  end
+
+  def test_conditional_skips_body_when_condition_is_false
+    out = run_repl(%(if nope-definitely-missing.log exists do archive nope-definitely-missing.log\n))
+    refute_match(/Archiving/, out)
+  end
+
+  # The body's own permission still gates it - a conditional can't be used to
+  # bypass confirmation on a destructive action.
+  def test_conditional_body_still_requires_its_own_confirmation
+    out = run_repl(%(if nlshell.rb exists do delete nlshell.rb\nn\n))
+    refute_match(/Deleting/, out)
+  end
 end