@r1z4x/tezgah 1.4.0 → 1.5.1
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 +120 -1
- package/MANIFEST +0 -1
- package/README.md +6 -4
- package/bin/tezgah-docs +3 -3
- package/bin/tezgah-route +12 -6
- package/bin/tezgah-setup +67 -24
- package/bin/tezgah-status +3 -1
- package/bin/tezgah-task +132 -39
- package/bin/tezgah-taste +49 -31
- package/bin/tezgah-triage +12 -7
- package/docs/architecture.md +1 -1
- package/docs/clarity-baseline.json +7 -7
- package/docs/contract.md +21 -11
- package/docs/environment.md +3 -1
- package/docs/evidence.md +4 -3
- package/docs/glossary.md +1 -1
- package/docs/hosts.md +9 -0
- package/docs/judge.md +95 -27
- package/docs/layers.md +1 -1
- package/docs/models.md +17 -7
- package/docs/operations.md +23 -13
- package/docs/skills.md +1 -1
- package/docs/testing.md +1 -1
- package/docs/vision.md +10 -5
- package/hooks/tezgah_agents.py +149 -105
- package/hooks/tezgah_context.py +42 -7
- package/hooks/tezgah_judge.py +252 -79
- package/hooks/tezgah_models.py +50 -3
- package/hooks/tezgah_paths.py +180 -27
- package/hooks/tezgah_policy.py +41 -10
- package/hooks/tezgah_replay.py +26 -15
- package/hooks/tezgah_task.py +160 -46
- package/hooks/tezgah_taste.py +2 -2
- package/hooks/tezgah_taste_ledger.py +13 -4
- package/hosts/codex/hook.py +5 -0
- package/package.json +1 -2
- package/skills/harness/SKILL.md +4 -0
- package/skills/plan-add/SKILL.md +23 -7
- package/skills/plan-add/render_table.py +3 -2
- package/skills/plan-status/SKILL.md +12 -4
- package/skills/plan-sync/SKILL.md +19 -9
- package/skills/tezgah-contract/SKILL.md +29 -14
- package/agents/tezgah-reviewer.md +0 -70
package/hooks/tezgah_agents.py
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Claude Code
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
2
|
+
"""Subagent definitions, generated from the detected infrastructure.
|
|
3
|
+
|
|
4
|
+
One role set, rendered into every host surface that actually exists, at the
|
|
5
|
+
place each host loads it from:
|
|
6
|
+
|
|
7
|
+
Claude Code `~/.claude/agents/*.md` user-level, written at install
|
|
8
|
+
Codex `$CODEX_HOME/agents/*.toml` user-level, written at install
|
|
9
|
+
omp `~/.omp/agent/agents/*.md` user-level, written at install
|
|
10
|
+
Cursor `.cursor/agents/*.md` per repo, at session start
|
|
11
|
+
opencode `.opencode/agents/*.md` (fallback) + config injection, per repo
|
|
12
|
+
|
|
13
|
+
Claude and Codex miss a repo agents dir written during their first session
|
|
14
|
+
(Claude does not watch a dir that was absent at start; Codex reads roles at
|
|
15
|
+
config load and ignores `.codex/agents` in an untrusted repo), so their roles
|
|
16
|
+
live user-level; a repo's `.claude/agents` and `.codex/agents` files an older
|
|
17
|
+
tezgah wrote are swept. Cursor's CLI reads no user dir, so it keeps a per-repo
|
|
18
|
+
dir. A role is only emitted when the capability it needs is present (the
|
|
19
|
+
reviewer needs the code graph); the three tier workers are emitted in every root,
|
|
20
|
+
because `tezgah-route` names one of them for every delegated task. The per-repo
|
|
21
|
+
set is regenerated when either this manifest
|
|
14
22
|
or the repo's infrastructure changes, so the definitions stay updatable instead
|
|
15
23
|
of drifting. Hosts with no such surface (dsh) get nothing here; the orchestrator
|
|
16
24
|
directive in the injected contract already covers them. The generated dirs are
|
|
@@ -30,16 +38,20 @@ import subprocess
|
|
|
30
38
|
import tezgah_models as tm
|
|
31
39
|
from tezgah_paths import (CONFIG_DIR, HOME, codegraph_bin, host_installed, off,
|
|
32
40
|
root_for)
|
|
41
|
+
from tezgah_paths import HOST_DIRS as HOST_HOMES
|
|
33
42
|
|
|
34
43
|
MARKER = "# tezgah: managed by tezgah-agents; do not edit"
|
|
35
44
|
STATE = os.path.join(CONFIG_DIR, "agents.state.json")
|
|
36
|
-
# hosts with a file-based custom-agent surface
|
|
37
|
-
#
|
|
38
|
-
# both when either is installed; the *detection* of whether a host is installed
|
|
39
|
-
# lives in tezgah_paths.host_installed, not here.
|
|
45
|
+
# hosts with a file-based custom-agent surface; the *detection* of whether a
|
|
46
|
+
# host is installed lives in tezgah_paths.host_installed, not here.
|
|
40
47
|
FILE_HOSTS = ("claude", "opencode", "codex", "cursor")
|
|
48
|
+
# the file hosts whose roles live in their user dir (user_agents), written by
|
|
49
|
+
# the installer; the others get a per-repo dir from sync_root
|
|
50
|
+
USER_HOSTS = ("claude", "codex")
|
|
51
|
+
# the repo-relative agent dirs. Claude's and Codex's are no longer written, only
|
|
52
|
+
# swept: a copy an older tezgah left there would sit beside the user-level one.
|
|
41
53
|
HOST_DIRS = {"claude": os.path.join(".claude", "agents"),
|
|
42
|
-
"cursor": os.path.join(".
|
|
54
|
+
"cursor": os.path.join(".cursor", "agents"),
|
|
43
55
|
"opencode": os.path.join(".opencode", "agents"),
|
|
44
56
|
"codex": os.path.join(".codex", "agents")}
|
|
45
57
|
# Stack markers -> a short hint. Exact commands are left to the repo's own docs
|
|
@@ -61,10 +73,11 @@ MCP_PREFIXES = ("mcp__codegraph__", "mcp__plugin_tezgah_codegraph__")
|
|
|
61
73
|
GRAPH_SELECT = ",".join(p + t for t in GRAPH_MCP for p in MCP_PREFIXES)
|
|
62
74
|
GRAPH_CLI = ("callers", "callees", "impact", "node", "files")
|
|
63
75
|
GRAPH_TOOLS = ", ".join("`codegraph %s`" % verb for verb in GRAPH_CLI)
|
|
64
|
-
# A role with no shell (Claude `disallowedTools` Bash, opencode `bash: deny
|
|
76
|
+
# A role with no shell (Claude `disallowedTools` Bash, opencode `bash: deny`;
|
|
77
|
+
# Cursor's `readonly` documents no shell scope, so it is held to the same recipe)
|
|
65
78
|
# cannot run `git diff` or the CLI, so its blast radius comes from the caller's
|
|
66
79
|
# brief and the MCP impact tool; a role with a shell runs the diff itself.
|
|
67
|
-
NO_SHELL = ("claude", "opencode")
|
|
80
|
+
NO_SHELL = ("claude", "opencode", "cursor")
|
|
68
81
|
# The floor every graph role carries (gortex edge provenance): the index holds
|
|
69
82
|
# only the edges its parser saw, so an absence of callers is a coverage gap
|
|
70
83
|
# until shown otherwise, never a safety verdict.
|
|
@@ -114,7 +127,7 @@ def _graph_howto(host):
|
|
|
114
127
|
"`codegraph status`). Do not call the `xd://mcp__codegraph_*`\n"
|
|
115
128
|
"devices: omp refuses them with \"Concurrent write refused\"\n"
|
|
116
129
|
"while the parent session holds the index." % GRAPH_TOOLS)
|
|
117
|
-
if host
|
|
130
|
+
if host in ("opencode", "cursor"):
|
|
118
131
|
return ("codegraph is registered as an MCP server (%s). You have no\n"
|
|
119
132
|
"shell, so the codegraph CLI is out of reach: use those MCP tools."
|
|
120
133
|
% ", ".join("`%s`" % t for t in GRAPH_MCP))
|
|
@@ -206,15 +219,22 @@ def _reviewer_body(host):
|
|
|
206
219
|
"is lowered only by evidence: a check you could not complete never\n"
|
|
207
220
|
"downgrades a confirmed finding - keep its severity and mark it\n"
|
|
208
221
|
"provisional. Disclose the order you read the files in.\n\n"
|
|
209
|
-
"Scope and budget.
|
|
210
|
-
"
|
|
211
|
-
"
|
|
212
|
-
"
|
|
213
|
-
"
|
|
214
|
-
"
|
|
215
|
-
"
|
|
216
|
-
"
|
|
217
|
-
"
|
|
222
|
+
"Scope and budget. One review per plan, over its whole diff in the\n"
|
|
223
|
+
"verification phase; at most two rounds - only a confirmed critical or\n"
|
|
224
|
+
"major finding opens round two, a minor or suggestion one is recorded\n"
|
|
225
|
+
"fix-later, and after round two what is left goes to the user or is\n"
|
|
226
|
+
"recorded fix-later, never a third round. A round may be a fan-out of\n"
|
|
227
|
+
"reviewers spawned at once, one per dimension (correctness and contract,\n"
|
|
228
|
+
"security, tests and performance) or per file group: when your brief\n"
|
|
229
|
+
"names a dimension or files, review only those - a sibling owns the rest.\n"
|
|
230
|
+
"Round two reads only the delta since the sha round one recorded\n"
|
|
231
|
+
"(`tezgah-task review` prints it), plus the round-one hunks and direct\n"
|
|
232
|
+
"callers of the symbols that delta touches, and marks every round-one\n"
|
|
233
|
+
"finding closed or open: re-reading the whole diff re-reports what was\n"
|
|
234
|
+
"already triaged. Test results are input, not a task - read the author's\n"
|
|
235
|
+
"output file; do not run the suite, and run at most one `-k` test when a\n"
|
|
236
|
+
"specific accusation needs it. The same defect family confirmed in both\n"
|
|
237
|
+
"rounds is a sign the design needs a pivot, not another patch.\n\n"
|
|
218
238
|
"%s" % (_graph_howto(host), _blast_radius(host), EMPTY_IS_NOT_PROOF,
|
|
219
239
|
_may_use(host)))
|
|
220
240
|
|
|
@@ -343,7 +363,8 @@ ROLES = (
|
|
|
343
363
|
("tezgah-reviewer",
|
|
344
364
|
"Adversarial, read-only review of a diff, branch or PR: derives the blast "
|
|
345
365
|
"radius, checks correctness, contract, security, tests and performance, and "
|
|
346
|
-
"classifies every finding confirmed/refuted/unverified."
|
|
366
|
+
"classifies every finding confirmed/refuted/unverified. One review per plan, "
|
|
367
|
+
"at most two rounds; spawn one per dimension in one message for a large diff.",
|
|
347
368
|
lambda infra: infra["caps"]["graph"], _reviewer_body, True),
|
|
348
369
|
("tezgah-cheap",
|
|
349
370
|
"Mechanical, fully specified edits on the cheapest model tier: renames, "
|
|
@@ -389,7 +410,10 @@ def _orch_body(names):
|
|
|
389
410
|
"verify; you delegate execution and read the evidence back. Available\n"
|
|
390
411
|
"specialists: %s.\n\n"
|
|
391
412
|
"Fan out by default: when the request or your todo list holds two or more\n"
|
|
392
|
-
"independent items, spawn one subagent per item in ONE
|
|
413
|
+
"independent items, count them and spawn one subagent per item in ONE\n"
|
|
414
|
+
"message - at every phase: a plan's tasks, its acceptance commands and the\n"
|
|
415
|
+
"sources of a survey are items too, and checks that share no state run as\n"
|
|
416
|
+
"tool calls in one message, never a queue. Give\n"
|
|
393
417
|
"every writing slice its own branch and checkout - `orca worktree create\n"
|
|
394
418
|
"--name <slug> --parent-worktree active --json` when the session runs\n"
|
|
395
419
|
"inside Orca, else `git worktree add -b <branch> <absolute path>` - and\n"
|
|
@@ -429,10 +453,8 @@ def _claude_model(model):
|
|
|
429
453
|
|
|
430
454
|
def _model_lines(name, family, model_fmt, effort_fmt):
|
|
431
455
|
"""The model (and effort) lines the table gives this agent on a family;
|
|
432
|
-
`model: inherit` on Claude
|
|
433
|
-
|
|
434
|
-
(`claude-opus-5[effort=high]`, cursor.com/docs/subagents) - unverified for
|
|
435
|
-
the ids this table names, so Cursor is documented, not guessed at."""
|
|
456
|
+
`model: inherit` on Claude for an agent with no slot. Cursor gets
|
|
457
|
+
`model: inherit` always (render_md_cursor)."""
|
|
436
458
|
picked = tm.pick(name, family)
|
|
437
459
|
if not picked:
|
|
438
460
|
return ["model: inherit"] if family == "anthropic" else []
|
|
@@ -451,35 +473,34 @@ def _fold(desc, width=74):
|
|
|
451
473
|
|
|
452
474
|
|
|
453
475
|
def render_md(name, description, readonly, body):
|
|
454
|
-
"""Claude Code /
|
|
455
|
-
|
|
476
|
+
"""Claude Code markdown (`~/.claude/agents`), which the Cursor editor also
|
|
477
|
+
reads (cursor.com/docs/subagents). The description is one line: Cursor's
|
|
478
|
+
line parser reads a folded `>` block as ">", and Claude's parser re-quotes
|
|
479
|
+
a plain value YAML rejects (a `: ` inside it). Claude denies the write/shell
|
|
480
|
+
tools explicitly; `readonly` is Cursor's key, which Claude ignores."""
|
|
456
481
|
lines = ["---", MARKER + " (manifest %s)" % manifest_sha(),
|
|
457
|
-
"name: %s" % name, "description:
|
|
458
|
-
lines += _fold(description)
|
|
482
|
+
"name: %s" % name, "description: %s" % " ".join(description.split())]
|
|
459
483
|
lines += _model_lines(name, "anthropic", "model: %s", "effort: %s")
|
|
460
484
|
if readonly:
|
|
461
|
-
# `disallowedTools` is Claude's; `readonly` is Cursor's. Both parsers
|
|
462
|
-
# ignore the other's field.
|
|
463
485
|
lines.append("readonly: true")
|
|
464
486
|
lines.append("disallowedTools: Write, Edit, NotebookEdit, Bash, Agent")
|
|
465
487
|
lines.append("---")
|
|
466
488
|
return "\n".join(lines) + "\n\n" + body.strip() + "\n"
|
|
467
489
|
|
|
468
490
|
|
|
469
|
-
def
|
|
470
|
-
"""
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
return out
|
|
491
|
+
def render_md_cursor(name, description, readonly, body):
|
|
492
|
+
"""Cursor `.cursor/agents/*.md`. cursor-agent splits each frontmatter line
|
|
493
|
+
at its first ':' (no YAML), passes `model` through as a raw id and has no
|
|
494
|
+
`effort` key, so: a one-line description, `model: inherit` (the only id
|
|
495
|
+
portable across Cursor accounts), `readonly`, and no `tools` line - its
|
|
496
|
+
comma split would cut a Claude `Agent(a, b)` list into broken names."""
|
|
497
|
+
lines = ["---", MARKER + " (manifest %s)" % manifest_sha(),
|
|
498
|
+
"name: %s" % name, "description: %s" % " ".join(description.split()),
|
|
499
|
+
"model: inherit"]
|
|
500
|
+
if readonly:
|
|
501
|
+
lines.append("readonly: true")
|
|
502
|
+
lines.append("---")
|
|
503
|
+
return "\n".join(lines) + "\n\n" + body.strip() + "\n"
|
|
483
504
|
|
|
484
505
|
|
|
485
506
|
def render_md_opencode(name, description, readonly, body):
|
|
@@ -518,13 +539,19 @@ def render_md_omp(name, description, body, tools=("read", "grep", "glob", "bash"
|
|
|
518
539
|
return "\n".join(lines) + "\n\n" + body.strip() + "\n"
|
|
519
540
|
|
|
520
541
|
|
|
542
|
+
def _active_roles(root):
|
|
543
|
+
"""The roles a user-level dir holds: none under `agents-off`, else those
|
|
544
|
+
whose capability the machine has (`~/.no-graph` counts as the repo's)."""
|
|
545
|
+
if off("agents-off"):
|
|
546
|
+
return []
|
|
547
|
+
infra = detect_infra(os.path.expanduser(root))
|
|
548
|
+
return [r for r in ROLES if r[2](infra)]
|
|
549
|
+
|
|
550
|
+
|
|
521
551
|
def omp_user_agents(root="~"):
|
|
522
552
|
"""{filename: text} for omp's user agent dir (~/.omp/agent/agents); empty
|
|
523
553
|
under `agents-off`, so the installer's sweep removes the files."""
|
|
524
|
-
|
|
525
|
-
return {}
|
|
526
|
-
infra = detect_infra(os.path.expanduser(root))
|
|
527
|
-
active = [r for r in ROLES if r[2](infra)]
|
|
554
|
+
active = _active_roles(root)
|
|
528
555
|
names = [r[0] for r in active]
|
|
529
556
|
if not names:
|
|
530
557
|
return {}
|
|
@@ -539,20 +566,50 @@ def omp_user_agents(root="~"):
|
|
|
539
566
|
return out
|
|
540
567
|
|
|
541
568
|
|
|
569
|
+
def user_agent_dir(host):
|
|
570
|
+
"""Where a USER_HOSTS host loads agents from in every session: Claude's
|
|
571
|
+
`~/.claude/agents`, Codex's `$CODEX_HOME/agents` (default ~/.codex)."""
|
|
572
|
+
return os.path.join(HOST_HOMES[host], "agents")
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def user_agents(host, root="~"):
|
|
576
|
+
"""{filename: text} for a USER_HOSTS host's user agent dir, each file
|
|
577
|
+
carrying MARKER so the installer sweeps only tezgah's; empty under
|
|
578
|
+
`agents-off`, so that sweep removes them. Codex gets no orchestrator: the
|
|
579
|
+
main thread orchestrates there."""
|
|
580
|
+
active = _active_roles(root)
|
|
581
|
+
names = [r[0] for r in active]
|
|
582
|
+
if not names:
|
|
583
|
+
return {}
|
|
584
|
+
if host == "codex":
|
|
585
|
+
return {name + ".toml": render_toml(name, desc, readonly, body("codex"))
|
|
586
|
+
for name, desc, _cap, body, readonly in active}
|
|
587
|
+
out = {name + ".md": render_md(name, desc, readonly, body("claude"))
|
|
588
|
+
for name, desc, _cap, body, readonly in active}
|
|
589
|
+
out["tezgah-orchestrator.md"] = render_orch_md(names)
|
|
590
|
+
return out
|
|
591
|
+
|
|
592
|
+
|
|
542
593
|
def steering(root, host=None):
|
|
543
594
|
"""One line naming the generated specialists this host can spawn, or None.
|
|
544
595
|
|
|
545
596
|
Nothing else in the contract points the main agent at them, so a model
|
|
546
597
|
reached for the host's generic explorer instead. Existence is read from
|
|
547
598
|
disk, not from the capabilities: a specialist is named only when its file
|
|
548
|
-
is there for the host to load -
|
|
549
|
-
|
|
550
|
-
|
|
599
|
+
is there for the host to load - the user agent dir on omp, Claude and
|
|
600
|
+
Codex, the repo's own dir on Cursor and opencode, and all of them for a
|
|
601
|
+
host that does not say which it is. A repo's `.no-graph` turns the graph
|
|
602
|
+
off there, so a graph role is not named in it even when a user-level file
|
|
551
603
|
exists."""
|
|
552
604
|
if host == "omp":
|
|
553
605
|
dirs = [os.path.join(HOME, ".omp", "agent", "agents")]
|
|
606
|
+
elif host in USER_HOSTS:
|
|
607
|
+
dirs = [user_agent_dir(host)]
|
|
608
|
+
elif host in HOST_DIRS:
|
|
609
|
+
dirs = [os.path.join(root, HOST_DIRS[host])]
|
|
554
610
|
else:
|
|
555
|
-
dirs = [
|
|
611
|
+
dirs = ([user_agent_dir(h) for h in USER_HOSTS]
|
|
612
|
+
+ [os.path.join(root, HOST_DIRS[h]) for h in ("cursor", "opencode")])
|
|
556
613
|
have = {os.path.splitext(os.path.basename(p))[0]
|
|
557
614
|
for d in dirs for p in glob.glob(os.path.join(d, "tezgah-*.*"))}
|
|
558
615
|
if os.path.exists(os.path.join(root, ".no-graph")):
|
|
@@ -645,14 +702,18 @@ def _write_if_changed(path, text):
|
|
|
645
702
|
return True
|
|
646
703
|
|
|
647
704
|
|
|
648
|
-
def
|
|
649
|
-
"""Delete only tezgah-managed files no longer in the wanted set
|
|
705
|
+
def remove_stale(directory, wanted):
|
|
706
|
+
"""Delete only tezgah-managed files no longer in the wanted set; returns
|
|
707
|
+
the paths removed."""
|
|
708
|
+
removed = []
|
|
650
709
|
for path in glob.glob(os.path.join(directory, "tezgah-*.*")):
|
|
651
710
|
if os.path.basename(path) not in wanted and _is_managed(_read(path)):
|
|
652
711
|
try:
|
|
653
712
|
os.remove(path)
|
|
713
|
+
removed.append(path)
|
|
654
714
|
except OSError:
|
|
655
715
|
pass
|
|
716
|
+
return removed
|
|
656
717
|
|
|
657
718
|
|
|
658
719
|
BLOCK_BEGIN = ("# tezgah: generated agents (managed; removed by "
|
|
@@ -785,66 +846,49 @@ def sync_root(root, report_steady=False):
|
|
|
785
846
|
return None
|
|
786
847
|
disabled = off("agents-off")
|
|
787
848
|
infra = detect_infra(root)
|
|
788
|
-
|
|
849
|
+
# Claude's and Codex's roles are user-level (user_agents): only Cursor and
|
|
850
|
+
# opencode get a dir here
|
|
851
|
+
hosts = set() if disabled else set(infra["hosts"]) - set(USER_HOSTS)
|
|
789
852
|
active = [] if disabled else [r for r in ROLES if r[2](infra)]
|
|
790
853
|
names = [r[0] for r in active]
|
|
791
854
|
|
|
792
|
-
|
|
793
|
-
md_dir = os.path.join(root, HOST_DIRS["claude"]) if hosts & {"claude", "cursor"} else None
|
|
855
|
+
cursor_dir = os.path.join(root, HOST_DIRS["cursor"]) if "cursor" in hosts else None
|
|
794
856
|
oc_dir = os.path.join(root, HOST_DIRS["opencode"]) if "opencode" in hosts else None
|
|
795
|
-
codex_dir = os.path.join(root, HOST_DIRS["codex"]) if "codex" in hosts else None
|
|
796
857
|
|
|
797
858
|
written, removed = 0, 0
|
|
798
859
|
paths = []
|
|
860
|
+
wanted = {h: set() for h in HOST_DIRS}
|
|
799
861
|
|
|
800
|
-
def emit(
|
|
862
|
+
def emit(host, name, text):
|
|
801
863
|
nonlocal written
|
|
802
|
-
wanted.add(name)
|
|
803
|
-
path = os.path.join(
|
|
864
|
+
wanted[host].add(name)
|
|
865
|
+
path = os.path.join(root, HOST_DIRS[host], name)
|
|
804
866
|
if _write_if_changed(path, text):
|
|
805
867
|
written += 1
|
|
806
868
|
paths.append(path)
|
|
807
869
|
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
nonlocal removed
|
|
812
|
-
before = set(os.path.basename(p) for p in glob.glob(os.path.join(directory, "tezgah-*.*")))
|
|
813
|
-
_remove_stale(directory, wanted)
|
|
814
|
-
after = set(os.path.basename(p) for p in glob.glob(os.path.join(directory, "tezgah-*.*")))
|
|
815
|
-
removed += len(before - after)
|
|
816
|
-
|
|
817
|
-
if md_dir and names: # Claude Code + Cursor
|
|
870
|
+
# Cursor gets no orchestrator: its files are subagents the main thread
|
|
871
|
+
# delegates to, and the orchestrator is a primary agent
|
|
872
|
+
if cursor_dir and names:
|
|
818
873
|
for name, desc, _cap, body, readonly in active:
|
|
819
|
-
emit(
|
|
820
|
-
|
|
874
|
+
emit("cursor", name + ".md",
|
|
875
|
+
render_md_cursor(name, desc, readonly, body("cursor")))
|
|
821
876
|
|
|
822
877
|
if oc_dir and names: # opencode
|
|
823
878
|
for name, desc, _cap, body, readonly in active:
|
|
824
|
-
emit(
|
|
879
|
+
emit("opencode", name + ".md",
|
|
825
880
|
render_md_opencode(name, desc, readonly, body("opencode")))
|
|
826
|
-
emit(
|
|
827
|
-
|
|
828
|
-
if codex_dir and names: # Codex (no orchestrator agent; the main thread orchestrates)
|
|
829
|
-
for name, desc, _cap, body, readonly in active:
|
|
830
|
-
emit(codex_dir, wanted_codex, name + ".toml",
|
|
831
|
-
render_toml(name, desc, readonly, body("codex")))
|
|
881
|
+
emit("opencode", "tezgah-orchestrator.md", render_orch_md_opencode(names))
|
|
832
882
|
|
|
833
|
-
# The sweep runs over every dir this module
|
|
834
|
-
# ones: a host dropped from the config,
|
|
835
|
-
#
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
883
|
+
# The sweep runs over every dir this module ever wrote, not only the
|
|
884
|
+
# selected ones: a host dropped from the config, a capability that
|
|
885
|
+
# disappeared, or a `.claude`/`.codex` copy from before the roles moved
|
|
886
|
+
# user-level must not stay behind for a host to keep loading.
|
|
887
|
+
for host, directory in HOST_DIRS.items():
|
|
888
|
+
removed += len(remove_stale(os.path.join(root, directory), wanted[host]))
|
|
839
889
|
|
|
840
890
|
if paths:
|
|
841
|
-
dirs =
|
|
842
|
-
if md_dir:
|
|
843
|
-
dirs.add(HOST_DIRS["claude"])
|
|
844
|
-
if oc_dir:
|
|
845
|
-
dirs.add(HOST_DIRS["opencode"])
|
|
846
|
-
if codex_dir:
|
|
847
|
-
dirs.add(HOST_DIRS["codex"])
|
|
891
|
+
dirs = {HOST_DIRS[h] for h in ("cursor", "opencode") if wanted[h]}
|
|
848
892
|
ex = ensure_exclude(root, dirs)
|
|
849
893
|
if ex:
|
|
850
894
|
paths.append(ex)
|
|
@@ -857,7 +901,7 @@ def sync_root(root, report_steady=False):
|
|
|
857
901
|
% (len(names), ", %d removed" % removed if removed else ""))
|
|
858
902
|
if removed:
|
|
859
903
|
return "%d stale agent file(s) removed" % removed
|
|
860
|
-
if report_steady and names and (
|
|
904
|
+
if report_steady and names and (cursor_dir or oc_dir):
|
|
861
905
|
return "%d agent(s) current" % len(names)
|
|
862
906
|
return None
|
|
863
907
|
|
package/hooks/tezgah_context.py
CHANGED
|
@@ -26,8 +26,9 @@ from tezgah_integrity import (STEP_KINDS, _heredocs, _path as _ledger_path,
|
|
|
26
26
|
UNTRUSTED_CHANNEL)
|
|
27
27
|
from tezgah_lessons import (lesson_key, lines as lesson_lines,
|
|
28
28
|
tainted as tainted_lessons)
|
|
29
|
-
from tezgah_policy import (CONDITIONAL_KEYS, CORE,
|
|
30
|
-
REPLY_LANG_TEXT, open_lines_note,
|
|
29
|
+
from tezgah_policy import (CONDITIONAL_KEYS, CORE, FANOUT_ACTION, POINTERS,
|
|
30
|
+
PROMPT_REMINDER, REPLY_LANG_TEXT, open_lines_note,
|
|
31
|
+
pony_level_line)
|
|
31
32
|
from tezgah_paths import (CACHE, REPO_MARKS, SWITCHES, ai_research_dir, armed,
|
|
32
33
|
cache_dir, codegraph_bin, consult_options,
|
|
33
34
|
ensure_workspace, fallback_cache, have_judge_key, off,
|
|
@@ -106,6 +107,21 @@ def _tr_ui_ask(word):
|
|
|
106
107
|
return (r"(?:\b(?:%s)\w*[^.!?\n]{0,40}\b(?:%s))" % (TR_UI_OBJECTS, word))
|
|
107
108
|
|
|
108
109
|
|
|
110
|
+
# The fan-out hint's parts (PROMPT_HINTS["fanout"]). A list item's marker:
|
|
111
|
+
# `1.`, `1)`, `1 -`, `1:`, `a)`, or a `-`/`*`/`+`/`•` bullet, then its text.
|
|
112
|
+
_FAN_ITEM = r"(?:\d+[ \t]*[.):\u2013-]|[a-z]\)|[-*+\u2022])[ \t]+\S"
|
|
113
|
+
# A file a prompt names: a path with a directory, or a name with an extension.
|
|
114
|
+
# Anchored at a token start: unanchored, a long run of word characters made
|
|
115
|
+
# every position a start and the search quadratic (100 KB took minutes).
|
|
116
|
+
_FAN_FILE = (r"(?<![\w./-])[`'\"]?(?:(?:[\w.-]+/)+[\w.-]*\w|"
|
|
117
|
+
r"\w[\w-]*\.[a-z]{1,4}\b)[`'\"]?")
|
|
118
|
+
# The imperative that opens an English clause and closes a Turkish one.
|
|
119
|
+
_FAN_VERB_EN = (r"(?:fix|add|update|write|remove|delete|rename|move|refactor|"
|
|
120
|
+
r"document|bump|create|implement|migrate|replace)")
|
|
121
|
+
_FAN_VERB_TR = (r"(?:düzelt|güncelle|ekle|yaz|sil|kaldır|taşı|bitir|tamamla|"
|
|
122
|
+
r"incele|oluştur|değiştir)")
|
|
123
|
+
|
|
124
|
+
|
|
109
125
|
PROMPT_HINTS = (
|
|
110
126
|
("spec", r"\b(normal (user )?behaviou?r|clean ui|nicer|more intuitive|"
|
|
111
127
|
r"un?professional|polish(ed)?|improve the (ui|ux)|make it (better|"
|
|
@@ -340,9 +356,22 @@ PROMPT_HINTS = (
|
|
|
340
356
|
# had to say "do everything with subagents and in parallel" on every task
|
|
341
357
|
# because nothing armed the fan-out rule from the shape of the request.
|
|
342
358
|
# `parallelize` alone is code work, so only the delegation phrasings count.
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
359
|
+
# The owner's own shapes armed nothing until 2026-10-09: `1 - `, `1- `,
|
|
360
|
+
# `1:`, `a)`, `+` items, an inline `1. ... 2. ...`, "subagentlerle", three
|
|
361
|
+
# files named as one list, "First, ... Second, ...", and three imperative
|
|
362
|
+
# clauses in one sentence (clause-initial in English, clause-final in
|
|
363
|
+
# Turkish). A timestamp, a range and a traceback stay out (the corpus).
|
|
364
|
+
("fanout", r"(?ms:^[ \t]*" + _FAN_ITEM + r".*?^[ \t]*" + _FAN_ITEM + r")|"
|
|
365
|
+
r"(?<![\w.])1[ \t]*[.)-][ \t]+\S[^\n]*?(?<![\w.])2[ \t]*[.)-][ \t]+\S|"
|
|
366
|
+
+ _FAN_FILE + r"(?:[ \t]*(?:,[ \t]*(?:(?:and|ve)[ \t]+)?|"
|
|
367
|
+
r"(?:and|ve|&)[ \t]+)" + _FAN_FILE + r"){2,}|"
|
|
368
|
+
r"(?s:\bfirst(?:ly)?[,:].*?\bsecond(?:ly)?[,:])|"
|
|
369
|
+
r"(?s:\bbirinci(?:si)?[,:].*?\bikinci(?:si)?[,:])|"
|
|
370
|
+
r"(?m:(?:(?:^|[,;]|\band\b)[ \t]*(?:and[ \t]+)?" + _FAN_VERB_EN
|
|
371
|
+
+ r"\b[^\n]*?){3})|"
|
|
372
|
+
r"(?m:(?:\b" + _FAN_VERB_TR + r"\w{0,4}[ \t]*(?:[,;.]|\bve\b|$)"
|
|
373
|
+
r"[^\n]*?){3})|"
|
|
374
|
+
r"\b(?:in parallel|paralel\w*|sub-?agent\w*|alt ?ajan\w*|"
|
|
346
375
|
r"worktrees?|fan[- ]?out|orchestrat\w*|orkestra\w*)\b"),
|
|
347
376
|
)
|
|
348
377
|
|
|
@@ -661,7 +690,8 @@ def autoindex(root):
|
|
|
661
690
|
|
|
662
691
|
|
|
663
692
|
def sync_agents(root):
|
|
664
|
-
"""Generate/refresh this repo's
|
|
693
|
+
"""Generate/refresh this repo's Cursor and opencode subagent files (best
|
|
694
|
+
effort); Claude's and Codex's roles are user-level, written at install."""
|
|
665
695
|
try:
|
|
666
696
|
from tezgah_agents import sync_root
|
|
667
697
|
return sync_root(root)
|
|
@@ -1094,8 +1124,13 @@ ARMED_RESURFACE = 20
|
|
|
1094
1124
|
def armed_again(key):
|
|
1095
1125
|
"""The one line a matching prompt pays for a paragraph this session was
|
|
1096
1126
|
already shown. The research rule keeps its `{OPEN_LINES}` slot: the open
|
|
1097
|
-
lines are a fact about the repo now, not rule text the session holds.
|
|
1127
|
+
lines are a fact about the repo now, not rule text the session holds. The
|
|
1128
|
+
fan-out rule keeps its action (`FANOUT_ACTION`): the rule is a thing to do
|
|
1129
|
+
on this turn's list, and a bare reminder that it exists was not done."""
|
|
1098
1130
|
label = dict(CORE_RULES)[key]
|
|
1131
|
+
if key == "fanout":
|
|
1132
|
+
return ("%s Armed again: %s The full rule is in the `tezgah-contract` "
|
|
1133
|
+
"skill." % (label, FANOUT_ACTION))
|
|
1099
1134
|
return ("%s Armed again: the full rule was given earlier this session and "
|
|
1100
1135
|
"is in the `tezgah-contract` skill.%s"
|
|
1101
1136
|
% (label, "{OPEN_LINES}" if key == "research" else ""))
|