@r1z4x/tezgah 1.1.0 → 1.2.0

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.
package/docs/judge.md CHANGED
@@ -28,11 +28,14 @@ A session CLI answers in 4-10 s (measured 2026-10-07), so the skill hint's
28
28
  4 s deadline usually ends first. `TEZGAH_TYPESAFE_URL`, `TEZGAH_OPENROUTER_URL`
29
29
  and `TEZGAH_OMP_BIN`/`TEZGAH_CLAUDE_BIN` are the test seams.
30
30
 
31
- Every caller reads an answer through the same two accessors rather than reaching
31
+ Every caller reads an answer through the same accessors rather than reaching
32
32
  into the raw reply, so a Choice and a Noul are read one way for all five
33
33
  (`choice`, `hooks/tezgah_judge.py::choice`; `noul`,
34
- `hooks/tezgah_judge.py::noul`). Both are total: a missing or wrongly-typed
35
- answer is a `None`, never an exception.
34
+ `hooks/tezgah_judge.py::noul`). A `text` question is the one shape that asks
35
+ for prose. `bin/tezgah-taste` asks it for a learning's readable line, and only
36
+ a generative provider answers one. TypeSafe is never sent one (`text`,
37
+ `hooks/tezgah_judge.py::text`). All three are total: a missing or
38
+ wrongly-typed answer is a `None`, never an exception.
36
39
 
37
40
  A judgement is an aid, never the claim. It ranks units or names a page; the agent
38
41
  still reads the selected refs and owns the finding, and the ledger's step kinds
@@ -69,7 +72,7 @@ why its redaction is not optional. The switches below are the off buttons.
69
72
  | `bin/tezgah-docs` | the docs page fallback: only when the keyword index placed nothing, one Choice over the pages with `none` offered (`judge_pick`, `bin/tezgah-docs::judge_pick`; the question wording is `ASK`, `bin/tezgah-docs::ASK`) | reads as no judgement; with none (no credential, `judge-off`, `docs-judge-off`, or a call that failed or came back without one of its options) the pages are ranked by shared words instead (`ranked`, BM25 in `hooks/tezgah_rank.py` over each page's title and answers and their Turkish phrasings) and the top three printed; a query sharing no word with any page, or a judged `none`, exits 1. With the opt-in embedding feature on, the ranking is fused with a static embedding (`hooks/tezgah_embed.py`), which places every page, so only a judged `none` exits 1 |
70
73
  | `hooks/tezgah_skill_pick.py` | the prompt-path skill hint: a Choice over the roster skills plus one Noul (`judge`, `hooks/tezgah_skill_pick.py::judge`, with the criteria cut from each skill's own clauses, `clause`, `hooks/tezgah_skill_pick.py::clause`), behind a threshold (`GATE`, `hooks/tezgah_skill_pick.py::GATE`), one attempt and a 4 s wall-clock deadline (`ASK_DEADLINE`, `hooks/tezgah_skill_pick.py::ASK_DEADLINE`), the prompt redacted and cut at 2,000 characters | returns `""`; the turn loses the hint |
71
74
  | `bin/tezgah-route` | the tier router: after the deterministic overrides, one Choice over the three tiers for a delegation brief (`route`, `hooks/tezgah_models.py::route`; `TIER_QUESTION`, `hooks/tezgah_models.py::TIER_QUESTION`) - see [models](models.md) | the static phase table, else the middle tier |
72
- | `bin/tezgah-taste` | the coding-taste measurement: `measure` and `rate` label the prompts a user sent after a writing turn, one Choice per prompt batched into one request, each prompt redacted first (`classify`, `bin/tezgah-taste::classify`) | with no credential or `judge-off` it exits 2 before any request; a failed call leaves its prompts unlabelled |
75
+ | `bin/tezgah-taste` | the coding-taste measurement and learning: `measure` and `rate` label the prompts a user sent after a writing turn, one Choice per prompt batched into one request, each prompt redacted first (`classify`, `bin/tezgah-taste::classify`). `learn` asks one typed decision per signal and requires TypeSafe (`decide`, `bin/tezgah-taste::decide`). For a learning that turns active it asks one `text` question of a generative provider (`write_line`, `bin/tezgah-taste::write_line`) | with no credential or `judge-off` it exits 2 before any request. A failed call leaves its prompts unlabelled. A decision another provider gave is recorded `unverified` and never applied. A missing line keeps the user's own words |
73
76
 
74
77
  The third caller is the one no shell row can see: it runs on the prompt path,
75
78
  caches one answer per `(session, prompt)` (`_remember`,
@@ -114,6 +117,12 @@ not. That answer, and a refusal with its failures, is said on stderr as one
114
117
  `hooks/tezgah_judge.py::_record`), which `tezgah-status --judge` prints. The skill
115
118
  hint's cost row and the router's `route` row also carry `judge=<provider>/<model>`.
116
119
 
120
+ A caller that needs one provider in particular passes `only` (`named`,
121
+ `hooks/tezgah_judge.py::named`). `learn`'s decision has to come from a typed
122
+ model, so it passes `only=("typesafe",)`. It gets `None` rather than another
123
+ provider's answer. The caller named the provider, so the `vendor` order does not
124
+ apply. `none` still keeps every third party out.
125
+
117
126
  ## The switches
118
127
 
119
128
  `judge-off` is the master: with it armed `available()` is false and no caller
@@ -385,15 +385,18 @@ extension the engine has no parser for and an extensionless script it cannot key
385
385
  on at all. The extensionless case is why `bin/*` carries `.py` twins, and the
386
386
  report counts a tracked `bin/x` as covered when the index holds `bin/x.py`.
387
387
 
388
- ## Opt-in taste capture
389
-
390
- Phase 1 of a coding-taste learner stores the signals tezgah already receives.
391
- It learns nothing and injects nothing yet. It stays off until the user arms it
392
- with a `taste-on` file in `~/.config/tezgah/` (`enabled`,
393
- `hooks/tezgah_taste.py::enabled`). The off path costs two stats. Armed, it writes
394
- only into a repository that already has a `.tezgah/` directory. A `.no-taste`
395
- mark turns it off for one repository (`hooks/tezgah_context.py::repo_marks`).
396
- A workspace of repo-provided data gets nothing.
388
+ ## Opt-in taste: capture, learn, apply
389
+
390
+ Taste learns the user's coding preferences from their own corrections. It is
391
+ taste-1's architecture as Command Code documents it, ported to tezgah (research
392
+ line taste-architecture, design T1P). An LLM-free structured signal goes to a
393
+ typed decision step, then a symbolic ledger, then a meta loop. It stays off
394
+ until the user arms it with a `taste-on` file in `~/.config/tezgah/` (`enabled`,
395
+ `hooks/tezgah_taste.py::enabled`). The off path costs two stats. Armed, it
396
+ writes only into a repository that already has a `.tezgah/` directory. A
397
+ `.no-taste` mark turns it off for one repository
398
+ (`hooks/tezgah_context.py::repo_marks`). A workspace of repo-provided data gets
399
+ nothing.
397
400
 
398
401
  Rows go to `<repo>/.tezgah/taste/signals.jsonl` through the ledger's own writer
399
402
  (`_write`, `hooks/tezgah_taste.py::_write`). A user turn adds a `prompt` row,
@@ -409,22 +412,69 @@ ledger row. A rollback therefore never reads it as a pre-state (`capture_after`,
409
412
  `hooks/tezgah_snapshot.py::capture_after`). A file outside the repository gets no
410
413
  after blob.
411
414
  opencode reaches the same call through a process (`noteTaste`,
412
- `hosts/opencode/plugins/tezgah.js:2310-2319`).
415
+ `hosts/opencode/plugins/tezgah.js:2380-2390`).
413
416
 
414
417
  ```sh
415
418
  touch ~/.config/tezgah/taste-on # arm; rm it to disarm
419
+ bin/tezgah-taste learn # decide the new capture signals, update the ledger
420
+ bin/tezgah-taste learn --from-transcripts # bootstrap from this repo's omp/Claude history
421
+ bin/tezgah-taste list # learnings: state, rule/hint, confidence, scope
422
+ bin/tezgah-taste accept|reject|show ID # the user's call on one learning
423
+ bin/tezgah-taste edit ID --text "..." # reword it; an edited line is never rewritten
424
+ bin/tezgah-taste export # accepted learnings into AGENTS.md
425
+ bin/tezgah-taste label; bin/tezgah-taste label ID preference # the calibration sample
426
+ bin/tezgah-taste calibrate # the bound a rule needs
427
+ bin/tezgah-taste gate # the pooled before/after test
416
428
  bin/tezgah-taste mine --host omp --stats # sessions, writes, prompts after a writing turn
417
- bin/tezgah-taste mine --out mined.jsonl # one row per such prompt, redacted
418
- bin/tezgah-taste measure --samples S --labels L # judge precision/recall against labels
419
- bin/tezgah-taste rate --in mined.jsonl # preference corrections per writing turn
429
+ bin/tezgah-taste measure --samples S --labels L [--typed] # judge precision/recall
420
430
  ```
421
431
 
422
- `mine` reads the host transcripts already on disk, so it needs no capture. It
423
- reads omp's top-level session files and Claude's top-level transcripts whose cwd
424
- is under `--root` (`cmd_mine`, `bin/tezgah-taste::cmd_mine`). `measure` and `rate`
425
- send the sample text to the judge seam's provider ([judge](judge.md)), redacted
426
- first (`classify`, `bin/tezgah-taste::classify`). They use a fixed labelling rule
427
- (`RULE`, `bin/tezgah-taste::RULE`). Without a credential they send nothing.
432
+ `learn` turns each prompt that followed a writing turn into one signal, and code
433
+ builds it (`capture_signals`, `bin/tezgah-taste::capture_signals`). A signal
434
+ holds the prompt, the files written, the agent's edits, and the user's own edit
435
+ between two agent writes. That last one compares the after blob with the next
436
+ write's pre-state, or with the file on disk (`user_diff`,
437
+ `bin/tezgah-taste::user_diff`). `learn` asks TypeSafe for one typed decision per
438
+ signal (`decide`, `bin/tezgah-taste::decide`, [judge](judge.md)). The decision
439
+ names preference, defect or none. It picks one of eleven fixed categories and a
440
+ scope. It also says how the signal relates to each learning held: supports,
441
+ contradicts, narrows or unrelated. When another provider answers, `learn` writes
442
+ the decision to `decisions.jsonl` as `unverified` and never applies it. A defect
443
+ goes to `defects.jsonl` and never into the ledger.
444
+
445
+ The ledger is `<repo>/.tezgah/taste/ledger.json`, plus
446
+ `~/.config/tezgah/taste/ledger.json` for user-scope learnings
447
+ (`hooks/tezgah_taste_ledger.py`). Confidence is (r+1)/(r+s+2) over supporting
448
+ and contradicting evidence. Each piece counts at its decision probability, and
449
+ the ledger forgets it per day at its scope's rate (`confidence`,
450
+ `hooks/tezgah_taste_ledger.py::confidence`). A learning turns active in its
451
+ second session. A contradiction flags an active learning `conflicted` and stores
452
+ the contrary claim `quarantined`. Neither overwrites the other. `narrows` adds a
453
+ narrower learning beside the broader one. When the user corrects an injected
454
+ learning's category in the same session, that learning loses confidence
455
+ (`apply`, `hooks/tezgah_taste_ledger.py::apply`). The hooks only read the
456
+ ledgers. `learn`, `accept`, `reject` and `edit` rewrite them one at a time on
457
+ this machine, under a lock on `~/.config/tezgah/taste/ledger.lock` (`locked`,
458
+ `hooks/tezgah_taste_ledger.py::locked`). The lock spans every repository,
459
+ because each command also rewrites the user ledger. A second one exits 2 and
460
+ names the holder. Every taste row goes through the evidence ledger's locked
461
+ writer (`append`, `hooks/tezgah_taste_ledger.py::append`).
462
+
463
+ Session start injects the active learnings (`block`,
464
+ `hooks/tezgah_taste_ledger.py::block`). A rule needs confidence 0.8 and a
465
+ Clopper-Pearson lower bound of 0.8 on preference precision, measured on the
466
+ labelled sample (`calibration`, `hooks/tezgah_taste_ledger.py::calibration`). A
467
+ hint needs 0.6. A learning whose evidence paths are all gone is withheld. A
468
+ write also carries the in-scope learnings it has not yet shown in the session,
469
+ on every host's post-tool channel (`write_note`, `hooks/tezgah_taste.py::write_note`).
470
+ `gate` compares preference corrections per writing turn before and after the
471
+ first injection, by when each turn happened. Once both arms hold 980 turns and
472
+ the after-rate is not lower
473
+ at one-sided p < 0.1, it writes `gate.json` and injection stops (`gate`,
474
+ `hooks/tezgah_taste_ledger.py::gate`). `mine` reads omp's top-level session
475
+ files and Claude's top-level transcripts whose cwd is under `--root`
476
+ (`cmd_mine`, `bin/tezgah-taste::cmd_mine`). `measure` and `rate` use a fixed
477
+ labelling rule (`RULE`, `bin/tezgah-taste::RULE`).
428
478
 
429
479
  ## Tidying the lessons ledger
430
480
 
@@ -580,8 +630,8 @@ question instead of paying an agent to read a page: the analyze-app loop's snaps
580
630
  triage (`bin/tezgah-triage`), `bin/tezgah-docs` for a query its keyword index
581
631
  cannot place, and the prompt-path skill hint (`hooks/tezgah_skill_pick.py`), the
582
632
  one caller no shell row sees, `bin/tezgah-route`, the tier router
583
- ([models](models.md)), and `bin/tezgah-taste`'s `measure` and `rate`
584
- ([above](#opt-in-taste-capture)). All five go through the same stdlib-only seam,
633
+ ([models](models.md)), and `bin/tezgah-taste`'s `measure`, `rate` and `learn`
634
+ ([above](#opt-in-taste-capture-learn-apply)). All five go through the same stdlib-only seam,
585
635
  which returns `None` rather than raising because a hook may import it
586
636
  (`available()`, `hooks/tezgah_judge.py::available`); the request is
587
637
  one batched call, and the credential resolves per call (`ask()`,
@@ -26,6 +26,7 @@ try:
26
26
  from tezgah_integrity import ( # noqa: E402
27
27
  SUBAGENT_CHANNEL, note_tool, ran_nothing, report_bytes, untrusted_source)
28
28
  from tezgah_paths import root_for # noqa: E402
29
+ from tezgah_taste import write_note # noqa: E402
29
30
  from tezgah_untrusted import marks # noqa: E402
30
31
  except Exception as exc:
31
32
  import_failed(exc)
@@ -139,6 +140,12 @@ def main():
139
140
  # empty-run contract (tezgah_integrity.EMPTY_RUN)
140
141
  empty_run=ran_nothing(result),
141
142
  agent=agent)
143
+ # the opt-in taste note: the learnings in scope for the first write to this
144
+ # file type in the session (hooks/tezgah_taste.write_note), beside the
145
+ # provenance line rather than instead of it
146
+ if event == "PostToolUse" and not failed:
147
+ notice = "\n".join(t for t in (notice, safe(session_id, write_note, session_id,
148
+ inp, cwd, tool)) if t)
142
149
  if notice:
143
150
  json.dump({"hookSpecificOutput": {
144
151
  "hookEventName": event,
@@ -1412,7 +1412,7 @@ DEFAULT_BUDGET = 13000
1412
1412
  # spend them would turn bloat into rule loss - which is the failure the budget
1413
1413
  # exists to prevent, not one it may cause.
1414
1414
  DROP_ORDER = ("knowledge", "worktrees", "lessons", "skill", "lessons_turn",
1415
- "plans", "subagents", "steer", "orca", "consult", "research",
1415
+ "taste", "plans", "subagents", "steer", "orca", "consult", "research",
1416
1416
  "research_broken", "graph", "offnote", "orchestrate", "index",
1417
1417
  "resume", "scratch", "task", "delta", "pointer")
1418
1418
 
@@ -2087,6 +2087,7 @@ def context_for(event, cwd, payload=None, with_core=True):
2087
2087
  else [("core", core)]) if with_core else []
2088
2088
  _, marks = repo_marks(cwd)
2089
2089
  injected = [] # the session block's lessons, when it carries them
2090
+ taste = "" # the session block's taste learnings, when it carries them
2090
2091
  if ".no-graph" in marks:
2091
2092
  parts.append(("graph", "Graph: disabled for this repo (.no-graph), so use "
2092
2093
  "grep/find and say the answer came from text "
@@ -2188,6 +2189,19 @@ def context_for(event, cwd, payload=None, with_core=True):
2188
2189
  recent = lesson_lines(root)[-LESSON_LINES:]
2189
2190
  injected = list(zip(recent, _lesson_shown(
2190
2191
  recent, tainted_lessons(root))))
2192
+ # The opt-in taste learnings (hooks/tezgah_taste_ledger.block): the
2193
+ # active ones as rules or hints, read from the ledger with no call out;
2194
+ # off, `enabled` costs the marker stat. The injection is recorded below,
2195
+ # only when the budget kept the block.
2196
+ taste_root = tezgah_taste.enabled(cwd) if tezgah_taste else None
2197
+ if taste_root:
2198
+ try:
2199
+ import tezgah_taste_ledger
2200
+ taste, taste_ids = tezgah_taste_ledger.block(taste_root)
2201
+ except Exception:
2202
+ taste = ""
2203
+ if taste:
2204
+ parts.append(("taste", taste))
2191
2205
  broken = tezgah_research.failing(root) if not off("research-off") else []
2192
2206
  if broken:
2193
2207
  line_slug, err = broken[0]
@@ -2219,6 +2233,11 @@ def context_for(event, cwd, payload=None, with_core=True):
2219
2233
  for ln, line in injected:
2220
2234
  if "\n- " + line + "\n" in text:
2221
2235
  note_lesson(session_of(payload), lesson_key(ln), "session")
2236
+ if taste and taste.split("\n", 1)[0] in text:
2237
+ try:
2238
+ tezgah_taste_ledger.record_injection(taste_root, session_of(payload), taste_ids)
2239
+ except Exception:
2240
+ pass
2222
2241
  return text
2223
2242
 
2224
2243
 
@@ -68,9 +68,10 @@ FALLBACK_MODEL = "deepseek/deepseek-v4-flash"
68
68
  # The contract the fallback asks a chat model to answer in, one shape per
69
69
  # question type this repository's callers send (`bin/tezgah-triage` and
70
70
  # `hooks/tezgah_skill_pick.py` ask `noul`, `bin/tezgah-docs` and the triage's
71
- # pair rows ask `choice`). It is prose because a chat endpoint has no schema
72
- # field to carry it; every field it names is validated on the way back, and a
73
- # question of any other type reads as unanswered rather than as a guess.
71
+ # pair rows ask `choice`, `bin/tezgah-taste` asks `text` for a learning's
72
+ # readable line). It is prose because a chat endpoint has no schema field to
73
+ # carry it; every field it names is validated on the way back, and a question
74
+ # of any other type reads as unanswered rather than as a guess.
74
75
  CHAT_SYSTEM = (
75
76
  "You answer questions about the state that follows. Reply with one JSON "
76
77
  "object and nothing else: {\"answers\": {\"<id>\": {...}}}, one entry per "
@@ -78,6 +79,7 @@ CHAT_SYSTEM = (
78
79
  "noul -> {\"noul\": <probability 0..1 that the statement is true>}\n"
79
80
  "choice -> {\"choice\": \"<the chosen label>\", \"probabilities\": "
80
81
  "{\"<label>\": <probability>, ...}, \"confidence\": <0..1>}\n"
82
+ "text -> {\"text\": \"<the answer as plain prose>\"}\n"
81
83
  "Every probability is a number in 0..1 and the ones you list for one "
82
84
  "question sum to 1. Answer every id you were given.")
83
85
 
@@ -188,6 +190,20 @@ def providers():
188
190
  (("typesafe", key()), ("openrouter", openrouter_key())) if secret]
189
191
 
190
192
 
193
+ def named(only):
194
+ """`providers()` for a caller that requires particular ones: every provider
195
+ in `only` that resolves, in `only`'s order. The `vendor` policy's
196
+ session-first order does not apply - the caller named the provider, so
197
+ nothing is a silent fallback - but `none` still keeps every third party
198
+ out."""
199
+ session = tp.session_cli()
200
+ third = tp.fallback_policy() != "none"
201
+ found = {session: getattr(tp, session + "_bin")()} if session else {}
202
+ if third:
203
+ found.update(typesafe=key(), openrouter=openrouter_key())
204
+ return [(name, found[name]) for name in only if found.get(name)]
205
+
206
+
191
207
  def credential():
192
208
  """`(provider, secret)` for the first provider `ask()` will try, else
193
209
  `(None, None)` - so `available()` and `ask()` cannot disagree."""
@@ -200,7 +216,8 @@ def available():
200
216
  return bool(credential()[1]) and not tp.off("judge-off")
201
217
 
202
218
 
203
- def ask(state, questions, *, model=MODEL, timeout=30, attempts=2, deadline=None):
219
+ def ask(state, questions, *, model=MODEL, timeout=30, attempts=2, deadline=None,
220
+ only=None):
204
221
  """One batched call over `questions`; `{"answers", "usage", "latency_ms",
205
222
  "model", "provider", "fallback"}`.
206
223
 
@@ -233,18 +250,29 @@ def ask(state, questions, *, model=MODEL, timeout=30, attempts=2, deadline=None)
233
250
 
234
251
  Total by design: no provider, an unreadable state, a refused request, a
235
252
  timeout, a reply that is not the documented shape - all `None`, never an
236
- exception."""
237
- if not credential()[1]:
253
+ exception.
254
+
255
+ `only` names the providers the caller requires (`named()`); a caller whose
256
+ decision must come from a typed model passes `only=("typesafe",)` and gets
257
+ None rather than another provider's answer."""
258
+ tried = providers() if only is None else named(only)
259
+ if any(isinstance(q, dict) and q.get("type") == "text" for q in questions.values()):
260
+ # TypeSafe answers no prose: left out up front, not logged as a failure
261
+ tried = [(p, s) for p, s in tried if p != "typesafe"]
262
+ if not tried or not tried[0][1]:
238
263
  return None
239
264
  stop = None if deadline is None else time.monotonic() + deadline
240
265
  failed = []
241
- for provider, secret in providers():
266
+ for provider, secret in tried:
242
267
  result, why = _ask_one(provider, secret, state, questions, model,
243
268
  timeout, attempts, stop)
244
269
  if result is None:
245
270
  failed.append("%s: %s" % (provider, why))
246
271
  continue
247
- note = "; ".join(failed) or (None if provider in SESSION_ARGV else NO_SESSION)
272
+ # a provider the caller named is not a stand-in: no note, unless an
273
+ # earlier one it named failed
274
+ note = "; ".join(failed) or (None if only is not None or provider in SESSION_ARGV
275
+ else NO_SESSION)
248
276
  result["fallback"] = note
249
277
  _record(result["provider"], result["model"], fallback=note)
250
278
  return result
@@ -328,6 +356,14 @@ def choice(result, id):
328
356
  return value if isinstance(value, str) else None
329
357
 
330
358
 
359
+ def text(result, id):
360
+ """The prose a `text` question came back with, stripped, or None. Only a
361
+ generative provider answers one; TypeSafe is never sent one."""
362
+ answer = _answer(result, id)
363
+ value = answer.get("text") if answer else None
364
+ return (value.strip() or None) if isinstance(value, str) else None
365
+
366
+
331
367
  def noul(result, id, option=None):
332
368
  """The probability a Noul question came back with, or None.
333
369
 
@@ -448,17 +484,20 @@ def _chat_answers(parsed, questions):
448
484
  def _clean_answer(raw, kind):
449
485
  """One answer in the shape its question type names, or None.
450
486
 
451
- The two shapes this repository's callers send - `noul`, the probability a
452
- statement holds, and `choice`, the label taken - map onto exactly what
453
- `choice()`/`noul()` already read, so a fallback answer and a TypeSafe answer
454
- are indistinguishable downstream. Any other type reads as no answer rather
455
- than as a guess. Probabilities are kept only when they are numbers, because
456
- `noul()` does the arithmetic on them."""
487
+ The shapes this repository's callers send - `noul`, the probability a
488
+ statement holds, `choice`, the label taken, and `text`, prose - map onto
489
+ exactly what `choice()`/`noul()`/`text()` already read, so a fallback answer
490
+ and a TypeSafe answer are indistinguishable downstream. Any other type reads
491
+ as no answer rather than as a guess. Probabilities are kept only when they
492
+ are numbers, because `noul()` does the arithmetic on them."""
457
493
  if not isinstance(raw, dict):
458
494
  return None
459
495
  if kind == "noul":
460
496
  value = raw.get("noul")
461
497
  return {"noul": float(value)} if isinstance(value, (int, float)) else None
498
+ if kind == "text":
499
+ value = raw.get("text")
500
+ return {"text": value} if isinstance(value, str) and value.strip() else None
462
501
  if kind != "choice":
463
502
  return None
464
503
  if not isinstance(raw.get("choice"), str):
@@ -9,11 +9,12 @@ workspace that came with the clone is data, and nothing of the user's is
9
9
  written into it). The store is `<git root>/.tezgah/taste/signals.jsonl`, one
10
10
  JSON object per line, owner-only.
11
11
 
12
- Every public function is total: it never raises, makes no network call and
13
- appends at most one row. They run on the prompt and PostToolUse hot paths, so
14
- the switch is read before anything else is.
12
+ Every public function is total: it never raises and makes no network call.
13
+ The capture functions append at most one row each. They run on the prompt and
14
+ PostToolUse hot paths, so the switch is read before anything else is.
15
15
 
16
- Nothing here is learned or fed back into a prompt; this is capture only.
16
+ Learning lives in `bin/tezgah-taste learn` and `tezgah_taste_ledger`; the one
17
+ thing fed back from here is `write_note`, the learnings in scope for a write.
17
18
  """
18
19
  import datetime
19
20
  import json
@@ -181,16 +182,41 @@ def note_write(session_id, row_id, inp, cwd, host=None):
181
182
  pass
182
183
 
183
184
 
185
+ def write_note(session_id, inp, cwd, tool=None):
186
+ """The in-scope taste learnings this write has not yet shown in the session
187
+ (`tezgah_taste_ledger.write_note`), or "". Every host's post-tool
188
+ channel carries it beside its other notices; off, it costs the marker stat.
189
+ `tool` is the call's tool name; a call that is not a write gets nothing."""
190
+ try:
191
+ if not armed(ARM) or not isinstance(inp, dict) or inp.get("command") == "view" \
192
+ or (tool is not None and str(tool).lower() not in ti.WRITE_TOOLS):
193
+ return ""
194
+ from tezgah_gate import write_paths
195
+ paths = write_paths(inp)
196
+ root = enabled(cwd)
197
+ rel = _rel(root, paths[0], cwd) if root and paths else None
198
+ if rel is None:
199
+ return ""
200
+ import tezgah_taste_ledger
201
+ return tezgah_taste_ledger.write_note(root, session_id, rel)
202
+ except Exception:
203
+ return ""
204
+
205
+
184
206
  def main(argv):
185
- """`tezgah_taste.py '<json>'`: `note_write` for a host that cannot call it
186
- in process (opencode's plugin is JavaScript). The one argument is
187
- {"session_id", "id", "input", "cwd", "host"}. Silent, exit 0 always."""
207
+ """`tezgah_taste.py '<json>'`: `note_write`, then `write_note`, for a host
208
+ that cannot call them in process (opencode's plugin is JavaScript). The one
209
+ argument is {"session_id", "id", "input", "cwd", "host"}. Prints the note
210
+ (nothing when there is none); exit 0 always."""
188
211
  try:
189
212
  payload = json.loads(argv[0]) if argv else {}
190
213
  if isinstance(payload, dict):
214
+ cwd = payload.get("cwd") or os.getcwd()
191
215
  note_write(payload.get("session_id"), payload.get("id"),
192
- payload.get("input"), payload.get("cwd") or os.getcwd(),
193
- payload.get("host"))
216
+ payload.get("input"), cwd, payload.get("host"))
217
+ note = write_note(payload.get("session_id"), payload.get("input"), cwd)
218
+ if note:
219
+ print(note)
194
220
  except Exception:
195
221
  pass
196
222
  return 0