@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
@@ -100,10 +100,51 @@ HOST_DIRS = {
100
100
  "opencode": os.path.join(XDG_CONFIG, "opencode"),
101
101
  "dsh": os.environ.get("DSH_HOME") or os.path.join(HOME, ".dsh"),
102
102
  "omp": os.path.join(HOME, ".omp"),
103
+ # qwen 0.21.13 reads a relocated home from QWEN_HOME (`getGlobalQwenDir`
104
+ # in its bundle), the same pattern as CODEX_HOME
105
+ "qwen": os.environ.get("QWEN_HOME") or os.path.join(HOME, ".qwen"),
106
+ # Factory Droid documents no relocated home: hooks.json, AGENTS.md and skills
107
+ # live under ~/.factory (docs.factory.com harness/hooks, agents-md, skills)
108
+ "droid": os.path.join(HOME, ".factory"),
109
+ # Command Code resolves ~/.commandcode from HOME alone (docs/settings,
110
+ # "Environment variables"): settings.json, AGENTS.md and skills live there
111
+ "commandcode": os.path.join(HOME, ".commandcode"),
112
+ # Copilot CLI reads a relocated home from COPILOT_HOME, which replaces the
113
+ # whole ~/.copilot path (docs.github.com cli-config-dir-reference)
114
+ "copilot": os.environ.get("COPILOT_HOME") or os.path.join(HOME, ".copilot"),
115
+ # Gemini CLI puts `.gemini` inside GEMINI_CLI_HOME, else the home dir
116
+ # (geminicli configuration "Environment variables"); a GEMINI_CLI_* name
117
+ # survives the sanitizer, so the hook sees it too. Antigravity shares
118
+ # ~/.gemini, so a machine with only Antigravity reads as Gemini-installed.
119
+ "gemini": os.path.join(os.environ.get("GEMINI_CLI_HOME") or HOME, ".gemini"),
120
+ # Antigravity CLI's own app dir (settings.json, skills); its hooks live in
121
+ # ~/.gemini/config/hooks.json and its rules in ~/.gemini/GEMINI.md, one level
122
+ # up. No relocated home is documented (antigravity.google/docs/settings)
123
+ "antigravity": os.path.join(HOME, ".gemini", "antigravity-cli"),
124
+ # Goose's config dir on macOS and Linux (docs guides/config-files): its
125
+ # global `.goosehints` lives here. Its hooks and skills come from a plugin
126
+ # under ~/.agents/plugins (guides/context-engineering/plugins), outside it.
127
+ # GOOSE_PATH_ROOT is a test/dev override whose effect on the hints file is
128
+ # undocumented, so it is not followed
129
+ "goose": os.path.join(HOME, ".config", "goose"),
130
+ # Amp's user dir: settings.json and AGENTS.md are HOME-literal in its docs
131
+ # (ampcode.com/docs cli/settings, customize/agents-md); skills under it too
132
+ "amp": os.path.join(HOME, ".config", "amp"),
103
133
  }
104
- # a host can be installed with no config dir yet (a CLI on PATH is enough)
134
+ # tezgah's own Goose plugin, wholly tezgah's: plugin.json, hooks/hooks.json and
135
+ # skill links (goose docs guides/context-engineering/plugins "Plugin Locations")
136
+ GOOSE_PLUGIN = os.path.join(HOME, ".agents", "plugins", "tezgah")
137
+ # tezgah's Amp system plugin, one file: Amp follows XDG_CONFIG_HOME for its
138
+ # plugin dir alone (ampcode.com/docs customize/plugins "Plugin Locations")
139
+ AMP_PLUGIN = os.path.join(XDG_CONFIG, "amp", "plugins", "tezgah.ts")
140
+ # a host can be installed with no config dir yet (a CLI on PATH is enough).
141
+ # Command Code's short alias `cmd` is Windows' own shell there, so only its
142
+ # full name counts (docs/quickstart)
105
143
  HOST_BINS = {"cursor": ("cursor-agent", "cursor"), "opencode": ("opencode",),
106
- "dsh": ("dsh",), "omp": ("omp",)}
144
+ "dsh": ("dsh",), "omp": ("omp",), "qwen": ("qwen",), "droid": ("droid",),
145
+ "commandcode": ("command-code",), "copilot": ("copilot",),
146
+ "gemini": ("gemini",), "antigravity": ("agy",), "goose": ("goose",),
147
+ "amp": ("amp",)}
107
148
 
108
149
 
109
150
  def host_installed(name):
@@ -78,7 +78,11 @@ The reader has to act on the answer, and the friction between "got it" and
78
78
  line - never also narrate the plan as prose.
79
79
  4. End with the approval questions the Ask policy leaves (the executive mode's
80
80
  "Ask policy"), or with nothing when none is left. A step you can take
81
- yourself is taken, never handed over.
81
+ yourself is taken, never handed over: "run `pytest -q` and tell me what it
82
+ says" is a step you run. In the host's plan mode nothing can run, and there
83
+ the plan is the deliverable: the approval question is the one question that
84
+ turn asks, every step waits for it, and nothing is reported as done or
85
+ tested, because nothing ran.
82
86
  5. Suppress tangents: finish the issue in hand, then raise the second as its own
83
87
  question. A question the reader raised mid-work is not a tangent - answer it
84
88
  and fold the result in.
@@ -123,7 +127,8 @@ intuitive". Such a request is NEVER built from a guess. Before any code:
123
127
  3. Ask at most three blocking questions, the ones that change the outcome and
124
128
  that no default may take. Every other question goes in the approval batch
125
129
  with a recommended default, and the work proceeds on the defaults in the
126
- same turn (the executive mode's "Ask policy").
130
+ same turn (the executive mode's "Ask policy"); a class-F item waits for the
131
+ answer.
127
132
  4. Verify design, behavior and quality claims against an external source, never
128
133
  from memory alone: `{CONSULT_BIN} --online "<question>"` for a fast second
129
134
  opinion, or the OpenResearch CLI (`{ORX_BIN}`) when it is a real
@@ -286,7 +291,12 @@ after round two what is left is fixed by the session and closed with
286
291
  A round spawns its reviewers in ONE message, one per dimension (correctness
287
292
  and contract, security, tests and performance) or file group; a diff under 300
288
293
  changed lines gets one reviewer. Per-task work gets tests, not a reviewer, and
289
- `{TASK_BIN} review` refuses a third round.
294
+ `{TASK_BIN} review` refuses a third round. No fresh subagent, no review verdict:
295
+ on a host or mode that cannot start a fresh reviewer, say so and ask the owner;
296
+ never record a verdict from your own read. The author contests a confirmed
297
+ finding only by citing evidence the reviewer could read (file:line); knowledge
298
+ outside the files goes to the owner as a question, and the author never lowers
299
+ a severity.
290
300
 
291
301
  **The process is yours: never hand a step back.** The orchestrator runs the
292
302
  plan lifecycle itself - `{TASK_BIN} start`, every `phase` move, `review`,
@@ -314,13 +324,13 @@ whether the user already decided it (named it this session, or granted it).
314
324
  | 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 |
315
325
  | 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 |
316
326
  | 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 ({GRANT_EXAMPLES}); else ask once per session, never again after a grant |
317
- | 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 | 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 |
327
+ | 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 |
318
328
  | G. {ACCESS} | a credential or grant no tool supplies | take every route that needs none first, then ask once, naming what it unlocks |
319
329
 
320
330
  A grant is read from the user's prompt and recorded for the session; "artık
321
331
  bana sor", "ask me again" or a later "don't push" lifts it. The batch is one
322
332
  block in the reply language: one "{APPROVAL_HEADING}" heading, one line saying
323
- the work proceeds on the defaults when no answer comes, then numbered items -
333
+ the work proceeds on the defaults when no answer comes (a class-F item waits for its answer), then numbered items -
324
334
  the question in bold, one line of context, "{RECOMMENDED}:" the default, and
325
335
  the other option. Class-F items come first, marked as waiting; the defaulted
326
336
  items follow, with no count cap, because they do not block. An ask tool (omp
@@ -415,7 +425,10 @@ Routing: `{ROUTE_BIN} "<brief>"` names the worker - tezgah-cheap, -standard or
415
425
  never the host's generic task agent (overrides for stored data, credentials,
416
426
  security first; then a Jev judgement; the static phase table with no key). A report missing an output field, or an
417
427
  `ESCALATE:`, gets one narrower re-brief of the same role naming that field or judgement; only a second miss is
418
- restarted on tezgah-frontier with the original brief, never continued; the main thread never switches model (the cache is per model). A
428
+ restarted on tezgah-frontier with the original brief, never continued; the main thread never switches model (the cache is per model).
429
+ A subagent's question only the owner can answer (a class-F item, `ESCALATE: owner`) is relayed to
430
+ the user in the approval batch: the orchestrator never answers it for the user
431
+ and never escalates it to a stronger model. A
419
432
  code-discovery brief names codegraph's tools (`codegraph callers`, `callees`,
420
433
  `impact`, `node`, `files`, `codegraph_explore`) and says "answer from
421
434
  the graph, grep only for literal text"; never a grep-only explorer (observed).
@@ -839,7 +852,10 @@ bounded action per step, and while it is in flight its position is restated in
839
852
  one line - the todo list is that source, never re-narrate the plan. End with the
840
853
  approval questions the Ask policy (below) leaves, or with nothing when none is
841
854
  left; a step you can take yourself is taken, never handed
842
- over. Finish the issue in hand before raising a second one; an error states
855
+ over, and in the host's plan mode, where nothing can run, the plan is the
856
+ deliverable: its approval is the question that turn asks and nothing is
857
+ reported as done or tested.
858
+ Finish the issue in hand before raising a second one; an error states
843
859
  location, cause and fix with no drama, naming a cause only when the evidence
844
860
  identifies it (else what is known and what would confirm it); after a change say
845
861
  what now works. A list shows at most five items, ranked, the rest kept in
@@ -904,15 +920,17 @@ lines: item - blocker - evidence - what unblocks it. No blockers: no heading.
904
920
  Work and process - the order, "shall I go on", a check, a plan, a consult
905
921
  member, a spend within `spend_cap`, a local merge of the session's branch,
906
922
  removing its worktree - are taken and stated in one line, never asked; a taste
907
- or intent fork goes in the batch with a default taken this turn, but building a
908
- plan this turn wrote waits for the answer. An outward step
923
+ or intent fork goes in the batch with a default taken this turn, but the
924
+ never-default items below wait for the answer. An outward step
909
925
  on the user's own remote (push, PR, release, their package) runs on a grant
910
926
  ({GRANT_EXAMPLES}), else is asked once. Never on a
911
927
  default - ask and wait for an answer naming action and target: a force push,
912
928
  deleting what the session did not make, production, a credential, a third
913
- party, a spend over the cap. The batch: one "{APPROVAL_HEADING}" heading, waiting
929
+ party, a spend over the cap, building a plan this turn wrote, picking a
930
+ recommended variant, a way around a tezgah refusal, another session's work. The batch: one "{APPROVAL_HEADING}" heading, waiting
914
931
  items first, each numbered item with "{RECOMMENDED}:" its default; an ask tool
915
- sets a recommended option.
932
+ sets a recommended option. No fresh subagent, no review verdict: say so and
933
+ ask. Contest a finding only with a file:line; never lower a severity.
916
934
  **Say what a number was measured on.** A figure produced by a fixture - a temp
917
935
  HOME, a generated repository, a stand-in, a hand-written sample - is evidence
918
936
  about the code path, never a property of the running system, and it is reported
@@ -1117,7 +1135,9 @@ edit one file - and say which. The todo list is the step queue: dispatch every
1117
1135
  ready step at once and mark each one blocked on its slice, never `wait` while
1118
1136
  a step is ready, and once every open step is blocked on a running slice,
1119
1137
  `wait` or end the turn. A subagent does its own slice and
1120
- never fans out again. Off: `orchestrate-off`.
1138
+ never fans out again; its question only the owner can answer (a never-default
1139
+ item) goes to the user in the approval batch, never answered by the
1140
+ orchestrator or escalated to a stronger model. Off: `orchestrate-off`.
1121
1141
 
1122
1142
  **Kill switches:** each one removes its own rule from this text, not just the
1123
1143
  status mark. `~/.config/tezgah/`: `exec-mode.off`, `orchestrate-off`,
@@ -1187,7 +1207,9 @@ irreversible calls; done/tested claims need observed
1187
1207
  evidence -> the gate denies a neutered check (`--no-verify`, `|| true`, a new
1188
1208
  test skip) and the Stop hook on Claude/Codex/Cursor/omp blocks an unverified
1189
1209
  "done"; never end on a question you can decide - ask only in one batch, each
1190
- with a recommended default; leftover work is done now, not listed - only a {DECISION}/{ACCESS}/{IRREVERSIBLE}/{ENV}
1210
+ with a recommended default; a research/analysis/review recommendation, a way around a
1211
+ tezgah refusal and another session's work wait for the owner's answer;
1212
+ leftover work is done now, not listed - only a {DECISION}/{ACCESS}/{IRREVERSIBLE}/{ENV}
1191
1213
  blocker with its evidence goes under "{BLOCKERS_HEADING}"; no
1192
1214
  AI/model attribution in any persisted or published artifact. Full
1193
1215
  detail: the tezgah-contract skill.{PONY_LEVEL} Kill switches under ~/.config/tezgah/.
@@ -469,6 +469,9 @@ def build_corpus(cutoff, since=None):
469
469
  "live_rule": rule_of(detail) if kind == "deny"
470
470
  else detail.split(":", 1)[1].strip() if kind == "claim"
471
471
  and detail.startswith("blocked:") else None,
472
+ # the host's permission mode, written on the row of a
473
+ # read-only turn only (plan 142): the verdict depended on it
474
+ "mode": row.get("mode"),
472
475
  "tool": row.get("tool"), "join": None}
473
476
  if kind == "claim":
474
477
  replies = [{"ts": ts, "text": text, "cwd": where} for ts, text, where in
@@ -586,8 +589,11 @@ def _replay_one(it, prefix, tg, seen, live_events):
586
589
 
587
590
  ti.events = events
588
591
  try:
589
- cls, _reason = ti._stop_block(it["text"], it["sid"], rows=turn,
590
- cwd=it["cwd"])
592
+ # a plan-mode turn is judged as one (plan 142): the mode rides
593
+ # the claim row this item came from
594
+ cls, _reason = ti._stop_block(
595
+ it["text"], it["sid"], rows=turn, cwd=it["cwd"],
596
+ read_only=ti.read_only_turn(it.get("mode")))
591
597
  finally:
592
598
  ti.events = live_events
593
599
  out.update(replay="block" if cls else "allow", rule=cls,
@@ -143,7 +143,7 @@ SOURCE_CLASSES = ("formal", "grey", "agent-report")
143
143
  # that owes no variants), 6 the selection warnings (`_check_selection`: the
144
144
  # held-out set, repeats per arm, the noise band), 7 the exploratory method (an
145
145
  # arena locked before the first idea, the idea/stage/tournament rows and their
146
- # rules, `_check_exploratory`). A `rules` above `EVIDENCE_RULES` is
146
+ # rules, `_check_exploratory`). A `rules` above `RULES` is
147
147
  # a line a later tezgah wrote, and every reader and writer here refuses it by name
148
148
  # (`newer_rules`) rather than judging it by rules it does not know.
149
149
  STANDARDS_RULES = 2
@@ -152,10 +152,13 @@ SCOPE_RULES = 5
152
152
  HOLDOUT_RULES = 6
153
153
  EXPLORE_RULES = 7
154
154
  # Rules 8 (plan 127): evidence captured by the tool and claims narrowed rather
155
- # than lines refused. `init` writes it since the plan's last wave moved `RULES`
156
- # here.
155
+ # than lines refused.
157
156
  EVIDENCE_RULES = 8
158
- RULES = EVIDENCE_RULES
157
+ # Rules 9 (plan 136): a decision.md writes the line's own pick as `recommended:`;
158
+ # `chosen:` is the owner's, recorded by `choose` with an `owner:` line carrying
159
+ # the owner's words. `init` writes 9.
160
+ OWNER_CHOICE_RULES = 9
161
+ RULES = OWNER_CHOICE_RULES
159
162
  # How a rules-5 line answers its question. `qualitative` is a source or
160
163
  # reference review and owes no metric; `measured` runs experiments and locks
161
164
  # one. The question picks it, not the tooling: a qualitative ask answered with a
@@ -362,6 +365,27 @@ def slugs(repo):
362
365
  return sorted(seen)
363
366
 
364
367
 
368
+ def stray_dirs(repo):
369
+ """[(path, error)] for the hidden dirs under research/, its open/ or its done/:
370
+ `slugs` skips them, so a line written into one (a `.staging` beside a refusal)
371
+ is invisible to every check. `status` and the no-slug `check` report each."""
372
+ out = []
373
+ for folder in layout_roots(repo):
374
+ try:
375
+ names = sorted(os.listdir(folder))
376
+ except OSError:
377
+ continue
378
+ for name in names:
379
+ path = os.path.join(folder, name)
380
+ if name.startswith(".") and os.path.isdir(path):
381
+ rel = os.path.relpath(path, repo)
382
+ out.append((rel, "%s is not a research line: a line lives under "
383
+ "research/open or research/done; import it with "
384
+ "`tezgah-research init` then move its files, or "
385
+ "delete it" % rel))
386
+ return out
387
+
388
+
365
389
  def line_dir(repo, slug):
366
390
  """The one resolver: the first layout root that holds the line, else the
367
391
  `open/` path a new line is created at."""
@@ -971,6 +995,13 @@ def _check_state(base, errors, warnings, strict):
971
995
  _check_derived_phase(base, state.get("phase"), state.get("direction"), warnings)
972
996
 
973
997
 
998
+ def _verdict_error(sid, verdict):
999
+ """The error a verdict outside `VERDICTS` gets; `broken_open_lines` builds the
1000
+ same text for a `pending` one, so an in-flight line is told from a broken one."""
1001
+ return "success criterion %s: verdict %r is not one of %s" % (
1002
+ sid, verdict, ", ".join(VERDICTS))
1003
+
1004
+
974
1005
  def _check_success(state, errors, warnings, strict):
975
1006
  """The ask contract, on a line that states an ask (rules 3 onward).
976
1007
 
@@ -1002,8 +1033,7 @@ def _check_success(state, errors, warnings, strict):
1002
1033
  if not sid or not criterion:
1003
1034
  errors.append("a success criterion carries no id or no text")
1004
1035
  if verdict and verdict not in VERDICTS:
1005
- errors.append("success criterion %s: verdict %r is not one of %s"
1006
- % (sid, verdict, ", ".join(VERDICTS)))
1036
+ errors.append(_verdict_error(sid, verdict))
1007
1037
  if verdict in VERDICTS and not evidence:
1008
1038
  errors.append("success criterion %s: a verdict with no evidence "
1009
1039
  "pointer is an opinion" % sid)
@@ -2828,16 +2858,16 @@ def _state(base):
2828
2858
  def newer_rules(state):
2829
2859
  """The refusal for a line a later tezgah wrote, or None.
2830
2860
 
2831
- A `rules` above `EVIDENCE_RULES` names rules this code does not implement.
2861
+ A `rules` above `RULES` names rules this code does not implement.
2832
2862
  Reading the line by the rules it does know would pass what the later set
2833
2863
  refuses, and a writer would append rows judged by the older set. So the line
2834
2864
  is refused by name, before any rule reads it."""
2835
2865
  found = _rules(state)
2836
- if found <= EVIDENCE_RULES:
2866
+ if found <= RULES:
2837
2867
  return None
2838
2868
  return ("state.json rules %d is newer than the rules this tezgah implements (%d): "
2839
2869
  "a later tezgah wrote this line, so it is refused rather than read as if "
2840
- "current - upgrade tezgah to read or write it" % (found, EVIDENCE_RULES))
2870
+ "current - upgrade tezgah to read or write it" % (found, RULES))
2841
2871
 
2842
2872
 
2843
2873
  def _concluding(base, state):
@@ -3244,6 +3274,34 @@ def _mentions(token, text):
3244
3274
  return re.search(r"(?<![\w.-])%s(?![\w.-])" % re.escape(token), text) is not None
3245
3275
 
3246
3276
 
3277
+ # A decision.md's pick: `chosen:` is the owner's, `recommended:` the line's own
3278
+ # (rules 9); every reader takes `chosen:` when present, else `recommended:`.
3279
+ PICK = r"(?im)^\W*%s\W*:\s*`?([\w.-]+)"
3280
+ # The `owner:` line `choose` writes: a date and the owner's words, quoted.
3281
+ OWNER_WORDS = re.compile(r'(?im)^\W*owner\W*:[^\n]*"[^"\n]*\S[^"\n]*"')
3282
+
3283
+
3284
+ def decision_pick(body):
3285
+ """(variant, "chosen" or "recommended") for a decision.md body, else (None, None)."""
3286
+ for field in ("chosen", "recommended"):
3287
+ found = re.search(PICK % field, body)
3288
+ if found:
3289
+ return found.group(1), field
3290
+ return None, None
3291
+
3292
+
3293
+ def line_pick(body):
3294
+ """The line's own pick, which the line's rules judge (rejected variants,
3295
+ dominance, post-hoc): `recommended:`, else a `chosen:` with no owner's words.
3296
+ An owner's `chosen:` is the owner's call, so a dominated or post-hoc owner
3297
+ choice is never the line's error."""
3298
+ found = re.search(PICK % "recommended", body)
3299
+ if found:
3300
+ return found.group(1)
3301
+ found = re.search(PICK % "chosen", body)
3302
+ return found.group(1) if found and not OWNER_WORDS.search(body) else None
3303
+
3304
+
3247
3305
  def _check_decision(repo, base, name, ddir, need, errors, warnings, strict, git,
3248
3306
  concluding):
3249
3307
  label = "decisions/%s" % name
@@ -3327,13 +3385,19 @@ def _check_decision(repo, base, name, ddir, need, errors, warnings, strict, git,
3327
3385
  "says what ruled it out" % label)
3328
3386
  return
3329
3387
  body = _note_text(os.path.join(ddir, "decision.md"))
3330
- found = re.search(r"(?im)^\W*chosen\W*:\s*`?([\w.-]+)", body)
3331
- chosen = found.group(1) if found else None
3388
+ chosen, field = decision_pick(body)
3332
3389
  if chosen not in produced:
3333
3390
  errors.append("%s: decision.md chosen %r is not a produced variant"
3334
3391
  % (label, chosen))
3392
+ if field == "chosen" and _rules(_state(base)) >= OWNER_CHOICE_RULES \
3393
+ and not OWNER_WORDS.search(body):
3394
+ errors.append("%s: decision.md chosen %s names no owner choice: the line "
3395
+ "writes recommended:, and the owner's pick is recorded with "
3396
+ "tezgah-research choose" % (label, chosen))
3397
+ pick = line_pick(body)
3398
+ owned = chosen if field == "chosen" and OWNER_WORDS.search(body) else None
3335
3399
  for vid in produced:
3336
- if vid == chosen:
3400
+ if vid in (pick, owned):
3337
3401
  continue
3338
3402
  lines = [line for line in body.splitlines() if _mentions(vid, line)]
3339
3403
  if not any(_mentions(cid, line) for line in lines for cid in criteria):
@@ -3342,12 +3406,12 @@ def _check_decision(repo, base, name, ddir, need, errors, warnings, strict, git,
3342
3406
  if not re.search(r"(?im)^\W*flip\W*:", body):
3343
3407
  errors.append("%s: decision.md names no `flip:` condition - the result that "
3344
3408
  "would reverse the choice" % label)
3345
- if chosen in produced:
3346
- better = dominated_by(chosen, produced, criteria, cells)
3409
+ if pick in produced:
3410
+ better = dominated_by(pick, produced, criteria, cells)
3347
3411
  if better:
3348
3412
  errors.append("%s: the chosen variant %s is dominated by %s, which is at "
3349
3413
  "least as good on every criterion and better on one"
3350
- % (label, chosen, better))
3414
+ % (label, pick, better))
3351
3415
 
3352
3416
 
3353
3417
  def _deliverable_path(state):
@@ -4534,12 +4598,12 @@ def _core_rules8(repo, slug, base, state, errors, warnings, strict, git, steps,
4534
4598
  if git:
4535
4599
  for name, ddir in _decision_dirs(base):
4536
4600
  body = _note_text(os.path.join(ddir, "decision.md"))
4537
- found = re.search(r"(?im)^\W*chosen\W*:\s*`?([\w.-]+)", body)
4538
- if found and found.group(1) in _post_hoc(repo, ddir) \
4601
+ picked = line_pick(body)
4602
+ if picked and picked in _post_hoc(repo, ddir) \
4539
4603
  and "post-hoc" not in body and "post_hoc" not in body:
4540
4604
  errors.append("decisions/%s: decision.md chooses %s, a variant first "
4541
4605
  "committed after the first comparison cell, without saying "
4542
- "post-hoc" % (name, found.group(1)))
4606
+ "post-hoc" % (name, picked))
4543
4607
  related = {(str(r.get("line")), str(r.get("claim")))
4544
4608
  for r in _row_objects(os.path.join(base, "related.jsonl"))
4545
4609
  if r.get("relation") in ("builds-on", "dismissed")}
@@ -4734,8 +4798,11 @@ def check(repo, slug=None, git=True, strict=False, all_lines=True, code=None, fl
4734
4798
  With `all_lines=False` and no slug, a line under `done/` is read by its order
4735
4799
  seal alone (`done_seal`), not re-checked, and appears only when that read
4736
4800
  fails: the default no-slug `check` and the session note report the open
4737
- lines, and `check --all-lines` re-runs the closed ones (ADR 015)."""
4738
- out = {}
4801
+ lines, and `check --all-lines` re-runs the closed ones (ADR 015). With no
4802
+ slug, each hidden dir under research/ (`stray_dirs`) is a row of its own,
4803
+ keyed by its path."""
4804
+ out = {} if slug else {path: {"errors": [error], "warnings": []}
4805
+ for path, error in stray_dirs(repo)}
4739
4806
  for name in ([slug] if slug else slugs(repo)):
4740
4807
  if not slug and not all_lines and sealed(repo, name):
4741
4808
  errors = done_seal(repo, name)[0]
@@ -5044,14 +5111,14 @@ def import_line(repo, source, slug=None, allow_open=None, date=""):
5044
5111
  unfinished = open_lines(repo) if arriving else []
5045
5112
  if (unfinished or len(arriving) > 1) and allow_open is None:
5046
5113
  return [], ("%s would open beside unfinished line(s) %s; say why with "
5047
- "--allow-open \"<reason>\"" % (
5114
+ "--allow-open \"<reason>\"; %s" % (
5048
5115
  ", ".join(arriving),
5049
5116
  "; ".join("%s (%s)" % (n, "; ".join(r)) for n, r in unfinished)
5050
- or ", ".join(arriving)))
5117
+ or ", ".join(arriving), OWNER_ASK))
5051
5118
  broken = broken_open_lines(repo) if unfinished else []
5052
5119
  if broken:
5053
5120
  return [], ("--allow-open does not open a line beside an open line check "
5054
- "refuses: %s" % ", ".join(n for n, _e in broken))
5121
+ "refuses: %s; %s" % (", ".join(n for n, _e in broken), OWNER_ASK))
5055
5122
  if fresh:
5056
5123
  done = tp.ws_git(repo, "commit", "-q", "--allow-empty", "-m",
5057
5124
  "research: workspace root")
@@ -5179,10 +5246,16 @@ def _variants_owed(base, state):
5179
5246
  def broken_open_lines(repo):
5180
5247
  """[(slug, errors)] for the open lines `check` refuses: what `init
5181
5248
  --allow-open` will not open a new line beside, because a hatch past broken
5182
- lines is how they stayed broken."""
5249
+ lines is how they stayed broken. A criterion whose verdict is still
5250
+ `pending` is a line in flight, not a broken one, so that error alone does
5251
+ not block: another session may be running the line."""
5183
5252
  out = []
5184
5253
  for slug, _reasons in open_lines(repo):
5185
5254
  errors, _ = check_line(repo, slug)
5255
+ pending = {_verdict_error(sid, verdict) for sid, _c, verdict, _e
5256
+ in success_rows(line_state(repo, slug).get("success"))
5257
+ if verdict == "pending"}
5258
+ errors = [e for e in errors if e not in pending]
5186
5259
  if errors:
5187
5260
  out.append((slug, errors))
5188
5261
  return out
@@ -5295,6 +5368,75 @@ def conclude_line(repo, slug, date=""):
5295
5368
  return ([problem] if problem else []), state
5296
5369
 
5297
5370
 
5371
+ # The line every refusal over open lines carries (`init`, `import`): deciding
5372
+ # about another session's work (its line, plan or branch) is the owner's.
5373
+ OWNER_ASK = ("a line another session is running is not this session's to fix or "
5374
+ "close: stop and ask the owner (a decision only the owner makes), never "
5375
+ "a way around a tezgah refusal (another folder, a hidden dir)")
5376
+
5377
+
5378
+ def choose_line(repo, slug, decision, variant, ack, date=""):
5379
+ """The problem that stopped the owner's choice being written, or None.
5380
+
5381
+ A line recommends; the owner chooses (rules 9). `chosen: <variant>` and
5382
+ `owner: <date> "<words>"` replace any earlier pair in the decision's
5383
+ decision.md; the line's own pick stays as written, and a pick a rules-8 line
5384
+ wrote as `chosen:` is renamed `recommended:`. log.md gets one line. A done
5385
+ line takes it too: its order seal hashes the experiments alone, so the
5386
+ decision note stays writable."""
5387
+ words = " ".join(str(ack or "").replace('"', "'").split())
5388
+ if not words:
5389
+ return ("choose needs --ack \"<the owner's words>\": choosing a variant or "
5390
+ "option a research, analysis or review recommends is the owner's call")
5391
+ base = line_dir(repo, slug)
5392
+ newer = newer_rules(_state(base))
5393
+ if newer:
5394
+ return newer
5395
+ ddir = dict(_decision_dirs(base)).get(decision)
5396
+ path = os.path.join(ddir or "", "decision.md")
5397
+ if not ddir or not os.path.isfile(path):
5398
+ return "decisions/%s has no decision.md in %s" % (decision, slug)
5399
+ _criteria, variants, _problems = load_decision(ddir)
5400
+ produced = sorted(v for v, row in variants.items() if row.get("status") == "produced")
5401
+ if variant not in produced:
5402
+ return "variant %s is not produced in decisions/%s (produced: %s)" % (
5403
+ variant, decision, ", ".join(produced) or "none")
5404
+ body = _note_text(path)
5405
+ replace = OWNER_WORDS.search(body) or re.search(PICK % "recommended", body)
5406
+ kept = []
5407
+ for line in body.splitlines():
5408
+ if re.match(r"(?i)\W*owner\W*:", line):
5409
+ continue
5410
+ if re.match(r"(?i)\W*chosen\W*:", line):
5411
+ if replace:
5412
+ continue
5413
+ # the line wrote its own pick as `chosen:` (rules 8): it stays the
5414
+ # line's pick, for the line's rules to judge
5415
+ line = re.sub(r"(?i)chosen", "recommended", line, count=1)
5416
+ kept.append(line)
5417
+ try:
5418
+ with open(path, "w", encoding="utf-8") as fh:
5419
+ fh.write("\n".join(kept + ["chosen: %s" % variant,
5420
+ 'owner: %s "%s"' % (date, words)]) + "\n")
5421
+ with open(os.path.join(base, "log.md"), "a", encoding="utf-8") as fh:
5422
+ fh.write('- %s owner chose %s in decisions/%s: "%s"\n'
5423
+ % (date, variant, decision, words))
5424
+ except OSError as exc:
5425
+ return str(exc)
5426
+ return None
5427
+
5428
+
5429
+ def owner_waits(repo, slug):
5430
+ """[(decision, variant)] for each decision.md that recommends a variant and
5431
+ records no owner choice: what `conclude` tells the session to report."""
5432
+ out = []
5433
+ for name, ddir in _decision_dirs(line_dir(repo, slug)):
5434
+ picked, field = decision_pick(_note_text(os.path.join(ddir, "decision.md")))
5435
+ if field == "recommended":
5436
+ out.append((name, picked))
5437
+ return out
5438
+
5439
+
5298
5440
  def open_lines(repo):
5299
5441
  """[(slug, [reason, ...])] for every line that is not finished, for `init`.
5300
5442
 
@@ -5353,6 +5495,7 @@ def summary(repo):
5353
5495
  if fixtures:
5354
5496
  note += ", %d fixture-scoped claim(s): %s" % (len(fixtures), ", ".join(fixtures))
5355
5497
  out.append("%s: %s" % (slug, note))
5498
+ out.extend("FAIL %s" % error for _path, error in stray_dirs(repo))
5356
5499
  unfinished = open_note(repo)
5357
5500
  if unfinished:
5358
5501
  out.append(unfinished)