@r1z4x/tezgah 1.13.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 (93) hide show
  1. package/CHANGELOG.md +175 -1
  2. package/MANIFEST +18 -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 +15 -8
  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 +157 -41
  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 +12 -6
  27. package/bin/tezgah-rollback +3 -0
  28. package/bin/tezgah-route +1 -1
  29. package/bin/tezgah-setup +596 -157
  30. package/bin/tezgah-status +11 -1
  31. package/bin/tezgah-task +3 -0
  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 +3 -3
  38. package/docs/contract.md +77 -31
  39. package/docs/environment.md +58 -24
  40. package/docs/evidence.md +49 -19
  41. package/docs/gate.md +71 -21
  42. package/docs/hosts.md +65 -52
  43. package/docs/layers.md +1 -1
  44. package/docs/operations.md +67 -24
  45. package/docs/research.md +1 -1
  46. package/docs/skills.md +31 -8
  47. package/docs/status-line.md +33 -23
  48. package/docs/testing.md +48 -17
  49. package/docs/vision.md +6 -2
  50. package/hooks/lang/en.json +5 -2
  51. package/hooks/lang/tr.json +3 -2
  52. package/hooks/projects-posttooluse.py +11 -3
  53. package/hooks/projects-stop.py +1 -1
  54. package/hooks/tezgah_context.py +40 -23
  55. package/hooks/tezgah_gate.py +404 -106
  56. package/hooks/tezgah_integrity.py +367 -97
  57. package/hooks/tezgah_lessons.py +5 -2
  58. package/hooks/tezgah_paths.py +17 -6
  59. package/hooks/tezgah_policy.py +13 -5
  60. package/hooks/tezgah_skill_pick.py +1 -1
  61. package/hooks/tezgah_snapshot.py +6 -3
  62. package/hooks/tezgah_stopspec.py +2 -1
  63. package/hosts/amp/hook.py +1 -1
  64. package/hosts/amp/tezgah-plugin.ts.in +13 -3
  65. package/hosts/antigravity/hook.py +1 -1
  66. package/hosts/codex/hook.py +1 -1
  67. package/hosts/commandcode/hook.py +1 -1
  68. package/hosts/copilot/hook.py +1 -1
  69. package/hosts/cursor/hook.py +2 -1
  70. package/hosts/droid/hook.py +1 -1
  71. package/hosts/gemini/hook.py +1 -1
  72. package/hosts/goose/hook.py +1 -1
  73. package/hosts/omp/hook.py +1 -1
  74. package/hosts/omp/tezgah-hook.ts.in +127 -35
  75. package/hosts/opencode/plugins/tezgah.js +209 -63
  76. package/hosts/qwen/hook.py +1 -1
  77. package/output-styles/tezgah.md +1 -1
  78. package/package.json +6 -3
  79. package/packaging/build.sh +4 -4
  80. package/packaging/install.sh +11 -0
  81. package/packaging/upgrade.sh +3 -0
  82. package/skills/ai-research/LICENSE +21 -0
  83. package/skills/design-contract/SKILL.md +27 -6
  84. package/skills/design-library/LICENSE +21 -0
  85. package/skills/plan-add/render_table.py +7 -0
  86. package/skills/pm-frameworks/LICENSE +21 -0
  87. package/skills/rl-env/LICENSE +201 -0
  88. package/skills/tezgah-contract/SKILL.md +2 -2
  89. package/third_party/open-code-review/LICENSE +201 -0
  90. package/third_party/open-code-review/README.md +15 -0
  91. package/third_party/open-code-review/prompts/main_task_system.md +26 -0
  92. package/third_party/open-code-review/prompts/review_filter_task_system.md +13 -0
  93. package/workflows/graph-map.js +14 -5
@@ -190,17 +190,17 @@ const EXPLORE_DENY =
190
190
  // as _CREDIT is there. The parity test in tests/test_opencode_plugin.py reads
191
191
  // this literal and the Python one and fails if the two alternations differ.
192
192
  const ATTRIB =
193
- /co-authored-by\s*:|generated with|made with|(?:generated|written|authored|built|assisted|drafted|produced|composed)\s+by\s+(?:(?:an?|the)\s+)?(?:ai|llm|claude|chatgpt|gpt|copilot|codex|cursor|gemini|deepseek|openai|anthropic)\b|(?:generated|written|authored|built|assisted|drafted|produced|composed)\s+(?:with|using)\s+(?:(?:an?|the)\s+)?(?:ai|llm|claude|chatgpt|gpt|copilot|codex|gemini|deepseek|openai|anthropic)(?:-[\w.]+)?(?:\s+(?:code|cli))?(?:\s*\([^)\n]*\))?(?=[.!)\]*_`]*(?:[\x22\x27,;]|\s+-\s|\s*(?:\n|$)))|noreply@anthropic|\u{1F916}/iu
193
+ /co-authored-by\s*[:=]|generated with|made with|(?:generated|written|authored|built|assisted|drafted|produced|composed)\s+by\s+(?:(?:an?|the)\s+)?(?:ai|llm|claude|chatgpt|gpt|copilot|codex|cursor|gemini|deepseek|openai|anthropic)\b|(?:generated|written|authored|built|assisted|drafted|produced|composed)\s+(?:with|using)\s+(?:(?:an?|the)\s+)?(?:ai|llm|claude|chatgpt|gpt|copilot|codex|gemini|deepseek|openai|anthropic)(?:-[\w.]+)?(?:\s+(?:code|cli))?(?:\s*\([^)\n]*\))?(?=[.!)\]*_`]*(?:[\x22\x27,;]|\s+-\s|\s*(?:\n|$)))|noreply@anthropic|\u{1F916}/iu
194
194
  // The same forms anchored to the start of a line, for the text a write/edit
195
195
  // tool is about to land. Mirrors hooks/tezgah_gate.py ATTRIB_LINE: a credit owns
196
196
  // its line, while prose naming the ban does not, and that anchor is what keeps
197
197
  // the rule from denying the documentation that describes it.
198
198
  const ATTRIB_LINE =
199
- /^[\s>#*/<!+-]*(?:co-authored-by\s*:|generated with|made with|(?:generated|written|authored|built|assisted|drafted|produced|composed)\s+by\s+(?:(?:an?|the)\s+)?(?:ai|llm|claude|chatgpt|gpt|copilot|codex|cursor|gemini|deepseek|openai|anthropic)\b|(?:generated|written|authored|built|assisted|drafted|produced|composed)\s+(?:with|using)\s+(?:(?:an?|the)\s+)?(?:ai|llm|claude|chatgpt|gpt|copilot|codex|gemini|deepseek|openai|anthropic)(?:-[\w.]+)?(?:\s+(?:code|cli))?(?:\s*\([^)\n]*\))?(?=[.!)\]*_`]*(?:[\x22\x27,;]|\s+-\s|\s*(?:\n|$)))|noreply@anthropic|\u{1F916})/imu
199
+ /^[\s>#*/<!+-]*(?:co-authored-by\s*[:=]|generated with|made with|(?:generated|written|authored|built|assisted|drafted|produced|composed)\s+by\s+(?:(?:an?|the)\s+)?(?:ai|llm|claude|chatgpt|gpt|copilot|codex|cursor|gemini|deepseek|openai|anthropic)\b|(?:generated|written|authored|built|assisted|drafted|produced|composed)\s+(?:with|using)\s+(?:(?:an?|the)\s+)?(?:ai|llm|claude|chatgpt|gpt|copilot|codex|gemini|deepseek|openai|anthropic)(?:-[\w.]+)?(?:\s+(?:code|cli))?(?:\s*\([^)\n]*\))?(?=[.!)\]*_`]*(?:[\x22\x27,;]|\s+-\s|\s*(?:\n|$)))|noreply@anthropic|\u{1F916})/imu
200
200
  const EDIT_TEXT = ["content", "new_string", "newString", "new_str", "file_text",
201
201
  "patch", "text"]
202
202
  const WRITE_CMD =
203
- /(?:^|[|;&]\s*|\s)git\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*(?:commit|merge|tag|notes)\b|(?:^|[|;&]\s*|\s)gh\s+api\b|(?:^|[|;&]\s*|\s)gh\s+(?:pr|issue|release)\s+(?:create|edit|comment|review|merge|close)\b/i
203
+ /(?:^|[|;&]\s*|\s)(?:\S*\/)?git\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*(?:commit|merge|tag|notes)\b|(?:^|[|;&]\s*|\s)(?:\S*\/)?gh\s+api\b|(?:^|[|;&]\s*|\s)(?:\S*\/)?gh\s+(?:pr|issue|release)\s+(?:create|edit|comment|review|merge|close)\b/i
204
204
  const ATTRIB_DENY =
205
205
  "Attribution is banned in every artifact tezgah touches. Remove the " +
206
206
  "Co-Authored-By / \"Generated with\" / robot-emoji / model-name credit from " +
@@ -314,12 +314,45 @@ const LEDGER_TAIL = 200
314
314
  // hooks/tezgah_integrity.VERIFY).
315
315
  const RUNNER_FLAGS =
316
316
  "(?:(?:--filter|--prefix|--workspace|--cwd|--dir|-C|-w)\\s+\\S+\\s+|-\\S+\\s+)*"
317
+ // hooks/tezgah_integrity.WRAPPER_VALUE_OPTS and _wrapper_prefix: the wrappers
318
+ // a check may sit behind, each with its env assignments, options, the options'
319
+ // values (a short one may close a cluster, `sudo -Hu deploy`) and `timeout`'s
320
+ // duration, so `timeout 60 pytest` and `nice -n 5 pytest` are checks on both
321
+ // sides (B-121; VerifyParity pins the agreement). The core reads this prefix
322
+ // case-sensitively inside its `re.I` (`(?-i:...)`), which node cannot spell, so
323
+ // WRAPPER_RUN strips it from the text with a regex of its own before VERIFY
324
+ // reads it: `sudo -H true pytest` runs `true`, as there. A value never starts
325
+ // with `-` nor looks like an assignment, so a failed match stays linear.
326
+ const WRAPPER_VALUE_OPTS = {
327
+ sudo: ["-u", "--user", "-g", "--group", "-h", "--host", "-p", "--prompt",
328
+ "-C", "--close-from", "-D", "--chdir", "-r", "--role", "-t", "--type",
329
+ "-U", "--other-user", "-T", "--command-timeout"],
330
+ env: ["-u", "--unset", "-C", "--chdir", "-S", "-P", "-L", "-U"],
331
+ nice: ["-n", "--adjustment"], nohup: [], exec: ["-a"], command: [],
332
+ builtin: [], time: ["-f", "--format", "-o", "--output"],
333
+ timeout: ["-s", "--signal", "-k", "--kill-after"],
334
+ stdbuf: ["-i", "--input", "-o", "--output", "-e", "--error"],
335
+ ionice: ["-c", "--class", "-n", "--classdata", "-p", "--pid"],
336
+ caffeinate: ["-t", "-w"],
337
+ }
338
+ const reEscape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
339
+ const WRAPPER_VALUE = "(?!\\w+=)[^-\\s]\\S*\\s+"
340
+ const WRAPPER_PREFIX = "(?:(?:[\\w./-]*/)?(?:" + Object.entries(WRAPPER_VALUE_OPTS)
341
+ .map(([name, opts]) => {
342
+ const shorts = [...new Set(opts.filter((o) => o.length === 2 && o !== "-S")
343
+ .map((o) => o[1]))].sort().join("")
344
+ const valued = (shorts ? "-[A-Za-z]*[" + reEscape(shorts) + "]\\s+" + WRAPPER_VALUE + "|" : "") +
345
+ opts.filter((o) => o.startsWith("--"))
346
+ .map((o) => reEscape(o) + "\\s+" + WRAPPER_VALUE + "|").join("")
347
+ return reEscape(name) + "\\s+(?:\\w+=\\S+\\s+|" + valued + "-\\S+\\s+)*" +
348
+ (name === "timeout" ? "(?:\\d\\S*\\s+)?" : "")
349
+ }).join("|") + "))*"
350
+ const WRAPPER_RUN = new RegExp("((?:^|[|;&(\\n])\\s*)" + WRAPPER_PREFIX, "g")
317
351
  const VERIFY = new RegExp(
318
352
  // command position only: `echo pytest` is not a check, and a `\\s` prefix made
319
353
  // the port read one where hooks/tezgah_integrity.VERIFY does not (review of
320
- // plan 135) - the wrapper set keeps `sudo pytest` and `env X=1 pytest`
321
- "(?:^|[|;&(\\n])\\s*(?:(?:sudo|env|time|nohup|command|exec)\\s+" +
322
- "(?:\\w+=\\S+\\s+)*(?:-\\S+\\s+)*)?(?:" +
354
+ // plan 135)
355
+ "(?:^|[|;&(\\n])\\s*(?:" +
323
356
  "(?:python3?|uv run)\\s+(?:(?:--project|--with|--python|--extra|--group|-p|-w)" +
324
357
  "\\s+\\S+\\s+|-\\S+\\s+)*(?:python3?\\s+-m\\s+)?" +
325
358
  "(?:pytest|unittest|mypy|ruff|flake8|compileall)|" +
@@ -337,14 +370,27 @@ const VERIFY = new RegExp(
337
370
  "bunx?|uvx)\\s+(?:-\\S+\\s+)*(?:playwright|cypress|storybook|chromatic|percy|" +
338
371
  "lighthouse|pa11y|axe-core|backstop|reg-suit|vitest|jest|ava|mocha|eslint|" +
339
372
  "prettier|tsc|pyright))\\b", "i")
340
- const NEUTER = /\|\|\s*(?:true|:|exit\s+0)(?:\s|$|[|;&])|;\s*true\s*(?:$|[|;&])/
373
+ // hooks/tezgah_integrity.NEUTER, the same alternatives: `|| true`, `|| :`,
374
+ // `|| exit 0`, `; true`, `; :` and `; exit 0` after a check. Also the bound on
375
+ // asking the core about a swallowed check: its own VERIFY decides the check.
376
+ const NEUTER = /\|\|\s*(?:true|:|exit\s+0)(?:\s|$|[|;&])|;\s*(?:true|:|exit\s+0)\s*(?:$|[|;&])/
377
+ // The bound on asking the core about the rest of the shortcut rule
378
+ // (hooks/tezgah_integrity.shortcut_command): a commit or push whose hooks a
379
+ // wrapper's options, an `eval` or an env var might hide, and an EXIT trap that
380
+ // could swallow a check's status. The core's readers step over the wrappers and
381
+ // open `eval`; this mirror's own (shortcutCommand) still runs after the core,
382
+ // and alone when the core cannot answer.
383
+ const SHORTCUT_CMD = /\b(?:commit|push|eval|trap)\b|--no-verify|\b(?:SKIP|HUSKY_SKIP_HOOKS|HUSKY)\s*=/i
341
384
  const SKIP_ENV = /\b(?:SKIP|HUSKY_SKIP_HOOKS)\s*=|\bHUSKY=0\b/
342
385
  const NO_VERIFY = /--no-verify\b/
343
386
  const GITISH = /\b(?:git|commit|push|husky|pre-commit|npm|yarn|pnpm)\b/i
344
387
  // mirrors _shell_segments/_hooks_redirect in hooks/tezgah_integrity.py: a
345
388
  // core.hooksPath assignment in the same line as a git commit/push skips the
346
- // hooks like --no-verify; read word by word through shellWords (shlex's reading)
347
- const HOOKS_KEY = "core.hookspath"
389
+ // hooks like --no-verify; read word by word through shellWords (shlex's reading).
390
+ // HOOKS_KEYS is the core's: an include (`include.path`, `includeIf.<cond>.path`)
391
+ // can carry a hooksPath too.
392
+ const HOOKS_KEYS = /core\.hookspath|include\.path|includeif\..+\.path/
393
+ const HOOKS_KEYS_FULL = new RegExp("^(?:" + HOOKS_KEYS.source + ")$")
348
394
  const GIT_CONFIG_ENV = /^GIT_CONFIG_(?:KEY_\d+|PARAMETERS)$/
349
395
  const ENV_WORD = /^[A-Za-z_]\w*=/
350
396
  const GIT_VALUE_OPTS = new Set(["-C", "-c", "--git-dir", "--work-tree",
@@ -548,13 +594,43 @@ function shellSegments(cmd) {
548
594
  // the separators after a check that keep its status the line's
549
595
  // (hooks/tezgah_integrity.OWNING_SEP)
550
596
  const OWNING_SEP = /^[()]*(?:&&|\|&?)[()]*$/
597
+ // hooks/tezgah_integrity.trap_owns_status: a trap may own the line's status
598
+ // unless it is an EXIT (or 0) trap whose every command is plain cleanup
599
+ // (TRAP_CLEANUP). Any other action can end the shell with a status of its own,
600
+ // an ERR/DEBUG/RETURN trap runs in the middle of the check, and a `trap` word
601
+ // the line's own commands do not set (inside a string, an `eval`, a function
602
+ // body, behind a wrapper) counts the same way. The word is counted with quotes
603
+ // and backslashes taken out, so `t''rap` and `t\rap` are traps here too, and a
604
+ // trap on a line with ANSI-C quoting (`$'...'`) is never read as cleanup - a
605
+ // lost credit, never an invented one.
606
+ const TRAP_WORD = /(?<![\w.-])trap(?![\w.-])/g
607
+ const TRAP_CLEANUP = new Set(["rm", "echo", "printf", ":"])
608
+ function trapOwnsStatus(cmd) {
609
+ const seen = (cmd.replace(/['"\\]/g, "").match(TRAP_WORD) || []).length
610
+ const traps = shellSegments(cmd).map((words) => {
611
+ let i = 0
612
+ while (i < words.length && (ENV_WORD.test(words[i]) || SECRET_KEYWORDS.has(words[i]))) i++
613
+ return words.slice(i)
614
+ }).filter((words) => words[0] === "trap")
615
+ if (traps.length < seen || (traps.length && cmd.includes("$'"))) return true
616
+ for (const words of traps) {
617
+ const args = words.slice(1).filter((w) => w !== "--")
618
+ if (args.length < 2 || args[0] === "-") continue // a listing or a reset
619
+ if (args.slice(1).some((s) => !["EXIT", "0"].includes(s.toUpperCase().replace(/^SIG/, ""))))
620
+ return true
621
+ if (shellSegments(args[0]).some((w) => !w.length || !TRAP_CLEANUP.has(w[0]))) return true
622
+ }
623
+ return false
624
+ }
551
625
 
552
626
  // True when the line's exit status is not its check's
553
627
  // (hooks/tezgah_integrity.status_hidden): a pipe owns it unless the line opens
554
628
  // with `set -o pipefail` and has no `||`, or a command after the check does -
555
629
  // a `;`, a newline or a trailing `&` - unless that command is `exit $?`. A
556
- // heredoc body and its terminator are data.
630
+ // heredoc body and its terminator are data. A trap may own it too
631
+ // (trapOwnsStatus).
557
632
  function statusHidden(cmd) {
633
+ if (trapOwnsStatus(cmd)) return true
558
634
  if (cmd.includes("|") && (cmd.includes("||") ||
559
635
  !/^\s*set\s+(?:-\w+\s+)*-\w*o\s+pipefail\s*(?:;|&&|\n)/.test(cmd))) return true
560
636
  const body = heredocBytes(cmd)
@@ -562,6 +638,10 @@ function statusHidden(cmd) {
562
638
  body[k] && ch !== "\n" ? " " : ch).join(""))
563
639
  for (let i = 0; i < cmds.length; i++) {
564
640
  if (!verifyCommand(cmds[i][0].map(quoteWord).join(" "))) continue
641
+ // an `exit`, or an `exec` that runs a command, before the check ends the
642
+ // line with its own status (`exit 0; pytest` never runs pytest)
643
+ if (cmds.slice(0, i).some(([w]) => w[0] === "exit" || (w[0] === "exec" && w.length > 1)))
644
+ return true
565
645
  for (let k = i; k < cmds.length - 1; k++) {
566
646
  if (OWNING_SEP.test(cmds[k][1])) continue
567
647
  const next = cmds[k + 1][0].join(" ")
@@ -580,7 +660,7 @@ function quoteWord(word) {
580
660
 
581
661
  function namesHooksKey(setting) {
582
662
  // a leading `$`: bash reads `$'core.hooksPath'` as the plain key, shlex keeps it
583
- return setting.replace(/^\$+/, "").toLowerCase().split("=")[0] === HOOKS_KEY
663
+ return HOOKS_KEYS_FULL.test(setting.replace(/^\$+/, "").toLowerCase().split("=")[0])
584
664
  }
585
665
 
586
666
  function hooksRedirect(cmd) {
@@ -593,7 +673,7 @@ function hooksRedirect(cmd) {
593
673
  if (ENV_WORD.test(words[i])) {
594
674
  const eq = words[i].indexOf("=")
595
675
  if (GIT_CONFIG_ENV.test(words[i].slice(0, eq)) &&
596
- words[i].slice(eq + 1).toLowerCase().includes(HOOKS_KEY)) assigns = true
676
+ HOOKS_KEYS.test(words[i].slice(eq + 1).toLowerCase())) assigns = true
597
677
  }
598
678
  i++
599
679
  }
@@ -619,7 +699,7 @@ function hooksRedirect(cmd) {
619
699
  CONFIG_READ_OPTS.has(a)) || (head.length > 0 && CONFIG_READ_SUBS.has(head[0]))
620
700
  const positional = head.filter((a) => !a.startsWith("-"))
621
701
  .concat(cut >= 0 ? args.slice(cut + 1) : [])
622
- if (!reads && positional.length >= 2 && positional[0] === HOOKS_KEY) assigns = true
702
+ if (!reads && positional.length >= 2 && HOOKS_KEYS_FULL.test(positional[0])) assigns = true
623
703
  }
624
704
  }
625
705
  return assigns && writes
@@ -657,13 +737,18 @@ const WRITE_TOOLS = new Set(["edit", "write", "multiedit", "notebookedit",
657
737
  // hosts send.
658
738
  const BASH_TOOLS = new Set(["bash", "shell", "command", "exec_command",
659
739
  "run_command", "powershell", "pwsh"])
660
- // The control rule's shell half (`hooks/tezgah_gate.control_reason`): a line
661
- // that could change tezgah's own control plane - a program that writes, removes
662
- // or moves a path, or a redirect, before a name that plane holds; or one of its
663
- // CLIs with an argument. The rule, its path table and its `~`/`$HOME`/XDG
664
- // expansion are the core's; this is only the bound on asking, loose the same
665
- // way IDENT_CMD is: a false hit costs one spawn whose answer comes back empty.
666
- const CONTROL_CMD = /(?:\b(?:touch|rm|rmdir|unlink|shred|truncate|chmod|chown|chgrp|mv|mkdir|tee|cp|ln|install|rsync|dd|sed|perl|git|sh|bash|zsh)\b|>)[\s\S]*(?:tezgah|\.no-|\.husky|\.git\/hooks|\.claude|\.codex|\.cursor|opencode|\.omp|\.local\/share)|\btezgah-(?:gate(?:\.py)?\s+decide|(?:capture|pony|adhd)(?:\.py)?\s+\S|context(?:\.py)?\s+(?:attest|user_prompt)\b|(?:codex|cursor)-hook\b)|\bprojects-(?:auto-init|pretooluse|posttooluse|stop)\b|\bhook\.py\b/
740
+ // The shell lines the core is asked about first: every line with a write shape
741
+ // its rules read - a redirect (a descriptor copy such as `2>&1` names no file,
742
+ // as `shell_control` reads it), a program that writes, removes or moves a path
743
+ // (hooks/tezgah_gate.CONTROL_PROGRAMS, OPTION_TARGETS, `tar`), a git staging
744
+ // command (hooks/tezgah_gate.STAGING) or `git config` - and tezgah's own CLIs,
745
+ // however the program word is quoted, and hook entries. The control rule
746
+ // (`control_reason`) follows a `cd`, expands `~`/`$HOME`/XDG and refuses the
747
+ // removal of a directory that holds its state, and the workspace and credential
748
+ // rules read the write's target, so no path shape can stand in for them here:
749
+ // this is only the bound on asking, loose the same way IDENT_CMD is - a false
750
+ // hit costs one spawn whose answer comes back empty.
751
+ const CONTROL_CMD = />(?!&(?:\d+|-)(?=$|[\s;&|)]))|\b(?:touch|rm|rmdir|unlink|shred|truncate|chmod|chown|chgrp|mv|mkdir|tee|cp|ln|install|rsync|dd|sed|perl|sqlite3|curl|wget|unzip|tar|ditto|mkfifo|mknod)\b|\bgit\b[\s\S]*\b(?:add|config|stage|commit|update-index)\b|\btezgah-(?:gate(?:\.py)?\b[^|;&\n]*\bdecide\b|capture\b|pony\b|adhd\b|context(?:\.py)?\b[^|;&\n]*\b(?:attest|user_prompt)\b|[\w-]+-hook\b)|\bprojects-(?:auto-init|pretooluse|posttooluse|stop)\b|\bhook\.py\b/i
667
752
  // The same bound for the language rule: the command shapes that dream up an
668
753
  // identifier which outlives the session - `git commit` (its message), `git
669
754
  // checkout -b`/`-B`/`--branch` and `git switch -c`/`-C`/`--create` (a branch),
@@ -682,7 +767,7 @@ const CONTROL_CMD = /(?:\b(?:touch|rm|rmdir|unlink|shred|truncate|chmod|chown|ch
682
767
  // comes first and the language rule comes first there too
683
768
  // (hooks/tezgah_gate.decision order).
684
769
  const IDENT_CMD =
685
- /\bgit\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*(?:commit\b|checkout\s+(?:-\S+\s+)*(?:-b|-B|--branch)\s+\S|switch\s+(?:-\S+\s+)*(?:-c|-C|--create)\s+\S|branch\s+(?!-)\S)|(?:^|[|;&(]\s*|\s)gh\s+(?:pr|issue)\s+(?:create|edit)\b/i
770
+ /\bgit\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*(?:commit\b|checkout\s+(?:-\S+\s+)*(?:(?:-b|-B|--branch)\s+\S|-[bB](?=[^\s;&|]))|switch\s+(?:-\S+\s+)*(?:-c|-C|--create)\s+\S|branch\s+(?!-)\S)|(?:^|[|;&(]\s*|\s)gh\s+(?:pr|issue)\s+(?:create|edit)\b/i
686
771
  // Mirrors hooks/tezgah_integrity.py READ_TOOLS: known calls that do no step of
687
772
  // work and that no rule reads a row for. A name missing from this set is not
688
773
  // silently dropped - it records as `unknown`, which would fill the trace with
@@ -942,7 +1027,7 @@ function verifyCommand(cmd) {
942
1027
  // the masked text, the convention shortcutCommand already follows: a check
943
1028
  // named inside a quoted string or a heredoc body is text ABOUT a command, not
944
1029
  // one, and reading it as one records a passing check nobody ran
945
- const text = maskText(cmd)
1030
+ const text = maskText(cmd).replace(WRAPPER_RUN, "$1")
946
1031
  for (const m of text.matchAll(VERIFY_ALL)) {
947
1032
  const words = m[0].trim().split(/\s+/)
948
1033
  const tool = words[words.length - 1].toLowerCase()
@@ -2025,12 +2110,47 @@ function orderReason(args, rows) {
2025
2110
  return ORDER_DENY.replace("%s", failedCheck(rows))
2026
2111
  }
2027
2112
 
2113
+ // A write or shell call's first row, as hooks/tezgah_gate.decision writes it
2114
+ // before any rule runs (tezgah_integrity.BEGAN_KIND): tool.execute.after writes
2115
+ // the second with the same id, a refusal answers it with its `deny`, and one
2116
+ // never answered is a call whose outcome nobody saw. BEGAN holds the args
2117
+ // object of each call whose row is written - by the core when it answered
2118
+ // (gateReason), else here - so a call gets exactly one, ahead of its `deny`.
2119
+ const BEGAN = new WeakSet()
2120
+ // The directory the plugin was loaded for, the `cwd` the core is handed: a
2121
+ // `began` row's `target` resolves against it as the core's does.
2122
+ let HOOK_DIR = null
2123
+ async function noteBegan(sessionID, tool, args, workspace) {
2124
+ const t = String(tool || "").toLowerCase()
2125
+ if (!sessionID || !args || typeof args !== "object" || BEGAN.has(args)
2126
+ || !(WRITE_TOOLS.has(t) || BASH_TOOLS.has(t))) return
2127
+ BEGAN.add(args)
2128
+ const command = String(args.command || args.cmd || "")
2129
+ const written = writtenPath(args)
2130
+ // `~` and `$HOME` expanded as the core's write_paths does
2131
+ const target = written
2132
+ ? absTarget(written.replace(/^(?:~|\$\{HOME\}|\$HOME)(?=\/|$)/, HOME), HOOK_DIR) : null
2133
+ await appendRow(sessionID, {
2134
+ kind: "began", ts: Math.floor(Date.now() / 1000),
2135
+ detail: redact(command || written || "").slice(0, DETAIL_MAX),
2136
+ id: actionID(t, args), tool: t, workspace,
2137
+ // decided from the whole command, as hooks/tezgah_gate.decision does: the
2138
+ // detail is cut at DETAIL_MAX
2139
+ ...(BASH_TOOLS.has(t) && verifyCommand(command) ? { check: 1 } : {}),
2140
+ // the file it writes, as the race rule matches it: a `began` row nothing
2141
+ // answered yet is a write in flight (hooks/tezgah_gate.writers_elsewhere)
2142
+ ...(target ? { target } : {}),
2143
+ })
2144
+ }
2145
+
2028
2146
  // The refusal recorded before it is returned, as the Python gate's `_deny` does:
2029
2147
  // a deny nobody counts is a rule whose effect can never be argued about, and the
2030
2148
  // row says which action the rule stopped and where. `extra` appends a fact the
2031
- // row must keep beyond the 80 characters of the reason it truncates.
2149
+ // row must keep beyond the 80 characters of the reason it truncates. The call's
2150
+ // `began` row goes first, as the core writes it before any rule.
2032
2151
  async function noteDeny(sessionID, rule, reason, tool, args, workspace, extra) {
2033
2152
  if (!sessionID) return
2153
+ await noteBegan(sessionID, tool, args, workspace)
2034
2154
  let detail = rule + ": " + String(reason).slice(0, 80)
2035
2155
  if (extra) detail += "; " + extra
2036
2156
  await appendRow(sessionID, {
@@ -2222,7 +2342,7 @@ function secretPrinter(text) {
2222
2342
  }
2223
2343
 
2224
2344
  // `words` past keywords, env assignments and the wrappers
2225
- // (hooks/tezgah_gate._past_wrappers)
2345
+ // (hooks/tezgah_integrity._past_wrappers)
2226
2346
  function secretPastWrappers(words) {
2227
2347
  let i = 0
2228
2348
  while (i < words.length) {
@@ -2674,8 +2794,15 @@ const SWITCHES = new Set(["adhd-off", "consult-off", "design-off", "docs-judge-o
2674
2794
  // switch file whose ctime or mtime is newer than the session ledger's first row
2675
2795
  // is ignored unless an `authorized` row names it or it is the full uninstall's
2676
2796
  // stand-down (STAND_DOWN); without one the file alone answers. This process
2677
- // serves many sessions, so the id is an argument, not process state.
2797
+ // serves many sessions, so the id is an argument, not process state. A child
2798
+ // session latches on the earlier of its own and its parent's first row, and
2799
+ // its parent's `authorized` rows count for it (B-036). The parent is the one
2800
+ // opencode named on the child's session.created (PARENTS), never a ledger row:
2801
+ // a `spawned` row is bytes an agent can write (review 165).
2802
+ // ponytail: held in this process only - a plugin restarted while a child runs
2803
+ // forgets its parent, and the child then latches on its own first row as before.
2678
2804
  const STAND_DOWN = "tezgah-setup --uninstall --full\n"
2805
+ const PARENTS = new Map()
2679
2806
 
2680
2807
  async function off(name, sessionID) {
2681
2808
  if (!SWITCHES.has(name)) return false
@@ -2691,15 +2818,24 @@ async function honored(path, name, sessionID) {
2691
2818
  let stats
2692
2819
  try { stats = [statSync(path), lstatSync(path)] } catch { return false }
2693
2820
  if (!sessionID) return true
2694
- const [first] = await ledgerRead(sessionID, "first")
2695
- const since = typeof first?.ts === "number" ? first.ts : null
2821
+ const parent = PARENTS.get(String(sessionID))
2822
+ const ids = parent && parent !== String(sessionID) ? [sessionID, parent] : [sessionID]
2823
+ const firsts = []
2824
+ for (const id of ids) {
2825
+ const [first] = await ledgerRead(id, "first")
2826
+ if (typeof first?.ts === "number") firsts.push(first.ts)
2827
+ }
2828
+ const since = firsts.length ? Math.min(...firsts) : null
2696
2829
  const newest = Math.max(...stats.flatMap((s) => [s.mtimeMs, s.ctimeMs])) / 1000
2697
2830
  if (since === null || newest < since + 1) return true
2698
2831
  try {
2699
2832
  if (name === "pretooluse-off" && readFileSync(path, "utf8") === STAND_DOWN) return true
2700
2833
  } catch {}
2701
- return (await ledgerRead(sessionID, "kind", "authorized")).some(
2702
- (row) => Array.isArray(row.authorized) && row.authorized.includes(name))
2834
+ for (const id of ids) {
2835
+ if ((await ledgerRead(id, "kind", "authorized")).some(
2836
+ (row) => Array.isArray(row.authorized) && row.authorized.includes(name))) return true
2837
+ }
2838
+ return false
2703
2839
  }
2704
2840
 
2705
2841
  function slug(p) {
@@ -2945,18 +3081,33 @@ function builderText(event, directory, payload) {
2945
3081
  // guards. What the silence costs is one row, never a refusal.
2946
3082
  // `core.answered` says the core did answer: its live `decide` has then kept the
2947
3083
  // snapshot of an allowed write itself, and this file must not keep a second one.
3084
+ // It has written the call's `began` row too, and its `deny` when `core.denied`.
3085
+ // A core that failed after its first line (a rule raised, the deadline hit) may
3086
+ // have written that `began` already: a row for this call since the ask began
3087
+ // counts as written, so the call is not opened twice.
2948
3088
  async function gateReason(tool, args, dir, sessionID, core = {}) {
2949
3089
  const state = {}
3090
+ const since = Math.floor(Date.now() / 1000)
2950
3091
  const reason = await collect(
2951
3092
  [GATE_BIN, "decide"], { stdio: ["pipe", "pipe", "ignore"] },
2952
3093
  JSON.stringify({
2953
- tool, input: args, cwd: dir, session_id: sessionID || null }),
3094
+ tool, input: args, cwd: dir, session_id: sessionID || null,
3095
+ parent: PARENTS.get(String(sessionID)) }),
2954
3096
  (code, out, failure) => {
2955
3097
  state.failure = failure
2956
3098
  return code === 0 ? out.trim() : ""
2957
3099
  })
2958
- if (state.failure) await noteDelegation(sessionID, state.failure)
2959
- else core.answered = true
3100
+ if (state.failure) {
3101
+ await noteDelegation(sessionID, state.failure)
3102
+ const digest = actionID(tool, args)
3103
+ if (sessionID && args && typeof args === "object" &&
3104
+ (await ledgerTail(sessionID, LEDGER_TAIL)).some((r) => r.kind === "began"
3105
+ && r.id === digest && Number(r.ts) >= since)) BEGAN.add(args)
3106
+ } else {
3107
+ core.answered = true
3108
+ core.denied = Boolean(reason)
3109
+ if (args && typeof args === "object") BEGAN.add(args)
3110
+ }
2960
3111
  return reason
2961
3112
  }
2962
3113
 
@@ -3101,6 +3252,7 @@ export const Tezgah = async ({ directory }) => {
3101
3252
  if (globalThis.__tezgahPluginLoaded) return {}
3102
3253
  globalThis.__tezgahPluginLoaded = true
3103
3254
  const dir = directory || process.cwd()
3255
+ HOOK_DIR = dir
3104
3256
 
3105
3257
  return {
3106
3258
  // Register the repo's generated subagents at config load so opencode sees
@@ -3149,8 +3301,8 @@ export const Tezgah = async ({ directory }) => {
3149
3301
 
3150
3302
  "tool.execute.before": async (input, output) => {
3151
3303
  let deny = null
3152
- // whether the core was asked: its live `decide` writes the call's `began`
3153
- // row first, so this file writes one only when the core did not answer
3304
+ // whether the core was asked, so it is asked at most once per call; what
3305
+ // it answered (and so which rows it wrote) is in `core`
3154
3306
  let asked = false
3155
3307
  const core = {}
3156
3308
  try {
@@ -3166,11 +3318,15 @@ export const Tezgah = async ({ directory }) => {
3166
3318
  // `verify-off` removes; attribution and explore are other rules and stay
3167
3319
  const shortcuts = !(await off("verify-off", sessionID))
3168
3320
  // The control rule is the Python gate's first (hooks/tezgah_gate.decision),
3169
- // so a shell line CONTROL_CMD says could touch the control plane is put to
3170
- // the core before any rule here: its answer is the whole gate's, in the
3171
- // gate's own order, and this file keeps no copy of the path table.
3321
+ // so a shell line CONTROL_CMD says could write - or one NEUTER or
3322
+ // SHORTCUT_CMD says could swallow a check's status or skip the hooks - is
3323
+ // put to the core before any rule here: its answer is the whole gate's,
3324
+ // in the gate's own order, and this file keeps no copy of the path table,
3325
+ // the credential's `git add` reader, the workspace rule or the shortcut
3326
+ // readers' wrapper table and `eval` opening.
3172
3327
  const line = BASH_TOOLS.has(tool) ? String(args.command || args.cmd || "") : ""
3173
- if (line && CONTROL_CMD.test(line)) {
3328
+ if (line && (CONTROL_CMD.test(line) ||
3329
+ (shortcuts && (NEUTER.test(line) || SHORTCUT_CMD.test(line))))) {
3174
3330
  asked = true
3175
3331
  deny = await gateReason(tool, args, dir, sessionID, core)
3176
3332
  }
@@ -3207,10 +3363,10 @@ export const Tezgah = async ({ directory }) => {
3207
3363
  }
3208
3364
  }
3209
3365
  // A refusal this file decided is counted the way the Python gate counts
3210
- // its own (hooks/tezgah_gate._deny); one the core answered (`asked`) is
3211
- // in the ledger already. Without the row, `--report --live` saw the
3212
- // shortcut refused and nothing recorded.
3213
- if (deny && !asked) {
3366
+ // its own (hooks/tezgah_gate._deny); one the core answered is in the
3367
+ // ledger already. Without the row, `--report --live` saw the shortcut
3368
+ // refused and nothing recorded.
3369
+ if (deny && !core.denied) {
3214
3370
  await noteDeny(sessionID, deny === ATTRIB_DENY ? "attribution"
3215
3371
  : deny === EXPLORE_DENY ? "explorer" : "shortcut", deny, tool, args, base)
3216
3372
  }
@@ -3292,24 +3448,9 @@ export const Tezgah = async ({ directory }) => {
3292
3448
  await captureSnapshot("write", { file_path: target }, dir, sessionID)
3293
3449
  }
3294
3450
  }
3295
- // The call's first row, as hooks/tezgah_gate.decision writes it on its
3296
- // allow path (tezgah_integrity.BEGAN_KIND): tool.execute.after writes the
3297
- // second with the same id, and one never answered is a call whose
3298
- // outcome nobody saw. A core that was asked and did not answer wrote
3299
- // none, so this file writes it then too.
3300
- if (!deny && !core.answered && sessionID &&
3301
- (WRITE_TOOLS.has(tool) || BASH_TOOLS.has(tool))) {
3302
- await appendRow(sessionID, {
3303
- kind: "began", ts: Math.floor(Date.now() / 1000),
3304
- detail: redact(String(args.command || args.cmd || writtenPath(args)
3305
- || "")).slice(0, DETAIL_MAX),
3306
- id: actionID(tool, args), tool, workspace: base,
3307
- // decided from the whole command, as hooks/tezgah_gate.decision
3308
- // does: the detail is cut at DETAIL_MAX
3309
- ...(BASH_TOOLS.has(tool) && verifyCommand(String(args.command || args.cmd || ""))
3310
- ? { check: 1 } : {}),
3311
- })
3312
- }
3451
+ // The call's `began` row on its allow path (noteBegan): written here
3452
+ // unless the core answered, or a refusal above already wrote it.
3453
+ if (!deny) await noteBegan(sessionID, tool, args, base)
3313
3454
  } catch {}
3314
3455
  if (deny) throw new Error(deny)
3315
3456
  },
@@ -3326,6 +3467,7 @@ export const Tezgah = async ({ directory }) => {
3326
3467
  const ev = input && input.event
3327
3468
  const info = ev && ev.type === "session.created" && ev.properties && ev.properties.info
3328
3469
  if (!info || !info.id || !info.parentID) return
3470
+ PARENTS.set(String(info.id), String(info.parentID))
3329
3471
  if (!(await rootFor(dir))) return
3330
3472
  const title = String(info.title || "")
3331
3473
  const typed = /\(@([^\s()]+) subagent\)\s*$/.exec(title)
@@ -3345,7 +3487,6 @@ export const Tezgah = async ({ directory }) => {
3345
3487
  const env = output.env && typeof output.env === "object" ? output.env : (output.env = {})
3346
3488
  const rs = await roots()
3347
3489
  if (rs.length) env.TEZGAH_ROOTS = rs.join(delimiter)
3348
- env.TEZGAH_HOME = CONFIG
3349
3490
  env.TEZGAH_STATUS_BIN = STATUS_BIN
3350
3491
  if (input && input.sessionID) env.TEZGAH_SESSION = String(input.sessionID)
3351
3492
  } catch {}
@@ -3363,7 +3504,8 @@ export const Tezgah = async ({ directory }) => {
3363
3504
  const context = Array.isArray(output.context) ? output.context : (output.context = [])
3364
3505
  const sessionID = String(input?.sessionID || "")
3365
3506
  const text = await builderText("post_compact", dir,
3366
- sessionID ? { session_id: sessionID } : {})
3507
+ sessionID ? { session_id: sessionID,
3508
+ parent: PARENTS.get(sessionID) } : {})
3367
3509
  if (text) context.push(text)
3368
3510
  } catch {}
3369
3511
  },
@@ -3389,8 +3531,12 @@ export const Tezgah = async ({ directory }) => {
3389
3531
  const prompt = parts
3390
3532
  .filter((p) => p && p.type === "text" && typeof p.text === "string")
3391
3533
  .map((p) => p.text).join("\n")
3534
+ // a child session's prompt is its parent's brief, never the user's
3535
+ // grant (B-086), and its latch counts from its parent too (B-036)
3536
+ const parent = PARENTS.get(sessionID)
3392
3537
  const text = await builderText("user_prompt", dir,
3393
- { prompt, session_id: sessionID, host: "opencode" })
3538
+ { prompt, session_id: sessionID, host: "opencode",
3539
+ ...(parent ? { subagent: true, parent } : {}) })
3394
3540
  if (text) {
3395
3541
  const anchor = output.message && typeof output.message === "object"
3396
3542
  ? output.message : {}
@@ -151,7 +151,7 @@ def main():
151
151
  event = payload.get("hook_event_name") or ""
152
152
  cwd = payload.get("cwd") or os.getcwd()
153
153
  session_id = payload.get("session_id")
154
- safe(session_id, bind_session, session_id) # the switch latch (tezgah_paths.off)
154
+ safe(session_id, bind_session, session_id, payload.get("parent")) # the switch latch
155
155
  out = {}
156
156
  if event == "PreToolUse":
157
157
  reason = safe(session_id, decision, gate_name(payload.get("tool_name", "")),
@@ -38,7 +38,7 @@ first; write what was cut only when you consciously cut something the user asked
38
38
  for, in at most three lines. In force from the first turn; levels
39
39
  lite|full|ultra are set with `~/.config/tezgah/bin/tezgah-pony` and ride the per-turn reminder when
40
40
  not `full`; read the full `ponytail` skill from the router for the level table
41
- and examples. Off: "stop ponytail".
41
+ and examples. Off: "stop ponytail", `ponytail-auto.off` or `.no-ponytail`.
42
42
 
43
43
  **Output shape: ADHD-friendly.** The answer or the next action is on the first
44
44
  line - a command, path, snippet or the decision itself, never context or the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@r1z4x/tezgah",
3
- "version": "1.13.0",
4
- "description": "One shared working contract for every AI coding assistant you run: Claude Code, Codex, Cursor, opencode, dsh and oh-my-pi (omp).",
3
+ "version": "1.14.0",
4
+ "description": "One shared working contract for every AI coding assistant you run, across fourteen hosts: omp, Claude Code, Codex, Cursor, opencode, dsh, Qwen Code, Factory Droid, Command Code, GitHub Copilot CLI, Gemini CLI, Antigravity CLI, Goose and Amp.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -23,7 +23,10 @@
23
23
  "MANIFEST",
24
24
  "CHANGELOG.md",
25
25
  "LICENSE",
26
- "packaging/"
26
+ "NOTICE",
27
+ "third_party/",
28
+ "packaging/",
29
+ "!**/__pycache__"
27
30
  ],
28
31
  "engines": {
29
32
  "node": ">=18"
@@ -79,12 +79,12 @@ def version():
79
79
 
80
80
 
81
81
  def changelog_version():
82
- """The newest release heading: the second source `plugin_version()` reads."""
82
+ """The newest `## [x.y.z]` heading, as `RELEASE` in hooks/tezgah_context.py."""
83
83
  try:
84
84
  with open(os.path.join(ROOT, "CHANGELOG.md")) as fh:
85
- for line in fh:
86
- if line.startswith("## ["):
87
- return line[4:].split("]")[0].strip()
85
+ for v in (ln[4:].split("]")[0] for ln in fh if ln.startswith("## [")):
86
+ if v.count(".") == 2 and all(p.isdigit() for p in v.split(".")):
87
+ return v
88
88
  except OSError:
89
89
  pass
90
90
  return ""
@@ -26,6 +26,17 @@ while [ $# -gt 0 ]; do case $1 in
26
26
  *) echo "tezgah: unknown option $1" >&2; exit 2 ;;
27
27
  esac; done
28
28
 
29
+ # Python 3.10+ is the floor (README; CI tests nothing older): refused here, by
30
+ # the version found, before anything is fetched.
31
+ py=$(command -v python3 2>/dev/null) || { echo "tezgah: needs Python 3.10 or newer, and no python3 is on PATH" >&2; exit 1; }
32
+ pyver=$("$py" --version 2>&1 | sed -n 's/^Python \([0-9][0-9]*\.[0-9][0-9.]*\).*/\1/p')
33
+ pymajor=0; pyminor=0
34
+ if [ -n "$pyver" ]; then pymajor=${pyver%%.*}; pyminor=${pyver#*.}; pyminor=${pyminor%%.*}; fi
35
+ if [ "$pymajor" -lt 3 ] || { [ "$pymajor" -eq 3 ] && [ "$pyminor" -lt 10 ]; }; then
36
+ echo "tezgah: needs Python 3.10 or newer; $py is ${pyver:-of unknown version}" >&2
37
+ exit 1
38
+ fi
39
+
29
40
  # The default upgrade.sh resolves: the installer is reached through the tree it picks.
30
41
  PREFIX=${PREFIX_ARG:-${TEZGAH_PREFIX:-${XDG_DATA_HOME:-$HOME/.local/share}/tezgah}}
31
42
  finish() { [ -z "$work" ] || rm -rf "$work"; }
@@ -41,6 +41,9 @@ die() {
41
41
  exit 1
42
42
  }
43
43
 
44
+ # First: a missing curl would otherwise read as a fetch failure blaming a token.
45
+ for t in curl tar; do command -v "$t" >/dev/null 2>&1 || die "$t is required (README: Install)"; done
46
+
44
47
  # A private repository answers 404 to `releases/download/...` from a session without
45
48
  # a token (measured 2026-10-17, with the token as URL userinfo and as a header
46
49
  # alike), while the REST asset endpoint returns the blob for `Accept:
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Claude AI Research Skills Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.