@r1z4x/tezgah 1.11.0 → 1.13.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 (60) hide show
  1. package/CHANGELOG.md +333 -1
  2. package/MANIFEST +22 -0
  3. package/README.md +4 -3
  4. package/bin/tezgah-docs +1 -1
  5. package/bin/tezgah-research +39 -4
  6. package/bin/tezgah-setup +1229 -22
  7. package/bin/tezgah-task +55 -17
  8. package/docs/citations-baseline.json +1 -1
  9. package/docs/clarity-baseline.json +9 -3
  10. package/docs/contract.md +6 -3
  11. package/docs/environment.md +3 -2
  12. package/docs/evidence.md +81 -8
  13. package/docs/gate.md +6 -2
  14. package/docs/glossary.md +1 -1
  15. package/docs/hosts.md +297 -15
  16. package/docs/layers.md +1 -1
  17. package/docs/models.md +6 -0
  18. package/docs/operations.md +16 -5
  19. package/docs/research.md +36 -10
  20. package/docs/status-line.md +6 -0
  21. package/docs/testing.md +3 -1
  22. package/hooks/hooks.json +1 -1
  23. package/hooks/lang/en.json +11 -0
  24. package/hooks/lang/tr.json +11 -0
  25. package/hooks/projects-stop.py +2 -1
  26. package/hooks/tezgah_agents.py +27 -3
  27. package/hooks/tezgah_attest.py +79 -10
  28. package/hooks/tezgah_context.py +21 -8
  29. package/hooks/tezgah_gate.py +79 -23
  30. package/hooks/tezgah_integrity.py +212 -38
  31. package/hooks/tezgah_paths.py +43 -2
  32. package/hooks/tezgah_policy.py +35 -13
  33. package/hooks/tezgah_replay.py +8 -2
  34. package/hooks/tezgah_research.py +167 -24
  35. package/hooks/tezgah_stack.py +236 -1
  36. package/hooks/tezgah_task.py +2 -1
  37. package/hosts/amp/hook.py +217 -0
  38. package/hosts/amp/tezgah-plugin.ts.in +386 -0
  39. package/hosts/antigravity/hook.py +255 -0
  40. package/hosts/codex/hook.py +2 -1
  41. package/hosts/commandcode/hook.py +239 -0
  42. package/hosts/copilot/hook.py +284 -0
  43. package/hosts/cursor/hook.py +2 -1
  44. package/hosts/droid/hook.py +259 -0
  45. package/hosts/dsh/hooks.json +1 -1
  46. package/hosts/gemini/hook.py +213 -0
  47. package/hosts/goose/hook.py +145 -0
  48. package/hosts/omp/hook.py +4 -1
  49. package/hosts/omp/tezgah-hook.ts.in +30 -7
  50. package/hosts/opencode/plugins/tezgah.js +2 -2
  51. package/hosts/qwen/hook.py +206 -0
  52. package/output-styles/tezgah.md +10 -5
  53. package/package.json +1 -1
  54. package/skills/harness/SKILL.md +2 -0
  55. package/skills/i-have-adhd/SKILL.md +10 -1
  56. package/skills/plan-add/SKILL.md +5 -1
  57. package/skills/research/SKILL.md +22 -9
  58. package/skills/tezgah-contract/SKILL.md +19 -6
  59. package/statusline.py +20 -9
  60. package/workflows/graph-review.js +14 -2
@@ -315,6 +315,22 @@ function emptyRun(content: any): boolean {
315
315
  return EMPTY_RUN.test(text.slice(-EMPTY_RUN_TAIL));
316
316
  }
317
317
 
318
+ // The text of one assistant message, whatever shape it arrives in: omp hands
319
+ // `session_stop` the message OBJECT its `getLastAssistantMessage()` returns,
320
+ // and the core must judge the prose, never its `str()` (plan 143). A thinking
321
+ // or tool block is not the reply: only `type: "text"` is.
322
+ function textOf(message: any): string | undefined {
323
+ if (typeof message === "string") return message;
324
+ if (typeof message?.content === "string") return message.content;
325
+ if (Array.isArray(message?.content)) {
326
+ // joined and trimmed exactly as the core does (`reply_prose`), so the two
327
+ // never hand the rule a different text for one reply
328
+ return message.content.map((p: any) => (p?.type === "text" && typeof p.text === "string"
329
+ ? p.text : "")).join("\n").trim();
330
+ }
331
+ return undefined;
332
+ }
333
+
318
334
  // The text of the newest assistant message in an agent_end `messages` list:
319
335
  // the subagent's final report, which the hook reads like a Stop reply.
320
336
  function lastReply(messages: any): string | undefined {
@@ -322,11 +338,8 @@ function lastReply(messages: any): string | undefined {
322
338
  for (let i = messages.length - 1; i >= 0; i--) {
323
339
  const m = messages[i];
324
340
  if (m?.role !== "assistant") continue;
325
- if (typeof m.content === "string") return m.content;
326
- if (Array.isArray(m.content)) {
327
- return m.content.map((p: any) => (p?.type === "text" && typeof p.text === "string"
328
- ? p.text : "")).join("\n");
329
- }
341
+ const text = textOf(m);
342
+ if (text !== undefined) return text;
330
343
  }
331
344
  return undefined;
332
345
  }
@@ -785,9 +798,19 @@ export default function tezgahOmp(pi: any): void {
785
798
  });
786
799
 
787
800
  pi.on("session_stop", async (event: any, ctx: any) => {
801
+ // `sessionModes` exists only in an interactive TUI session; its `plan`
802
+ // state is what every other host sends as `permission_mode`, so the core
803
+ // reads one field (tezgah_integrity.read_only_turn). A session without it
804
+ // (print, RPC, a subagent) sends nothing and is judged as `default`.
805
+ let mode: string | undefined;
806
+ try {
807
+ mode = ctx?.sessionModes?.getState?.()?.plan === "active" ? "plan" : undefined;
808
+ } catch { mode = undefined; }
788
809
  const res = ask({ event: "stop", cwd: cwdOf(ctx), session_id: sessionOf(ctx),
789
- last_assistant_message: event?.last_assistant_message,
790
- stop_hook_active: event?.stop_hook_active === true });
810
+ last_assistant_message: textOf(event?.last_assistant_message)
811
+ ?? lastReply(event?.messages),
812
+ stop_hook_active: event?.stop_hook_active === true,
813
+ permission_mode: mode });
791
814
  if (hookError) {
792
815
  markBroken(ctx);
793
816
  return undefined; // an unanswerable Stop rule must not block the turn
@@ -190,13 +190,13 @@ const EXPLORE_DENY =
190
190
  // as _CREDIT is there. The parity test in tests/test_opencode_plugin.py reads
191
191
  // this literal and the Python one and fails if the two alternations differ.
192
192
  const ATTRIB =
193
- /co-authored-by\s*:|generated with|made with|(?:generated|written|authored|built|assisted)\s+by\s+(?:(?:an?|the)\s+)?(?:ai|llm|claude|chatgpt|gpt|copilot|codex|cursor|gemini|deepseek|openai|anthropic)\b|noreply@anthropic|\u{1F916}/iu
193
+ /co-authored-by\s*:|generated with|made with|(?:generated|written|authored|built|assisted|drafted|produced|composed)\s+by\s+(?:(?:an?|the)\s+)?(?:ai|llm|claude|chatgpt|gpt|copilot|codex|cursor|gemini|deepseek|openai|anthropic)\b|(?:generated|written|authored|built|assisted|drafted|produced|composed)\s+(?:with|using)\s+(?:(?:an?|the)\s+)?(?:ai|llm|claude|chatgpt|gpt|copilot|codex|gemini|deepseek|openai|anthropic)(?:-[\w.]+)?(?:\s+(?:code|cli))?(?:\s*\([^)\n]*\))?(?=[.!)\]*_`]*(?:[\x22\x27,;]|\s+-\s|\s*(?:\n|$)))|noreply@anthropic|\u{1F916}/iu
194
194
  // The same forms anchored to the start of a line, for the text a write/edit
195
195
  // tool is about to land. Mirrors hooks/tezgah_gate.py ATTRIB_LINE: a credit owns
196
196
  // its line, while prose naming the ban does not, and that anchor is what keeps
197
197
  // the rule from denying the documentation that describes it.
198
198
  const ATTRIB_LINE =
199
- /^[\s>#*/<!+-]*(?:co-authored-by\s*:|generated with|made with|(?:generated|written|authored|built|assisted)\s+by\s+(?:(?:an?|the)\s+)?(?:ai|llm|claude|chatgpt|gpt|copilot|codex|cursor|gemini|deepseek|openai|anthropic)\b|noreply@anthropic|\u{1F916})/imu
199
+ /^[\s>#*/<!+-]*(?:co-authored-by\s*:|generated with|made with|(?:generated|written|authored|built|assisted|drafted|produced|composed)\s+by\s+(?:(?:an?|the)\s+)?(?:ai|llm|claude|chatgpt|gpt|copilot|codex|cursor|gemini|deepseek|openai|anthropic)\b|(?:generated|written|authored|built|assisted|drafted|produced|composed)\s+(?:with|using)\s+(?:(?:an?|the)\s+)?(?:ai|llm|claude|chatgpt|gpt|copilot|codex|gemini|deepseek|openai|anthropic)(?:-[\w.]+)?(?:\s+(?:code|cli))?(?:\s*\([^)\n]*\))?(?=[.!)\]*_`]*(?:[\x22\x27,;]|\s+-\s|\s*(?:\n|$)))|noreply@anthropic|\u{1F916})/imu
200
200
  const EDIT_TEXT = ["content", "new_string", "newString", "new_str", "file_text",
201
201
  "patch", "text"]
202
202
  const WRITE_CMD =
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env python3
2
+ """Qwen Code lifecycle hook. Reads Qwen's JSON on stdin and answers in Qwen's own
3
+ output shape, onto the shared tezgah core (hooks/).
4
+
5
+ Qwen Code 0.21.13 fires SessionStart, UserPromptSubmit, PreToolUse, PostToolUse,
6
+ PostToolUseFailure, SubagentStart, SubagentStop and Stop here
7
+ (`bin/tezgah-setup::QWEN_HOOK_EVENTS`); anything else is ignored. Inert outside
8
+ the roots.
9
+
10
+ The always-on core is the managed block in `~/.qwen/QWEN.md`, which Qwen loads
11
+ into every session, so SessionStart repeats it only when that block is missing.
12
+ PreToolUse translates Qwen's runtime tool ids into the gate's vocabulary and
13
+ denies with `hookSpecificOutput.permissionDecision` on exit 0 - never exit 2,
14
+ whose stderr channel carries no reason the ledger and the probe can read. Qwen
15
+ fires PostToolUse for a successful call and PostToolUseFailure for a failed one
16
+ (a shell exit Qwen counts as an error, `isShellExitError`), so the event is the
17
+ call's outcome. Stop blocks a done/tested claim with no successful check behind
18
+ it, judged on `last_assistant_message`, the same rule Claude's Stop hook enforces
19
+ (hooks/tezgah_integrity.py). The status line is `tezgah-statusline --qwen`, Qwen's
20
+ `ui.statusLine` command, so no event here carries it.
21
+ """
22
+ import json
23
+ import os
24
+ import sys
25
+
26
+ ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
27
+ sys.path.insert(0, os.path.join(ROOT, "hooks"))
28
+ from tezgah_guard import attest_session, import_failed, safe # noqa: E402
29
+ try:
30
+ from tezgah_context import command_text, context_for, record, shell_kind # noqa: E402
31
+ from tezgah_gate import cross_project_note, decision, graph_advice # noqa: E402
32
+ from tezgah_integrity import ( # noqa: E402
33
+ SUBAGENT_CHANNEL, bind_session, note, note_tool, ran_nothing, report_bytes,
34
+ stop_reason, untrusted_source)
35
+ from tezgah_paths import HOST_DIRS, off, root_for # noqa: E402
36
+ from tezgah_taste import write_note # noqa: E402
37
+ from tezgah_untrusted import marks # noqa: E402
38
+ except Exception as exc:
39
+ import_failed(exc)
40
+
41
+ # Qwen's runtime tool ids (the `ToolNames` table in the 0.21.13 bundle) -> the
42
+ # shared gate's vocabulary. `replace` is the edit tool's id in older builds; the
43
+ # 0.21.13 bundle has none, and an unknown id passes the gate untouched. MCP tools
44
+ # arrive as `mcp__<server>__<tool>`, the shared shape, and pass through.
45
+ # `monitor` runs a shell command too (Qwen's headless deny list pairs it with
46
+ # SHELL), and `notebook_edit` writes an .ipynb (review of plan 138).
47
+ GATE_TOOLS = {
48
+ "run_shell_command": "Bash",
49
+ "monitor": "Bash",
50
+ "write_file": "Write",
51
+ "edit": "Edit",
52
+ "replace": "Edit",
53
+ "notebook_edit": "NotebookEdit",
54
+ "agent": "Task",
55
+ "grep_search": "Grep",
56
+ }
57
+
58
+
59
+ def gate_name(name):
60
+ """The tool name the gate denies under and the ledger row records.
61
+
62
+ `call_id` hashes the name it is handed, so both halves map through here: a
63
+ PostToolUse row under the raw id would never match the PreToolUse call and
64
+ the loop guard would never deny (`hosts/codex/hook.py::gate_name`)."""
65
+ return GATE_TOOLS.get(name, name)
66
+
67
+
68
+ def classify(payload):
69
+ """The used-tool kind for a tool payload, or None."""
70
+ name = payload.get("tool_name", "") or ""
71
+ if name.startswith("mcp__") and "codegraph" in name:
72
+ return "graph"
73
+ if name == "agent":
74
+ return "orch"
75
+ if name == "run_shell_command":
76
+ return shell_kind(command_text(payload.get("tool_input")))
77
+ return None
78
+
79
+
80
+ def subagent_id(payload):
81
+ """The subagent id Qwen adds to a hook input run inside a subagent, else None."""
82
+ aid = payload.get("agent_id")
83
+ return aid if isinstance(aid, str) and aid else None
84
+
85
+
86
+ def rules_carry_core():
87
+ """True when `QWEN.md` in Qwen's home holds tezgah-setup's managed block."""
88
+ try:
89
+ with open(os.path.join(HOST_DIRS["qwen"], "QWEN.md"), encoding="utf-8") as fh:
90
+ return "<!-- tezgah:start" in fh.read()
91
+ except OSError:
92
+ return False
93
+
94
+
95
+ def result_size(result):
96
+ """The result's top-level length, the Claude-family writer's measure; a value
97
+ with no length counts as non-empty and only a missing result is None."""
98
+ if isinstance(result, (str, bytes, bytearray, list, tuple, dict, set, frozenset)):
99
+ return len(result)
100
+ return 1 if result is not None else None
101
+
102
+
103
+ def inp_of(payload):
104
+ inp = payload.get("tool_input")
105
+ return inp if isinstance(inp, dict) else {}
106
+
107
+
108
+ def post_tool(payload, cwd, session_id, failed):
109
+ """The ledger row for one finished call, and the notice for the model."""
110
+ tool = gate_name(payload.get("tool_name", ""))
111
+ inp = inp_of(payload)
112
+ # a `monitor` PostToolUse fires when the watch starts ("Monitor started"),
113
+ # long before its command ends: it records a run, never a pass (review of
114
+ # plan 138, round 2); a failed start still arrives as PostToolUseFailure
115
+ if payload.get("tool_name") == "monitor" and failed is False:
116
+ failed = None
117
+ safe(session_id, record, session_id, classify(payload))
118
+ if not root_for(cwd):
119
+ return None
120
+ result = payload.get("tool_response")
121
+ # read before this call's row lands: `source` is the taint's own mark
122
+ got = safe(session_id, marks, tool, inp, session_id, result)
123
+ source, notice = got or (None, None)
124
+ safe(session_id, note_tool, session_id, tool, inp, failed=failed,
125
+ interrupted=bool(failed and payload.get("is_interrupt") is True),
126
+ source=source, cwd=cwd,
127
+ out_bytes=(report_bytes(result)
128
+ if untrusted_source(tool, inp) == SUBAGENT_CHANNEL
129
+ else result_size(result)),
130
+ empty_run=ran_nothing(result), agent=subagent_id(payload))
131
+ advice = safe(session_id, graph_advice, tool, inp, cwd, "qwen")
132
+ taste = cross = None
133
+ if not failed:
134
+ taste = safe(session_id, write_note, session_id, inp, cwd, tool)
135
+ cross = safe(session_id, cross_project_note, session_id, tool, inp, cwd)
136
+ return "\n".join(p for p in (notice, advice, taste, cross) if p)
137
+
138
+
139
+ def context(event, text):
140
+ return {"hookSpecificOutput": {"hookEventName": event,
141
+ "additionalContext": text}} if text else {}
142
+
143
+
144
+ def main():
145
+ try:
146
+ payload = json.load(sys.stdin)
147
+ except Exception:
148
+ return
149
+ if not isinstance(payload, dict):
150
+ return
151
+ event = payload.get("hook_event_name") or ""
152
+ cwd = payload.get("cwd") or os.getcwd()
153
+ session_id = payload.get("session_id")
154
+ safe(session_id, bind_session, session_id) # the switch latch (tezgah_paths.off)
155
+ out = {}
156
+ if event == "PreToolUse":
157
+ reason = safe(session_id, decision, gate_name(payload.get("tool_name", "")),
158
+ inp_of(payload), cwd, session_id, agent=subagent_id(payload))
159
+ if reason:
160
+ out = {"hookSpecificOutput": {
161
+ "hookEventName": "PreToolUse",
162
+ "permissionDecision": "deny",
163
+ "permissionDecisionReason": reason,
164
+ }}
165
+ elif event in ("PostToolUse", "PostToolUseFailure"):
166
+ out = context(event, post_tool(payload, cwd, session_id,
167
+ event == "PostToolUseFailure"))
168
+ elif event == "SessionStart":
169
+ safe(session_id, attest_session, "qwen", session_id, cwd)
170
+ out = context(event, safe(session_id, context_for, "session_start", cwd,
171
+ payload, with_core=not rules_carry_core()))
172
+ elif event == "UserPromptSubmit":
173
+ out = context(event, safe(session_id, context_for, "user_prompt", cwd, payload))
174
+ elif event == "SubagentStart":
175
+ safe(session_id, record, session_id, "orch")
176
+ kind = payload.get("agent_type")
177
+ if subagent_id(payload) and isinstance(kind, str) and root_for(cwd):
178
+ safe(session_id, note, session_id, "subagent_start", kind,
179
+ agent=subagent_id(payload))
180
+ # the delegate is a fresh context: Qwen documents additionalContext here
181
+ # as the subagent's initial context
182
+ out = context(event, safe(session_id, context_for, "subagent_start", cwd,
183
+ payload))
184
+ elif event == "SubagentStop":
185
+ # a subagent's end is judged record-only (ADR 011): one `subagent_end`
186
+ # row from its last message, never a block
187
+ safe(session_id, record, session_id, "orch")
188
+ if not off("verify-off") and root_for(cwd):
189
+ safe(session_id, stop_reason, payload.get("last_assistant_message") or "",
190
+ session_id, cwd=cwd, subagent=True)
191
+ elif event == "Stop":
192
+ # Qwen's Stop carries `last_assistant_message` and `stop_hook_active`,
193
+ # and takes {"decision": "block", "reason": ...}. The reply after a block
194
+ # is judged again and refused at most once more (tezgah_integrity).
195
+ if not off("verify-off") and root_for(cwd):
196
+ reason = safe(session_id, stop_reason,
197
+ payload.get("last_assistant_message"), session_id,
198
+ cwd=cwd, after_block=bool(payload.get("stop_hook_active")),
199
+ mode=payload.get("permission_mode"))
200
+ if reason:
201
+ out = {"decision": "block", "reason": reason}
202
+ if out:
203
+ print(json.dumps(out))
204
+
205
+
206
+ main()
@@ -47,7 +47,10 @@ bounded action per step, and while it is in flight its position is restated in
47
47
  one line - the todo list is that source, never re-narrate the plan. End with the
48
48
  approval questions the Ask policy (below) leaves, or with nothing when none is
49
49
  left; a step you can take yourself is taken, never handed
50
- over. Finish the issue in hand before raising a second one; an error states
50
+ over, and in the host's plan mode, where nothing can run, the plan is the
51
+ deliverable: its approval is the question that turn asks and nothing is
52
+ reported as done or tested.
53
+ Finish the issue in hand before raising a second one; an error states
51
54
  location, cause and fix with no drama, naming a cause only when the evidence
52
55
  identifies it (else what is known and what would confirm it); after a change say
53
56
  what now works. A list shows at most five items, ranked, the rest kept in
@@ -112,15 +115,17 @@ lines: item - blocker - evidence - what unblocks it. No blockers: no heading.
112
115
  Work and process - the order, "shall I go on", a check, a plan, a consult
113
116
  member, a spend within `spend_cap`, a local merge of the session's branch,
114
117
  removing its worktree - are taken and stated in one line, never asked; a taste
115
- or intent fork goes in the batch with a default taken this turn, but building a
116
- plan this turn wrote waits for the answer. An outward step
118
+ or intent fork goes in the batch with a default taken this turn, but the
119
+ never-default items below wait for the answer. An outward step
117
120
  on the user's own remote (push, PR, release, their package) runs on a grant
118
121
  ("push without asking", "I approve", "bana sormadan pushla", "onay veriyorum"), else is asked once. Never on a
119
122
  default - ask and wait for an answer naming action and target: a force push,
120
123
  deleting what the session did not make, production, a credential, a third
121
- party, a spend over the cap. The batch: one "Approval questions"/"Onay için sorular" heading, waiting
124
+ party, a spend over the cap, building a plan this turn wrote, picking a
125
+ recommended variant, a way around a tezgah refusal, another session's work. The batch: one "Approval questions"/"Onay için sorular" heading, waiting
122
126
  items first, each numbered item with "Recommended:"/"Öneri:" its default; an ask tool
123
- sets a recommended option.
127
+ sets a recommended option. No fresh subagent, no review verdict: say so and
128
+ ask. Contest a finding only with a file:line; never lower a severity.
124
129
  **Say what a number was measured on.** A figure produced by a fixture - a temp
125
130
  HOME, a generated repository, a stand-in, a hand-written sample - is evidence
126
131
  about the code path, never a property of the running system, and it is reported
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r1z4x/tezgah",
3
- "version": "1.11.0",
3
+ "version": "1.13.0",
4
4
  "description": "One shared working contract for every AI coding assistant you run: Claude Code, Codex, Cursor, opencode, dsh and oh-my-pi (omp).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -66,6 +66,8 @@ is reported as such, and an empty list beside it is not evidence of absence.
66
66
 
67
67
  `graph-review` splits findings into `confirmed` (no refuter refuted it), `refuted` (do not report as bugs),
68
68
  and `unverified` (hit the cap, or every refuter failed - label them as such). Never promote a refuted or unverified finding.
69
+ Before the cap, findings with the same file, line and title merge into one. The merge keeps the higher severity
70
+ and lists every raising lens in `dimensions`; `caps.deduplicated` counts the merges.
69
71
 
70
72
  `graph-review` is one review round, not a loop: inside a plan it is round one or round two of the
71
73
  plan's single review (two rounds at most, `~/.config/tezgah/bin/tezgah-task review` refuses a third), never one run per
@@ -102,7 +102,10 @@ If something is left that only the reader can do - a decision, a credential or
102
102
  grant, consent for a step that cannot be undone - name that ONE step, small
103
103
  enough to start now. Several such decisions go in one numbered approval batch,
104
104
  each with a recommended default, and the work proceeds on the defaults (the
105
- tezgah contract's Ask policy). A step you can take yourself is not a next step:
105
+ tezgah contract's Ask policy) - except a class-F item (choosing a variant or
106
+ option a research, analysis or review recommends, a way around a tezgah
107
+ refusal, deciding about another session's work), which waits for the answer.
108
+ A step you can take yourself is not a next step:
106
109
  take it, and never end on asking whether to. When nothing is left, end on what
107
110
  now works.
108
111
 
@@ -110,6 +113,12 @@ Bad: "Hope that helps. Let me know if you want to dig deeper."
110
113
  Bad: "Next: run `npm test` and paste the first failing line." (you can run it)
111
114
  Good: "Blockers: the push to `main` - IRREVERSIBLE - `git push origin main` waits - your yes." (tr: "Engeller", GERİ ALINAMAZ)
112
115
 
116
+ In the host's plan mode nothing can run. There the plan is the deliverable:
117
+ the approval is the one step that is the reader's, every other step waits for
118
+ it, and nothing is reported as done or tested, because nothing ran. The Stop
119
+ rule reads the mode from the host and holds both ends of that
120
+ (`hooks/tezgah_integrity.py::read_only_turn`).
121
+
113
122
  ### 5. Suppress tangents
114
123
 
115
124
  A second issue waits until the first is finished, then arrives as its own
@@ -97,7 +97,11 @@ Input: `$ARGUMENTS` (free text describing the work). Run these steps in order.
97
97
  is refused while the tree is dirty, because the phase starts by recording
98
98
  `checkpoint:` - the commit the work returns to - and uncommitted work has none
99
99
  to record. Make the checkpoint commit it prints, then run the phase move
100
- again and the sha is written. Move the phase yourself as the work reaches it
100
+ again and the sha is written. Before `implementation`, spawn one
101
+ tezgah-reviewer in plan-readiness mode over the plan file. Fix each major
102
+ finding in the plan, or ask the owner when only the owner can decide, then
103
+ move the phase. A plan of one small task may skip this review; say so in
104
+ one line. Move the phase yourself as the work reaches it
101
105
  (`~/.config/tezgah/bin/tezgah-task phase implementation`, then `verification`); when a write is
102
106
  refused for its path and the plan's work genuinely reaches that file, widen
103
107
  the scope with `~/.config/tezgah/bin/tezgah-task allow <glob> ... --why "<reason>"`, which records the reason. Never retype the record:
@@ -653,18 +653,27 @@ opened under):
653
653
  | `criteria.json` | `question`, `baseline_variant` (V0, the status quo or current design) and `criteria[]`, each with `id` (`K1`), `name`, `kind` (`measured` or `judged`), `direction` (`max`, `min` or `pass`), optional `threshold` and `guardrail` |
654
654
  | `variants.jsonl` | one row per variant: `id`, `title`, `artifact` (a path in the line or the repository, `git:<branch>` or `orx:<expId>`), `status` (`produced` or `dropped` with `drop_reason`), and `line` when it is an older line's deliverable |
655
655
  | `comparison.jsonl` | one row per cell: `variant`, `criterion`, `value` with its `source`, or `not_checked` with the reason; `rater` on a judged cell. Append with `~/.config/tezgah/bin/tezgah-research compare <slug> <id>` (one JSON object on stdin), which refuses what `check` refuses |
656
- | `decision.md` | `chosen: V2`, one line per rejected id naming the criterion id that ruled it out, and `flip:` - the result that would reverse the choice |
656
+ | `decision.md` | `recommended: V2` (the line's pick), one line per rejected id naming the criterion id that ruled it out, and `flip:` - the result that would reverse the choice; `chosen:` and `owner:` only once the owner picks |
657
657
 
658
658
  `check` refuses: fewer produced variants than the deliverable takes; two produced
659
659
  files with the same content (a renamed copy is not an alternative); `criteria.json`
660
660
  not committed before the first `comparison.jsonl` commit; a comparison row changed
661
661
  after it was committed; a missing cell once `decision.md` exists or the line is
662
- delivering; a `chosen` that is not a produced variant, a rejected variant with no
663
- criterion, no `flip:`; and a chosen variant another one beats or ties on every
664
- numeric criterion. A judged cell with fewer than two raters warns (`--strict`
665
- refuses). A design or plan (and an analysis on a line before rules 5) with no
666
- `decisions/` warns while the line runs, is refused once it delivers, and keeps
667
- the line open.
662
+ delivering; a pick (`chosen:`, else `recommended:`) that is not a produced
663
+ variant, a rejected variant with no criterion, no `flip:`; and a picked variant
664
+ another one beats or ties on every numeric criterion. A judged cell with fewer
665
+ than two raters warns (`--strict` refuses). A design or plan (and an analysis on
666
+ a line before rules 5) with no `decisions/` warns while the line runs, is refused
667
+ once it delivers, and keeps the line open.
668
+
669
+ The line recommends; the owner chooses. Choosing a variant or option a research,
670
+ analysis or review recommends is the owner's call, so a rules-9 line writes
671
+ `recommended:` and never `chosen:` itself - a `chosen:` with no `owner:` line
672
+ fails `check`. Report the recommendation and wait for the answer; building it
673
+ waits too. When the owner answers, record their words:
674
+ `~/.config/tezgah/bin/tezgah-research choose <slug> <decision> <variant> --ack
675
+ "<the owner's words>"` writes `chosen:` and `owner:`, on an open or a done line.
676
+ `conclude` names each recommendation still waiting for the owner.
668
677
 
669
678
  When the variants are code or config, they are sibling orx nodes under one head
670
679
  node (orx cardinal rule 4: fan within a round, then descend onto the winner), and
@@ -681,8 +690,12 @@ into `state.json` `closed` and `log.md`, seals its experiments' file hashes
681
690
  way, and an edit after the seal fails `check`. The no-slug `check` reads a done
682
691
  line by its seal alone and counts it on one line; its other errors show in
683
692
  `check <slug>` and `check --all-lines`. `init --allow-open` is refused
684
- while an open line has `check` errors. A line moves to another checkout with
685
- `import`, never as a copy: a copy loses the order proof.
693
+ while an open line has `check` errors, other than a criterion whose verdict is
694
+ still `pending` (a line in flight). A line another session is running is not this
695
+ session's to fix or close: when a refusal names one, stop and ask the owner -
696
+ never a way around a tezgah refusal (another folder, a hidden dir). A hidden dir
697
+ under `research/` is not a line: `check` and `status` fail it. A line moves to
698
+ another checkout with `import`, never as a copy: a copy loses the order proof.
686
699
 
687
700
  Finally, a `## Patterns` bullet names what it generalises from - a claim id, a
688
701
  `literature/` note or a run - because a pattern is the one finding a reader carries
@@ -265,7 +265,10 @@ Routing: `~/.config/tezgah/bin/tezgah-route "<brief>"` names the worker - tezgah
265
265
  never the host's generic task agent (overrides for stored data, credentials,
266
266
  security first; then a Jev judgement; the static phase table with no key). A report missing an output field, or an
267
267
  `ESCALATE:`, gets one narrower re-brief of the same role naming that field or judgement; only a second miss is
268
- restarted on tezgah-frontier with the original brief, never continued; the main thread never switches model (the cache is per model). A
268
+ restarted on tezgah-frontier with the original brief, never continued; the main thread never switches model (the cache is per model).
269
+ A subagent's question only the owner can answer (a class-F item, `ESCALATE: owner`) is relayed to
270
+ the user in the approval batch: the orchestrator never answers it for the user
271
+ and never escalates it to a stronger model. A
269
272
  code-discovery brief names codegraph's tools (`codegraph callers`, `callees`,
270
273
  `impact`, `node`, `files`, `codegraph_explore`) and says "answer from
271
274
  the graph, grep only for literal text"; never a grep-only explorer (observed).
@@ -391,7 +394,11 @@ The reader has to act on the answer, and the friction between "got it" and
391
394
  line - never also narrate the plan as prose.
392
395
  4. End with the approval questions the Ask policy leaves (the executive mode's
393
396
  "Ask policy"), or with nothing when none is left. A step you can take
394
- yourself is taken, never handed over.
397
+ yourself is taken, never handed over: "run `pytest -q` and tell me what it
398
+ says" is a step you run. In the host's plan mode nothing can run, and there
399
+ the plan is the deliverable: the approval question is the one question that
400
+ turn asks, every step waits for it, and nothing is reported as done or
401
+ tested, because nothing ran.
395
402
  5. Suppress tangents: finish the issue in hand, then raise the second as its own
396
403
  question. A question the reader raised mid-work is not a tangent - answer it
397
404
  and fold the result in.
@@ -438,7 +445,8 @@ intuitive". Such a request is NEVER built from a guess. Before any code:
438
445
  3. Ask at most three blocking questions, the ones that change the outcome and
439
446
  that no default may take. Every other question goes in the approval batch
440
447
  with a recommended default, and the work proceeds on the defaults in the
441
- same turn (the executive mode's "Ask policy").
448
+ same turn (the executive mode's "Ask policy"); a class-F item waits for the
449
+ answer.
442
450
  4. Verify design, behavior and quality claims against an external source, never
443
451
  from memory alone: `~/.config/tezgah/bin/consult --online "<question>"` for a
444
452
  fast second opinion, or the OpenResearch CLI (`orx` on PATH, else
@@ -614,7 +622,12 @@ a third round.
614
622
  A round spawns its reviewers in ONE message, one per dimension (correctness
615
623
  and contract, security, tests and performance) or file group; a diff under 300
616
624
  changed lines gets one reviewer. Per-task work gets tests, not a reviewer, and
617
- `~/.config/tezgah/bin/tezgah-task review` refuses a third round.
625
+ `~/.config/tezgah/bin/tezgah-task review` refuses a third round. No fresh subagent, no review verdict:
626
+ on a host or mode that cannot start a fresh reviewer, say so and ask the owner;
627
+ never record a verdict from your own read. The author contests a confirmed
628
+ finding only by citing evidence the reviewer could read (file:line); knowledge
629
+ outside the files goes to the owner as a question, and the author never lowers
630
+ a severity.
618
631
 
619
632
  **The process is yours: never hand a step back.** The orchestrator runs the
620
633
  plan lifecycle itself - `~/.config/tezgah/bin/tezgah-task start`, every `phase` move, `review`,
@@ -642,13 +655,13 @@ whether the user already decided it (named it this session, or granted it).
642
655
  | C. Reversible choice the evidence settles | a fix's scope inside the ask, naming, a palette when tokens exist | decide from evidence; real taste goes in the batch with a default, taken this turn |
643
656
  | D. Intent fork | two readings of the ask with different deliverables | batch with a default; do the shared part first and proceed on the default |
644
657
  | E. Outward on the user's own remote | push, PR, tag, release, publishing the user's own package | act on a grant written in chat ("push without asking", "I approve", "bana sormadan pushla", "onay veriyorum"); else ask once per session, never again after a grant |
645
- | F. Never on a default | force push or history rewrite on a shared ref; deleting data the session did not make; production deploy, stop or migration; a credential, account or identity change; sending to a third party; a spend over `spend_cap` | do all other work first, ask, and wait: silence is no consent, and only an answer that names the action and its target clears it |
658
+ | F. Never on a default | force push or history rewrite on a shared ref; deleting data the session did not make; production deploy, stop or migration; a credential, account or identity change; sending to a third party; a spend over `spend_cap`; building a plan this turn wrote from a research, analysis, review or plan ask; choosing a variant or option a research, analysis or review recommends; a way around a tezgah refusal (another folder, a hidden dir, a hatch the owner did not grant); deciding about another session's work (its line, plan or branch) | do all other work first, ask, and wait: silence is no consent, and only an answer that names the action and its target clears it |
646
659
  | G. ACCESS | a credential or grant no tool supplies | take every route that needs none first, then ask once, naming what it unlocks |
647
660
 
648
661
  A grant is read from the user's prompt and recorded for the session; "artık
649
662
  bana sor", "ask me again" or a later "don't push" lifts it. The batch is one
650
663
  block in the reply language: one "Approval questions"/"Onay için sorular" heading, one line saying
651
- the work proceeds on the defaults when no answer comes, then numbered items -
664
+ the work proceeds on the defaults when no answer comes (a class-F item waits for its answer), then numbered items -
652
665
  the question in bold, one line of context, "Recommended:"/"Öneri:" the default, and
653
666
  the other option. Class-F items come first, marked as waiting; the defaulted
654
667
  items follow, with no count cap, because they do not block. An ask tool (omp
package/statusline.py CHANGED
@@ -3,6 +3,10 @@
3
3
 
4
4
  python3 statusline.py # Claude Code (or any Claude-format status line)
5
5
  python3 statusline.py --cursor # Cursor CLI (same stdin shape, no Orca passthrough)
6
+ python3 statusline.py --qwen # Qwen Code `ui.statusLine` command (same fields read)
7
+ python3 statusline.py --droid # Factory Droid `statusLine` command (`current_dir`)
8
+ python3 statusline.py --copilot # Copilot CLI `statusLine` command (fields undocumented)
9
+ python3 statusline.py --antigravity # Antigravity CLI `statusLine` command (`workspace`)
6
10
 
7
11
  Claude Code's statusLine pipes session JSON on stdin; Cursor's spec is aligned
8
12
  with it. This wraps Orca's own status line for Claude, then appends the tezgah
@@ -35,8 +39,9 @@ except Exception as exc:
35
39
 
36
40
  HOME = os.path.expanduser("~")
37
41
  ORCA = os.path.join(HOME, ".orca", "agent-hooks", "claude-statusline.sh")
38
- HOST = "cursor" if ("--cursor" in sys.argv or
39
- os.environ.get("TEZGAH_STATUS_HOST") == "cursor") else "claude"
42
+ HOST = next((h for h in ("cursor", "qwen", "droid", "copilot", "antigravity")
43
+ if "--" + h in sys.argv
44
+ or os.environ.get("TEZGAH_STATUS_HOST") == h), "claude")
40
45
 
41
46
  data = sys.stdin.read()
42
47
  try:
@@ -54,8 +59,11 @@ if HOST == "claude" and os.access(ORCA, os.X_OK):
54
59
  except Exception:
55
60
  orca_out = ""
56
61
 
57
- cwd = ((payload.get("workspace") or {}).get("current_dir")
58
- or payload.get("cwd") or "")
62
+ # Droid 0.251.0 sends `current_dir` at the top level (its status line snapshot
63
+ # carries session_id, transcript_path, current_dir, workspace, model)
64
+ workspace = payload.get("workspace")
65
+ cwd = ((workspace if isinstance(workspace, dict) else {}).get("current_dir")
66
+ or payload.get("current_dir") or payload.get("cwd") or "")
59
67
  try:
60
68
  real = os.path.realpath(cwd) if cwd else ""
61
69
  except OSError:
@@ -113,11 +121,14 @@ def claude_used():
113
121
  return used
114
122
 
115
123
 
116
- seen = used_kinds(payload.get("session_id")) if HOST == "cursor" else claude_used()
117
- # Cursor's line reads tezgah's store, which sees the tool calls its hook fires on
118
- # and nothing else; Claude's transcript carries the skill reads too.
119
- observable = TOOL_USE_MEASURES if HOST == "cursor" else None
120
- segs = health_segments(real or os.getcwd(), payload.get("session_id"),
124
+ # Copilot documents its status line input only as "session JSON", so its
125
+ # camelCase spelling is read too
126
+ session_id = payload.get("session_id") or payload.get("sessionId")
127
+ seen = used_kinds(session_id) if HOST != "claude" else claude_used()
128
+ # Cursor's, Qwen's, Droid's, Copilot's and Antigravity's lines read tezgah's store, which sees the tool calls their
129
+ # hook fires on and nothing else; Claude's transcript carries the skill reads too.
130
+ observable = TOOL_USE_MEASURES if HOST != "claude" else None
131
+ segs = health_segments(real or os.getcwd(), session_id,
121
132
  used_override=seen, observable=observable)
122
133
  # Claude Code and Cursor render ANSI; let a user or a plain terminal opt out.
123
134
  color = color_default()
@@ -114,7 +114,18 @@ const unknown = DIMENSIONS.filter((d, i) => !reviewed[i]).map(d => `review:${d.k
114
114
  const reviews = reviewed.filter(Boolean)
115
115
 
116
116
  const rank = { high: 0, medium: 1, low: 2 }
117
- const all = reviews.flatMap(r => r.findings || []).sort((a, b) => rank[a.severity] - rank[b.severity])
117
+ // two lenses often raise one defect: merge on (file, line, lowercased title), keep the higher severity
118
+ const byKey = new Map()
119
+ let raised = 0
120
+ reviewed.forEach((r, i) => ((r && r.findings) || []).forEach(f => {
121
+ raised++
122
+ const key = `${f.file}\u0000${f.line || ''}\u0000${String(f.title).trim().toLowerCase()}`
123
+ const prev = byKey.get(key)
124
+ const keep = prev && rank[prev.severity] <= rank[f.severity] ? prev : f
125
+ byKey.set(key, { ...keep, dimensions: [...new Set([...((prev && prev.dimensions) || []), DIMENSIONS[i].key])] })
126
+ }))
127
+ const all = [...byKey.values()].sort((a, b) => rank[a.severity] - rank[b.severity])
128
+ const caps = { max_verified: MAX_VERIFIED, raised, deduplicated: raised - all.length }
118
129
  if (!all.length) {
119
130
  const note = !unknown.length ? 'all four dimensions came back clean'
120
131
  : !reviews.length ? 'every dimension failed - nothing was reviewed; unknown, not clean'
@@ -124,7 +135,7 @@ if (!all.length) {
124
135
 
125
136
  const toVerify = all.slice(0, MAX_VERIFIED)
126
137
  if (all.length > toVerify.length) {
127
- log(`${all.length} findings raised; verifying the ${toVerify.length} most severe. NOT verified (reported as unverified): ${all.slice(MAX_VERIFIED).map(f => f.title).join('; ')}`)
138
+ log(`${all.length} distinct findings (${caps.deduplicated} duplicates merged); verifying the ${toVerify.length} most severe. NOT verified (reported as unverified): ${all.slice(MAX_VERIFIED).map(f => f.title).join('; ')}`)
128
139
  }
129
140
 
130
141
  phase('Verify')
@@ -167,4 +178,5 @@ return {
167
178
  unverified: unsettled.map(v => ({ ...v.finding, verdict: 'UNVERIFIED - every refuter failed' }))
168
179
  .concat(all.slice(MAX_VERIFIED).map(f => ({ ...f, verdict: 'UNVERIFIED - budget cap' }))),
169
180
  unknown,
181
+ caps,
170
182
  }