@r1z4x/tezgah 1.11.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 (109) hide show
  1. package/CHANGELOG.md +507 -1
  2. package/MANIFEST +40 -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 +18 -10
  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 +158 -42
  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 +51 -10
  27. package/bin/tezgah-rollback +3 -0
  28. package/bin/tezgah-route +1 -1
  29. package/bin/tezgah-setup +1741 -95
  30. package/bin/tezgah-status +11 -1
  31. package/bin/tezgah-task +58 -17
  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 +12 -6
  38. package/docs/contract.md +83 -34
  39. package/docs/environment.md +59 -24
  40. package/docs/evidence.md +128 -25
  41. package/docs/gate.md +77 -23
  42. package/docs/glossary.md +1 -1
  43. package/docs/hosts.md +343 -48
  44. package/docs/layers.md +2 -2
  45. package/docs/models.md +6 -0
  46. package/docs/operations.md +83 -29
  47. package/docs/research.md +37 -11
  48. package/docs/skills.md +31 -8
  49. package/docs/status-line.md +39 -23
  50. package/docs/testing.md +50 -17
  51. package/docs/vision.md +6 -2
  52. package/hooks/hooks.json +1 -1
  53. package/hooks/lang/en.json +16 -2
  54. package/hooks/lang/tr.json +14 -2
  55. package/hooks/projects-posttooluse.py +11 -3
  56. package/hooks/projects-stop.py +3 -2
  57. package/hooks/tezgah_agents.py +27 -3
  58. package/hooks/tezgah_attest.py +79 -10
  59. package/hooks/tezgah_context.py +61 -31
  60. package/hooks/tezgah_gate.py +480 -126
  61. package/hooks/tezgah_integrity.py +577 -133
  62. package/hooks/tezgah_lessons.py +5 -2
  63. package/hooks/tezgah_paths.py +60 -8
  64. package/hooks/tezgah_policy.py +48 -18
  65. package/hooks/tezgah_replay.py +8 -2
  66. package/hooks/tezgah_research.py +167 -24
  67. package/hooks/tezgah_skill_pick.py +1 -1
  68. package/hooks/tezgah_snapshot.py +6 -3
  69. package/hooks/tezgah_stack.py +236 -1
  70. package/hooks/tezgah_stopspec.py +2 -1
  71. package/hooks/tezgah_task.py +2 -1
  72. package/hosts/amp/hook.py +217 -0
  73. package/hosts/amp/tezgah-plugin.ts.in +396 -0
  74. package/hosts/antigravity/hook.py +255 -0
  75. package/hosts/codex/hook.py +3 -2
  76. package/hosts/commandcode/hook.py +239 -0
  77. package/hosts/copilot/hook.py +284 -0
  78. package/hosts/cursor/hook.py +4 -2
  79. package/hosts/droid/hook.py +259 -0
  80. package/hosts/dsh/hooks.json +1 -1
  81. package/hosts/gemini/hook.py +213 -0
  82. package/hosts/goose/hook.py +145 -0
  83. package/hosts/omp/hook.py +5 -2
  84. package/hosts/omp/tezgah-hook.ts.in +157 -42
  85. package/hosts/opencode/plugins/tezgah.js +209 -63
  86. package/hosts/qwen/hook.py +206 -0
  87. package/output-styles/tezgah.md +11 -6
  88. package/package.json +6 -3
  89. package/packaging/build.sh +4 -4
  90. package/packaging/install.sh +11 -0
  91. package/packaging/upgrade.sh +3 -0
  92. package/skills/ai-research/LICENSE +21 -0
  93. package/skills/design-contract/SKILL.md +27 -6
  94. package/skills/design-library/LICENSE +21 -0
  95. package/skills/harness/SKILL.md +2 -0
  96. package/skills/i-have-adhd/SKILL.md +10 -1
  97. package/skills/plan-add/SKILL.md +5 -1
  98. package/skills/plan-add/render_table.py +7 -0
  99. package/skills/pm-frameworks/LICENSE +21 -0
  100. package/skills/research/SKILL.md +22 -9
  101. package/skills/rl-env/LICENSE +201 -0
  102. package/skills/tezgah-contract/SKILL.md +21 -8
  103. package/statusline.py +20 -9
  104. package/third_party/open-code-review/LICENSE +201 -0
  105. package/third_party/open-code-review/README.md +15 -0
  106. package/third_party/open-code-review/prompts/main_task_system.md +26 -0
  107. package/third_party/open-code-review/prompts/review_filter_task_system.md +13 -0
  108. package/workflows/graph-map.js +14 -5
  109. package/workflows/graph-review.js +14 -2
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
- # cell reads: `hooks/tezgah_policy.py:806-1135` (`CORE`).
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
@@ -25,6 +25,8 @@
25
25
  tezgah-research verdict <slug> <id> met|not-met|unanswerable --evidence "<ref>"
26
26
  record one success criterion's verdict
27
27
  tezgah-research conclude <slug> conclude a line whose criteria all carry a verdict
28
+ tezgah-research choose <slug> <decision> <variant> --ack "<the owner's words>"
29
+ record the owner's pick of a recommended variant
28
30
  tezgah-research migrate-layout move flat research/<slug> into open/ or done/
29
31
  tezgah-research close <slug> --limit "<reason>" conclude a line as a deliberate limit
30
32
  tezgah-research seal <slug> [--history-lost] --ack "<owner decision>"
@@ -121,14 +123,20 @@ no status, a design/plan/analysis/code deliverable with no decision comparing it
121
123
  variants - printing every open slug with its reasons. `--allow-open "<reason>"`
122
124
  opens the new line anyway and writes the reason into its `log.md` as the first
123
125
  entry; an empty reason is misuse, and the hatch is refused while any open line has
124
- `check` errors. `close <slug> --limit "<reason>"` concludes a line on purpose,
126
+ `check` errors other than a criterion whose verdict is still `pending` (a line in
127
+ flight). Both refusals say that a line another session is running goes to the
128
+ owner, not to this session. `close <slug> --limit "<reason>"` concludes a line on purpose,
125
129
  writing the reasons it was still open into `state.json` `closed` and `log.md`.
126
130
  `--supersedes <old>` opens a new version of an existing line, which then carries
127
131
  the old deliverable as a variant; a line asking an existing line's exact question
128
132
  without it is refused. `compare` appends one cell to
129
133
  `decisions/<decision>/comparison.jsonl` under the rule `check` applies to it.
134
+ A rules-9 decision.md writes the line's pick as `recommended:`; `choose` records
135
+ the owner's `chosen:` with an `owner:` line holding their words, and `conclude`
136
+ names each recommendation still waiting for it.
130
137
  `check` prints the same list of open lines after the per-line report, and so does
131
- `status`, so neither state has to be discovered by being refused.
138
+ `status`, so neither state has to be discovered by being refused. Both also fail
139
+ a hidden dir under `research/`, which no line may live in.
132
140
 
133
141
  `init --method exploratory` (rules 7) opens a campaign instead of a question:
134
142
  `--arena` and `--budget` (and, for a measurable arena, `--evaluator` with its
@@ -286,6 +294,7 @@ def cmd_init(args):
286
294
  print(" %s: %s" % (name, "; ".join(reasons)))
287
295
  print('to open %s anyway, say why: tezgah-research init %s '
288
296
  '--allow-open "<reason>"' % (slug, slug))
297
+ print(tr.OWNER_ASK)
289
298
  return 1
290
299
  broken = tr.broken_open_lines(repo) if unfinished else []
291
300
  if broken:
@@ -297,6 +306,7 @@ def cmd_init(args):
297
306
  print(" %s: %d error(s), first: %s" % (name, len(errors), errors[0]))
298
307
  print('fix them, or record what is left: tezgah-research close <slug> '
299
308
  '--limit "<reason>"')
309
+ print(tr.OWNER_ASK)
300
310
  return 1
301
311
  twins = [t for t in tr.serial_twins(repo, slug, {"question": question})
302
312
  if t != supersedes]
@@ -486,26 +496,29 @@ def cmd_check(args):
486
496
  known = tr.slugs(repo)
487
497
  if rest and rest[0] not in known:
488
498
  print("FAIL %s: no such research line (have: %s)"
489
- % (rest[0], ", ".join(known) or "none"))
499
+ % (rest[0], ", ".join(known) or "none"),
500
+ file=sys.stderr if as_json else sys.stdout)
490
501
  return 1
491
502
  problems = 0
492
503
  report = {}
493
- if known:
504
+ # under --json the rows already carry every FAIL/warn: stdout is the payload
505
+ say = (lambda _text: None) if as_json else print
506
+ if known or tr.stray_dirs(repo):
494
507
  report = tr.check(repo, rest[0] if rest else None, strict=strict,
495
508
  all_lines=all_lines, code=checkout_root(os.getcwd()),
496
509
  flags={"online": online, "orx": ask_orx})
497
510
  for slug, row in report.items():
498
511
  for err in row["errors"]:
499
512
  problems += 1
500
- print("FAIL %s: %s" % (slug, err))
513
+ say("FAIL %s: %s" % (slug, err))
501
514
  for warn in row["warnings"]:
502
- print("warn %s: %s" % (slug, warn))
515
+ say("warn %s: %s" % (slug, warn))
503
516
  # rules 8's report lines (certainty, stats, effect, budget, ...): never
504
517
  # problems, and inside the JSON row rather than before it
505
518
  for line in [] if as_json else row.get("report", []):
506
519
  print(line)
507
520
  else:
508
- print("no research line under %s" % tr.root(repo))
521
+ say("no research line under %s" % tr.root(repo))
509
522
  lines = len(report)
510
523
  if ask_orx:
511
524
  # The registry is the repository's, not a line's, so this runs whatever the
@@ -513,9 +526,9 @@ def cmd_check(args):
513
526
  report["orx"] = tr.check_orx(repo, tp.orx_bin(), strict=strict)
514
527
  for err in report["orx"]["errors"]:
515
528
  problems += 1
516
- print("FAIL orx: %s" % err)
529
+ say("FAIL orx: %s" % err)
517
530
  for warn in report["orx"]["warnings"]:
518
- print("warn orx: %s" % warn)
531
+ say("warn orx: %s" % warn)
519
532
  if as_json:
520
533
  # The payload is the whole answer for a machine caller: a sentence after
521
534
  # it is not JSON, and one before it is prose the reader never asked for.
@@ -1067,6 +1080,28 @@ def cmd_conclude(args):
1067
1080
  print("FAIL conclude: %s" % problem)
1068
1081
  return 1
1069
1082
  print("concluded %s; it moved to research/done/%s" % (slug, slug))
1083
+ for decision, variant in tr.owner_waits(repo, slug):
1084
+ print("report to the owner: %s is recommended, not chosen; building it waits "
1085
+ "for the owner's answer (tezgah-research choose %s %s %s --ack "
1086
+ "\"<their words>\")" % (variant, slug, decision, variant))
1087
+ return 0
1088
+
1089
+
1090
+ def cmd_choose(args):
1091
+ """Record the owner's pick among a decision's produced variants."""
1092
+ usage = 'choose <slug> <decision> <variant> --ack "<the owner\'s words>"'
1093
+ if len(args) != 5 or args[3] != "--ack":
1094
+ return misuse(usage)
1095
+ slug, decision, variant, _flag, ack = args
1096
+ repo = repo_root(os.getcwd())
1097
+ if slug not in tr.slugs(repo):
1098
+ return misuse("no such research line: %s" % slug)
1099
+ problem = tr.choose_line(repo, slug, decision, variant, ack,
1100
+ time.strftime("%Y-%m-%d"))
1101
+ if problem:
1102
+ print("FAIL choose: %s" % problem)
1103
+ return 1
1104
+ print("decisions/%s: the owner chose %s" % (decision, variant))
1070
1105
  return 0
1071
1106
 
1072
1107
 
@@ -1184,6 +1219,9 @@ def main(argv):
1184
1219
  print(__doc__, file=sys.stderr)
1185
1220
  return 2
1186
1221
  cmd, rest = argv[1], argv[2:]
1222
+ if cmd in ("-h", "--help"):
1223
+ print(__doc__.strip())
1224
+ return 0
1187
1225
  if cmd == "--all":
1188
1226
  return cmd_all(rest)
1189
1227
  if cmd == "init":
@@ -1212,6 +1250,8 @@ def main(argv):
1212
1250
  return cmd_verdict(rest)
1213
1251
  if cmd == "conclude":
1214
1252
  return cmd_conclude(rest)
1253
+ if cmd == "choose":
1254
+ return cmd_choose(rest)
1215
1255
  if cmd == "migrate-layout":
1216
1256
  return cmd_migrate_layout(rest)
1217
1257
  if cmd == "close":
@@ -1237,7 +1277,8 @@ def main(argv):
1237
1277
  if cmd in commands:
1238
1278
  return commands[cmd](repo_root(os.getcwd()), rest)
1239
1279
  return misuse("unknown command %r (init, commit, check, status, --all, claim, predict, "
1240
- "components, migrate, source, compare, verdict, conclude, migrate-layout, "
1280
+ "components, migrate, source, compare, verdict, conclude, choose, "
1281
+ "migrate-layout, "
1241
1282
  "close, seal, import, next, idea, stage, match, board, dedup, yield, cite, "
1242
1283
  "fetch, search, instrument, gold, label, switch, release-check, "
1243
1284
  "calibration)" % cmd)
@@ -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