@r1z4x/tezgah 1.13.0 → 1.14.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +175 -1
  2. package/MANIFEST +18 -0
  3. package/NOTICE +152 -0
  4. package/README.de.md +41 -20
  5. package/README.es.md +39 -19
  6. package/README.fr.md +41 -20
  7. package/README.md +15 -8
  8. package/README.tr.md +39 -21
  9. package/bin/codegen +9 -2
  10. package/bin/consult +6 -0
  11. package/bin/tezgah-adhd +3 -0
  12. package/bin/tezgah-agents +3 -0
  13. package/bin/tezgah-capture +3 -0
  14. package/bin/tezgah-design +329 -54
  15. package/bin/tezgah-docs +157 -41
  16. package/bin/tezgah-dsh +1 -1
  17. package/bin/tezgah-gate +7 -2
  18. package/bin/tezgah-import-ai-research +16 -4
  19. package/bin/tezgah-index +3 -0
  20. package/bin/tezgah-lessons +13 -4
  21. package/bin/tezgah-mcp +4 -0
  22. package/bin/tezgah-migrate +3 -0
  23. package/bin/tezgah-ocr-review +2 -1
  24. package/bin/tezgah-optimize +4 -1
  25. package/bin/tezgah-pony +3 -0
  26. package/bin/tezgah-research +12 -6
  27. package/bin/tezgah-rollback +3 -0
  28. package/bin/tezgah-route +1 -1
  29. package/bin/tezgah-setup +596 -157
  30. package/bin/tezgah-status +11 -1
  31. package/bin/tezgah-task +3 -0
  32. package/bin/tezgah.js +10 -3
  33. package/commands/adhd.md +1 -1
  34. package/docs/README.md +19 -8
  35. package/docs/architecture.md +2 -2
  36. package/docs/citations-baseline.json +11 -17
  37. package/docs/clarity-baseline.json +3 -3
  38. package/docs/contract.md +77 -31
  39. package/docs/environment.md +58 -24
  40. package/docs/evidence.md +49 -19
  41. package/docs/gate.md +71 -21
  42. package/docs/hosts.md +65 -52
  43. package/docs/layers.md +1 -1
  44. package/docs/operations.md +67 -24
  45. package/docs/research.md +1 -1
  46. package/docs/skills.md +31 -8
  47. package/docs/status-line.md +33 -23
  48. package/docs/testing.md +48 -17
  49. package/docs/vision.md +6 -2
  50. package/hooks/lang/en.json +5 -2
  51. package/hooks/lang/tr.json +3 -2
  52. package/hooks/projects-posttooluse.py +11 -3
  53. package/hooks/projects-stop.py +1 -1
  54. package/hooks/tezgah_context.py +40 -23
  55. package/hooks/tezgah_gate.py +404 -106
  56. package/hooks/tezgah_integrity.py +367 -97
  57. package/hooks/tezgah_lessons.py +5 -2
  58. package/hooks/tezgah_paths.py +17 -6
  59. package/hooks/tezgah_policy.py +13 -5
  60. package/hooks/tezgah_skill_pick.py +1 -1
  61. package/hooks/tezgah_snapshot.py +6 -3
  62. package/hooks/tezgah_stopspec.py +2 -1
  63. package/hosts/amp/hook.py +1 -1
  64. package/hosts/amp/tezgah-plugin.ts.in +13 -3
  65. package/hosts/antigravity/hook.py +1 -1
  66. package/hosts/codex/hook.py +1 -1
  67. package/hosts/commandcode/hook.py +1 -1
  68. package/hosts/copilot/hook.py +1 -1
  69. package/hosts/cursor/hook.py +2 -1
  70. package/hosts/droid/hook.py +1 -1
  71. package/hosts/gemini/hook.py +1 -1
  72. package/hosts/goose/hook.py +1 -1
  73. package/hosts/omp/hook.py +1 -1
  74. package/hosts/omp/tezgah-hook.ts.in +127 -35
  75. package/hosts/opencode/plugins/tezgah.js +209 -63
  76. package/hosts/qwen/hook.py +1 -1
  77. package/output-styles/tezgah.md +1 -1
  78. package/package.json +6 -3
  79. package/packaging/build.sh +4 -4
  80. package/packaging/install.sh +11 -0
  81. package/packaging/upgrade.sh +3 -0
  82. package/skills/ai-research/LICENSE +21 -0
  83. package/skills/design-contract/SKILL.md +27 -6
  84. package/skills/design-library/LICENSE +21 -0
  85. package/skills/plan-add/render_table.py +7 -0
  86. package/skills/pm-frameworks/LICENSE +21 -0
  87. package/skills/rl-env/LICENSE +201 -0
  88. package/skills/tezgah-contract/SKILL.md +2 -2
  89. package/third_party/open-code-review/LICENSE +201 -0
  90. package/third_party/open-code-review/README.md +15 -0
  91. package/third_party/open-code-review/prompts/main_task_system.md +26 -0
  92. package/third_party/open-code-review/prompts/review_filter_task_system.md +13 -0
  93. package/workflows/graph-map.js +14 -5
package/bin/tezgah-docs CHANGED
@@ -99,22 +99,48 @@ def matches(query):
99
99
  # captured output) is data, not a citation, and is not read.
100
100
 
101
101
  CODE_FILES = ("hooks/*.py", "bin/*", "statusline.py", "hosts/*/*.py",
102
- "hosts/*/*.ts.in", "hosts/*/plugins/*.js", "tests/*.py")
102
+ "hosts/*/*.ts.in", "hosts/*/plugins/*.js", "hosts/*/tui/*.tsx",
103
+ "hosts/*/statusline/lib/*.js", "tests/*.py")
103
104
  STRING = re.compile(r'"""(?:.|\n)*?"""|\'\'\'(?:.|\n)*?\'\'\'')
104
105
  TOP = re.compile(r"^(?:def|class)\s+(\w+)|^([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^=]+)?=")
106
+ # A JS/TS file's top-level `const`/`let`/`function`/`class`, exported or not.
107
+ JS_FILES = (".js", ".mjs", ".ts", ".tsx", ".ts.in")
108
+ JS_TOP = re.compile(r"^(?:export\s+(?:default\s+)?)?(?:async\s+)?"
109
+ r"(?:function\*?|const|let|class)\s+([A-Za-z_$][\w$]*)")
105
110
  RUN = re.compile(r"((?:`[A-Za-z_][A-Za-z0-9_]*(?:\(\))?`\s*[/,]?\s*){1,5})$")
106
- CITE = re.compile(r"`([\w.-]+(?:/[\w.-]+)+|[\w-]+\.\w+):(\d+)(?:-(\d+))?`"
107
- r"|`(:(\d+)(?:-(\d+))?)`")
111
+ # A cited path: one with a `/`, a `name.ext`, or a dot file (`.gitignore`); its
112
+ # lines a range or a comma list of them (x.py:12,40-41). The path is optional:
113
+ # a backticked colon and lines alone is a bare range of the path before it.
114
+ PATH = r"[\w.-]+(?:/[\w.-]+)+|\.?[\w-]+\.\w+|\.[\w-]+"
115
+ SPEC = r"\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*"
116
+ CITE = re.compile(r"`(%s)?:(%s)`" % (PATH, SPEC))
117
+ SCRIPTS = (".sh", ".ps1", ".py", ".js", ".mjs", ".ts", ".tsx", ".in")
108
118
  # The symbol written after the citation instead of before it, the way a table
109
119
  # cell reads: `hooks/tezgah_policy.py:819-1158` (`CORE`).
110
120
  AFTER = re.compile(r"\s*\(`([A-Za-z_][A-Za-z0-9_]*)(?:\(\))?`\)")
111
121
  # Any `path:line`, backticked or not: what CITE does not match of these (a
112
- # bare token, a comma list `x.py:12,40`) is counted as unjudged, so a citation
122
+ # bare token such as x.py:12) is counted as unjudged, so a citation
113
123
  # written in a shape the audit cannot read is still a number someone sees.
114
124
  # Scanned as one token run and then shape-checked: the shape alternation as
115
125
  # the scanner (nested `+` under a look-behind) cost a third of a second a pass.
116
126
  LOOSE = re.compile(r"(?<![\w./:-])([\w./-]*\w):(\d+)(?:-(\d+))?(?!\w)")
117
127
  LOOSE_SHAPE = re.compile(r"(?:[\w.-]+/)+[\w.-]*\w|[\w-]+\.\w+")
128
+ # A path a bare range can follow: backticked, with or without its line or
129
+ # symbol (a loose `path:line` counts too). The bare ranges themselves:
130
+ # `(8-9)` or `(14,34-38)` - a lone `(300)` is a value, not a line - and an
131
+ # unquoted :8-9; the backticked one is CITE's second half. Each one is read
132
+ # against the path before it in the same sentence or table row, never further.
133
+ MARK = re.compile(r"`(%s)(?::%s|::[\w.]+)?`" % (PATH, SPEC))
134
+ BARE = re.compile(r"(?<![\w(])\((\d+-\d+|\d+(?:-\d+)?(?:,\d+(?:-\d+)?)+)\)"
135
+ r"|(?<=[\s(,]):(\d+(?:-\d+)?)(?![\w:-])")
136
+ # A comment source masks its code as NUL: the code between two comments ends
137
+ # the sentence too.
138
+ SENTENCE_END = re.compile(r"[.!?](?:\s|$)|\n\s*\n|\n\s*[|*-]|\0")
139
+
140
+
141
+ def spec_parts(spec):
142
+ """[(lo, hi)] of a SPEC such as `12,40-41`."""
143
+ return [(int(a), int(b or a)) for a, _, b in (p.partition("-") for p in spec.split(","))]
118
144
 
119
145
 
120
146
  def loose(text):
@@ -179,12 +205,17 @@ def anchor_span(names, qual):
179
205
 
180
206
 
181
207
  CITATION_BASELINE = os.path.join("docs", "citations-baseline.json")
208
+ # The gitignored, private workspace: a CI checkout or a worktree has no copy, so
209
+ # a citation into it is unjudged in every checkout, never judged in one alone.
210
+ # ponytail: one fixed prefix, not a .gitignore reader; add another if one lands.
211
+ PRIVATE = (".tezgah/",)
182
212
 
183
213
 
184
214
  def code_symbols(path):
185
215
  """{name: [(start, end)]} for this file's top-level defs, classes and
186
- assignments. A triple-quoted body is masked first, so a line inside a
187
- contract string cannot read as a definition and cut another one short."""
216
+ assignments (a JS/TS file's `JS_TOP` declarations). A triple-quoted body is
217
+ masked first, so a line inside a contract string cannot read as a
218
+ definition and cut another one short."""
188
219
  text = open(path, encoding="utf-8", errors="replace").read()
189
220
  chars = list(text)
190
221
  for m in STRING.finditer(text):
@@ -192,8 +223,9 @@ def code_symbols(path):
192
223
  if chars[i] != "\n":
193
224
  chars[i] = " "
194
225
  lines = "".join(chars).splitlines()
195
- starts = [(i, m.group(1) or m.group(2))
196
- for i, line in enumerate(lines, 1) if (m := TOP.match(line))]
226
+ top = JS_TOP if path.endswith(JS_FILES) else TOP
227
+ starts = [(i, m.group(1) or m.group(top.groups))
228
+ for i, line in enumerate(lines, 1) if (m := top.match(line))]
197
229
  out = {}
198
230
  for k, (start, name) in enumerate(starts):
199
231
  end = starts[k + 1][0] - 1 if k + 1 < len(starts) else len(lines)
@@ -264,6 +296,11 @@ def comment_text(path):
264
296
  lead = (tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT, tokenize.ENCODING)
265
297
  real = [t for t in tokens if t.type not in (tokenize.NL, tokenize.COMMENT)]
266
298
  kept = [span(t, 1) for t in tokens if t.type == tokenize.COMMENT]
299
+ # a comment's `#` is a space, not code: two comment lines stay one sentence
300
+ chars = list(masked)
301
+ for lo, _ in kept:
302
+ chars[lo - 1] = " "
303
+ masked = "".join(chars)
267
304
  kept += [span(t) for k, t in enumerate(real)
268
305
  if t.type == tokenize.STRING and (k == 0 or real[k - 1].type in lead)
269
306
  and k + 1 < len(real)
@@ -298,58 +335,134 @@ def citations(known):
298
335
  `note_tool` in `hooks/x.py`, so a same-named symbol elsewhere is not the
299
336
  thing being cited and the row is left unjudged rather than misjudged. A bare
300
337
  file name (`tezgah_gate.py:12`) is that path when exactly one code file has
301
- the name, and unjudged otherwise. A symbol anchor (`SYMCITE`) is always
302
- judged: against the qualnames() of the file it names."""
303
- names = {}
304
- for rel in code_files():
338
+ the name, else the repository-root file of that name, and unjudged
339
+ otherwise; a path under `PRIVATE` is unjudged in every checkout. A bare
340
+ range (`BARE`, or CITE's second
341
+ half) takes the path written before it in the same sentence (`MARK`); with
342
+ none, a backticked one is judged against the one code file defining its
343
+ symbol. A range past the end of its file, or over blank lines only, is
344
+ flagged as (`file:line`, token, why, -1, -1) whether or not it names a
345
+ symbol; a bare range naming none is otherwise left alone, a backticked one
346
+ counted unjudged as before. A symbol anchor (`SYMCITE`) is always judged:
347
+ against the qualnames() of the file it names, a JS/TS file's
348
+ code_symbols()."""
349
+ names, code, local, lines_of = {}, set(code_files()), {}, {}
350
+ for rel in code:
305
351
  names.setdefault(os.path.basename(rel), []).append(rel)
306
352
 
307
353
  def resolve(path):
354
+ if path.startswith(PRIVATE):
355
+ return None
308
356
  if "/" in path:
309
357
  return path
310
358
  found = names.get(path, ())
311
- return found[0] if len(found) == 1 else None
359
+ if found:
360
+ return found[0] if len(found) == 1 else None
361
+ return path if os.path.isfile(os.path.join(HERE, path)) else None
362
+
363
+ def named_at(text, start, end):
364
+ # RUN is anchored at the end and five names fit in far fewer than
365
+ # 600 characters: slicing the whole page per citation was the cost.
366
+ pre = text[max(0, start - 600):start].rstrip().rstrip("(") \
367
+ .rstrip().rstrip(",").rstrip()
368
+ run = RUN.search(pre) if pre.endswith("`") else None
369
+ if run:
370
+ return re.findall(r"`([A-Za-z_][A-Za-z0-9_]*)(?:\(\))?`", run.group(1))
371
+ after = AFTER.match(text, end)
372
+ return [after.group(1)] if after else []
373
+
374
+ def path_before(text, marks, start):
375
+ k = bisect.bisect_right(marks, (start,)) - 1
376
+ while k >= 0 and marks[k][1] > start:
377
+ k -= 1
378
+ if k < 0 or SENTENCE_END.search(text, marks[k][1], start):
379
+ return None
380
+ return marks[k][2]
381
+
382
+ def judge(rel, text, m, path, parts, count):
383
+ """Judge one citation `m` of `parts` [(lo, hi)] into `path` (None: the
384
+ known symbol's file); `count` it as unjudged when it names no symbol."""
385
+ nonlocal judged
386
+
387
+ def line():
388
+ return "%s:%d" % (rel, text.count("\n", 0, m.start()) + 1)
389
+ full = os.path.join(HERE, path) if path else None
390
+ if full and os.path.isfile(full):
391
+ if path not in lines_of:
392
+ with open(full, encoding="utf-8", errors="replace") as fh:
393
+ lines_of[path] = fh.read().splitlines()
394
+ body = lines_of[path]
395
+ if any(lo < 1 or hi > len(body) for lo, hi in parts):
396
+ if not count and max(hi for _, hi in parts) > 10 * len(body):
397
+ return # a bare value span such as a year range, not lines
398
+ flagged.append((line(), m.group(0), "%s has %d lines" % (path, len(body)),
399
+ -1, -1))
400
+ return
401
+ if not any(body[i].strip() for lo, hi in parts for i in range(lo - 1, hi)):
402
+ flagged.append((line(), m.group(0), "%s is blank there" % path, -1, -1))
403
+ return
404
+ if path not in code:
405
+ # A file with no symbol table: its range is judged, and in a
406
+ # script (packaging/build.sh) a name written beside it must be
407
+ # on those lines; a data or prose file is read for its range.
408
+ judged += 1
409
+ said = named_at(text, m.start(), m.end()) if path.endswith(SCRIPTS) else []
410
+ cut = "\n".join(s for lo, hi in parts for s in body[lo - 1:hi])
411
+ if said and not any(re.search(r"\b%s\b" % re.escape(n), cut) for n in said):
412
+ flagged.append((line(), m.group(0), "`%s` is not on %s there"
413
+ % ("`, `".join(said), path), -1, -1))
414
+ return
415
+ spans, where = [], path
416
+ for name in named_at(text, m.start(), m.end()):
417
+ if path:
418
+ if path not in local:
419
+ local[path] = code_symbols(full) if path in code else {}
420
+ spans += local[path].get(name, [])
421
+ elif name in known:
422
+ where, spans = known[name][0], spans + known[name][1]
423
+ if not spans:
424
+ if count:
425
+ unjudged[rel] = unjudged.get(rel, 0) + 1
426
+ return
427
+ low, high = min(s for s, _ in spans), max(e for _, e in spans)
428
+ judged += 1
429
+ if any(lo < low or hi > high for lo, hi in parts):
430
+ flagged.append((line(), m.group(0), where, low, high))
312
431
 
313
432
  flagged, judged, unjudged, invisible, tables = [], 0, {}, 0, {}
314
433
  for rel, text in sources():
434
+ marks = [(p.start(), p.end(), resolve(p.group(1)))
435
+ for p in list(MARK.finditer(text)) + list(loose(text))]
436
+ marks = sorted(mk for mk in marks
437
+ if mk[2] and os.path.isfile(os.path.join(HERE, mk[2])))
315
438
  read = []
316
439
  for m in CITE.finditer(text):
317
440
  read.append(m.span())
318
- # RUN is anchored at the end and five names fit in far fewer than
319
- # 600 characters: slicing the whole page per citation was the cost.
320
- pre = text[max(0, m.start() - 600):m.start()].rstrip().rstrip("(") \
321
- .rstrip().rstrip(",")
322
- run = RUN.search(pre.rstrip()) if pre.rstrip().endswith("`") else None
323
- named = re.findall(r"`([A-Za-z_][A-Za-z0-9_]*)(?:\(\))?`",
324
- run.group(1)) if run else []
325
- after = AFTER.match(text, m.end())
326
- if not named and after:
327
- named = [after.group(1)]
328
- cited = resolve(m.group(1)) if m.group(1) else None
329
- spans, where = [], None
330
- for name in named:
331
- hit = known.get(name)
332
- if not hit or (m.group(1) and hit[0] != cited):
441
+ parts = spec_parts(m.group(2))
442
+ if m.group(1):
443
+ path = resolve(m.group(1))
444
+ if path is None:
445
+ unjudged[rel] = unjudged.get(rel, 0) + 1
333
446
  continue
334
- where, spans = hit[0], spans + hit[1]
335
- if not spans:
336
- unjudged[rel] = unjudged.get(rel, 0) + 1
337
- continue
338
- lo = int(m.group(2) or m.group(5))
339
- hi = int(m.group(3) or m.group(6) or lo)
340
- low, high = min(s for s, _ in spans), max(e for _, e in spans)
341
- judged += 1
342
- if lo >= low and hi <= high:
343
- continue
344
- line = text.count("\n", 0, m.start()) + 1
345
- flagged.append(("%s:%d" % (rel, line), m.group(0), where, low, high))
447
+ else:
448
+ path = path_before(text, marks, m.start())
449
+ judge(rel, text, m, path, parts, True)
450
+ for m in BARE.finditer(text):
451
+ path = path_before(text, marks, m.start())
452
+ if path:
453
+ parts = spec_parts(m.group(1) or m.group(2))
454
+ if any(lo < 1 or lo > hi for lo, hi in parts):
455
+ continue # a value range such as 0-100, not lines
456
+ judge(rel, text, m, path, parts, False)
346
457
  for m in SYMCITE.finditer(text):
347
458
  judged += 1
348
459
  path = resolve(m.group(1))
349
460
  full = os.path.join(HERE, path) if path else None
350
461
  if full and os.path.isfile(full):
351
462
  if path not in tables:
352
- tables[path] = qualnames(full) or {}
463
+ tables[path] = {n: (s[0][0], s[-1][1], False) for n, s in
464
+ code_symbols(full).items()} \
465
+ if path.endswith(JS_FILES) else qualnames(full) or {}
353
466
  if anchor_span(tables[path], m.group(2)):
354
467
  continue
355
468
  line = text.count("\n", 0, m.start()) + 1
@@ -1136,6 +1249,9 @@ def main(argv):
1136
1249
  if "--citations" in flags:
1137
1250
  flagged, judged, unjudged, invisible = citations(symbols())
1138
1251
  for where, token, symbol_in, low, high in flagged:
1252
+ if low < 0:
1253
+ print("%s %s -> %s" % (where, token, symbol_in))
1254
+ continue
1139
1255
  if not low:
1140
1256
  print("%s %s -> %s defines no such symbol" % (where, token, symbol_in))
1141
1257
  continue
package/bin/tezgah-dsh CHANGED
@@ -16,7 +16,7 @@
16
16
  set -eu
17
17
  DSH_HOME="${DSH_HOME:-$HOME/.dsh}"
18
18
  BIN="$DSH_HOME/profiles/node_modules/@deepseek-ai/dsh/lib/bin.js"
19
- INDEX="${TEZGAH_INDEX_BIN:-$HOME/.config/tezgah/bin/tezgah-index}"
19
+ INDEX="${TEZGAH_INDEX_BIN:-${XDG_CONFIG_HOME:-$HOME/.config}/tezgah/bin/tezgah-index}"
20
20
  warm_index() {
21
21
  if [ -x "$INDEX" ]; then
22
22
  "$INDEX" "$PWD" >/dev/null 2>&1 || true
package/bin/tezgah-gate CHANGED
@@ -10,7 +10,8 @@
10
10
  `--sheet` draws the blind label sample (hooks/tezgah_replay.py)
11
11
 
12
12
  Reads the host's own call as {"tool":..., "input":{...}, "cwd":...,
13
- "session_id":...} and prints what hooks/tezgah_gate.decision() returns for it,
13
+ "session_id":..., "parent":...} (`parent`: the session that spawned this one,
14
+ as the host named it) and prints what hooks/tezgah_gate.decision() returns for it,
14
15
  or nothing when the call passes. A host whose plugin is written in another
15
16
  language (opencode's tezgah.js) asks `decide` instead of mirroring a rule: that
16
17
  mirror is documented as incomplete and divergent, so a new rule reaches such a
@@ -49,6 +50,9 @@ def main(argv):
49
50
  if len(argv) < 2:
50
51
  print(__doc__, file=sys.stderr)
51
52
  return 2
53
+ if argv[1] in ("-h", "--help"):
54
+ print(__doc__.strip())
55
+ return 0
52
56
  if argv[1] == "replay":
53
57
  import tezgah_replay
54
58
  return tezgah_replay.main(argv[2:])
@@ -65,7 +69,8 @@ def main(argv):
65
69
  inp = payload.get("input")
66
70
  reason = decision(payload.get("tool"), inp if isinstance(inp, dict) else {},
67
71
  payload.get("cwd") or os.getcwd(),
68
- payload.get("session_id"), record=argv[1] == "decide")
72
+ payload.get("session_id"), record=argv[1] == "decide",
73
+ parent=payload.get("parent"))
69
74
  if reason:
70
75
  sys.stdout.write(reason if reason.endswith("\n") else reason + "\n")
71
76
  return 0
@@ -23,6 +23,8 @@ the point of keeping it.
23
23
 
24
24
  `skills/ai-research/SKILL.md` is hand-written and is not in the manifest: it is
25
25
  the entry point, not vendored content. `library.json` is the manifest itself.
26
+ `LICENSE` is upstream's root licence, copied verbatim at the vendored revision
27
+ and, like SOURCE, outside the manifest.
26
28
 
27
29
  Exit codes: 0 ok, 1 drift or an upstream surprise, 2 misuse.
28
30
  """
@@ -358,6 +360,14 @@ def cmd_import(source, rev, out):
358
360
  texts = dict(files)
359
361
  texts.update(generated)
360
362
  texts["SOURCE"] = source_text(manifest, rev)
363
+ # upstream's licence, bytes verbatim at the vendored revision (NOTICE, OQ-21)
364
+ proc = subprocess.run(("git", "-C", source, "show", "%s:LICENSE" % rev),
365
+ capture_output=True)
366
+ if proc.returncode != 0:
367
+ raise SystemExit("tezgah-import-ai-research: upstream has no LICENSE at %s: %s"
368
+ % (rev, proc.stderr.decode(errors="replace").strip()
369
+ or proc.returncode))
370
+ licence = proc.stdout
361
371
  library = json.dumps(manifest, indent=1, sort_keys=True) + "\n"
362
372
 
363
373
  # SKILL.md is tezgah's own prose, not generated: the import refreshes what it
@@ -376,6 +386,8 @@ def cmd_import(source, rev, out):
376
386
  fh.write(text)
377
387
  with open(os.path.join(out, "library.json"), "w", encoding="utf-8") as fh:
378
388
  fh.write(library)
389
+ with open(os.path.join(out, "LICENSE"), "wb") as fh:
390
+ fh.write(licence)
379
391
  if kept is not None:
380
392
  with open(entry, "w", encoding="utf-8") as fh:
381
393
  fh.write(kept)
@@ -411,10 +423,10 @@ def cmd_check(out):
411
423
  for name in sorted(names):
412
424
  rel = os.path.relpath(os.path.join(root, name), out)
413
425
  # library.json is the manifest itself, SKILL.md is tezgah's own entry
414
- # point, and SOURCE is prose derived from the manifest (a test pins
415
- # that it names the manifest's revision); none is vendored upstream
416
- # content, so none carries a digest.
417
- if rel in ("library.json", "SKILL.md", "SOURCE") or rel in expected:
426
+ # point, SOURCE is prose derived from the manifest (a test pins
427
+ # that it names the manifest's revision), and LICENSE is upstream's
428
+ # licence file; none is vendored skill content, so none carries a digest.
429
+ if rel in ("library.json", "SKILL.md", "SOURCE", "LICENSE") or rel in expected:
418
430
  continue
419
431
  problems.append("unlisted %s" % rel)
420
432
  if problems:
package/bin/tezgah-index CHANGED
@@ -21,6 +21,9 @@ def main():
21
21
  if "-h" in args or "--help" in args:
22
22
  print(__doc__.strip())
23
23
  return 0
24
+ if any(a.startswith("-") for a in args):
25
+ print("usage: tezgah-index [PATH]", file=sys.stderr)
26
+ return 2
24
27
  cwd = os.path.realpath(args[0] if args else os.getcwd())
25
28
  if not under(cwd):
26
29
  return 0
@@ -118,6 +118,18 @@ def main(argv):
118
118
  ap.add_argument("--root", default=linked_main(here) or here)
119
119
  ap.add_argument("--fixture")
120
120
  args = ap.parse_args(argv)
121
+ prompts = None
122
+ if args.fixture:
123
+ # read before any proposal prints: an unreadable fixture is usage (2),
124
+ # never exit 1, which means the merges would lower recall
125
+ try:
126
+ with open(args.fixture, encoding="utf-8") as fh:
127
+ prompts = [(p["q"], [g - 1 for g in p["gt"]])
128
+ for p in json.load(fh)["lessons_prompts"]]
129
+ except (OSError, ValueError, KeyError, TypeError) as exc:
130
+ print("tezgah-lessons: cannot read --fixture %s: %s" % (args.fixture, exc),
131
+ file=sys.stderr)
132
+ return 2
121
133
  lines = tezgah_lessons.lines(args.root)
122
134
  print("Proposals for %s (nothing is written; apply what you agree with by hand):"
123
135
  % os.path.join(args.root, ".tezgah", "lessons.md"))
@@ -133,11 +145,8 @@ def main(argv):
133
145
  for needle, by in RETIRE:
134
146
  if needle in ln:
135
147
  print("retire %d: append `|| enforced_by: %s`" % (n, by))
136
- if not args.fixture:
148
+ if prompts is None:
137
149
  return 0
138
- with open(args.fixture, encoding="utf-8") as fh:
139
- prompts = [(p["q"], [g - 1 for g in p["gt"]])
140
- for p in json.load(fh)["lessons_prompts"]]
141
150
  after_lines, where = merged(lines, groups)
142
151
  before = recall(lines, prompts)
143
152
  after = recall(after_lines, [(q, sorted({where[g] for g in gt if g in where}))
package/bin/tezgah-mcp CHANGED
@@ -312,6 +312,10 @@ def serve(stdin, stdout):
312
312
 
313
313
 
314
314
  def main():
315
+ if sys.argv[1:]:
316
+ print("tezgah-mcp: takes no arguments; it is a stdio MCP server a host "
317
+ "starts", file=sys.stderr)
318
+ return 2
315
319
  try:
316
320
  return serve(sys.stdin, sys.stdout)
317
321
  except BrokenPipeError:
@@ -299,6 +299,9 @@ def discover():
299
299
  def main(argv):
300
300
  apply, repos, i = False, [], 0
301
301
  while i < len(argv):
302
+ if argv[i] in ("-h", "--help"):
303
+ print(__doc__.strip())
304
+ return 0
302
305
  if argv[i] == "--apply":
303
306
  apply = True
304
307
  elif argv[i] == "--repo" and i + 1 < len(argv):
@@ -19,7 +19,8 @@ points at ~/.gitconfig, XDG_CONFIG_HOME at the user's config dir, and a
19
19
  core.excludesFile resolved with the user's HOME is passed in absolute. The omp
20
20
  child keeps the user's env - it needs its own credentials.
21
21
 
22
- Exit 0 ok; 2 when `ocr` or `omp` is missing; 3 when a path OCR could read looks
22
+ Exit 0 ok; 2 on misuse (an unknown flag, --commit beside --from/--to) or a missing
23
+ tool (`ocr` or `omp`); 3 when a path OCR could read looks
23
24
  like a secret (its file_read could send it to the model; --allow-secrets skips
24
25
  the check) - the working tree with untracked and ignored files, or the tree of
25
26
  --to / --commit; 4 when the runner's own setup fails (not a git repository, git
@@ -302,7 +302,7 @@ def cmd_push(args):
302
302
  "it out, or push an explicit ref)" % root])
303
303
  at = time.time()
304
304
  for row in reversed(recorded(root, "pushes")):
305
- if row.get("sha") != sha or not isinstance(row.get("at"), (int, float)):
305
+ if not same_commit(row.get("sha"), sha) or not isinstance(row.get("at"), (int, float)):
306
306
  continue
307
307
  waited = at - float(row["at"])
308
308
  if waited < PUSH_WINDOW:
@@ -324,6 +324,9 @@ def main(argv):
324
324
  if len(argv) < 2:
325
325
  print(__doc__, file=sys.stderr)
326
326
  return 2
327
+ if argv[1] in ("-h", "--help"):
328
+ print(__doc__.strip())
329
+ return 0
327
330
  cmd, rest = argv[1], argv[2:]
328
331
  if cmd == "next":
329
332
  return cmd_next(rest)
package/bin/tezgah-pony CHANGED
@@ -23,6 +23,9 @@ def main(argv):
23
23
  print(__doc__.strip())
24
24
  return 0
25
25
  args = [a for a in argv if not a.startswith("-")]
26
+ if len(args) != len(argv):
27
+ print("%s\nlevels: %s" % (USAGE, ", ".join(PONY_LEVELS)), file=sys.stderr)
28
+ return 2
26
29
  if not args:
27
30
  print(pony_level())
28
31
  return 0
@@ -496,10 +496,13 @@ def cmd_check(args):
496
496
  known = tr.slugs(repo)
497
497
  if rest and rest[0] not in known:
498
498
  print("FAIL %s: no such research line (have: %s)"
499
- % (rest[0], ", ".join(known) or "none"))
499
+ % (rest[0], ", ".join(known) or "none"),
500
+ file=sys.stderr if as_json else sys.stdout)
500
501
  return 1
501
502
  problems = 0
502
503
  report = {}
504
+ # under --json the rows already carry every FAIL/warn: stdout is the payload
505
+ say = (lambda _text: None) if as_json else print
503
506
  if known or tr.stray_dirs(repo):
504
507
  report = tr.check(repo, rest[0] if rest else None, strict=strict,
505
508
  all_lines=all_lines, code=checkout_root(os.getcwd()),
@@ -507,15 +510,15 @@ def cmd_check(args):
507
510
  for slug, row in report.items():
508
511
  for err in row["errors"]:
509
512
  problems += 1
510
- print("FAIL %s: %s" % (slug, err))
513
+ say("FAIL %s: %s" % (slug, err))
511
514
  for warn in row["warnings"]:
512
- print("warn %s: %s" % (slug, warn))
515
+ say("warn %s: %s" % (slug, warn))
513
516
  # rules 8's report lines (certainty, stats, effect, budget, ...): never
514
517
  # problems, and inside the JSON row rather than before it
515
518
  for line in [] if as_json else row.get("report", []):
516
519
  print(line)
517
520
  else:
518
- print("no research line under %s" % tr.root(repo))
521
+ say("no research line under %s" % tr.root(repo))
519
522
  lines = len(report)
520
523
  if ask_orx:
521
524
  # The registry is the repository's, not a line's, so this runs whatever the
@@ -523,9 +526,9 @@ def cmd_check(args):
523
526
  report["orx"] = tr.check_orx(repo, tp.orx_bin(), strict=strict)
524
527
  for err in report["orx"]["errors"]:
525
528
  problems += 1
526
- print("FAIL orx: %s" % err)
529
+ say("FAIL orx: %s" % err)
527
530
  for warn in report["orx"]["warnings"]:
528
- print("warn orx: %s" % warn)
531
+ say("warn orx: %s" % warn)
529
532
  if as_json:
530
533
  # The payload is the whole answer for a machine caller: a sentence after
531
534
  # it is not JSON, and one before it is prose the reader never asked for.
@@ -1216,6 +1219,9 @@ def main(argv):
1216
1219
  print(__doc__, file=sys.stderr)
1217
1220
  return 2
1218
1221
  cmd, rest = argv[1], argv[2:]
1222
+ if cmd in ("-h", "--help"):
1223
+ print(__doc__.strip())
1224
+ return 0
1219
1225
  if cmd == "--all":
1220
1226
  return cmd_all(rest)
1221
1227
  if cmd == "init":
@@ -55,6 +55,9 @@ def session(sid, force, dry_run):
55
55
 
56
56
  def main(argv):
57
57
  args = list(argv)
58
+ if "-h" in args or "--help" in args:
59
+ print(__doc__.strip())
60
+ return 0
58
61
  sid = None
59
62
  if "--session" in args:
60
63
  at = args.index("--session")
package/bin/tezgah-route CHANGED
@@ -5,7 +5,7 @@
5
5
  tezgah-route --check the table's age and refresh flags
6
6
  tezgah-route --refresh re-read prices/ids (OpenRouter),
7
7
  resolve opencode selectors
8
- tezgah-route --mode auto|anthropic|zai|any|openai|off
8
+ tezgah-route --mode auto|anthropic|zai|any|experientiallabs|openai|off
9
9
  omp's family, then re-apply omp
10
10
  tezgah-route --health probe the other funded families;
11
11
  one at a limit leaves the chain