@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.
- package/CHANGELOG.md +333 -1
- package/MANIFEST +22 -0
- package/README.md +4 -3
- package/bin/tezgah-docs +1 -1
- package/bin/tezgah-research +39 -4
- package/bin/tezgah-setup +1229 -22
- package/bin/tezgah-task +55 -17
- package/docs/citations-baseline.json +1 -1
- package/docs/clarity-baseline.json +9 -3
- package/docs/contract.md +6 -3
- package/docs/environment.md +3 -2
- package/docs/evidence.md +81 -8
- package/docs/gate.md +6 -2
- package/docs/glossary.md +1 -1
- package/docs/hosts.md +297 -15
- package/docs/layers.md +1 -1
- package/docs/models.md +6 -0
- package/docs/operations.md +16 -5
- package/docs/research.md +36 -10
- package/docs/status-line.md +6 -0
- package/docs/testing.md +3 -1
- package/hooks/hooks.json +1 -1
- package/hooks/lang/en.json +11 -0
- package/hooks/lang/tr.json +11 -0
- package/hooks/projects-stop.py +2 -1
- package/hooks/tezgah_agents.py +27 -3
- package/hooks/tezgah_attest.py +79 -10
- package/hooks/tezgah_context.py +21 -8
- package/hooks/tezgah_gate.py +79 -23
- package/hooks/tezgah_integrity.py +212 -38
- package/hooks/tezgah_paths.py +43 -2
- package/hooks/tezgah_policy.py +35 -13
- package/hooks/tezgah_replay.py +8 -2
- package/hooks/tezgah_research.py +167 -24
- package/hooks/tezgah_stack.py +236 -1
- package/hooks/tezgah_task.py +2 -1
- package/hosts/amp/hook.py +217 -0
- package/hosts/amp/tezgah-plugin.ts.in +386 -0
- package/hosts/antigravity/hook.py +255 -0
- package/hosts/codex/hook.py +2 -1
- package/hosts/commandcode/hook.py +239 -0
- package/hosts/copilot/hook.py +284 -0
- package/hosts/cursor/hook.py +2 -1
- package/hosts/droid/hook.py +259 -0
- package/hosts/dsh/hooks.json +1 -1
- package/hosts/gemini/hook.py +213 -0
- package/hosts/goose/hook.py +145 -0
- package/hosts/omp/hook.py +4 -1
- package/hosts/omp/tezgah-hook.ts.in +30 -7
- package/hosts/opencode/plugins/tezgah.js +2 -2
- package/hosts/qwen/hook.py +206 -0
- package/output-styles/tezgah.md +10 -5
- package/package.json +1 -1
- package/skills/harness/SKILL.md +2 -0
- package/skills/i-have-adhd/SKILL.md +10 -1
- package/skills/plan-add/SKILL.md +5 -1
- package/skills/research/SKILL.md +22 -9
- package/skills/tezgah-contract/SKILL.md +19 -6
- package/statusline.py +20 -9
- package/workflows/graph-review.js +14 -2
package/hooks/tezgah_paths.py
CHANGED
|
@@ -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
|
-
#
|
|
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):
|
package/hooks/tezgah_policy.py
CHANGED
|
@@ -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).
|
|
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
|
|
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
|
|
908
|
-
|
|
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
|
|
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
|
|
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;
|
|
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/.
|
package/hooks/tezgah_replay.py
CHANGED
|
@@ -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
|
-
|
|
590
|
-
|
|
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,
|
package/hooks/tezgah_research.py
CHANGED
|
@@ -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 `
|
|
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.
|
|
156
|
-
# here.
|
|
155
|
+
# than lines refused.
|
|
157
156
|
EVIDENCE_RULES = 8
|
|
158
|
-
|
|
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(
|
|
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 `
|
|
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 <=
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
3346
|
-
better = dominated_by(
|
|
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,
|
|
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
|
-
|
|
4538
|
-
if
|
|
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,
|
|
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
|
-
|
|
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)
|