@r1z4x/tezgah 1.11.0 → 1.14.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 (109) hide show
  1. package/CHANGELOG.md +507 -1
  2. package/MANIFEST +40 -0
  3. package/NOTICE +152 -0
  4. package/README.de.md +41 -20
  5. package/README.es.md +39 -19
  6. package/README.fr.md +41 -20
  7. package/README.md +18 -10
  8. package/README.tr.md +39 -21
  9. package/bin/codegen +9 -2
  10. package/bin/consult +6 -0
  11. package/bin/tezgah-adhd +3 -0
  12. package/bin/tezgah-agents +3 -0
  13. package/bin/tezgah-capture +3 -0
  14. package/bin/tezgah-design +329 -54
  15. package/bin/tezgah-docs +158 -42
  16. package/bin/tezgah-dsh +1 -1
  17. package/bin/tezgah-gate +7 -2
  18. package/bin/tezgah-import-ai-research +16 -4
  19. package/bin/tezgah-index +3 -0
  20. package/bin/tezgah-lessons +13 -4
  21. package/bin/tezgah-mcp +4 -0
  22. package/bin/tezgah-migrate +3 -0
  23. package/bin/tezgah-ocr-review +2 -1
  24. package/bin/tezgah-optimize +4 -1
  25. package/bin/tezgah-pony +3 -0
  26. package/bin/tezgah-research +51 -10
  27. package/bin/tezgah-rollback +3 -0
  28. package/bin/tezgah-route +1 -1
  29. package/bin/tezgah-setup +1741 -95
  30. package/bin/tezgah-status +11 -1
  31. package/bin/tezgah-task +58 -17
  32. package/bin/tezgah.js +10 -3
  33. package/commands/adhd.md +1 -1
  34. package/docs/README.md +19 -8
  35. package/docs/architecture.md +2 -2
  36. package/docs/citations-baseline.json +11 -17
  37. package/docs/clarity-baseline.json +12 -6
  38. package/docs/contract.md +83 -34
  39. package/docs/environment.md +59 -24
  40. package/docs/evidence.md +128 -25
  41. package/docs/gate.md +77 -23
  42. package/docs/glossary.md +1 -1
  43. package/docs/hosts.md +343 -48
  44. package/docs/layers.md +2 -2
  45. package/docs/models.md +6 -0
  46. package/docs/operations.md +83 -29
  47. package/docs/research.md +37 -11
  48. package/docs/skills.md +31 -8
  49. package/docs/status-line.md +39 -23
  50. package/docs/testing.md +50 -17
  51. package/docs/vision.md +6 -2
  52. package/hooks/hooks.json +1 -1
  53. package/hooks/lang/en.json +16 -2
  54. package/hooks/lang/tr.json +14 -2
  55. package/hooks/projects-posttooluse.py +11 -3
  56. package/hooks/projects-stop.py +3 -2
  57. package/hooks/tezgah_agents.py +27 -3
  58. package/hooks/tezgah_attest.py +79 -10
  59. package/hooks/tezgah_context.py +61 -31
  60. package/hooks/tezgah_gate.py +480 -126
  61. package/hooks/tezgah_integrity.py +577 -133
  62. package/hooks/tezgah_lessons.py +5 -2
  63. package/hooks/tezgah_paths.py +60 -8
  64. package/hooks/tezgah_policy.py +48 -18
  65. package/hooks/tezgah_replay.py +8 -2
  66. package/hooks/tezgah_research.py +167 -24
  67. package/hooks/tezgah_skill_pick.py +1 -1
  68. package/hooks/tezgah_snapshot.py +6 -3
  69. package/hooks/tezgah_stack.py +236 -1
  70. package/hooks/tezgah_stopspec.py +2 -1
  71. package/hooks/tezgah_task.py +2 -1
  72. package/hosts/amp/hook.py +217 -0
  73. package/hosts/amp/tezgah-plugin.ts.in +396 -0
  74. package/hosts/antigravity/hook.py +255 -0
  75. package/hosts/codex/hook.py +3 -2
  76. package/hosts/commandcode/hook.py +239 -0
  77. package/hosts/copilot/hook.py +284 -0
  78. package/hosts/cursor/hook.py +4 -2
  79. package/hosts/droid/hook.py +259 -0
  80. package/hosts/dsh/hooks.json +1 -1
  81. package/hosts/gemini/hook.py +213 -0
  82. package/hosts/goose/hook.py +145 -0
  83. package/hosts/omp/hook.py +5 -2
  84. package/hosts/omp/tezgah-hook.ts.in +157 -42
  85. package/hosts/opencode/plugins/tezgah.js +209 -63
  86. package/hosts/qwen/hook.py +206 -0
  87. package/output-styles/tezgah.md +11 -6
  88. package/package.json +6 -3
  89. package/packaging/build.sh +4 -4
  90. package/packaging/install.sh +11 -0
  91. package/packaging/upgrade.sh +3 -0
  92. package/skills/ai-research/LICENSE +21 -0
  93. package/skills/design-contract/SKILL.md +27 -6
  94. package/skills/design-library/LICENSE +21 -0
  95. package/skills/harness/SKILL.md +2 -0
  96. package/skills/i-have-adhd/SKILL.md +10 -1
  97. package/skills/plan-add/SKILL.md +5 -1
  98. package/skills/plan-add/render_table.py +7 -0
  99. package/skills/pm-frameworks/LICENSE +21 -0
  100. package/skills/research/SKILL.md +22 -9
  101. package/skills/rl-env/LICENSE +201 -0
  102. package/skills/tezgah-contract/SKILL.md +21 -8
  103. package/statusline.py +20 -9
  104. package/third_party/open-code-review/LICENSE +201 -0
  105. package/third_party/open-code-review/README.md +15 -0
  106. package/third_party/open-code-review/prompts/main_task_system.md +26 -0
  107. package/third_party/open-code-review/prompts/review_filter_task_system.md +13 -0
  108. package/workflows/graph-map.js +14 -5
  109. package/workflows/graph-review.js +14 -2
@@ -301,6 +301,68 @@ function labelled(content: any, label: string): any {
301
301
  return undefined;
302
302
  }
303
303
 
304
+ // The calls one omp tool call carries, as the core names them (B-010). omp
305
+ // routes an MCP call through its device tool - `write` to `xd://mcp__<name>`
306
+ // with the call's arguments as JSON in `content` - and the core's MCP rules,
307
+ // the MCP_EFFECT spawn filter and the Stop rule's `external` row are keyed on
308
+ // the real name, so the device form is read as `mcp__<name>` with the parsed
309
+ // arguments (an unparsable body stays readable as `content`). The URL is
310
+ // matched the way omp resolves it: trimmed, the scheme in any case. `ast_edit`
311
+ // (direct or as `xd://ast_edit`) only stages its rewrite and omp applies it
312
+ // later through `write xd://resolve`, so the gate reads its targets here: one
313
+ // `edit` per `paths` item, the ops' replacement text as the new content, marked
314
+ // `via: "ast_edit"` - only such an item is read as a glob or a directory.
315
+ // `outer` keeps the device `write` itself in front, for the gate only: its
316
+ // content rules (a credential, a skip) read the whole body whatever class the
317
+ // carried call is, as they did before the route (review 153 F1). The gate
318
+ // writes no `began` for it (tezgah_gate.ROUTED_DEVICE): its result is recorded
319
+ // as the call it carries.
320
+ const DEVICE = /^xd:\/\/(mcp__[^/?#]+|ast_edit)$/i;
321
+ function deviceOf(tool: string, args: any): RegExpExecArray | null {
322
+ return tool.toLowerCase() === "write" ? DEVICE.exec(String(args?.path ?? "").trim()) : null;
323
+ }
324
+ function routed(tool: string, args: any, outer = false): Array<[string, any]> {
325
+ let name = tool;
326
+ let input = args ?? {};
327
+ const device = deviceOf(name, input);
328
+ const calls: Array<[string, any]> = device && outer ? [[name, input]] : [];
329
+ if (device) {
330
+ name = device[1].toLowerCase() === "ast_edit" ? "ast_edit"
331
+ : "mcp__" + device[1].slice("mcp__".length);
332
+ const raw = String(input?.content ?? "");
333
+ try {
334
+ const parsed = JSON.parse(raw);
335
+ input = parsed && typeof parsed === "object" ? parsed : { content: raw };
336
+ } catch {
337
+ input = { content: raw };
338
+ }
339
+ }
340
+ if (name.toLowerCase() !== "ast_edit") return [...calls, [name, input]];
341
+ const ops = Array.isArray(input?.ops) ? input.ops : [];
342
+ const out = ops.map((op: any) => String(op?.out ?? "")).join("\n");
343
+ const paths = Array.isArray(input?.paths) ? input.paths : [];
344
+ return [...calls, ...paths.map((p: any): [string, any] =>
345
+ ["edit", { path: String(p), new_string: out, via: "ast_edit" }])];
346
+ }
347
+
348
+ // omp emits a device call and then, under the same id, the native event of the
349
+ // call it carries: the device event leaves one key per carried call it passed
350
+ // and that native event consumes it, so a row, a label and a retry count are
351
+ // written once (review 153 F4). Nothing else is skipped: a direct call leaves
352
+ // no key, and a reused id, an empty one or a refused call reaches the gate and
353
+ // the ledger again (review 153 round 2 #1). ponytail: the newest 512 keys.
354
+ const CARRIED = { call: new Set<string>(), result: new Set<string>() };
355
+ function carriedKey(id: any, tool: string, input: any): string | null {
356
+ if (id == null || id === "") return null;
357
+ return `${id}:${tool.toLowerCase()}:${String(input?.path ?? "")}`;
358
+ }
359
+ function carry(seen: Set<string>, keys: string[]): void {
360
+ for (const key of keys) {
361
+ seen.add(key);
362
+ if (seen.size > 512) seen.delete(seen.values().next().value as string);
363
+ }
364
+ }
365
+
304
366
  // A check's own output saying it ran nothing: the same literal as
305
367
  // hooks/tezgah_integrity.py `EMPTY_RUN` (tests/test_omp_extension.py pins the
306
368
  // two equal), read here because the bridge sends the hook a size and never the
@@ -315,6 +377,22 @@ function emptyRun(content: any): boolean {
315
377
  return EMPTY_RUN.test(text.slice(-EMPTY_RUN_TAIL));
316
378
  }
317
379
 
380
+ // The text of one assistant message, whatever shape it arrives in: omp hands
381
+ // `session_stop` the message OBJECT its `getLastAssistantMessage()` returns,
382
+ // and the core must judge the prose, never its `str()` (plan 143). A thinking
383
+ // or tool block is not the reply: only `type: "text"` is.
384
+ function textOf(message: any): string | undefined {
385
+ if (typeof message === "string") return message;
386
+ if (typeof message?.content === "string") return message.content;
387
+ if (Array.isArray(message?.content)) {
388
+ // joined and trimmed exactly as the core does (`reply_prose`), so the two
389
+ // never hand the rule a different text for one reply
390
+ return message.content.map((p: any) => (p?.type === "text" && typeof p.text === "string"
391
+ ? p.text : "")).join("\n").trim();
392
+ }
393
+ return undefined;
394
+ }
395
+
318
396
  // The text of the newest assistant message in an agent_end `messages` list:
319
397
  // the subagent's final report, which the hook reads like a Stop reply.
320
398
  function lastReply(messages: any): string | undefined {
@@ -322,11 +400,8 @@ function lastReply(messages: any): string | undefined {
322
400
  for (let i = messages.length - 1; i >= 0; i--) {
323
401
  const m = messages[i];
324
402
  if (m?.role !== "assistant") continue;
325
- if (typeof m.content === "string") return m.content;
326
- if (Array.isArray(m.content)) {
327
- return m.content.map((p: any) => (p?.type === "text" && typeof p.text === "string"
328
- ? p.text : "")).join("\n");
329
- }
403
+ const text = textOf(m);
404
+ if (text !== undefined) return text;
330
405
  }
331
406
  return undefined;
332
407
  }
@@ -460,6 +535,13 @@ function isSubagent(ctx: any): boolean {
460
535
  }
461
536
  }
462
537
 
538
+ // A subagent's events name the main session as `parent`, as its session_start
539
+ // does: the switch latch counts from the earlier of the two sessions' first
540
+ // rows, so a switch the parent made mid-session holds no sway here (B-036).
541
+ function parentOf(ctx: any): string | undefined {
542
+ return isSubagent(ctx) ? process.env.TEZGAH_SESSION || undefined : undefined;
543
+ }
544
+
463
545
  // The session an omp fork was forked from: a main session whose header still
464
546
  // names a `parentSession` (an id, or a `<stamp>_<id>.jsonl` path). The hook
465
547
  // writes it as a `forked` row, so the race rule names a fork as one instead of
@@ -575,7 +657,8 @@ export default function tezgahOmp(pi: any): void {
575
657
  // A failed hook answers nothing, so the last good line would keep claiming a
576
658
  // gate that is not running: the widget is repainted with the failure instead.
577
659
  const refresh = (ctx: any): void => {
578
- const res = ask({ event: "status", cwd: cwdOf(ctx), session_id: sessionOf(ctx) });
660
+ const res = ask({ event: "status", cwd: cwdOf(ctx), session_id: sessionOf(ctx),
661
+ parent: parentOf(ctx) });
579
662
  if (hookError) return markBroken(ctx);
580
663
  show(ctx, res);
581
664
  };
@@ -706,7 +789,7 @@ export default function tezgahOmp(pi: any): void {
706
789
  // `subagent`: a task prompt is the parent's brief, never the user's grant
707
790
  const res = ask({ event: "user_prompt", cwd: cwdOf(ctx), prompt,
708
791
  session_id: session, transcript_path: sessionFileOf(ctx),
709
- subagent: isSubagent(ctx) });
792
+ subagent: isSubagent(ctx), parent: parentOf(ctx) });
710
793
  if (hookError) return noticeMessage(ctx, session);
711
794
  if (!res.context) return undefined;
712
795
  // A hidden message, not a system-prompt rewrite: omp keeps per-request
@@ -717,38 +800,44 @@ export default function tezgahOmp(pi: any): void {
717
800
  });
718
801
 
719
802
  pi.on("tool_call", async (event: any, ctx: any) => {
720
- const tool = String(event?.toolName ?? event?.name ?? "");
721
803
  // An MCP tool is named per server (`mcp__<server>_<tool>`), so no exact
722
804
  // list can hold it: an effect verb in the name reaches the gate, whose
723
- // content rules read its payload (tezgah_integrity.mcp_class).
724
- if (!GATED.includes(tool.toLowerCase()) && !MCP_EFFECT.test(tool)) {
725
- return undefined;
726
- }
727
- const res = ask({ event: "pre_tool_use", cwd: cwdOf(ctx), tool,
728
- input: event?.input ?? event?.args ?? {},
729
- session_id: sessionOf(ctx) });
730
- if (hookError) {
731
- markBroken(ctx);
732
- return undefined; // a hook that cannot answer cannot refuse either
733
- }
734
- if (res.deny) return { block: true, reason: res.deny };
735
- if (res.advice && event?.toolCallId != null) {
736
- ADVICE.set(String(event.toolCallId), String(res.advice));
805
+ // content rules read its payload (tezgah_integrity.mcp_class). A device
806
+ // call is judged as itself and as the call it carries (`routed`), and the
807
+ // native event of a carried call it passed is not asked twice (`CARRIED`).
808
+ const callId = event?.toolCallId;
809
+ const name = String(event?.toolName ?? event?.name ?? "");
810
+ const args = event?.input ?? event?.args ?? {};
811
+ const device = deviceOf(name, args) != null;
812
+ const carried: string[] = [];
813
+ for (const [n, [tool, input]] of routed(name, args, true).entries()) {
814
+ if (!GATED.includes(tool.toLowerCase()) && !MCP_EFFECT.test(tool)) continue;
815
+ const key = carriedKey(callId, tool, input);
816
+ if (device) {
817
+ if (n > 0 && key) carried.push(key); // the device `write` itself is first
818
+ } else if (key && CARRIED.call.delete(key)) {
819
+ continue;
820
+ }
821
+ const res = ask({ event: "pre_tool_use", cwd: cwdOf(ctx), tool, input,
822
+ session_id: sessionOf(ctx), parent: parentOf(ctx) });
823
+ if (hookError) {
824
+ markBroken(ctx);
825
+ return undefined; // a hook that cannot answer cannot refuse either
826
+ }
827
+ if (res.deny) return { block: true, reason: res.deny };
828
+ if (res.advice && event?.toolCallId != null) {
829
+ ADVICE.set(String(event.toolCallId), String(res.advice));
830
+ }
737
831
  }
832
+ carry(CARRIED.call, carried);
738
833
  return undefined;
739
834
  });
740
835
 
741
836
  pi.on("tool_result", async (event: any, ctx: any) => {
742
- const tool = String(event?.toolName ?? "");
743
- const args = event?.input ?? {};
744
837
  const id = event?.toolCallId != null ? String(event.toolCallId) : "";
745
838
  const advice = ADVICE.get(id);
746
839
  ADVICE.delete(id);
747
840
  const graph = advice ? { additionalContext: advice } : undefined;
748
- if (!WATCHED.includes(tool.toLowerCase()) && !tool.startsWith("mcp__")
749
- && !skillRead(tool, args)) {
750
- return graph;
751
- }
752
841
  // Tri-state: an unread outcome must never be written as a passed check,
753
842
  // because the Stop rule below reads this ledger as evidence.
754
843
  const failed = typeof event?.isError === "boolean" ? event.isError : null;
@@ -762,18 +851,34 @@ export default function tezgahOmp(pi: any): void {
762
851
  const content = event?.content;
763
852
  const resultLen = typeof content === "string" ? content.length
764
853
  : Array.isArray(content) ? content.length : undefined;
765
- // omp's todo call holds one op and its result's `details.phases` the whole
766
- // list, so that list crosses for this one tool: the queue the Stop rule and
767
- // the idle-wait warning read (tezgah_integrity.note_todo).
768
- const phases = tool.toLowerCase() === "todo" ? event?.details?.phases : undefined;
769
- const todo = Array.isArray(phases)
770
- ? phases.flatMap((p: any) => (Array.isArray(p?.tasks) ? p.tasks : []))
771
- : undefined;
772
- const res = ask({ event: "post_tool_use", cwd: cwdOf(ctx), tool,
773
- input: event?.input ?? {}, failed, idx: idxGlyph,
774
- session_id: sessionOf(ctx), result_len: resultLen,
775
- empty_run: emptyRun(content) || undefined, todo });
776
- if (hookError) return markBroken(ctx);
854
+ let res: any;
855
+ // a device call is recorded as the call it carries (`routed`): an MCP
856
+ // query's answer is an `external` row, never an edit of `xd://...`
857
+ const name = String(event?.toolName ?? "");
858
+ const input = event?.input ?? {};
859
+ const device = deviceOf(name, input) != null;
860
+ for (const [tool, args] of routed(name, input)) {
861
+ if (!WATCHED.includes(tool.toLowerCase()) && !tool.startsWith("mcp__")
862
+ && !skillRead(tool, args)) {
863
+ continue;
864
+ }
865
+ const key = carriedKey(event?.toolCallId, tool, args);
866
+ if (!device && key && CARRIED.result.delete(key)) continue;
867
+ // omp's todo call holds one op and its result's `details.phases` the whole
868
+ // list, so that list crosses for this one tool: the queue the Stop rule and
869
+ // the idle-wait warning read (tezgah_integrity.note_todo).
870
+ const phases = tool.toLowerCase() === "todo" ? event?.details?.phases : undefined;
871
+ const todo = Array.isArray(phases)
872
+ ? phases.flatMap((p: any) => (Array.isArray(p?.tasks) ? p.tasks : []))
873
+ : undefined;
874
+ res = ask({ event: "post_tool_use", cwd: cwdOf(ctx), tool,
875
+ input: args, failed, idx: idxGlyph,
876
+ session_id: sessionOf(ctx), parent: parentOf(ctx), result_len: resultLen,
877
+ empty_run: emptyRun(content) || undefined, todo });
878
+ if (hookError) return markBroken(ctx);
879
+ if (device && key) carry(CARRIED.result, [key]);
880
+ }
881
+ if (!res) return graph;
777
882
  show(ctx, res);
778
883
  // A result that came from outside the user and the workspace is labelled in
779
884
  // place; every other result is left exactly as the host produced it.
@@ -785,9 +890,19 @@ export default function tezgahOmp(pi: any): void {
785
890
  });
786
891
 
787
892
  pi.on("session_stop", async (event: any, ctx: any) => {
893
+ // `sessionModes` exists only in an interactive TUI session; its `plan`
894
+ // state is what every other host sends as `permission_mode`, so the core
895
+ // reads one field (tezgah_integrity.read_only_turn). A session without it
896
+ // (print, RPC, a subagent) sends nothing and is judged as `default`.
897
+ let mode: string | undefined;
898
+ try {
899
+ mode = ctx?.sessionModes?.getState?.()?.plan === "active" ? "plan" : undefined;
900
+ } catch { mode = undefined; }
788
901
  const res = ask({ event: "stop", cwd: cwdOf(ctx), session_id: sessionOf(ctx),
789
- last_assistant_message: event?.last_assistant_message,
790
- stop_hook_active: event?.stop_hook_active === true });
902
+ last_assistant_message: textOf(event?.last_assistant_message)
903
+ ?? lastReply(event?.messages),
904
+ stop_hook_active: event?.stop_hook_active === true,
905
+ permission_mode: mode });
791
906
  if (hookError) {
792
907
  markBroken(ctx);
793
908
  return undefined; // an unanswerable Stop rule must not block the turn