@r1z4x/tezgah 1.6.0 → 1.8.2

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 (71) hide show
  1. package/CHANGELOG.md +140 -1
  2. package/MANIFEST +7 -0
  3. package/README.md +6 -3
  4. package/bin/tezgah-decisions +6 -14
  5. package/bin/tezgah-design +21 -5
  6. package/bin/tezgah-docs +18 -16
  7. package/bin/tezgah-doctor +152 -0
  8. package/bin/tezgah-lessons +4 -1
  9. package/bin/tezgah-research +249 -17
  10. package/bin/tezgah-route +70 -8
  11. package/bin/tezgah-setup +26 -13
  12. package/bin/tezgah-task +877 -60
  13. package/bin/tezgah-taste +56 -15
  14. package/docs/architecture.md +28 -11
  15. package/docs/clarity-baseline.json +2 -2
  16. package/docs/contract.md +108 -24
  17. package/docs/environment.md +3 -1
  18. package/docs/evidence.md +209 -28
  19. package/docs/feature-audit.md +1 -1
  20. package/docs/gate.md +78 -23
  21. package/docs/glossary.md +4 -4
  22. package/docs/hosts.md +8 -8
  23. package/docs/index.json +11 -5
  24. package/docs/judge.md +9 -3
  25. package/docs/models.md +75 -14
  26. package/docs/operations.md +133 -3
  27. package/docs/research.md +349 -15
  28. package/docs/skills.md +1 -1
  29. package/docs/status-line.md +5 -5
  30. package/docs/testing.md +20 -13
  31. package/docs/vision.md +12 -4
  32. package/hooks/hooks.json +2 -2
  33. package/hooks/lang/en.json +323 -0
  34. package/hooks/lang/tr.json +344 -0
  35. package/hooks/projects-auto-init.py +29 -1
  36. package/hooks/projects-posttooluse.py +24 -5
  37. package/hooks/projects-pretooluse.py +9 -4
  38. package/hooks/projects-stop.py +28 -16
  39. package/hooks/tezgah_agents.py +53 -17
  40. package/hooks/tezgah_clarity.py +7 -3
  41. package/hooks/tezgah_context.py +489 -438
  42. package/hooks/tezgah_gate.py +996 -80
  43. package/hooks/tezgah_integrity.py +1547 -197
  44. package/hooks/tezgah_models.py +427 -45
  45. package/hooks/tezgah_paths.py +147 -16
  46. package/hooks/tezgah_policy.py +261 -129
  47. package/hooks/tezgah_rank.py +29 -27
  48. package/hooks/tezgah_replay.py +200 -30
  49. package/hooks/tezgah_research.py +1303 -14
  50. package/hooks/tezgah_stopspec.py +7 -0
  51. package/hooks/tezgah_store.py +74 -27
  52. package/hooks/tezgah_task.py +1201 -40
  53. package/hooks/tezgah_taste.py +8 -4
  54. package/hosts/codex/hook.py +32 -13
  55. package/hosts/cursor/hook.py +32 -14
  56. package/hosts/dsh/hooks.json +2 -2
  57. package/hosts/omp/hook.py +44 -23
  58. package/hosts/omp/tezgah-hook.ts.in +93 -7
  59. package/hosts/opencode/plugins/tezgah.js +273 -6
  60. package/output-styles/tezgah.md +76 -42
  61. package/package.json +1 -1
  62. package/skills/i-have-adhd/EVALS.md +22 -2
  63. package/skills/i-have-adhd/SKILL.md +12 -5
  64. package/skills/plan-add/SKILL.md +27 -11
  65. package/skills/plan-add/render_table.py +8 -3
  66. package/skills/plan-sync/SKILL.md +5 -3
  67. package/skills/ponytail/EVALS.md +21 -2
  68. package/skills/ponytail/SKILL.md +4 -4
  69. package/skills/research/SKILL.md +89 -1
  70. package/skills/tezgah-contract/EVALS.md +52 -7
  71. package/skills/tezgah-contract/SKILL.md +154 -59
package/CHANGELOG.md CHANGED
@@ -6,6 +6,141 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.8.2] - 2026-10-13
10
+
11
+ ### Fixed
12
+
13
+ - **The v1.8.1 release builds.** A packaging test still pinned the serial suite step that 1.8.1 removed from CI. So the release workflow stopped before it built the artifact. `tests/impacted.py` no longer crashes on a deleted test module. Each neuter fall-through module runs in its own HOME, as the control does.
14
+
15
+ ## [1.8.1] - 2026-10-13
16
+
17
+ ### Changed
18
+
19
+ - **The neuter matrix runs in CI, and in minutes.** Each mutant first runs the one test that killed it last time, kept in `tests/neuter_killers.json`. A full search runs only when that test passes. A cached run took 240 s, against about an hour before. CI runs it on a push that changes a guarded file, and weekly. A session checks a new rule's mutant with `--only <name>`.
20
+ - **The suite is faster.** It starts the largest modules first. Slow tests share their expensive installs per class, and one test now covers each duplicated path. A change under `benchmarks/arm-bench/` runs that lab's module, not all 100.
21
+ - **CI runs the suite once per Python.** The serial `unittest discover` step is gone. The sharded job runs the same modules, and both caught the same failures. A push to `main` now takes about 11 minutes, not 30.
22
+
23
+ ### Fixed
24
+
25
+ - **A checkout under the temp dir is not scratch.** The gate treated any path under the temp dir as scratch. So the task scope, the race rule and the main-checkout rule skipped it, and a plan worktree there let a write outside its scope pass. A file inside a git checkout now meets those rules, and a plain temp file stays scratch.
26
+ - **The test suite runs on CI runners.** A fixture had no git identity for the record commit that `close` now makes. The opencode capture test read only the SQLite ledger, so it missed the rows node 20 writes to the legacy file.
27
+
28
+ ## [1.8.0] - 2026-10-13
29
+
30
+ ### Changed
31
+
32
+ - **Typed worker output and a recall hop.** Tier workers and `tezgah-tester` now end their report with an `Output to orchestrator` block. It comes after any other shape the session asks for. A worker lists changed files, the commit sha and the `ESCALATE:` reason. For each check it gives the command, the exit code and the last lines of its output file. The tester gives pass, fail and skip counts and the first failure verbatim. A report that misses a field, or an `ESCALATE:`, gets one narrower re-brief of the same role. Only a second miss goes to `tezgah-frontier`, with the original brief and a fresh start. This ships as text only. An h05 spike on omp+tezgah spawned no subagent, so the arm-bench pilot could not measure it. No default changed.
33
+ - **A route joins the workers it actually started.** `tezgah-route --report` used to drop a route when the session spawned unrouted workers of its type. It also dropped a route whose one task call started several workers. A route now owns the first task call of its type after it. One failed first check in that call fails the route. A task call more than 30 minutes after its route stays unjoined. Two routes before one shared task call stay unjoined, since the report cannot tell whose worker is whose. On this machine 6 of the 10 real omp routes now join, up from 1.
34
+ - **On Claude Code, `tezgah-task` now reads the session id.** Claude's shells now carry `TEZGAH_SESSION`, as omp's do. So the review guard counts only this session's slice records. A bare name in `slice board`, `slice done` or `slice resume` now matches only this session's or unowned records.
35
+ - **A key passed to a script as an environment prefix is no longer refused.** `API_KEY="$(cat key)" python3 tool.py > run.log` now passes, since the key reaches only the program's environment. Only a script runner given a script file qualifies. A trace or verbose option, a startup or trace variable, `/proc/*/environ` or a function defined on the line holds it. A later printer that reads a key assigned earlier on the line is now refused too. The opencode plugin answers the same.
36
+ - **One `.tezgah` per repository, in its main checkout.** A session or CLI in a linked worktree now reads and writes the main checkout's `.tezgah`. That covers the session context, research lines, decisions, lessons, taste and the design contract. Before, `git worktree remove` deleted research lines and lessons written there. A worktree's session also never saw the lessons or open plans. `tezgah-research import` moves a line left in an old worktree workspace.
37
+ - **Any `plan/NNN-*` branch answers to plan NNN.** A plan's slice worktrees on their own `plan/NNN-*` branches now meet that plan's phase and allowlist. When every phased plan is held by a worktree, the main checkout has no active task. It used to take the first held plan's scope.
38
+ - **`tezgah-task close NNN done` refuses an open Acceptance item.** It names each unticked item that carries no `UNMET: <reason>`. `--resolved` does not lift it, and `discarded` still closes freely. Close also records the `landed:` commit once main holds the work, and commits the moved record in the private `.tezgah` repository.
39
+
40
+ ### Added
41
+
42
+ - **Claude Code, Codex and Cursor routes can join their workers.** On Claude and Codex, SubagentStart writes a `subagent_start` row. It names the child's id and type, and it goes into the parent's ledger. Claude's SessionStart exports `TEZGAH_SESSION` through `CLAUDE_ENV_FILE`. On Codex, `tezgah-route` writes under the shell's `CODEX_THREAD_ID`. On Cursor it writes under `CURSOR_CONVERSATION_ID`. Cursor's subagentStart opens the child's ledger with a row that names the parent conversation. The report counts that link only after the child writes a row of its own. `cursor-agent` fires no subagentStart, so its routes stay unjoined, and so do dsh routes.
43
+ - **Two plans at once, one worktree each.** A linked worktree on a plan's own `branch:` now answers to that plan's phase, allowlist and checkpoint. This holds for a session inside the worktree and for a write into it from anywhere else. `tezgah-task start` keeps another plan's phase only when both plans run in their own worktrees on disjoint scopes. Otherwise it clears the others as before. The main checkout reads the first phased plan no worktree holds. A worktree on any other branch reads the main checkout's task. `allow` and `phase --any-path` refuse a scope that a held plan could share. The CLI takes a pending checkpoint from the plan's own worktree, and its refusal names `git -C <worktree>`. `tick`, `note`, `spike`, `review` and `close` run from any worktree. `review` records the plan worktree's HEAD, and the verification steps print `git -C <worktree> diff`.
44
+ - **`tezgah-task spike NNN "<answer>"`.** It writes `spike_recorded:` on an open plan, active or not. `phase implementation` then passes, with no stop, hand edit and restart.
45
+ - **A forced-delegation arm and 20 harder tasks for arm-bench.** `omp+tezgah-delegate` hands every edit to the worker `tezgah-route` names, so a run exercises the role text. One paid arming run verified it. `block.py` takes task globs and `--dry-run`. Tasks x01-x20 cover hidden invariants, sites a grep misses, spec edge clauses and long sweeps. On deepseek-v4-flash they passed 99 of 100 runs, so they stay as a regression corpus.
46
+ - **The Stop rule holds a done claim over an in-progress todo item.** The new `open todo` class names the open items. Finish them, mark them done, or type each one with its blocker. A step a running slice works on passes. A Cursor `merge: true` list is partial, so the class skips it.
47
+
48
+ ### Fixed
49
+
50
+ - **A route row never lands in another host's ledger.** A host started from another host's agent shell inherits that host's session id. `tezgah-route` now writes no row when its three session variables name different sessions. Such a route counts as unjoined.
51
+ - **A Codex subagent's repeat limits count only its own attempts**, as on Claude. The root's next try no longer fails for the subagent's attempts.
52
+ - **A shell line is read target by target.** Each redirect and `tee` target meets the phase, allowlist and checkpoint of the plan it lands under. Some lines also meet the task where the session runs. That holds for a line with more write shapes than read targets. It also holds for a positional writer such as `cp` or `sed -i`. So `tee <plan worktree>/a README.md` no longer escapes a reading phase. Two redirects into one plan worktree meet only that plan.
53
+ - **Two edits cannot give a plan a phase.** The gate judged an edit to an open plan by its new string alone. So `xphase: implementation` and then `xphase` -> `phase` both passed. It now judges the plan's text as it will read after the write.
54
+ - **The plan record is the record in any case and from any checkout.** The gate refuses a write to `.TEZGAH/PLANS/open/<plan>.md` as a record write. It refuses a write to any phased record from a session in a linked worktree too.
55
+ - **A slice keeps its own plan.** Its tag comes from its `plan/NNN` branch first. Next comes `plan NNN` in its brief or shared context, then its earlier tag. A re-open with no brief keeps the tag.
56
+ - **`tezgah-task close` run inside a landed worktree keeps that worktree.** The session's own directory is no longer removed from under it.
57
+ - **arm-bench's arming proof reads the evidence store.** `session_rows` and `stop_classes` read the old JSONL ledger, which the hooks no longer write. So every arm recorded 0 and `{}`, armed or not. Both now read the SQLite store, as `counters` does. A fixture copy also leaves out stale `__pycache__` and bytecode.
58
+ - **The task rule holds repository paths only.** Some writes land under no git checkout and no configured root, such as a user's `~/.config/runpod/key`. Those no longer meet the active plan's phase, allowlist or checkpoint. The secret rule and every other rule still read them. A target in another repository is still refused. Any doubt holds the write. That covers a line that moves the directory or reads `$PWD`, and a line that makes a link. It also covers a target whose parent does not exist yet and a file with a second hard link. Host agent and instruction files and shell rc files stay held too, in any letter case.
59
+ - **`tezgah-task` exits cleanly when its reader leaves early.** `tezgah-task stop | head -1` prints no traceback. It finishes its work and keeps its own exit code, buffered or under `PYTHONUNBUFFERED=1`.
60
+ - **Nothing inside `.tezgah` treats it as a project.** The workspace keeps its own `.git`, so git named `.tezgah` itself as the top level there. Session start then set up `.tezgah/.tezgah`, agents and a graph index inside it. The plans table renderer wrote `.tezgah/.tezgah/plans/README.md`. Every resolver now maps a path inside `.tezgah` to the repository around it.
61
+ - **Plan records survive concurrent writers.** `tick`, `note`, field writes and `close` hold a lock on the plans directory and replace the file atomically. A reader saw a torn record in 29 of 40 rounds before. `tick` now finds its item under the lock.
62
+ - **The slice journal closes a landed slice whose worktree `close` removed.** `reconcile` no longer names the main checkout's HEAD as a removed worktree's last commit.
63
+ - **`close` deletes a squash-merged plan branch.** It used `git branch -d`, which refuses a squash merge, and dropped the error. It now deletes with `-D` only after proving the branch landed, and names a branch it keeps.
64
+ - **Two worktrees can commit to the private `.tezgah` repository at once.** In a fixture, a busy `index.lock` failed 11 of 19 close commits. Close still exited 0. Commits there now wait out a short lock, and close exits 1 when its record commit fails.
65
+
66
+ ## [1.7.0] - 2026-10-12
67
+
68
+ ### Added
69
+
70
+ - **Exploratory research lines (rules 7).** An ask for new ideas, methods or approaches now gets a campaign: `tezgah-research init --method exploratory`. Its arena holds the problem and a budget, and the line's scope says what it covers. A measurable arena adds an evaluator, its sha256 and its metric. The session commits the arena before the first idea. `idea`, `stage` and `match` append `ideas.jsonl`, `stages.jsonl` and `tournament.jsonl` under the lock. One function judges each row type for the writer and for `check`. An idea moves forward only, and each stage needs the one before it: prior art, then screening, a pilot, a full run and a confirmation. A kill with a reason ends it at any stage, and a duplicate ends in a kill. Prior art needs three indexed nearest works and a verdict, and `known` kills the idea in the same append. `check` refuses ideas or results committed before the arena and an arena changed after it. It refuses every stage row while the evaluator hash differs. It also refuses a confirmation on the promoting run. The same goes for a set another idea looked at first, or one this idea looked at before its promotion. It warns about thin judging and about a report that hides how many ideas it tested. `board`, `dedup` and `yield` are reports. `board` gives a Bradley-Terry prior and each idea's next action, and it is the resume point after a crash. `dedup` names its backend, embed-mrl or BM25. Its thresholds come from 40 fixture pairs and 400 human-labelled MRPC pairs. `yield` counts per operator and model family. Lines opened under rules 1-6 read none of it. The research paragraph armed per turn grows by 149 characters, and the on-demand research rule by 593.
71
+ - **An ask policy.** One class table in the contract now says what is asked at all. Work and process steps, a local merge of the session's branch included, are taken, never asked. A push or a release runs on a grant the user wrote in chat ("bana sormadan pushla") and is otherwise asked once. A force push, a delete, production, a credential or a spend over the new `spend_cap` setting waits for an answer. Every other question goes in one "Onay için sorular" batch with a recommended default, and the work goes on with the defaults. A new Stop class, `needless ask`, refuses once per turn a reply that ends on a question the agent can decide. A KARAR item with no options or default is no blocker any more. An omp `ask` or Claude AskUserQuestion question with no recommended option gets a warning, never a deny, and leaves an `ask` row. Each prompt's grants and lifts leave `grant` rows. `tezgah-gate replay` prints four new counts: process questions, cheap answers, ask-tool questions without a default and outward turns without a grant.
72
+ - **Language packs.** The detection words now live in data files, one per language: `hooks/lang/en.json` and `hooks/lang/tr.json`. They cover claims, admissions, openers, closers, residual labels, blocker tokens, prompt hints, constraint shapes and ranking stopwords. Every reader compiles the union of the packs, so a third language is one file. On a corpus of 45,810 test, doc and transcript strings, the en and tr packs answer as the inlined regexes did.
73
+ - **`language` and `--language`.** The reply-language setting is `language` in `config.json`. An older `reply_lang` is still read and never rewritten, and `--reply-lang` still works. A first install takes the locale (`LANGUAGE`, `LC_ALL`, `LC_MESSAGES`, `LANG`), and a locale with no pack gives English. An install over a config with neither key writes `tr`.
74
+ - **A router context nudge.** The prompt hook reads the router's newest input-side token count from the transcript. This works on omp, Claude and Codex, and omp now hands its session file to the prompt hook for it. At 700 k or more it adds one line, once per session until a compaction. The line says to push heavy reads and check output through a slice, or to run `/compact`. The threshold sits just under the measured p90 of 724 k. `orchestrate-off` silences it. The contract skill now says heavy check output goes through a reader slice. The disarmed-gate check now reads omp's transcript too.
75
+ - **An ownership lease on every delegated slice.** `Owns:` lines in a brief
76
+ name the globs only that slice writes. They sit on the line or as `- glob`
77
+ items under it. The journal stores them as `owns`. The gate refuses a
78
+ sibling's write into them while the owner runs. A brief may declare its
79
+ worktree on a `Worktree:`, `Checkout` or `Cwd:` line. The gate then refuses
80
+ that slice's write into the main checkout, the shape two wave-1 slices hit
81
+ with relative paths. A shell target resolves against the call's own `cwd`
82
+ and its `cd` or `pushd`. A path the brief only names declares nothing, so an
83
+ integrator that lists worktrees to remove still works in the main checkout.
84
+ The journal keeps that named worktree for reconcile, resume and the board.
85
+ The refusal names the owner, its globs, how to reach it - omp `write
86
+ agent://<name>`, Claude `SendMessage`, else the parent - and the
87
+ `tezgah-task slice done <id>` that frees it. `.tezgah/` and scratch stay
88
+ free. A closed owner frees its files. A reader agent holds no lease. omp
89
+ maps a writing session to its
90
+ record through its ledger's `spawned` row. A Claude background child maps
91
+ through the `agentId` its launch result names, and its SubagentStop closes
92
+ the record. Every other host fails open. `tezgah-task slice board` lists one
93
+ parent's open slices with their lease, worktree, branch and last commit.
94
+ `slice merge-plan` puts a branch touching MANIFEST or the citation baseline
95
+ last. It orders the rest by their forecast `git merge-tree` conflicts and
96
+ leaves CHANGELOG.md to its union driver. It names a branch it cannot
97
+ forecast and exits 1. A spawn whose writing slices name one worktree gets a
98
+ warning. The orchestration text now tells every writing brief to name
99
+ `Owns:` globs and declare its worktree. It gives the shared append points to
100
+ the integrator. Siblings message each other only on an interface or
101
+ ownership change. `orchestrate-off` and `slices-off` disarm it.
102
+
103
+ - **`CHANGELOG.md` merges with the union driver.** A new `.gitattributes` keeps both branches' lines, so parallel slices no longer conflict on the `[Unreleased]` head.
104
+ - **The todo list is the orchestrator's step queue.** The orchestration text, the fan-out rule and the orchestrator agent say to dispatch every ready step at once, mark each dispatched step blocked on its slice and never `wait` while a step is ready. Once every open step waits on a running slice, `wait` and ending the turn are both fine. The per-turn checks still run in the turn, and only the heavy tier joins at merge. Every host's todo call now leaves a `todo` ledger row with its open, ready and blocked counts and the ready heads: omp `todo` and `wait` are watched, Claude and dsh `TodoWrite` joined the PostToolUse matcher, opencode records `todowrite`, and Cursor `todo_write` and Codex `update_plan` arrive with no matcher. No pre-tool rule reads a todo call, so only omp's `wait` reaches the gate. A `ready steps` Stop class refuses, once per turn, a turn that did work and ends while the session's newest todo list holds a ready step the reply neither did nor named beside KARAR, YETKİ, GERİ ALINAMAZ or ORTAM. A step an open slice of the session works on is not ready, and a subagent's own list never counts. An omp `wait` right after a spawn, with ready steps open, gets a warning with its result, never a block.
105
+ - **`tezgah-task tick` and `tezgah-task note`.** `tick NNN <n>` ticks Acceptance item n, and `--undo` unticks it. `note NNN state|next "<text>"` replaces the newest State body or the Next body. Both work on any open plan and commit nothing. The gate refuses a direct write to the active plan, so an agent used to report a box it could not tick as a blocker. The refusal now names both commands.
106
+ - **The subagent model chain follows provider health.** A family whose provider answered a limit leaves omp's chain until its reset. The reset is read from a `429 ... reset at` or a `retry-after-ms`. An OpenRouter key with no limit left is down too. `tezgah-route --health` probes the other families, records the downs and re-applies the chain. Session start and each prompt start it in the background when it is due. `tezgah-route --fallback vendor|any|none` sets which families a chain may fall back to. Under `any`, zai comes before OpenRouter for every agent. Before, any credential counted as funded, so subagents fell back onto a key past its limit.
107
+ - **A `residual work` Stop class.** A turn that did work can no longer end on a list of what is left, such as "Açık kalanlar", "Doğrulanmadı:", "Sıradaki adım", "TODO" or "Next steps". The Stop rule refuses it once with "Bu maddeleri şimdi yap veya engel türü ve kanıtla yaz." An item stays when it names KARAR or GERİ ALINAMAZ in capitals. It also stays when it names YETKİ or ORTAM in capitals and quotes a command that failed in the turn; the type or the quote alone does not keep it. A label that closes itself ("Düzeltilen eksik", "Doğrulanmayan kalmadı"), a risk, and "kalan" as an adjective ("Kalan riskler", "Kalan süre") open no list. A turn with no work, a subagent's end and a nested session stay unjudged. Such a refusal counts in `blocked_claims` but not as a false completion. Before the class, 503 of 1876 final replies carried such a label, 467 of them in turns with work.
108
+ - **Trap cases for doable work listed as open.** The EVALS of tezgah-contract, i-have-adhd and ponytail each gained one.
109
+ - **Held-out, repeat and noise-band warnings (rules 6).** A measured line may name `evaluation.held_out` and log each look at it in `held_out_log.jsonl`. It may also declare `evaluation.noise_band`. `check` warns when a decision picks and reports a variant on the same cells. It warns when one candidate looks at the held-out set twice. It warns when an armed experiment runs once per arm. It warns when `capability_tolerance` has no measured band, or a wider one. None of these refuses, `--strict` included.
110
+ - **`tezgah-gate replay --split search|holdout`.** A stable hash of the session id picks the side. Tune a gate threshold on one side and report it on the other. The report names its side.
111
+ - **A `lesson_changed` ledger row.** Rewriting or removing a `.tezgah/lessons.md` line now leaves one row per line. The row holds the old line's key and its redacted text. Taint does not matter, and it never refuses.
112
+ - **`tezgah-doctor --repair-evidence`.** It finds sessions whose legacy ledger file went into the store more than once and counts the extra rows. Each import offset row of a file is one import, so a file imported three times keeps one copy, not two. A row written to the database directly beside the copies stays. `--apply` backs the database up first and deletes the copies in one transaction. It counts the rows against the plan before the commit and rolls back, exiting 1, if one is missing or left. It leaves a session whole for manual review when the session holds a line fewer times than its imports put it in. It names a file that is gone and skips it.
113
+ - **A slice journal for delegated work.** Each spawned subagent slice gets one row in `.tezgah/slices/slices.db`, a SQLite file opened like the taste store. The first open imports an older one-file-per-record journal once and deletes it. A crash, an omp `/clear`, a quota or 429 stop, or a model or host switch no longer loses it. The hooks open a record on every spawn call of omp, Claude, dsh, Cursor, Codex and opencode, and close it with a failure class. Session start lists the pending slices. `tezgah-task slice reconcile --prune` lists them with a resume brief and removes landed worktrees and branches. `slice resume` prints the native resume path or a re-dispatch brief that starts from the last commit. `slice stats` counts failures per class and host and names the lever for a repeating one. `slices-off` turns the journal off.
114
+ - **A SWE-Bench Pro split for arm-bench.** `benchmarks/arm-bench/swe_pro.py` samples public Pro instances and grades them with the official test script. Its `validate` checks that the gold patch passes and the base fails. The committed 3-instance sample validates 3 of 3.
115
+ - **A cross-project write notice.** A write into another repository than the session's cwd gets one line, once per session and target repository. The line says that repository's plans, lessons and slice journal do not apply to this session. It never refuses. A worktree counts as its main checkout. opencode does not show it yet.
116
+
117
+ ### Fixed
118
+
119
+ - **A subagent's limit stop moves the routing at once.** A slice that stops on a quota or rate limit now holds its provider family down in the health record and re-applies omp's chain. Before, the next session start's probe moved it. A quota is held until its reset or six hours, a rate limit until its retry-after or ten minutes. The mode's own family still leads every chain. The record is re-read under a lock before each write. A YETKİ or ORTAM blocker that quotes a model provider's quota or rate limit is refused while another family the fallback setting allows is up. The refusal names that family. A GitHub or npm rate limit stays a blocker. The task gate's scope refusal prints the ready `tezgah-task allow` line with the current globs and the new path, and none for a path outside the repository. `RELEASING.md` ends with a local `tezgah-setup --refresh` and version read.
120
+ - **An English install no longer asks for Turkish tokens.** The contract and the Stop refusals name the blocker heading, the admission word and the four blocker tokens in the configured language: `Blockers`, `unverified` and DECISION/ACCESS/IRREVERSIBLE/ENV under `en`. Under `tr` they stay "Engeller", `doğrulanmadı` and KARAR/YETKİ/GERİ ALINAMAZ/ORTAM. The Turkish first lines of the residual, ready-step and stopped-slice refusals now come only with `tr`. The reviewer and orchestrator agents and `tezgah-task`'s review lines take the token from the same pack. The contract, plan-sync and i-have-adhd skills name both spellings. opencode's todo reader takes its tokens from the packs. The Stop rule accepts both spellings.
121
+ - **Spanish, French and Dutch prose no longer passes as Turkish.** The reply-language markers dropped de, da, en, ne, ya, her and mi. Those replies now score 0. No Turkish reply in the 4,003 judged corpus texts changed sides.
122
+ - **Another session no longer takes over a stopped slice.** The journal keyed a slice record by the agent name alone. A second session that spawned an unrelated `Fixer` re-opened the first session's stopped `Fixer`, which then left that session's resume line and its Stop rule. A same-named spawn under another brief now gets its own record, `Fixer-<sha1[:6]>`. The same brief from another session still re-opens the record. `slice done|stop|resume` takes an exact id from any session, so a crashed session's slice stays reachable. A bare spawned name answers only for this session under `TEZGAH_SESSION`. The review guard counts this session's records by id, agent or name, and its refusal prints the exact id. A slice whose brief works in another repository names it as `repo`, and its line says `(in <name>)`.
123
+ - **The race rule knows a session's own subagent and its fork.** A subagent stopped on a quota error. The rule then refused its parent's takeover for ten minutes. A subagent whose slice record closed after its last write of the file is now no race. A live one still is. The refusal now names how each writer relates to this session, such as your subagent or an omp fork. It used to print a bare ledger id. The omp hook records a fork's parent at its start.
124
+ - **The transcript miners read only the current repository.** `tezgah-taste mine` and `tests/replay_context.py` read every project under `~/Projects` by default. Another project's prompts and paths then landed in this repository's research data. Both now read the current repository. `--all-projects` reads every project. `mine` writes a pseudonym (`project-<sha1[:8]>`) for each mined repository, in its `repo` and `cwd` and in every prompt, in any case.
125
+ - **The resume block names this repository's last check.** A session that last ran a check in another project showed that check as this repository's.
126
+ - **The residual-list rule asks every turn it should.** On omp the reply after a Stop refusal sat in a turn of its own. A rewrite with no tool call skipped `residual work`, and a failure quoted from the refused turn did not count. That reply is now judged on the rows of every turn in the re-ask chain. A subagent's end is still never refused, but its `subagent_end` row counts the items no type accounts for. The parent's Stop refuses a reply that types none of its own. Claude and Cursor write that row from their subagent-stop events. omp's bridge now writes it from a task session's `agent_end`, into the ledger of the session that spawned the child, once per child. Codex, opencode and dsh still write none. A turn of known reads, such as `git status`, `tail`, `find`, `jq`, `sed -n`, `gh pr view`, `curl -s`, a read-only `sqlite3` or `ssh host ls`, no longer owes a check. A reader with a writing option or script, such as `find -exec`, `sort -o` or `curl -o`, still does, and so does any other command, a commit or an unknown program included. The labels gained first-person negatives ("Yapamadım", "Test etmedim"), "Yarım kalanlar", "Henüz kontrol edilmeyen", "Unresolved", "Still open" and a residual word alone on a line over a list. "Engeller: yok", an empty "Engeller" heading and a blocker item that reports itself gone ("… yoktu. Artık var.") hold no item. A YETKİ or ORTAM item now needs an error of its type, such as a missing tool or a 403. It also needs a quoted command that failed, or, for an item about a subagent or a provider, a spawn call of the turn that failed at the provider. An assertion, a grep alone that matched nothing, a bare "yok" or a failed edit no longer passes as one. A spent quota now classifies as a `user` failure.
127
+ - **A session lists only its own slices.** Every prompt listed each pending slice of the repository, so another session's stopped slices rode this session's context. The prompt now lists only the slices this session spawned and those opened by hand. The resume block at session start adds one line that counts the other sessions' slices. `tezgah-task slice reconcile` still lists them all. The `.tezgah` repository also ignores the journal now.
128
+ - **A device-id change no longer imports a ledger twice.** The store found a legacy file's offset by name, device and inode. After the volume's device id changed, every file went in again from the start, and most ledgers read every row twice. The store now resumes from the longest import under any device id, so an old id coming back imports nothing again either. It keeps one offset row per earlier import for the repair to count.
129
+ - **The task rule no longer refuses targets that are no project file.** It refused `write xd://<tool>`, `agent://X` and `conflict://2` as paths outside the allowlist. It refused `git diff > $B/x.patch` under the literal name `$B/x.patch`. It refused files in tezgah's own `.tezgah/` workspace. Now a URI, a shell target with an unknown variable and the `.tezgah/` workspace pass. A `file://` URI and a variable set to the checkout stay held. So do the slice journal and a plan file that could make itself active. The gate first expands a variable set earlier on the line. The record rule still refuses the active plan record, and `tezgah-task start`, `review`, `tick` and `note` take only an open plan.
130
+ - **The task allowlist reads linked worktrees.** The gate used to place a file in a linked worktree outside the repo root. It then refused every in-scope write there. The gate now matches such a file against the worktree's own top. A worktree of another repository stays outside.
131
+ - **The task allowlist expands `~`.** It read a `~/...` path as relative to the working directory. It then refused an in-scope file spelled that way. With a `**` allowlist it also let a write to the home directory through as a repo path. It now expands `~` first.
132
+ - **The plan and slow role fallbacks reach omp.** `apply_omp` wrote them under `retry.fallbackChains.fallbackChains.plan`, a key omp never reads. They now go under `plan` and `slow`, and `apply_omp` deletes the misplaced keys.
133
+
134
+ ### Changed
135
+
136
+ - **The Stop rule judges the reply after a block.** The rule used to only record that reply, so a new fault in it went unjudged. Now the rule refuses it once more for a new class, one it has not refused in that turn. An example is a blocker list with no evidence after a `no verify_ok` refusal. A turn takes at most two refusals and never two of one class. The core counts them from the turn's ledger rows for Claude, Codex, Cursor and omp. On omp the block comes back as a new `turn` row, and a refusal row's `reask` key ties that row to its turn. Before, omp left that reply unjudged and unrecorded. A host's own flag decides only when the session has no turn row.
137
+ - **"doğrulanmadı" no longer ends a turn by itself.** The admission used to clear every Stop refusal, so writing it cost less than verifying. It now clears only a class whose repair is a check: check failed, partial failure, stale evidence and no verify_ok. It needs a check that ran and failed after the turn's newest change. It clears no ui_ok only when that failed check is the screen or design check itself, it never clears no external read or evidence tampered, and the tamper check now runs first.
138
+ - **Residuals are work, not a report section.** The Integrity paragraph carries this rule. Its label reads "Integrity: evidence, or a named blocker." It asks for every open item to be done now. An item that cannot be done stays as one of the four blockers, with its evidence, under one "Engeller" heading of at most three lines. The per-turn reminder carries the short form. Ponytail notes only a requested part the session cut. A reply ends on the one step only the reader can take, or on nothing. The decision-quality line no longer asks to name a skipped check: it asks to run every check the change reaches. The session fixes a minor review finding, and fix-later needs a user decision (KARAR).
139
+ - **The session budgets move with the core.** The residual rule added 1473 B to the core and 151 B to the reminder. `session_start` and `post_compact` go from 13000 to 14500 B, and `user_prompt` from 6000 to 6500 B.
140
+ - **Heavy checks run once, at merge.** In a turn the session runs compileall, ruff and the impacted tests of what changed. The full suite, neuter, both docs audits, `render_table.py --acceptance --strict` and the e2e scripts run once at the merge step and in CI. A replay of 522 turns keeps about 0.6 of the in-turn checks with no lost failure. The Stop refusals for stale evidence and a missing check now name `python3 tests/impacted.py --run <paths>`. `tezgah-task` lists the deferred checks as the merge step's one run, and CI runs `tezgah-docs --clarity` beside every citations step.
141
+ - **The session resumes a stopped slice instead of absorbing it.** The orchestration text and the orchestrator agent say so. A slice commits `wip:` after each step. The session resumes or re-dispatches a stopped slice, and `tezgah-task slice reconcile --prune` runs before the turn that merged slices ends.
142
+ - **Judge figures name their provider.** Each judge figure in `docs/judge.md` and `docs/models.md` now names its provider and model. Where the record does not say, the page says so. A figure does not carry over to another default.
143
+
9
144
  ## [1.6.0] - 2026-10-10
10
145
 
11
146
  ### Added
@@ -1026,7 +1161,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1026
1161
  the copy could not find `hooks/` and died on import. It now links into the
1027
1162
  installed tree, and also installs the command as `tezgah`.
1028
1163
 
1029
- [Unreleased]: https://github.com/r1z4x/tezgah/compare/v1.6.0...HEAD
1164
+ [Unreleased]: https://github.com/r1z4x/tezgah/compare/v1.8.2...HEAD
1165
+ [1.8.2]: https://github.com/r1z4x/tezgah/releases/tag/v1.8.2
1166
+ [1.8.1]: https://github.com/r1z4x/tezgah/releases/tag/v1.8.1
1167
+ [1.8.0]: https://github.com/r1z4x/tezgah/releases/tag/v1.8.0
1168
+ [1.7.0]: https://github.com/r1z4x/tezgah/releases/tag/v1.7.0
1030
1169
  [1.6.0]: https://github.com/r1z4x/tezgah/releases/tag/v1.6.0
1031
1170
  [1.5.1]: https://github.com/r1z4x/tezgah/releases/tag/v1.5.1
1032
1171
  [1.5.0]: https://github.com/r1z4x/tezgah/releases/tag/v1.5.0
package/MANIFEST CHANGED
@@ -1,3 +1,4 @@
1
+ .gitattributes
1
2
  .github/ISSUE_TEMPLATE/bug_report.yml
2
3
  .github/ISSUE_TEMPLATE/config.yml
3
4
  .github/ISSUE_TEMPLATE/feature_request.yml
@@ -111,6 +112,8 @@ docs/status-line.md
111
112
  docs/testing.md
112
113
  docs/vision.md
113
114
  hooks/hooks.json
115
+ hooks/lang/en.json
116
+ hooks/lang/tr.json
114
117
  hooks/projects-auto-init.py
115
118
  hooks/projects-posttooluse.py
116
119
  hooks/projects-pretooluse.py
@@ -664,6 +667,8 @@ tests/e2e_tezgah_mcp.py
664
667
  tests/fuzz_shell.py
665
668
  tests/gate_vectors.json
666
669
  tests/impacted.py
670
+ tests/install_cache.py
671
+ tests/neuter_killers.json
667
672
  tests/neuter_matrix.py
668
673
  tests/replay_context.py
669
674
  tests/routing-fixtures.md
@@ -719,6 +724,7 @@ tests/test_integrity.py
719
724
  tests/test_interpreter.py
720
725
  tests/test_judge.py
721
726
  tests/test_lang.py
727
+ tests/test_lang_packs.py
722
728
  tests/test_lesson_lineage.py
723
729
  tests/test_lessons.py
724
730
  tests/test_lessons_compile.py
@@ -752,6 +758,7 @@ tests/test_shapes.py
752
758
  tests/test_skill_pick.py
753
759
  tests/test_skill_search.py
754
760
  tests/test_skills.py
761
+ tests/test_slices.py
755
762
  tests/test_snapshot.py
756
763
  tests/test_stack.py
757
764
  tests/test_statusline.py
package/README.md CHANGED
@@ -120,9 +120,12 @@ it set out to arm is not armed.
120
120
  turns it off.
121
121
  - **orx.** The install fetches the OpenResearch CLI that research tasks route
122
122
  through; `TEZGAH_NO_DEPS=1` skips it.
123
- - **Reply language.** Replies are Turkish by default and the Stop rule holds
124
- them to it. `--reply-lang en` asks for English, and `any` for your own
125
- language; neither is checked: `curl -fsSL … | sh -s -- --reply-lang en`.
123
+ - **Reply language.** A first install asks for replies in your locale's
124
+ language (`LANG` and friends) when tezgah ships a pack for it (`tr`, `en`),
125
+ else English; an existing install keeps its language. `--language en` asks
126
+ for English and `any` for your own language: `curl -fsSL … | sh -s --
127
+ --reply-lang en` (the installer's older spelling of `--language`). Only
128
+ Turkish replies are checked by the Stop rule.
126
129
 
127
130
  Rather let your coding assistant do it? Paste this into omp, Claude Code,
128
131
  Codex, Cursor or opencode:
@@ -46,7 +46,6 @@ Exit codes: 0 every record is whole (or there are none), 1 a record was refused
46
46
  import json
47
47
  import os
48
48
  import re
49
- import subprocess
50
49
  import sys
51
50
 
52
51
  HERE = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
@@ -67,19 +66,12 @@ RECORD = re.compile(r"^(\d{3})-[^/]+\.md$")
67
66
 
68
67
 
69
68
  def repo_root(path):
70
- """The repository this run is about: git's own answer, else the directory the
71
- CLI was called in - a decision recorded under a tree git does not know is
72
- still a decision. Resolved here the way bin/tezgah-task resolves its root,
73
- because the two CLIs have to agree on which repository they are talking
74
- about."""
75
- try:
76
- out = subprocess.run(["git", "-C", path, "rev-parse", "--show-toplevel"],
77
- capture_output=True, text=True)
78
- if out.returncode == 0 and out.stdout.strip():
79
- return out.stdout.strip()
80
- except OSError:
81
- pass
82
- return os.path.realpath(path)
69
+ """The repository this run is about: its main checkout, the one whose
70
+ `.tezgah` holds the records from a linked worktree or from inside `.tezgah`
71
+ too (`tezgah_task.main_repo`, the root bin/tezgah-task keeps plans under),
72
+ else the directory the CLI was called in - a decision recorded under a tree
73
+ git does not know is still a decision."""
74
+ return tt.main_repo(path) or os.path.realpath(path)
83
75
 
84
76
 
85
77
  def decision_record(text):
package/bin/tezgah-design CHANGED
@@ -30,6 +30,10 @@ import os
30
30
  import re
31
31
  import sys
32
32
 
33
+ HERE = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
34
+ sys.path.insert(0, os.path.join(HERE, "hooks"))
35
+ from tezgah_paths import linked_main, project_top # noqa: E402
36
+
33
37
  # The state set every interactive control and every data view owes: seven per
34
38
  # control, eight per data view, thirteen distinct states - `loading` and `error`
35
39
  # are the two the halves share, and the shipped lists name them twice rather than
@@ -818,10 +822,14 @@ def _print_json(payload):
818
822
 
819
823
 
820
824
  def derive_cli(opts):
821
- root = os.path.abspath(opts.get("repo") or "")
825
+ root = project_top(os.path.abspath(opts.get("repo") or ""))
822
826
  if not os.path.isdir(root):
823
827
  raise Usage("--repo must name a directory")
824
- out = opts.get("out") or os.path.join(root, ".tezgah", "design-contract.md")
828
+ # the tokens are this checkout's; the contract goes to the repository's one
829
+ # workspace, the main checkout's `.tezgah` (plan 097), which a worktree's
830
+ # removal does not take with it
831
+ ws = linked_main(root) or root
832
+ out = opts.get("out") or os.path.join(ws, ".tezgah", "design-contract.md")
825
833
  if os.path.exists(out):
826
834
  # A contract that exists is the repository's own decision: it carries
827
835
  # the component inventory someone filled in, so overwriting it would
@@ -829,7 +837,7 @@ def derive_cli(opts):
829
837
  # deliberate `rm` first.
830
838
  raise Usage("the contract already exists at %s: a contract is edited, "
831
839
  "not overwritten - remove it first or pass --out elsewhere"
832
- % os.path.relpath(out, root))
840
+ % os.path.abspath(out))
833
841
  tokens, source, sources = derive(root)
834
842
  if tokens is None:
835
843
  if opts.get("json"):
@@ -850,7 +858,7 @@ def derive_cli(opts):
850
858
  fh.write(text)
851
859
  except OSError as exc:
852
860
  raise Usage("cannot write %s: %s" % (out, exc))
853
- shown = os.path.relpath(out, root)
861
+ shown = os.path.relpath(out, ws)
854
862
  if opts.get("json"):
855
863
  _print_json({"ok": True, "out": shown, "source": source,
856
864
  "colors": len(data["tokens"]["colors"]),
@@ -858,9 +866,11 @@ def derive_cli(opts):
858
866
  "type_scale": data["tokens"]["type_scale"],
859
867
  "components": len(data["components"])})
860
868
  else:
869
+ # absolute: from a worktree the contract is the main checkout's, which a
870
+ # `.tezgah/...` read from the worktree's cwd does not name (review N1)
861
871
  sys.stdout.write(
862
872
  "wrote %s (source: %s, %d colour roles, %s, %d type steps)\n"
863
- % (shown, source, len(data["tokens"]["colors"]),
873
+ % (os.path.abspath(out), source, len(data["tokens"]["colors"]),
864
874
  "%spx unit" % data["tokens"]["space_unit"]
865
875
  if data["tokens"]["space_unit"] else "no spacing unit found",
866
876
  len(data["tokens"]["type_scale"])))
@@ -876,6 +886,12 @@ def check_cli(opts):
876
886
  measured = opts.get("measured")
877
887
  if not contract or not measured:
878
888
  raise Usage("check wants --contract FILE and --measured FILE|-")
889
+ if (not os.path.isabs(contract) and not os.path.exists(contract)
890
+ and contract.replace(os.sep, "/").startswith(".tezgah/")):
891
+ # a worktree's `.tezgah/...` is the main checkout's workspace (plan 097,
892
+ # review N1): the path the Stop refusal and the reviewer prompt name
893
+ cwd = os.getcwd()
894
+ contract = os.path.join(linked_main(cwd) or cwd, contract)
879
895
  data, reason = read_contract(contract)
880
896
  if data is None:
881
897
  raise Usage(reason)
package/bin/tezgah-docs CHANGED
@@ -106,7 +106,7 @@ RUN = re.compile(r"((?:`[A-Za-z_][A-Za-z0-9_]*(?:\(\))?`\s*[/,]?\s*){1,5})$")
106
106
  CITE = re.compile(r"`([\w.-]+(?:/[\w.-]+)+|[\w-]+\.\w+):(\d+)(?:-(\d+))?`"
107
107
  r"|`(:(\d+)(?:-(\d+))?)`")
108
108
  # The symbol written after the citation instead of before it, the way a table
109
- # cell reads: `hooks/tezgah_policy.py:697-972` (`CORE`).
109
+ # cell reads: `hooks/tezgah_policy.py:784-1100` (`CORE`).
110
110
  AFTER = re.compile(r"\s*\(`([A-Za-z_][A-Za-z0-9_]*)(?:\(\))?`\)")
111
111
  # Any `path:line`, backticked or not: what CITE does not match of these (a
112
112
  # bare token, a comma list `x.py:12,40`) is counted as unjudged, so a citation
@@ -405,12 +405,14 @@ def ratchet_drift(unjudged, baseline):
405
405
  # audit reads a symbol: the gate's rules are the `_deny(session_id, "<rule>",
406
406
  # ...)` calls in hooks/tezgah_gate.py, and the Stop rule's triggers are the
407
407
  # classes `_stop_block` returns - including the four `_shape_block` and the six
408
- # `_evidence_block` hand back, which is why those two functions are read too.
408
+ # `_evidence_block` hand back, and the one `_residual_block` adds at every exit
409
+ # that would end the turn, which is why those three functions are read too.
409
410
 
410
411
  GATE = os.path.join(HERE, "hooks", "tezgah_gate.py")
411
412
  STOP = os.path.join(HERE, "hooks", "tezgah_integrity.py")
412
413
  LEDGER = os.path.join(HERE, "docs", "gate.md")
413
414
  LEDGER_COLUMNS = ("rule", "incident", "evidence", "pin")
415
+ STOP_FUNCS = ("_stop_block", "_shape_block", "_evidence_block", "_residual_block")
414
416
 
415
417
 
416
418
  def source(path):
@@ -450,13 +452,12 @@ def gate_rules(text=None):
450
452
 
451
453
 
452
454
  def stop_triggers():
453
- """The classes `_stop_block` can return, the shape and evidence halves
454
- included."""
455
+ """The classes `_stop_block` can return, the shape, evidence and residual
456
+ halves included."""
455
457
  tree = ast.parse(source(STOP))
456
458
  out = set()
457
459
  for node in ast.walk(tree):
458
- if isinstance(node, ast.FunctionDef) and node.name in (
459
- "_stop_block", "_shape_block", "_evidence_block"):
460
+ if isinstance(node, ast.FunctionDef) and node.name in STOP_FUNCS:
460
461
  out |= returned_classes(node)
461
462
  return out
462
463
 
@@ -535,12 +536,13 @@ CONTRACT_SKILL = os.path.join(HERE, "skills", "tezgah-contract", "SKILL.md")
535
536
  SETUP = os.path.join(HERE, "bin", "tezgah-setup")
536
537
  NUMBERS = ("zero", "one", "two", "three", "four", "five", "six", "seven",
537
538
  "eight", "nine", "ten")
538
- # ADR 007 (2026-10-05) leaves these unclassified: two `off()` names the CORE
539
- # kill-switch paragraph does not list and two opt-in markers read with
540
- # `armed()`. Until the owner decides whether they are rule switches the check
541
- # pins CORE against the code and carries these as the one explicit exception.
539
+ # ADR 007 (2026-10-05) leaves these unclassified: three `off()` names the CORE
540
+ # kill-switch paragraph does not list (`slices-off` joined on 2026-10-11 with
541
+ # the slice journal) and two opt-in markers read with `armed()`. Until the owner
542
+ # decides whether they are rule switches the check pins CORE against the code
543
+ # and carries these as the one explicit exception.
542
544
  UNCLASSIFIED_SWITCHES = frozenset((
543
- "agents-off", "update-check-off", "taste-on", "skill-suggest-on"))
545
+ "agents-off", "update-check-off", "slices-off", "taste-on", "skill-suggest-on"))
544
546
 
545
547
 
546
548
  def rule_sites(text=None):
@@ -835,8 +837,8 @@ def _guarded_returns(func, funcs, pick):
835
837
  names, with what its enclosing `if` tests guard it by. A conjunct of a test
836
838
  that reads a switch (`_switch_words`, or a call to a function in `funcs`
837
839
  that reads one: `_adhd_armed`) is a switch guard; a conjunct that names no
838
- argument or local of `func` is a condition on the environment (`reply_lang()
839
- == "tr"`); a conjunct reading the call's own data is the trigger itself and
840
+ argument or local of `func` is a condition on the environment
841
+ (`_markers(language())`); a conjunct reading the call's own data is the trigger itself and
840
842
  is not a guard. The switch a whole hook checks first (`pretooluse-off`, the
841
843
  Stop hook's `verify-off`) is outside these functions and is not counted."""
842
844
  own = {a.arg for a in func.args.args + func.args.kwonlyargs} | {
@@ -874,11 +876,11 @@ def _guarded_returns(func, funcs, pick):
874
876
 
875
877
 
876
878
  def stop_sites():
877
- """[(class, switches, conditions)] for every class `_shape_block`,
878
- `_stop_block` and `_evidence_block` return (`_guarded_returns`)."""
879
+ """[(class, switches, conditions)] for every class the `STOP_FUNCS`
880
+ return (`_guarded_returns`)."""
879
881
  tree = ast.parse(source(STOP), filename=STOP)
880
882
  funcs = {n.name: n for n in tree.body if isinstance(n, ast.FunctionDef)}
881
- return [site for name in ("_shape_block", "_stop_block", "_evidence_block")
883
+ return [site for name in STOP_FUNCS
882
884
  for site in _guarded_returns(funcs[name], funcs,
883
885
  lambda r: sorted(returned_classes(r)))]
884
886
 
package/bin/tezgah-doctor CHANGED
@@ -42,8 +42,13 @@ this tree.
42
42
  tezgah-doctor --prune-sessions 30 delete sessions idle > 30 days, then vacuum
43
43
  tezgah-doctor --import-evidence import the legacy JSONL evidence ledgers
44
44
  into the evidence database now
45
+ tezgah-doctor --repair-evidence [--apply]
46
+ the rows a legacy ledger imported again
47
+ under another device id holds; --apply
48
+ backs the database up and deletes them
45
49
  """
46
50
  import argparse
51
+ import collections
47
52
  import importlib.machinery
48
53
  import importlib.util
49
54
  import json
@@ -343,6 +348,143 @@ def import_evidence():
343
348
  for base in dict.fromkeys((CACHE, fallback_cache()))}
344
349
 
345
350
 
351
+ def evidence_plan(conn, cache):
352
+ """The copies every legacy ledger the remount bug imported more than once
353
+ put in the cache database `conn`: ({session: [row n to delete]},
354
+ {(name, ino): (current dev, longest import's bytes)} to collapse to,
355
+ {(session, ino): why that file is left alone}, {session: {line text: rows it
356
+ holds after the repair}}).
357
+
358
+ Every `imported` row of one (name, ino) is one import stream: a store from
359
+ before the fix started over at offset 0 under a renumbered device id, and
360
+ the store now swaps only the row it resumed from and keeps a file cut
361
+ below what went in as one stream (`tezgah_store._record`).
362
+ A stream put the file's first `bytes` in, so a line text went in once per
363
+ occurrence in each stream's prefix. The longest stream's count stays; every
364
+ other stream's count is a copy, and that many of the newest rows go. A row
365
+ written to the database directly beside them stays. A session holding a
366
+ line fewer times than its streams put it in is not what they explain - a
367
+ lost row, or a row that only continued another import, as the unreleased
368
+ plan-078 store wrote - so it is left whole for review; only a direct row
369
+ equal to every line such a row covers would hide it.
370
+
371
+ Only the file still at its name with that inode, and at least as long as
372
+ its longest stream, is read: another file there is listed and skipped,
373
+ because counting its lines would delete the wrong rows."""
374
+ import tezgah_store
375
+ streams = collections.defaultdict(list)
376
+ for name, ino, size in conn.execute(
377
+ "SELECT name, ino, bytes FROM imported WHERE (name, ino) IN "
378
+ "(SELECT name, ino FROM imported GROUP BY name, ino HAVING count(*) > 1) "
379
+ "ORDER BY name"):
380
+ streams[(name, ino)].append(size)
381
+ doomed, collapse, review, bounds = {}, {}, {}, {}
382
+ for (name, ino), sizes in streams.items():
383
+ path = os.path.join(cache, "evidence", name)
384
+ session = tezgah_store._ledger(path)[1]
385
+ try:
386
+ st = os.stat(path)
387
+ with open(path, "rb") as fh:
388
+ raw = fh.read(max(sizes))
389
+ except OSError:
390
+ st = None
391
+ if st is None or st.st_ino != ino or st.st_size < max(sizes):
392
+ review[(session, ino)] = ("skipped: its file is gone, replaced or shorter "
393
+ "than its imports")
394
+ continue
395
+ counts = []
396
+ for size in sizes:
397
+ prefix = raw[:size]
398
+ lines = prefix[:prefix.rfind(b"\n") + 1].split(b"\n")
399
+ counts.append(collections.Counter(filter(None, map(tezgah_store._line_text, lines))))
400
+ longest = counts[sizes.index(max(sizes))]
401
+ put = sum(counts, collections.Counter())
402
+ rows = conn.execute("SELECT n, row FROM evidence WHERE session = ? ORDER BY n DESC",
403
+ (session,)).fetchall()
404
+ copies = collections.defaultdict(list)
405
+ for n, text in rows:
406
+ copies[text].append(n)
407
+ short = sum(1 for text, many in put.items() if len(copies[text]) < many)
408
+ if short:
409
+ review[(session, ino)] = ("manual review: %d line(s) held fewer times than its "
410
+ "imports put them in" % short)
411
+ continue
412
+ extra = put - longest
413
+ doomed[session] = [n for text, many in extra.items() for n in copies[text][:many]]
414
+ bounds[session] = {text: len(copies[text]) - extra[text] for text in longest}
415
+ collapse[(name, ino)] = (st.st_dev, max(sizes))
416
+ return doomed, collapse, review, bounds
417
+
418
+
419
+ def print_plan(plan):
420
+ doomed, _collapse, review, _bounds = plan
421
+ for session, ns in doomed.items():
422
+ if ns:
423
+ print(" %s %d row(s)" % (session, len(ns)))
424
+ for (session, _ino), why in review.items():
425
+ print(" %s %s" % (session, why))
426
+ total = sum(map(len, doomed.values()))
427
+ print(" total %d row(s) in %d session(s)" % (total, sum(1 for ns in doomed.values() if ns)))
428
+ return total
429
+
430
+
431
+ def repair_evidence(apply):
432
+ """`--repair-evidence` over the cache database and its sandbox fallback's:
433
+ the dry run prints `evidence_plan`; with `apply` the database is backed up
434
+ through the SQLite backup API first, the rows go and `imported` collapses to
435
+ one row per file in one transaction, whose rows are counted against the
436
+ plan before it commits: a count off the plan rolls it back. The exit code."""
437
+ import tezgah_store
438
+ code = 0
439
+ for cache in dict.fromkeys((CACHE, fallback_cache())):
440
+ db = os.path.join(cache, tezgah_store.CACHE_DB)
441
+ if not os.path.exists(db):
442
+ continue
443
+ print("%s: evidence repair, %s" % (db, "apply" if apply else "dry run"))
444
+ conn = sqlite3.connect(db, isolation_level=None)
445
+ try:
446
+ conn.execute("PRAGMA busy_timeout = %d" % tezgah_store.BUSY_MS)
447
+ if not apply:
448
+ print_plan(evidence_plan(conn, cache))
449
+ continue
450
+ backup = "%s.bak-%s" % (db, time.strftime("%Y%m%d-%H%M%S"))
451
+ os.close(os.open(backup, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600))
452
+ dst = sqlite3.connect(backup)
453
+ try:
454
+ conn.backup(dst)
455
+ finally:
456
+ dst.close()
457
+ print(" backup %s" % backup)
458
+ with tezgah_store.transaction(conn):
459
+ doomed, collapse, _review, bounds = plan = evidence_plan(conn, cache)
460
+ print_plan(plan)
461
+ conn.executemany("DELETE FROM evidence WHERE n = ?",
462
+ [(n,) for ns in doomed.values() for n in ns])
463
+ for (name, ino), (dev, size) in collapse.items():
464
+ conn.execute("DELETE FROM imported WHERE name = ? AND ino = ?", (name, ino))
465
+ conn.execute("INSERT INTO imported (name, dev, ino, bytes) "
466
+ "VALUES (?, ?, ?, ?)", (name, dev, ino, size))
467
+ # the rows against the plan before the commit: a row the plan
468
+ # kept that is gone (missing), or a copy it deleted still there
469
+ # (left), means the deletion went wrong, and it rolls back
470
+ missing = left = 0
471
+ for session, want in bounds.items():
472
+ held = collections.Counter(text for (text,) in conn.execute(
473
+ "SELECT row FROM evidence WHERE session = ?", (session,)))
474
+ for text, many in want.items():
475
+ missing += max(0, many - held[text])
476
+ left += max(0, held[text] - many)
477
+ print(" re-check: %d row(s) missing, %d row(s) left" % (missing, left))
478
+ if missing or left:
479
+ raise sqlite3.DatabaseError("the re-check is off the plan; rolled back")
480
+ except (OSError, sqlite3.Error) as exc:
481
+ print(" failed: %s" % exc, file=sys.stderr)
482
+ code = 1
483
+ finally:
484
+ conn.close()
485
+ return code
486
+
487
+
346
488
 
347
489
  def load_probe():
348
490
  """The bench spike, imported by path so its two coverage rules stay the only
@@ -465,14 +607,24 @@ def main():
465
607
  help="import the legacy JSONL evidence ledgers into the "
466
608
  "evidence database now, instead of at the next "
467
609
  "session start's daily run")
610
+ ap.add_argument("--repair-evidence", action="store_true",
611
+ help="count the evidence rows a legacy ledger imported "
612
+ "again under another device id (dry run)")
613
+ ap.add_argument("--apply", action="store_true",
614
+ help="with --repair-evidence: back the database up, then "
615
+ "delete those rows")
468
616
  args = ap.parse_args()
469
617
  if args.retention_days < 1:
470
618
  ap.error("--retention-days wants a positive day count")
619
+ if args.apply and not args.repair_evidence:
620
+ ap.error("--apply goes with --repair-evidence")
471
621
 
472
622
  if args.coverage:
473
623
  repo = os.path.realpath(os.path.expanduser(args.repo[0]) if args.repo
474
624
  else os.getcwd())
475
625
  return coverage_report(repo, args.dump)
626
+ if args.repair_evidence:
627
+ return repair_evidence(args.apply)
476
628
  if args.import_evidence:
477
629
  import tezgah_store
478
630
  try: