@r1z4x/tezgah 1.0.0 → 1.2.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 +92 -1
  2. package/MANIFEST +10 -0
  3. package/bin/tezgah-agents +10 -1
  4. package/bin/tezgah-docs +1 -1
  5. package/bin/tezgah-doctor +11 -0
  6. package/bin/tezgah-research +31 -14
  7. package/bin/tezgah-setup +154 -29
  8. package/bin/tezgah-skill +59 -0
  9. package/bin/tezgah-skill.py +59 -0
  10. package/bin/tezgah-status +62 -1
  11. package/bin/tezgah-taste +504 -5
  12. package/bin/tezgah-triage +5 -3
  13. package/docs/README.md +1 -0
  14. package/docs/architecture.md +5 -2
  15. package/docs/citations-baseline.json +1 -1
  16. package/docs/clarity-baseline.json +9 -9
  17. package/docs/contract.md +68 -26
  18. package/docs/environment.md +6 -3
  19. package/docs/evidence.md +7 -0
  20. package/docs/gate.md +13 -7
  21. package/docs/glossary.md +1 -1
  22. package/docs/hosts.md +48 -1
  23. package/docs/index.json +34 -3
  24. package/docs/judge.md +75 -60
  25. package/docs/models.md +14 -8
  26. package/docs/operations.md +80 -25
  27. package/docs/orca.md +126 -0
  28. package/docs/research.md +38 -3
  29. package/docs/skills.md +74 -0
  30. package/docs/status-line.md +3 -1
  31. package/docs/testing.md +33 -21
  32. package/docs/vision.md +12 -8
  33. package/hooks/projects-posttooluse.py +7 -0
  34. package/hooks/projects-pretooluse.py +17 -5
  35. package/hooks/tezgah_agents.py +135 -13
  36. package/hooks/tezgah_components.py +3 -0
  37. package/hooks/tezgah_context.py +84 -22
  38. package/hooks/tezgah_gate.py +133 -17
  39. package/hooks/tezgah_integrity.py +60 -13
  40. package/hooks/tezgah_judge.py +288 -66
  41. package/hooks/tezgah_models.py +41 -5
  42. package/hooks/tezgah_orca.py +144 -0
  43. package/hooks/tezgah_paths.py +40 -6
  44. package/hooks/tezgah_policy.py +109 -35
  45. package/hooks/tezgah_research.py +104 -8
  46. package/hooks/tezgah_skill_pick.py +459 -8
  47. package/hooks/tezgah_stack.py +382 -0
  48. package/hooks/tezgah_stopspec.py +2 -2
  49. package/hooks/tezgah_taste.py +35 -9
  50. package/hooks/tezgah_taste_ledger.py +628 -0
  51. package/hosts/codex/hook.py +12 -1
  52. package/hosts/cursor/hook.py +12 -4
  53. package/hosts/omp/hook.py +18 -6
  54. package/hosts/omp/tezgah-hook.ts.in +20 -7
  55. package/hosts/opencode/plugins/tezgah.js +28 -15
  56. package/output-styles/tezgah.md +34 -19
  57. package/package.json +1 -1
  58. package/skills/i-have-adhd/SKILL.md +2 -2
  59. package/skills/research/SKILL.md +60 -17
  60. package/skills/tezgah-contract/SKILL.md +55 -13
package/CHANGELOG.md CHANGED
@@ -6,6 +6,95 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.2.0] - 2026-10-07
10
+
11
+ ### Added
12
+
13
+ - **Taste learns and applies, not only records.** Arm it with `taste-on`.
14
+ `tezgah-taste learn` then turns each correction after a writing turn into one
15
+ typed decision, and only TypeSafe may give it. The decision names a
16
+ preference, a defect or neither. It picks one of eleven fixed categories and
17
+ a scope, and relates the correction to each learning already held. A ledger
18
+ computes each learning's confidence from its evidence and forgets it over
19
+ time. A contradiction flags a learning and never overwrites it. A learning
20
+ goes active in its second session. Session start injects active learnings as
21
+ rules or hints. Every host notes the in-scope ones on a write, each once per
22
+ session. A rule needs a measured calibration bound. A defect never enters the
23
+ ledger. A before/after gate stops injection when corrections do not fall.
24
+ `list`, `accept`, `reject`, `edit` and `export` give the user control, and
25
+ `learn --from-transcripts` bootstraps from history. The judge seam gains
26
+ `only=` and a `text` question. Two commands never rewrite the ledger at once:
27
+ a second `learn`, `accept`, `reject` or `edit` exits 2 while one runs.
28
+
29
+ ## [1.1.0] - 2026-10-07
30
+
31
+ ### Added
32
+
33
+ - **Parallel by default.** A prompt with two or more independent items arms a
34
+ fan-out rule. One batch of subagents runs, one worktree per writing slice,
35
+ and the main thread integrates. Five specialist agents join the roster:
36
+ `tezgah-tester`, `tezgah-docs`, `tezgah-security`, `tezgah-ui` and
37
+ `tezgah-researcher`. `tezgah-agents --list` prints each with its tier,
38
+ skills and tools.
39
+ - **tezgah detects an Orca session** (`hooks/tezgah_orca.py`). Inside Orca a slice
40
+ checkout goes through `orca worktree create`, and `tezgah-status --orca`
41
+ lists the checkouts Orca tracks and the git worktrees it does not.
42
+ `docs/orca.md` maps what Orca owns and what tezgah owns.
43
+ - **`tezgah-doctor --stack`** prints each host's effective stack: context
44
+ files in load order, skill dirs and which copy wins, hooks per event, MCP
45
+ servers. It flags duplicate or stale skills, doubled hooks and rules, and
46
+ backups inside a scanned dir. `docs/hosts.md` holds the precedence table.
47
+ - **`tezgah-skill <words>`** searches every installed skill by section and
48
+ returns `skill://<name>:<start>-<end>` plus the file path. Every session is
49
+ told to use it. A local, model-free hint is on by default: when a prompt's
50
+ terms match one section well enough, the turn names that section once. The
51
+ armed judge hint names the section too, not only the skill.
52
+ - **`tezgah-status --judge`** shows what the judgement seam used last, and why
53
+ it was not the session's own model when it was not.
54
+
55
+ ### Fixed
56
+
57
+ - **The judgement seam asks the session's own CLI first.** It used a third
58
+ party (TypeSafe, then OpenRouter) even when the session's frontier
59
+ credential was there. A third party now answers only as the `fallback`
60
+ setting in `config.json` allows. The default is `vendor`: no cross-vendor
61
+ stand-in while a session CLI exists. Every stand-in or refusal is said on
62
+ stderr and recorded. omp subagent chains stay inside the session vendor by
63
+ default too.
64
+ - **Research stays on the question asked.** A line records its scope (`--in`,
65
+ `--out`) and its method (`--method qualitative|measured`). A qualitative
66
+ line owes no metric. Every claim must trace to a scope item. An analysis no
67
+ longer owes three variants. A plain factual question gets an answer from
68
+ primary sources, with no experiment line.
69
+ - **Ponytail and adhd are in force from the first turn.** Their operative rules
70
+ ride the always-on contract instead of a "read the skill later" pointer.
71
+ - **The graph engages.** A stale index stamp now starts a background re-index.
72
+ It no longer tells the session to fall back to text search. omp's `find`,
73
+ `search` and `ast_grep` reach the gate. The graph nudge fires on
74
+ symbol-shaped searches only, not on plain words, and honours `.no-graph`.
75
+ The session line names the host's own graph tool.
76
+ - **A prose edit owes no check.** The Stop rule no longer counts a `.md`,
77
+ `.rst` or `.adoc` edit outside the instruction files as work. Such an edit
78
+ neither demands a check nor makes an earlier pass stale. Refusals ask for
79
+ the smallest check that proves the change.
80
+ - **`tests/impacted.py` maps a host file to the modules that name it**, not to
81
+ nearly the whole suite.
82
+ - **Host-file backups leave the dirs a host scans.** They go to
83
+ `~/.config/tezgah/backups/`, and `--install` sweeps the old ones. Cursor gets
84
+ each tezgah skill from one dir, not two. `--sync` refreshes only the plugin
85
+ copy Claude loads.
86
+
87
+ ### Changed
88
+
89
+ - **A release run skips the serial suite.** `release.yml` calls CI with
90
+ `release: true`. One 3.10 `test` leg then runs compile, lint and the citation
91
+ audit, and `test-sharded` runs the suite.
92
+ - **CI runs less.** It runs on pushes to `main`, on pull requests and by hand.
93
+ A newer run cancels an older one on the same ref, and every job has a
94
+ timeout. A docs-only change runs only the citations audit and its impacted
95
+ tests. The `test` matrix is 3.10 and 3.14. A pull request runs the impacted
96
+ set. `main` and a release run the whole suite.
97
+
9
98
  ## [1.0.0] - 2026-10-07
10
99
 
11
100
  ### Upgrade note
@@ -734,5 +823,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
734
823
  the copy could not find `hooks/` and died on import. It now links into the
735
824
  installed tree, and also installs the command as `tezgah`.
736
825
 
737
- [Unreleased]: https://github.com/r1z4x/tezgah/compare/v1.0.0...HEAD
826
+ [Unreleased]: https://github.com/r1z4x/tezgah/compare/v1.2.0...HEAD
827
+ [1.2.0]: https://github.com/r1z4x/tezgah/releases/tag/v1.2.0
828
+ [1.1.0]: https://github.com/r1z4x/tezgah/releases/tag/v1.1.0
738
829
  [1.0.0]: https://github.com/r1z4x/tezgah/releases/tag/v1.0.0
package/MANIFEST CHANGED
@@ -74,6 +74,8 @@ bin/tezgah-route
74
74
  bin/tezgah-route.py
75
75
  bin/tezgah-setup
76
76
  bin/tezgah-setup.py
77
+ bin/tezgah-skill
78
+ bin/tezgah-skill.py
77
79
  bin/tezgah-status
78
80
  bin/tezgah-status.py
79
81
  bin/tezgah-task
@@ -101,6 +103,7 @@ docs/judge.md
101
103
  docs/layers.md
102
104
  docs/models.md
103
105
  docs/operations.md
106
+ docs/orca.md
104
107
  docs/research.md
105
108
  docs/results/paired-outcome-2026-10.jsonl
106
109
  docs/results/paired-outcome-2026-10.md
@@ -128,6 +131,7 @@ hooks/tezgah_judge.py
128
131
  hooks/tezgah_lang.py
129
132
  hooks/tezgah_lessons.py
130
133
  hooks/tezgah_models.py
134
+ hooks/tezgah_orca.py
131
135
  hooks/tezgah_paths.py
132
136
  hooks/tezgah_policy.py
133
137
  hooks/tezgah_rank.py
@@ -136,9 +140,11 @@ hooks/tezgah_research.py
136
140
  hooks/tezgah_shapes.py
137
141
  hooks/tezgah_skill_pick.py
138
142
  hooks/tezgah_snapshot.py
143
+ hooks/tezgah_stack.py
139
144
  hooks/tezgah_stopspec.py
140
145
  hooks/tezgah_task.py
141
146
  hooks/tezgah_taste.py
147
+ hooks/tezgah_taste_ledger.py
142
148
  hooks/tezgah_untrusted.py
143
149
  hooks/tezgah_update.py
144
150
  hosts/codex/hook.py
@@ -719,6 +725,7 @@ tests/test_neuter_matrix.py
719
725
  tests/test_omp_extension.py
720
726
  tests/test_omp_hook.py
721
727
  tests/test_opencode_plugin.py
728
+ tests/test_orca.py
722
729
  tests/test_packaging.py
723
730
  tests/test_packaging_scripts.py
724
731
  tests/test_paths.py
@@ -735,8 +742,10 @@ tests/test_research_seal.py
735
742
  tests/test_setup.py
736
743
  tests/test_shapes.py
737
744
  tests/test_skill_pick.py
745
+ tests/test_skill_search.py
738
746
  tests/test_skills.py
739
747
  tests/test_snapshot.py
748
+ tests/test_stack.py
740
749
  tests/test_statusline.py
741
750
  tests/test_stop_after_block.py
742
751
  tests/test_stopspec.py
@@ -744,6 +753,7 @@ tests/test_switch_latching.py
744
753
  tests/test_task.py
745
754
  tests/test_taste.py
746
755
  tests/test_taste_cli.py
756
+ tests/test_taste_ledger.py
747
757
  tests/test_tezgah_mcp_wiring.py
748
758
  tests/test_triage.py
749
759
  tests/test_untrusted.py
package/bin/tezgah-agents CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env python3
2
2
  """tezgah-agents [PATH] generate/refresh this repo's tezgah subagent set.
3
3
  tezgah-agents --json [PATH] print the opencode `config.agent` entries as JSON.
4
+ tezgah-agents --list print every role: name, tier, read-only or writing,
5
+ and its description (skills and tools included).
4
6
 
5
7
  The hook hosts run the plain form from SessionStart via the shared context
6
8
  (tezgah_context calls tezgah_agents.sync_root). opencode has no such hook, so
@@ -14,7 +16,8 @@ import sys
14
16
 
15
17
  HERE = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
16
18
  sys.path.insert(0, os.path.join(HERE, "hooks"))
17
- from tezgah_agents import opencode_agents_json, sync_root # noqa: E402
19
+ from tezgah_agents import ROLES, opencode_agents_json, sync_root # noqa: E402
20
+ from tezgah_models import AGENT_SLOT # noqa: E402
18
21
  from tezgah_context import repo_root, under # noqa: E402
19
22
 
20
23
 
@@ -25,6 +28,12 @@ def main():
25
28
  if "-h" in args or "--help" in args:
26
29
  print(__doc__.strip())
27
30
  return 0
31
+ if "--list" in args:
32
+ for name, desc, _cap, _body, readonly in ROLES:
33
+ print("%s tier=%s %s\n %s" % (
34
+ name, AGENT_SLOT.get(name, "-"),
35
+ "read-only" if readonly else "writes", desc))
36
+ return 0
28
37
  as_json = "--json" in args
29
38
  args = [a for a in args if a != "--json"]
30
39
  cwd = os.path.realpath(args[0] if args else os.getcwd())
package/bin/tezgah-docs CHANGED
@@ -106,7 +106,7 @@ RUN = re.compile(r"((?:`[A-Za-z_][A-Za-z0-9_]*(?:\(\))?`\s*[/,]?\s*){1,5})$")
106
106
  CITE = re.compile(r"`([\w.-]+(?:/[\w.-]+)+|[\w-]+\.\w+):(\d+)(?:-(\d+))?`"
107
107
  r"|`(:(\d+)(?:-(\d+))?)`")
108
108
  # The symbol written after the citation instead of before it, the way a table
109
- # cell reads: `hooks/tezgah_policy.py:634-853` (`CORE`).
109
+ # cell reads: `hooks/tezgah_policy.py:667-928` (`CORE`).
110
110
  AFTER = re.compile(r"\s*\(`([A-Za-z_][A-Za-z0-9_]*)(?:\(\))?`\)")
111
111
  # Any `path:line`, backticked or not: what CITE does not match of these (a
112
112
  # bare token, a comma list `x.py:12,40`) is counted as unjudged, so a citation
package/bin/tezgah-doctor CHANGED
@@ -410,6 +410,10 @@ def main():
410
410
  ap.add_argument("--json", action="store_true", help="machine-readable report")
411
411
  ap.add_argument("--coverage", action="store_true",
412
412
  help="the tracked files the graph index does not hold (exits 0)")
413
+ ap.add_argument("--stack", action="store_true",
414
+ help="per host: context files, skills and which copy wins, "
415
+ "hooks per event, MCP servers, and the conflicts "
416
+ "(exits 1 when any conflict is found)")
413
417
  ap.add_argument("--repo", action="append", metavar="PATH",
414
418
  help="a repository to account for instead of the configured "
415
419
  "roots (repeatable; --coverage reports the first)")
@@ -435,6 +439,13 @@ def main():
435
439
  repo = os.path.realpath(os.path.expanduser(args.repo[0]) if args.repo
436
440
  else os.getcwd())
437
441
  return coverage_report(repo, args.dump)
442
+ if args.stack:
443
+ import tezgah_stack
444
+ repo = os.path.realpath(os.path.expanduser(args.repo[0]) if args.repo
445
+ else os.getcwd())
446
+ data = tezgah_stack.report(repo)
447
+ print(json.dumps(data, indent=2) if args.json else tezgah_stack.render(data))
448
+ return 1 if any(s["conflicts"] for s in data.values()) else 0
438
449
 
439
450
  pairs = targets(args.repo)
440
451
  rep = collect(pairs)
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env python3
2
2
  """tezgah-research - the research workspace CLI.
3
3
 
4
- tezgah-research init <slug> [--question "..."] [--allow-open "<why>"]
5
- [--supersedes <slug>] scaffold a line
4
+ tezgah-research init <slug> --ask "<the user's words>" [--question "..."]
5
+ [--method qualitative|measured] [--in "<covered>"]... [--out "<left alone>"]...
6
+ [--allow-open "<why>"] [--supersedes <slug>] scaffold a line
6
7
  tezgah-research commit <slug> "<message>" commit the line in .tezgah's own repo
7
8
  tezgah-research check [<slug>] [--json] [--strict] [--orx] [--all-lines] [-- <slug>]
8
9
  the discipline checks
@@ -136,7 +137,7 @@ def cmd_init(args):
136
137
  return misuse("init <slug>: %r is not a slug - lowercase letters, digits "
137
138
  "and dashes" % args[0])
138
139
  question, allow, supersedes = "", None, None
139
- ask, tier = "", str(tr.DEFAULT_TIER)
140
+ ask, tier, method, scope_in, scope_out = "", str(tr.DEFAULT_TIER), "", [], []
140
141
  rest = args[1:]
141
142
  while rest:
142
143
  flag = rest.pop(0)
@@ -146,13 +147,21 @@ def cmd_init(args):
146
147
  ask = rest.pop(0)
147
148
  elif flag == "--tier" and rest:
148
149
  tier = rest.pop(0)
150
+ elif flag == "--method" and rest and rest[0] in tr.METHODS:
151
+ method = rest.pop(0)
152
+ elif flag == "--in" and rest:
153
+ scope_in.append(rest.pop(0))
154
+ elif flag == "--out" and rest:
155
+ scope_out.append(rest.pop(0))
149
156
  elif flag == "--allow-open" and rest:
150
157
  allow = rest.pop(0)
151
158
  elif flag == "--supersedes" and rest:
152
159
  supersedes = rest.pop(0)
153
160
  else:
154
161
  return misuse('init <slug> --ask "<the user\'s words>" [--tier quick|study|program]'
155
- ' [--question "..."] [--allow-open "<reason>"] [--supersedes <slug>]')
162
+ ' [--method qualitative|measured] [--in "<covered>"]...'
163
+ ' [--out "<left alone>"]... [--question "..."]'
164
+ ' [--allow-open "<reason>"] [--supersedes <slug>]')
156
165
  problems = tr.ask_problems(ask, tier)
157
166
  if problems:
158
167
  for problem in problems:
@@ -196,7 +205,8 @@ def cmd_init(args):
196
205
  return 1
197
206
  created = time.strftime("%Y-%m-%d")
198
207
  made = tr.init(repo, slug, question=question, created=created,
199
- supersedes=supersedes, ask=ask, tier=tier)
208
+ supersedes=supersedes, ask=ask, tier=tier, method=method,
209
+ scope_in=scope_in, scope_out=scope_out)
200
210
  if allow is not None:
201
211
  problem = tr.note_open(repo, slug, allow, created)
202
212
  if problem:
@@ -205,15 +215,22 @@ def cmd_init(args):
205
215
  print("research line: %s" % tr.line_dir(repo, slug))
206
216
  for path in made:
207
217
  print(" wrote %s" % path)
208
- print("\nnext: write experiments/<hypothesis>/protocol.md - what the change is, "
209
- "what it predicts, what result would falsify it, why - and COMMIT it "
210
- "before the run: `tezgah-research commit %s \"protocol <hypothesis>\"`. "
211
- "Then record the run in results.jsonl and commit it the same way, in a "
212
- "later commit. The commits land in `.tezgah`'s own git repository (the "
213
- "project ignores `.tezgah/`); `git -C .tezgah add ... && git -C .tezgah "
214
- "commit` is the same step by hand. Then analyse it in analysis.md, and "
215
- "put the claim in claims.jsonl with its kind, falsification criterion and "
216
- "evidence." % slug)
218
+ for problem in tr.scope_problems(tr.line_state(repo, slug)):
219
+ print(" owed: %s" % problem)
220
+ if method == "qualitative":
221
+ print("\nnext: read the primary sources for each state.json scope.in item "
222
+ "(`orx discover`, `orx paper`), save each to literature/ as you read "
223
+ "it, and record every claim with a `trace` naming the S<n> it answers "
224
+ "and the source it rests on. No experiment, no metric.")
225
+ else:
226
+ print("\nnext: write experiments/<hypothesis>/protocol.md - what the change "
227
+ "is, what it predicts, what result would falsify it, why - and COMMIT "
228
+ "it before the run: `tezgah-research commit %s \"protocol "
229
+ "<hypothesis>\"`. Then record the run in results.jsonl and commit it "
230
+ "the same way, in a later commit. The commits land in `.tezgah`'s own "
231
+ "git repository (the project ignores `.tezgah/`). Then analyse it in "
232
+ "analysis.md, and put the claim in claims.jsonl with its kind, "
233
+ "falsification criterion, evidence and the S<n> it traces to." % slug)
217
234
  print("kill switch: research-off")
218
235
  return 0
219
236
 
package/bin/tezgah-setup CHANGED
@@ -54,6 +54,7 @@ _TREE = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
54
54
  sys.path.insert(0, os.path.join(_TREE, "hooks"))
55
55
  import tezgah_apps # noqa: E402
56
56
  import tezgah_context # noqa: E402
57
+ import tezgah_orca # noqa: E402
57
58
  import tezgah_paths as tp # noqa: E402
58
59
  import tezgah_policy as policy # noqa: E402
59
60
  import tezgah_agents # noqa: E402
@@ -465,8 +466,9 @@ def hook_entries(hosts):
465
466
 
466
467
  def link(src, dst):
467
468
  """Idempotent symlink - or, where this platform refuses one, an entry
468
- materialised in place. A real file in the way is kept as <dst>.tezgah-bak
469
- and replaced, so an old shim (codex/bin/consult) does not block the build."""
469
+ materialised in place. A real file in the way is kept as
470
+ `<backup_base(dst)>.tezgah-bak` and replaced, so an old shim
471
+ (codex/bin/consult) does not block the build."""
470
472
  if is_tezgah_copy(dst):
471
473
  # tezgah's own materialised entry from an earlier symlink-less run: it
472
474
  # is replaced, not reported as a user's file and not left pointing at a
@@ -480,8 +482,10 @@ def link(src, dst):
480
482
  os.unlink(dst)
481
483
  elif os.path.exists(dst):
482
484
  try:
483
- os.replace(dst, dst + ".tezgah-bak")
484
- except OSError as exc:
485
+ bak = backup_base(dst) + ".tezgah-bak"
486
+ mkdirp(os.path.dirname(bak))
487
+ shutil.move(dst, bak)
488
+ except (OSError, shutil.Error) as exc:
485
489
  return "%s %s (%s)" % (NO, dst, exc)
486
490
  try:
487
491
  mkdirp(os.path.dirname(dst))
@@ -681,32 +685,94 @@ def write_json(path, data):
681
685
  # ponytail: a fixed cap, no setting.
682
686
  BACKUP_CAP = 5
683
687
  BACKUP_RE = re.compile(r"(.+)\.\d{8}T\d{12}(?:-\d+)?\.tezgah-bak")
688
+ # Where a host file's backups live. A host enumerates its own config dirs - omp
689
+ # 18.6's hooks capability lists every non-dot file in hooks/pre, so a
690
+ # `tezgah-hook.ts.<stamp>.tezgah-bak` beside the bridge was one more hook entry -
691
+ # so a copy laid beside a host file is one more thing that host discovers.
692
+ # tezgah's own state dir is scanned by no host and keeps its copies beside the
693
+ # file, where the uninstall that owns them finds them.
694
+ BACKUP_DIR = os.path.join(tp.CONFIG_DIR, "backups")
695
+
696
+
697
+ def backup_base(path):
698
+ """The path a backup of `path` is named after: `path` itself inside
699
+ tezgah's config dir, otherwise its mirror under BACKUP_DIR (relative to
700
+ HOME).
701
+
702
+ ponytail: a file outside HOME keeps its backup beside it - every host dir
703
+ lives under HOME on a real machine, and a mirror of an arbitrary absolute
704
+ path would let an in-process caller write into the real home."""
705
+ real = os.path.abspath(path)
706
+ if real.startswith(tp.CONFIG_DIR + os.sep) or not real.startswith(tp.HOME + os.sep):
707
+ return real
708
+ return os.path.join(BACKUP_DIR, os.path.relpath(real, tp.HOME))
684
709
 
685
710
 
686
711
  def backup(path):
687
- """Copy `path` to `<path>.<timestamp>.tezgah-bak` before a write changes it.
712
+ """Copy `path` to `<backup_base>.<timestamp>.tezgah-bak` before a write
713
+ changes it.
688
714
 
689
715
  The suffix stays `.tezgah-bak`, so managed() and the uninstall cleanup still
690
716
  know it; past BACKUP_CAP the middle copies go, never the oldest."""
691
717
  if not os.path.exists(path):
692
718
  return
719
+ base = backup_base(path)
693
720
  stamp = datetime.datetime.now().strftime("%Y%m%dT%H%M%S%f")
694
- dst = "%s.%s.tezgah-bak" % (path, stamp)
721
+ dst = "%s.%s.tezgah-bak" % (base, stamp)
695
722
  n = 0
696
723
  while os.path.exists(dst):
697
724
  n += 1
698
- dst = "%s.%s-%d.tezgah-bak" % (path, stamp, n)
725
+ dst = "%s.%s-%d.tezgah-bak" % (base, stamp, n)
699
726
  try:
727
+ mkdirp(os.path.dirname(dst))
700
728
  shutil.copy2(path, dst)
701
- d, base = os.path.split(path)
702
- old = sorted(name for name in os.listdir(d or ".")
703
- if (m := BACKUP_RE.fullmatch(name)) and m.group(1) == base)
704
- for name in old[1:max(1, len(old) - BACKUP_CAP + 1)]:
705
- os.remove(os.path.join(d, name))
729
+ d, name = os.path.split(base)
730
+ old = sorted(entry for entry in os.listdir(d or ".")
731
+ if (m := BACKUP_RE.fullmatch(entry)) and m.group(1) == name)
732
+ for entry in old[1:max(1, len(old) - BACKUP_CAP + 1)]:
733
+ os.remove(os.path.join(d, entry))
706
734
  except OSError:
707
735
  pass
708
736
 
709
737
 
738
+ # Dirs a backup sweep never enters: a host's transcripts, caches and plugin
739
+ # marketplaces hold no file tezgah ever backed up, and they are where the bytes are.
740
+ SWEEP_SKIP = frozenset((
741
+ ".git", ".tmp", "blobs", "browser-profiles", "cache", "file-history",
742
+ "history", "log", "logs", "marketplaces", "memories", "node_modules", "packages",
743
+ "projects", "sessions", "shell-snapshots", "shell_snapshots", "tmp",
744
+ "vendor", "vendor_imports"))
745
+ SWEEP_DEPTH = 3
746
+
747
+
748
+ def sweep_backups():
749
+ """Move every `.tezgah-bak` an older release laid beside a host file under
750
+ BACKUP_DIR. Returns the moved destinations. A destination that already
751
+ exists is left alone rather than overwritten: it may be the only copy of the
752
+ user's original."""
753
+ roots = set(tp.HOST_DIRS.values()) | {os.path.join(tp.HOME, ".codex")}
754
+ moved = []
755
+ for root in sorted(roots):
756
+ depth0 = root.rstrip(os.sep).count(os.sep)
757
+ for dirpath, dirnames, filenames in os.walk(root):
758
+ deep = dirpath.count(os.sep) - depth0 >= SWEEP_DEPTH
759
+ dirnames[:] = [] if deep else sorted(d for d in dirnames if d not in SWEEP_SKIP)
760
+ for name in filenames:
761
+ if not name.endswith(".tezgah-bak"):
762
+ continue
763
+ src = os.path.join(dirpath, name)
764
+ dst = backup_base(src)
765
+ if dst == os.path.abspath(src) or os.path.lexists(dst):
766
+ continue
767
+ try:
768
+ mkdirp(os.path.dirname(dst))
769
+ shutil.move(src, dst)
770
+ moved.append(dst)
771
+ except (OSError, shutil.Error):
772
+ pass
773
+ return moved
774
+
775
+
710
776
  def set_managed_block(path, block, start="# tezgah:start", end="# tezgah:end"):
711
777
  """Replace the tezgah block in a text file, or append it. A YAML file that
712
778
  is an empty sequence (`[]`, the dsh patch-layer default) loses that token
@@ -995,6 +1061,7 @@ def install_common(new_roots, hosts):
995
1061
  ("tezgah-decisions", os.path.join(HERE, "bin", "tezgah-decisions")),
996
1062
  ("tezgah-route", os.path.join(HERE, "bin", "tezgah-route")),
997
1063
  ("tezgah-lessons", os.path.join(HERE, "bin", "tezgah-lessons")),
1064
+ ("tezgah-skill", os.path.join(HERE, "bin", "tezgah-skill")),
998
1065
  # `tezgah` is this installer under the name `tezgah update` is typed
999
1066
  # with (npm ships the same name). Not here on purpose: tezgah-dsh
1000
1067
  # (wired as ~/.local/bin/dsh, see dsh_launcher) and
@@ -1821,6 +1888,37 @@ def cursor_hook_entries():
1821
1888
  for ev, extra in CURSOR_HOOK_EVENTS.items()}
1822
1889
 
1823
1890
 
1891
+ # Cursor also reads ~/.codex/skills, ~/.claude/skills and ~/.agents/skills
1892
+ # (cursor.com/docs/context/skills) and documents no winner for a name found in
1893
+ # two of them. A tezgah skill Codex's install already linked into
1894
+ # ~/.codex/skills therefore reached Cursor twice once ~/.cursor/skills linked it
1895
+ # too: ~/.cursor/skills holds only the names no other dir Cursor reads resolves
1896
+ # to this tree. Cursor installs last (install_all), so it sees Codex's links.
1897
+ CURSOR_SHARED_SKILLS = tuple(os.path.join(HOME, d, "skills")
1898
+ for d in (".codex", ".claude", ".agents"))
1899
+
1900
+
1901
+ def cursor_shared_copy(s):
1902
+ """The other dir Cursor reads that already links skill `s` to this tree."""
1903
+ want = os.path.realpath(os.path.join(HERE, "skills", s))
1904
+ return next((d for d in CURSOR_SHARED_SKILLS
1905
+ if os.path.realpath(os.path.join(d, s)) == want), None)
1906
+
1907
+
1908
+ def link_cursor_skills():
1909
+ shared = []
1910
+ for s in SKILLS:
1911
+ dst = os.path.join(CURSOR, "skills", s)
1912
+ if cursor_shared_copy(s):
1913
+ unlink_tezgah(dst)
1914
+ shared.append(s)
1915
+ else:
1916
+ print(link(os.path.join(HERE, "skills", s), dst))
1917
+ if shared:
1918
+ print("%s %d skills reach Cursor through %s, not linked twice"
1919
+ % (OK, len(shared), ", ".join(sorted({cursor_shared_copy(s) for s in shared}))))
1920
+
1921
+
1824
1922
  def install_cursor():
1825
1923
  print("\ncursor:")
1826
1924
  path = os.path.join(CURSOR, "hooks.json")
@@ -1836,8 +1934,7 @@ def install_cursor():
1836
1934
  arr.append(entry)
1837
1935
  write_json(path, data)
1838
1936
  print("%s %s" % (OK, path))
1839
- for s in SKILLS:
1840
- print(link(os.path.join(HERE, "skills", s), os.path.join(CURSOR, "skills", s)))
1937
+ link_cursor_skills()
1841
1938
  cc = os.path.join(CURSOR, "cli-config.json")
1842
1939
  cfg = read_config(cc, {})
1843
1940
  if cfg is not None:
@@ -3038,8 +3135,9 @@ def remove_generated_state(full):
3038
3135
  kept = []
3039
3136
  for name in names:
3040
3137
  path = os.path.join(tp.CONFIG_DIR, name)
3041
- if name == "adopted":
3042
- kept.append((path, "predecessor originals --adopt moved here"))
3138
+ if name in ("adopted", "backups"):
3139
+ kept.append((path, "predecessor originals --adopt moved here"
3140
+ if name == "adopted" else "host-file backups, for a manual restore"))
3043
3141
  continue
3044
3142
  if name in GENERATED_STATE or name.endswith((".off", "-off")) \
3045
3143
  or (name.endswith(".tezgah-bak")
@@ -3253,7 +3351,7 @@ def unwire_claude_settings():
3253
3351
  changed = True
3254
3352
  if changed:
3255
3353
  write_json(sp, s)
3256
- print("%s removed tezgah statusLine/attribution/enabledPlugins from %s (restore from its .tezgah-bak copies if wanted)" % (OK, sp))
3354
+ print("%s removed tezgah statusLine/attribution/enabledPlugins from %s (restore from its .tezgah-bak copies under %s if wanted)" % (OK, sp, BACKUP_DIR))
3257
3355
 
3258
3356
 
3259
3357
  def unwire_opencode_config():
@@ -3484,8 +3582,8 @@ def uninstall(hosts, dry_run=False):
3484
3582
  tezgah wrote outlives the command, and the verify pass at the end re-derives
3485
3583
  that from the disk.
3486
3584
 
3487
- What never goes, in any run: the user's own files, `.tezgah-bak` backups
3488
- outside the config dir, the `adopted/` predecessor snapshots, and every
3585
+ What never goes, in any run: the user's own files, the host-file backups
3586
+ under `BACKUP_DIR`, the `adopted/` predecessor snapshots, and every
3489
3587
  repository's `.tezgah/` research state. A partial run (some armed hosts
3490
3588
  stay) also keeps config.json and the install tree, because the hosts still
3491
3589
  armed read both at runtime."""
@@ -3523,6 +3621,9 @@ def uninstall(hosts, dry_run=False):
3523
3621
  uninstall_common()
3524
3622
  for h in hosts:
3525
3623
  UNINSTALLERS[h]()
3624
+ if "codex" in hosts and "cursor" in (armed or []) and "cursor" not in hosts:
3625
+ print("\ncursor (its skills came through ~/.codex/skills):")
3626
+ link_cursor_skills()
3526
3627
  if "claude" in hosts:
3527
3628
  print("\nclaude plugin copy:")
3528
3629
  remove_plugin_copies()
@@ -4304,7 +4405,7 @@ def dsh_servers(path):
4304
4405
 
4305
4406
 
4306
4407
  def host_checks_claude():
4307
- copies = plugin_copies()
4408
+ copies = live_plugin_copies()
4308
4409
  return [
4309
4410
  ("statusline wired (link or copy)",
4310
4411
  is_tezgah_entry(os.path.join(CLAUDE, "statusline.py"))),
@@ -4560,7 +4661,9 @@ def host_checks_cursor():
4560
4661
  ("hooks.json wired", _hooks_has(os.path.join(CURSOR, "hooks.json"),
4561
4662
  "tezgah-cursor-hook",
4562
4663
  list(CURSOR_HOOK_EVENTS))),
4563
- ("skills linked", skills_linked(os.path.join(CURSOR, "skills"))),
4664
+ ("skills linked", all(
4665
+ os.path.isfile(os.path.join(CURSOR, "skills", s, "SKILL.md"))
4666
+ or cursor_shared_copy(s) for s in SKILLS)),
4564
4667
  ("MCP in mcp.json", "codegraph" in (mcp.get("mcpServers") or {})),
4565
4668
  ] + mcp_rows(set(mcp.get("mcpServers") or {}), "mcp.json") + [
4566
4669
  mcp_row(TEZGAH_MCP_NAME, (mcp.get("mcpServers") or {}).get(TEZGAH_MCP_NAME),
@@ -4870,6 +4973,19 @@ def plugin_copies():
4870
4973
  return sorted(out)
4871
4974
 
4872
4975
 
4976
+ def live_plugin_copies():
4977
+ """The plugin copies a refresh keeps current: the one installed_plugins.json
4978
+ names, or every copy when it names none. An older version dir is Claude's
4979
+ cache to collect; refreshing it rewrote a tree no session loads, and its
4980
+ staleness turned the report red for nothing."""
4981
+ copies = plugin_copies()
4982
+ rows = read_json(INSTALLED_PLUGINS, {}).get("plugins") or {}
4983
+ named = {os.path.realpath(r.get("installPath") or "")
4984
+ for k, v in rows.items() if k.split("@")[0] == "tezgah"
4985
+ for r in (v or [])[-1:] if isinstance(r, dict)}
4986
+ return [t for t in copies if os.path.realpath(t) in named] or copies
4987
+
4988
+
4873
4989
  def copy_files(target):
4874
4990
  """Every managed relpath the copy holds.
4875
4991
 
@@ -5100,7 +5216,7 @@ def sync():
5100
5216
  cache = os.path.join(HOME, ".claude", "plugins", "cache")
5101
5217
  plugins = os.path.dirname(cache)
5102
5218
  _recover_sync(plugins)
5103
- targets = plugin_copies()
5219
+ targets = live_plugin_copies()
5104
5220
  if not targets:
5105
5221
  print("no installed tezgah found under %s (nothing to sync)" % cache)
5106
5222
  return 1
@@ -5176,12 +5292,12 @@ def refresh_plugin_copy(hosts):
5176
5292
  `.mcp.json` is excluded from `plugin_copy_current` in both directions on
5177
5293
  purpose - rendering into the copy must not make a current copy read stale -
5178
5294
  which also means a copy carrying no tezgah row at all is still judged
5179
- current. So a claude install always renders the file into every copy with
5180
- that copy as the root, the same half `sync()` performs."""
5295
+ current. So a claude install always renders the file into every live copy
5296
+ (`live_plugin_copies`) with that copy as the root, the same half `sync()` performs."""
5181
5297
  if "claude" in hosts:
5182
- for t in plugin_copies():
5298
+ for t in live_plugin_copies():
5183
5299
  write_mcp_json(path=os.path.join(t, MCP_JSON), selected=selection(), root=t)
5184
- if any(not plugin_copy_current(t) for t in plugin_copies()):
5300
+ if any(not plugin_copy_current(t) for t in live_plugin_copies()):
5185
5301
  print()
5186
5302
  sync()
5187
5303
 
@@ -5322,7 +5438,8 @@ def wizard_plan(hosts, roots, deps_off, features, adopt_now):
5322
5438
  if adopt_now:
5323
5439
  print(" %s retire the predecessor wiring (moved aside, not deleted)" % OK)
5324
5440
  print(" files tezgah rewrites are backed up as <file>.<timestamp>.tezgah-bak "
5325
- "(oldest and newest four kept); an unparseable host config is refused")
5441
+ "under %s (oldest and newest four kept); an unparseable host config "
5442
+ "is refused" % BACKUP_DIR)
5326
5443
 
5327
5444
 
5328
5445
  def wizard(default_hosts, default_roots, deps_off_default, default_features,
@@ -5374,7 +5491,7 @@ def upgrade(version, dry_run):
5374
5491
  so its farm links would point back into it. The hosts and roots come from
5375
5492
  config.json, so an upgrade does not re-ask the install questions. Nothing
5376
5493
  else calls this: a flag never upgrades tezgah as a side effect of another
5377
- (hooks/tezgah_policy.py:786). Prints every step before running it, and
5494
+ (hooks/tezgah_policy.py:792). Prints every step before running it, and
5378
5495
  `--dry-run` stops after the printing."""
5379
5496
  cfg = tp.config()
5380
5497
  hosts = cfg.get("hosts") or detected_hosts()
@@ -5472,8 +5589,11 @@ def install_all(hosts, new_roots, deps_off, dry_run, redetect):
5472
5589
  install_openresearch(hosts)
5473
5590
  else:
5474
5591
  print("\nopenresearch (orx):\n%s skipped: orx is not selected" % NO)
5475
- for h in hosts:
5592
+ # cursor last: link_cursor_skills reads the skill links the other hosts made
5593
+ for h in sorted(hosts, key=lambda h: h == "cursor"):
5476
5594
  INSTALLERS[h]()
5595
+ for dst in sweep_backups():
5596
+ print("%s moved a backup out of a host dir: %s" % (OK, dst))
5477
5597
  refresh_plugin_copy(hosts)
5478
5598
  record_hooks(hosts)
5479
5599
  print()
@@ -5643,6 +5763,11 @@ def main(argv):
5643
5763
  label, ok = hosts_row()
5644
5764
  if not ok:
5645
5765
  print("%s%s" % (NO, label))
5766
+ # Orca's presence, and whose codex home CODEX_HOME names: printed only
5767
+ # where Orca is installed or this shell runs inside it.
5768
+ orca = tezgah_orca.summary(CODEX)
5769
+ if orca:
5770
+ print(orca)
5646
5771
  return 0
5647
5772
  if args.deps and not args.install:
5648
5773
  return 1 if install_deps(dry_run=args.dry_run) else 0