@delorenj/pjangler 1.4.2 → 1.4.4

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 (39) hide show
  1. package/README.md +528 -0
  2. package/contracts/fleet-contract.yaml +513 -0
  3. package/dist/index.js +9333 -1509
  4. package/dist/mcp-server.js +6634 -1096
  5. package/dist/prompt.js +2 -1
  6. package/package.json +10 -4
  7. package/templates/hermes-agent/copier.yml +16 -3
  8. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
  9. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
  10. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
  11. package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
  12. package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
  13. package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
  14. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
  15. package/templates/hermes-agent/template/.scripts/70-systemd.sh +129 -15
  16. package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
  17. package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
  18. package/templates/hermes-agent/template/.scripts/_lib.sh +773 -0
  19. package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
  20. package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
  21. package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
  22. package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
  23. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
  24. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
  25. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +93 -4
  26. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
  27. package/templates/hermes-agent/template/.scripts/providers/linear.sh +138 -25
  28. package/templates/hermes-agent/template/.scripts/providers/plane.sh +408 -51
  29. package/templates/hermes-agent/template/.scripts/providers/trello.sh +54 -6
  30. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +257 -43
  31. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +142 -25
  32. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
  33. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
  34. package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +3 -1
  35. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
  36. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
  37. package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
  38. package/templates/hermes-agent/template/hermes.jinja +1 -1
  39. package/templates/hermes-agent/template/role.yaml.jinja +15 -4
@@ -35,9 +35,15 @@ oauth_file = "~/.hermes/auth.json"
35
35
  codex_home = "~/.codex"
36
36
  # Canonical external skills dir mirrored into each agent profile.
37
37
  canonical_skills_dir = "/home/delorenj/.agents/skills"
38
- # Voxxy plugin + service defaults for Hermes PM agents.
39
- voxxy_plugin_dir = "~/code/voxxy/plugins/tts/voxxy"
38
+ # Optional Vox plugin + service defaults for Hermes PM agents.
39
+ vox_plugin_name = "vox"
40
+ vox_plugin_dir = "~/code/voxxy/plugins/tts/vox"
41
+ vox_voice = "carlin"
40
42
  vox_url = "https://vox.delo.sh"
43
+ # Channel credentials are written directly to Password items in this vault;
44
+ # only op:// references are recorded in named-profile config.delta.yaml.
45
+ onepassword_vault = "DeLoSecrets"
46
+ onepassword_item_prefix = "hermes-agent"
41
47
  # Canonical PM config.yaml the provisioner seeds each runtime's config.yaml from.
42
48
  # This is the fleet-wide Hermes inference/skill config (model, toolsets, etc.)
43
49
  # every agent runtime starts from. ~ is expanded.
@@ -45,6 +45,11 @@ if ! REPO_ROOT="$(git -C "$ROLE_DIR" rev-parse --show-toplevel 2>/dev/null)"; th
45
45
  fi
46
46
  export TERMINAL_CWD="$REPO_ROOT"
47
47
 
48
+ # Do not forward a stale/raw channel value inherited from the service manager,
49
+ # login shell, or a legacy runtime EnvironmentFile. Hermes hydrates the named
50
+ # profile's validated 1Password references after this launcher execs it.
51
+ unset TELEGRAM_BOT_TOKEN SLACK_BOT_TOKEN SLACK_APP_TOKEN
52
+
48
53
  load_credential() {
49
54
  local credential_id="$1" env_name="$2" credential_file value
50
55
  [[ "$env_name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] \
@@ -57,7 +62,6 @@ load_credential() {
57
62
  unset value
58
63
  }
59
64
 
60
- load_credential telegram_bot_token TELEGRAM_BOT_TOKEN
61
65
  MODEL_KEY_ENV="$(yaml_get model.key_env)"
62
66
  if [[ -n "$MODEL_KEY_ENV" ]]; then
63
67
  load_credential model_api_key "$MODEL_KEY_ENV"
@@ -145,9 +145,8 @@ else
145
145
  fi
146
146
 
147
147
  # Reconcile gate: the autonomous board-reconciliation pass runs only when
148
- # role.yaml's reconcile.enabled is true. Default off the heartbeat just
149
- # checkpoints (behaves like the legacy hourly checkpoint timer). Flip
150
- # reconcile.enabled to opt a repo into autonomous board reconciliation.
148
+ # role.yaml's reconcile.enabled is true. Deployed PMs default on; setting it
149
+ # false explicitly opts into checkpoint-only operation.
151
150
  if [[ "$(reconcile_enabled)" != "true" ]]; then
152
151
  printf '[heartbeat] reconcile disabled (reconcile.enabled != true) — checkpoint-only tick\n'
153
152
  maybe_checkpoint
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env python3
2
+ """Shared serialization for one Hermes profile's generated configuration.
3
+
4
+ All code that can read and then rewrite ``config.delta.yaml`` or ``config.yaml``
5
+ must hold this lock from topology validation through commit or rollback. A
6
+ channel transaction already holds the fleet registry lock before it enters this
7
+ lock; config-only reconcilers never acquire the registry lock. The only valid
8
+ ordering is therefore::
9
+
10
+ fleet registry lock -> profile config lock
11
+
12
+ Never acquire the fleet registry lock while holding a profile config lock.
13
+ ``flock`` ownership is kernel-scoped, so process exit releases the lock even
14
+ though the deliberately persistent lock file remains on disk.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import fcntl
20
+ import os
21
+ import pathlib
22
+ import stat
23
+ import time
24
+ from types import TracebackType
25
+ from typing import Self
26
+
27
+
28
+ DEFAULT_TIMEOUT_SECONDS = 30.0
29
+ LOCK_TIMEOUT_ENV = "HERMES_PROFILE_CONFIG_LOCK_TIMEOUT_SECONDS"
30
+ TEST_BARRIER_ENV = "PJANGLER_TEST_PROFILE_CONFIG_BARRIER"
31
+ TEST_BARRIER_TIMEOUT_ENV = "PJANGLER_TEST_PROFILE_CONFIG_BARRIER_TIMEOUT_SECONDS"
32
+ TEST_BARRIER_LABEL_ENV = "PJANGLER_TEST_PROFILE_CONFIG_BARRIER_LABEL"
33
+ TEST_LOCK_ATTEMPT_ENV = "PJANGLER_TEST_PROFILE_CONFIG_LOCK_ATTEMPT"
34
+
35
+
36
+ class ProfileConfigLockError(RuntimeError):
37
+ """The profile lock could not be acquired safely."""
38
+
39
+
40
+ def lock_path(profile: pathlib.Path) -> pathlib.Path:
41
+ """Return a lock path that does not require following the profile root."""
42
+
43
+ return profile.parent / f".{profile.name}.config.lock"
44
+
45
+
46
+ def _timeout_seconds() -> float:
47
+ raw = os.environ.get(LOCK_TIMEOUT_ENV, str(DEFAULT_TIMEOUT_SECONDS))
48
+ try:
49
+ timeout = float(raw)
50
+ except ValueError as exc:
51
+ raise ProfileConfigLockError(
52
+ f"{LOCK_TIMEOUT_ENV} must be a non-negative number"
53
+ ) from exc
54
+ if timeout < 0 or timeout != timeout:
55
+ raise ProfileConfigLockError(
56
+ f"{LOCK_TIMEOUT_ENV} must be a non-negative number"
57
+ )
58
+ return timeout
59
+
60
+
61
+ class ProfileConfigLock:
62
+ """Exclusive, bounded, symlink-safe lock for one profile config pair."""
63
+
64
+ def __init__(self, profile: pathlib.Path | str, timeout: float | None = None):
65
+ self.profile = pathlib.Path(profile)
66
+ self.path = lock_path(self.profile)
67
+ self.timeout = _timeout_seconds() if timeout is None else timeout
68
+ self.fd: int | None = None
69
+
70
+ def acquire(self) -> Self:
71
+ parent = self.profile.parent
72
+ if parent.is_symlink() or not parent.is_dir():
73
+ raise ProfileConfigLockError(
74
+ f"profile parent must be a real directory before locking: {parent}"
75
+ )
76
+ if self.path.is_symlink():
77
+ raise ProfileConfigLockError(
78
+ f"refusing profile config lock symlink: {self.path}"
79
+ )
80
+ flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
81
+ flags |= getattr(os, "O_NOFOLLOW", 0)
82
+ try:
83
+ fd = os.open(self.path, flags, 0o600)
84
+ except OSError as exc:
85
+ raise ProfileConfigLockError(
86
+ f"cannot open profile config lock {self.path}: {exc.strerror or type(exc).__name__}"
87
+ ) from exc
88
+ try:
89
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
90
+ raise ProfileConfigLockError(
91
+ f"profile config lock is not a regular file: {self.path}"
92
+ )
93
+ os.fchmod(fd, 0o600)
94
+ _notify_test_lock_attempt(self.path)
95
+ deadline = time.monotonic() + self.timeout
96
+ while True:
97
+ try:
98
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
99
+ break
100
+ except BlockingIOError as exc:
101
+ if time.monotonic() >= deadline:
102
+ raise ProfileConfigLockError(
103
+ f"timed out waiting for profile config lock: {self.path}"
104
+ ) from exc
105
+ time.sleep(min(0.05, max(0.0, deadline - time.monotonic())))
106
+ except BaseException:
107
+ os.close(fd)
108
+ raise
109
+ self.fd = fd
110
+ return self
111
+
112
+ def release(self) -> None:
113
+ if self.fd is None:
114
+ return
115
+ fd, self.fd = self.fd, None
116
+ try:
117
+ fcntl.flock(fd, fcntl.LOCK_UN)
118
+ finally:
119
+ os.close(fd)
120
+
121
+ def __enter__(self) -> Self:
122
+ return self.acquire()
123
+
124
+ def __exit__(
125
+ self,
126
+ exc_type: type[BaseException] | None,
127
+ exc: BaseException | None,
128
+ traceback: TracebackType | None,
129
+ ) -> None:
130
+ self.release()
131
+
132
+
133
+ def _test_path_from_env(name: str) -> pathlib.Path | None:
134
+ configured = os.environ.get(name)
135
+ if not configured:
136
+ return None
137
+ if "PYTEST_CURRENT_TEST" not in os.environ:
138
+ raise ProfileConfigLockError(
139
+ f"{name} is test-only and requires a pytest process"
140
+ )
141
+ path = pathlib.Path(configured)
142
+ if path.is_symlink() or path.parent.is_symlink() or not path.parent.is_dir():
143
+ raise ProfileConfigLockError(f"unsafe test profile-config path: {path}")
144
+ return path
145
+
146
+
147
+ def _notify_test_lock_attempt(lock: pathlib.Path) -> None:
148
+ target = _test_path_from_env(TEST_LOCK_ATTEMPT_ENV)
149
+ if target is not None:
150
+ target.write_text(str(lock) + "\n", encoding="utf-8")
151
+
152
+
153
+ def test_snapshot_barrier(label: str) -> None:
154
+ """Pause a test process after its locked snapshot has been captured.
155
+
156
+ This hook is inert outside pytest. It exists so the regression suite can
157
+ deterministically exercise the stale-snapshot interleaving that motivated
158
+ this lock without widening the production interface.
159
+ """
160
+
161
+ selected_label = os.environ.get(TEST_BARRIER_LABEL_ENV)
162
+ if selected_label and selected_label != label:
163
+ return
164
+ barrier = _test_path_from_env(TEST_BARRIER_ENV)
165
+ if barrier is None:
166
+ return
167
+ ready = pathlib.Path(f"{barrier}.ready")
168
+ resume = pathlib.Path(f"{barrier}.resume")
169
+ ready.write_text(label + "\n", encoding="utf-8")
170
+ try:
171
+ timeout = float(os.environ.get(TEST_BARRIER_TIMEOUT_ENV, "15"))
172
+ except ValueError as exc:
173
+ raise ProfileConfigLockError(
174
+ f"{TEST_BARRIER_TIMEOUT_ENV} must be a positive number"
175
+ ) from exc
176
+ deadline = time.monotonic() + timeout
177
+ while not resume.is_file():
178
+ if time.monotonic() >= deadline:
179
+ raise ProfileConfigLockError(
180
+ f"timed out waiting at test profile-config barrier: {barrier}"
181
+ )
182
+ time.sleep(0.02)
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env python3
2
+ """Seed a named profile's override delta under the shared profile lock."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import errno
8
+ import importlib.util
9
+ import os
10
+ import pathlib
11
+ import stat
12
+ import tempfile
13
+
14
+ SEED_CONTENT = (
15
+ "# Override-only delta for this Hermes profile.\n"
16
+ "# Merged over ~/.hermes/config.yaml to produce config.yaml (which is GENERATED).\n"
17
+ "# Empty == identical to the fleet base. Add ONLY what must differ.\n"
18
+ "{}\n"
19
+ ).encode("utf-8")
20
+
21
+
22
+ def load_profile_lock_module():
23
+ source = pathlib.Path(__file__).parent / "profile-config-lock.py"
24
+ if source.is_symlink() or not source.is_file():
25
+ raise RuntimeError(
26
+ f"trusted profile config lock helper is unavailable: {source}"
27
+ )
28
+ spec = importlib.util.spec_from_file_location(
29
+ "pjangler_profile_config_lock", source
30
+ )
31
+ if spec is None or spec.loader is None:
32
+ raise RuntimeError(f"cannot load profile config lock helper: {source}")
33
+ module = importlib.util.module_from_spec(spec)
34
+ spec.loader.exec_module(module)
35
+ return module
36
+
37
+
38
+ PROFILE_LOCK = load_profile_lock_module()
39
+
40
+
41
+ def fsync_parent(path: pathlib.Path) -> None:
42
+ unsupported = {errno.EINVAL, getattr(errno, "ENOTSUP", errno.EINVAL), errno.ENOSYS}
43
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
44
+ try:
45
+ directory_fd = os.open(path.parent, flags)
46
+ except OSError as exc:
47
+ if exc.errno in unsupported:
48
+ return
49
+ raise
50
+ try:
51
+ try:
52
+ os.fsync(directory_fd)
53
+ except OSError as exc:
54
+ if exc.errno not in unsupported:
55
+ raise
56
+ finally:
57
+ os.close(directory_fd)
58
+
59
+
60
+ def seed(profile: pathlib.Path) -> str:
61
+ with PROFILE_LOCK.ProfileConfigLock(profile):
62
+ PROFILE_LOCK.test_snapshot_barrier("seed")
63
+ if profile.is_symlink() or not profile.is_dir():
64
+ raise RuntimeError(f"profile root must be a real directory: {profile}")
65
+ target = profile / "config.delta.yaml"
66
+ if target.is_symlink():
67
+ raise RuntimeError(f"refusing config delta symlink: {target}")
68
+ if target.exists():
69
+ if not target.is_file():
70
+ raise RuntimeError(f"config delta is not a regular file: {target}")
71
+ return "exists"
72
+
73
+ descriptor, temporary_name = tempfile.mkstemp(
74
+ prefix=".config.delta.yaml.seed-", dir=profile
75
+ )
76
+ temporary = pathlib.Path(temporary_name)
77
+ try:
78
+ os.fchmod(descriptor, 0o600)
79
+ with os.fdopen(descriptor, "wb") as handle:
80
+ handle.write(SEED_CONTENT)
81
+ handle.flush()
82
+ os.fsync(handle.fileno())
83
+ os.replace(temporary, target)
84
+ os.chmod(target, stat.S_IRUSR | stat.S_IWUSR)
85
+ fsync_parent(target)
86
+ except BaseException:
87
+ try:
88
+ temporary.unlink()
89
+ except FileNotFoundError:
90
+ pass
91
+ raise
92
+ return "seeded"
93
+
94
+
95
+ def main() -> None:
96
+ parser = argparse.ArgumentParser()
97
+ parser.add_argument("--profile", required=True)
98
+ args = parser.parse_args()
99
+ try:
100
+ print(seed(pathlib.Path(args.profile)))
101
+ except PROFILE_LOCK.ProfileConfigLockError as exc:
102
+ raise SystemExit(f"profile config seed failed: {exc}") from exc
103
+ except (OSError, RuntimeError) as exc:
104
+ raise SystemExit(f"profile config seed failed: {exc}") from exc
105
+
106
+
107
+ if __name__ == "__main__":
108
+ main()
@@ -10,15 +10,25 @@
10
10
  # resolve -> JSON {provider, board_id, board_url}
11
11
  # active_milestone -> JSON {id, name, state}
12
12
  # list_issues -> JSON [ {id,key,title,state,state_type,
13
- # updated_at,assignee,url}, ... ]
14
- # get_issue <id> -> JSON {id,key,title,description,acceptance,
13
+ # updated_at,assignee,url,...}, ... ]
14
+ # get_issue <issue-ref> -> JSON {id,key,title,description,acceptance,
15
15
  # state,state_type,comments:[...],
16
16
  # attachments:[...]}
17
- # comment <id> <body> -> prints comment id
18
- # transition <id> <normalized> -> moves issue; normalized in
17
+ # comment <issue-ref> <body> -> prints comment id
18
+ # transition <issue-ref> <normalized>
19
+ # -> resolves id/human key, then moves issue; normalized in
19
20
  # backlog|unstarted|started|in_review|completed|
20
21
  # cancelled
21
22
  # create_board <name> <id> <d> -> JSON {board_id, board_url}
23
+ # describe_board <ws> <board_id>
24
+ # -> JSON {board_id, identifier, workspace,
25
+ # name}
26
+ # Read-only lookup against an EXPLICIT
27
+ # workspace, so no ambient binding can send
28
+ # the query elsewhere. `identifier` is
29
+ # whatever the provider itself reports, and
30
+ # is empty when the provider mints none
31
+ # (Trello). Never a locally-computed guess.
22
32
  # create_issue [--if-absent] <title> [desc]
23
33
  # -> JSON {issue_id, key, issue_url, created}
24
34
  # Files a new ticket on the bound board.
@@ -29,6 +39,63 @@
29
39
  # Each provider reads its credentials from the environment (see providers/*.sh
30
40
  # headers) and the board binding from repo-root .project.json.
31
41
 
42
+ # Read the human key prefix from the project binding. Plane list_issues exposes
43
+ # sequence_id as a number while people use PREFIX-number; the dispatcher owns
44
+ # that provider-neutral seam so callers never have to know the native id shape.
45
+ tp_project_identifier() {
46
+ local role_dir
47
+ role_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)"
48
+ python3 - "$role_dir" <<'PY' 2>/dev/null
49
+ import json
50
+ import pathlib
51
+ import sys
52
+
53
+ start = pathlib.Path(sys.argv[1]).resolve()
54
+ for parent in [start, *start.parents]:
55
+ manifest = parent / ".project.json"
56
+ if manifest.is_file():
57
+ try:
58
+ provider = json.loads(manifest.read_text()).get("ticket_provider") or {}
59
+ print(str(provider.get("identifier") or ""))
60
+ except Exception:
61
+ print("")
62
+ break
63
+ else:
64
+ print("")
65
+ PY
66
+ }
67
+
68
+ # Resolve an id, provider key, or project-prefixed numeric key against the
69
+ # normalized list contract. Mutating operations fail before reaching a provider
70
+ # if the reference is absent or ambiguous.
71
+ tp_resolve_issue_reference() {
72
+ local name="$1" impl="$2" reference="$3" identifier issues
73
+ identifier="$(tp_project_identifier)"
74
+ issues="$(TICKET_PROVIDER="$name" sh "$impl" list_issues)" || return 1
75
+ TP_REFERENCE="$reference" TP_IDENTIFIER="$identifier" python3 -c 'import json,os,sys
76
+ reference=os.environ["TP_REFERENCE"].strip(); identifier=os.environ.get("TP_IDENTIFIER", "").strip()
77
+ try:
78
+ data=json.load(sys.stdin)
79
+ except Exception as exc:
80
+ raise SystemExit(f"tp: could not resolve issue reference {reference!r}: list_issues returned invalid JSON ({exc})")
81
+ rows=data if isinstance(data,list) else data.get("results", []) if isinstance(data,dict) else []
82
+ want=reference.casefold(); matches=[]
83
+ for issue in rows:
84
+ native=str(issue.get("id") or "").strip(); key=str(issue.get("key") or "").strip()
85
+ aliases={native.casefold(), key.casefold()}
86
+ if identifier and key.isdigit():
87
+ aliases.add(f"{identifier}-{key}".casefold())
88
+ if native and want in aliases:
89
+ matches.append(native)
90
+ matches=list(dict.fromkeys(matches))
91
+ if len(matches) != 1:
92
+ detail="not found" if not matches else "ambiguous"
93
+ raise SystemExit(f"tp: could not resolve issue reference {reference!r}: {detail}")
94
+ print(matches[0])' <<EOF
95
+ $issues
96
+ EOF
97
+ }
98
+
32
99
  # Resolve the provider name: explicit env wins, then repo-root .project.json
33
100
  # (the SOT), then role.yaml (self-parsed so this works even when _lib.sh /
34
101
  # yaml_get is not loaded), then default.
@@ -87,6 +154,19 @@ tp() {
87
154
  local op="${1:-}"; shift || true
88
155
  [ -n "$op" ] || { echo "tp: missing operation" >&2; return 2; }
89
156
 
157
+ # Reject blank references before provider discovery or list_issues. Besides
158
+ # being invalid input, an empty value can equal a provider's missing `key`
159
+ # field and must never become authority to read or mutate that issue.
160
+ case "$op" in
161
+ get_issue|comment|transition)
162
+ [ "$#" -ge 1 ] || { echo "tp: $op requires a non-blank issue reference" >&2; return 2; }
163
+ if [[ "$1" =~ ^[[:space:]]*$ ]]; then
164
+ echo "tp: $op requires a non-blank issue reference" >&2
165
+ return 2
166
+ fi
167
+ ;;
168
+ esac
169
+
90
170
  local name impl
91
171
  name="$(tp_provider_name)"
92
172
  impl="$(tp_providers_dir)/${name}.sh"
@@ -96,6 +176,15 @@ tp() {
96
176
  return 2
97
177
  fi
98
178
 
179
+ case "$op" in
180
+ get_issue|comment|transition)
181
+ local reference native_id
182
+ reference="$1"; shift
183
+ native_id="$(tp_resolve_issue_reference "$name" "$impl" "$reference")" || return 1
184
+ set -- "$native_id" "$@"
185
+ ;;
186
+ esac
187
+
99
188
  TICKET_PROVIDER="$name" sh "$impl" "$op" "$@"
100
189
  }
101
190