@delorenj/pjangler 1.4.6 → 1.5.3

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 (83) hide show
  1. package/README.md +41 -596
  2. package/dist/assets/project-notebook-skill/SHA256SUMS +5 -5
  3. package/dist/assets/project-notebook-skill/export-manifest.json +5 -5
  4. package/dist/assets/project-notebook-skill/hooks/session-end.sh +20 -1
  5. package/dist/assets/project-notebook-skill/hooks/session-start.sh +20 -1
  6. package/dist/assets/project-notebook-skill/references/configuration.md +2 -0
  7. package/dist/assets/project-notebook-skill/scripts/project-hooks.py +33 -3
  8. package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +37 -0
  9. package/dist/index.js +9385 -18379
  10. package/dist/mcp-server.js +9432 -17852
  11. package/dist/notebook/journal-service-main.js +5682 -0
  12. package/dist/project-registry-service.js +748 -0
  13. package/dist/prompt.js +2 -1
  14. package/package.json +15 -10
  15. package/scripts/install-project-registry.mjs +34 -0
  16. package/scripts/migrate-project-registry.mjs +117 -0
  17. package/src/project/registryClient.ts +62 -0
  18. package/templates/commonproject/copier.yml +16 -19
  19. package/templates/commonproject/template/.agents/hooks/README.md +10 -4
  20. package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +12 -2
  21. package/templates/commonproject/template/.agents/hooks/hooks.master.json +10 -10
  22. package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +13 -0
  23. package/templates/commonproject/template/.agents/hooks/sync.py +66 -17
  24. package/templates/commonproject/template/.agents/skills.json +0 -1
  25. package/templates/commonproject/template/.github/workflows/code-review.yml.jinja +7 -1
  26. package/templates/commonproject/template/.project.json.jinja +1 -1
  27. package/templates/commonproject/template/mise.toml.jinja +11 -21
  28. package/contracts/fleet-contract.yaml +0 -584
  29. package/templates/commonproject/template/.mise/scripts/provision-packs.py +0 -587
  30. package/templates/commonproject/template/.mise/scripts/sync-skills.py +0 -2183
  31. package/templates/hermes-agent/copier.yml +0 -186
  32. package/templates/hermes-agent/template/.gitignore.jinja +0 -11
  33. package/templates/hermes-agent/template/.runtime-scaffold/.gitattributes +0 -10
  34. package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +0 -44
  35. package/templates/hermes-agent/template/.runtime-scaffold/README.md +0 -56
  36. package/templates/hermes-agent/template/.runtime-scaffold/decisions/.gitkeep +0 -2
  37. package/templates/hermes-agent/template/.runtime-scaffold/logs/.gitkeep +0 -1
  38. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +0 -50
  39. package/templates/hermes-agent/template/.runtime-scaffold/memories/USER.md +0 -51
  40. package/templates/hermes-agent/template/.scripts/00-banner.sh +0 -21
  41. package/templates/hermes-agent/template/.scripts/01-config.sh +0 -60
  42. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -42
  43. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +0 -222
  44. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -199
  45. package/templates/hermes-agent/template/.scripts/30-telegram.sh +0 -363
  46. package/templates/hermes-agent/template/.scripts/31-slack.sh +0 -457
  47. package/templates/hermes-agent/template/.scripts/40-plane.sh +0 -60
  48. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +0 -373
  49. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +0 -18
  50. package/templates/hermes-agent/template/.scripts/70-systemd.sh +0 -354
  51. package/templates/hermes-agent/template/.scripts/80-registry.sh +0 -185
  52. package/templates/hermes-agent/template/.scripts/99-summary.sh +0 -87
  53. package/templates/hermes-agent/template/.scripts/_lib.sh +0 -1138
  54. package/templates/hermes-agent/template/.scripts/channel-transaction.py +0 -2340
  55. package/templates/hermes-agent/template/.scripts/checkpoint.sh +0 -82
  56. package/templates/hermes-agent/template/.scripts/config.example.toml +0 -70
  57. package/templates/hermes-agent/template/.scripts/credential-launch.sh +0 -85
  58. package/templates/hermes-agent/template/.scripts/heartbeat.sh +0 -283
  59. package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +0 -202
  60. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +0 -761
  61. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +0 -182
  62. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +0 -108
  63. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +0 -199
  64. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +0 -546
  65. package/templates/hermes-agent/template/.scripts/lifecycle.sh +0 -126
  66. package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +0 -137
  67. package/templates/hermes-agent/template/.scripts/providers/linear.sh +0 -329
  68. package/templates/hermes-agent/template/.scripts/providers/plane.sh +0 -671
  69. package/templates/hermes-agent/template/.scripts/providers/trello.sh +0 -241
  70. package/templates/hermes-agent/template/.scripts/reporter-watchdog.py +0 -242
  71. package/templates/hermes-agent/template/.scripts/secret-scan.py +0 -134
  72. package/templates/hermes-agent/template/.scripts/sentinel/bin/emit-event.py +0 -106
  73. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +0 -339
  74. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +0 -179
  75. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +0 -162
  76. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +0 -42
  77. package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +0 -77
  78. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +0 -116
  79. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +0 -260
  80. package/templates/hermes-agent/template/SOUL.md.jinja +0 -197
  81. package/templates/hermes-agent/template/hermes.jinja +0 -109
  82. package/templates/hermes-agent/template/momo.jinja +0 -177
  83. package/templates/hermes-agent/template/role.yaml.jinja +0 -112
@@ -1,182 +0,0 @@
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)
@@ -1,108 +0,0 @@
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()
@@ -1,199 +0,0 @@
1
- # shellcheck shell=bash
2
- # Ticket-provider adapter dispatcher — the single seam between the Scrum Master
3
- # sentinel engine and a concrete ticket system (Linear | Plane | Trello).
4
- #
5
- # The engine NEVER calls a provider directly. It calls `tp <op> [args...]`,
6
- # which dispatches to providers/<provider>.sh. Repo-root .project.json owns the
7
- # provider/board binding; role.yaml is only a legacy provider-name fallback.
8
- #
9
- # Contract (operations every provider must implement):
10
- # resolve -> JSON {provider, board_id, board_url}
11
- # active_milestone -> JSON {id, name, state}
12
- # list_issues -> JSON [ {id,key,title,state,state_type,
13
- # updated_at,assignee,url,...}, ... ]
14
- # get_issue <issue-ref> -> JSON {id,key,title,description,acceptance,
15
- # state,state_type,comments:[...],
16
- # attachments:[...]}
17
- # comment <issue-ref> <body> -> prints comment id
18
- # transition <issue-ref> <normalized>
19
- # -> resolves id/human key, then moves issue; normalized in
20
- # backlog|unstarted|started|in_review|completed|
21
- # cancelled
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.
32
- # create_issue [--if-absent] <title> [desc]
33
- # -> JSON {issue_id, key, issue_url, created}
34
- # Files a new ticket on the bound board.
35
- # NOT idempotent by default (two issues may
36
- # share a title); --if-absent reuses an exact
37
- # title match and reports created:false.
38
- #
39
- # Each provider reads its credentials from the environment (see providers/*.sh
40
- # headers) and the board binding from repo-root .project.json.
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
-
99
- # Resolve the provider name: explicit env wins, then repo-root .project.json
100
- # (the SOT), then role.yaml (self-parsed so this works even when _lib.sh /
101
- # yaml_get is not loaded), then default.
102
- tp_provider_name() {
103
- if [ -n "${TICKET_PROVIDER:-}" ]; then
104
- printf '%s\n' "$TICKET_PROVIDER"
105
- return 0
106
- fi
107
- # .project.json ticket_provider.type — walk up from the role dir to repo root.
108
- local role_dir
109
- role_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)"
110
- if [ -n "$role_dir" ]; then
111
- local sot_type
112
- sot_type="$(python3 - "$role_dir" <<'PY' 2>/dev/null
113
- import sys, json, pathlib
114
- start = pathlib.Path(sys.argv[1]).resolve()
115
- for parent in [start, *start.parents]:
116
- f = parent / ".project.json"
117
- if f.is_file():
118
- try:
119
- print((json.loads(f.read_text()).get("ticket_provider") or {}).get("type", ""))
120
- except Exception:
121
- print("")
122
- break
123
- PY
124
- )"
125
- [ -n "$sot_type" ] && { printf '%s\n' "$sot_type"; return 0; }
126
- fi
127
- local role_yaml
128
- role_yaml="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/role.yaml"
129
- if [ -f "$role_yaml" ]; then
130
- local name
131
- name="$(python3 - "$role_yaml" <<'PY'
132
- import re, sys, pathlib
133
- t = pathlib.Path(sys.argv[1]).read_text()
134
- m = re.search(r'(?ms)^ticket_provider:\s*$(.*?)(?=^\S)', t + "\n\x00")
135
- block = m.group(1) if m else ""
136
- mm = re.search(r'(?m)^\s*name:\s*"?([^"\n]*)"?\s*$', block)
137
- print(mm.group(1).strip() if mm else "")
138
- PY
139
- )"
140
- [ -n "$name" ] && { printf '%s\n' "$name"; return 0; }
141
- fi
142
- printf 'linear\n'
143
- }
144
-
145
- # Directory holding provider implementations (sibling of this lib).
146
- tp_providers_dir() {
147
- local here
148
- here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
149
- printf '%s/../providers\n' "$here"
150
- }
151
-
152
- # Dispatch one operation to the active provider.
153
- tp() {
154
- local op="${1:-}"; shift || true
155
- [ -n "$op" ] || { echo "tp: missing operation" >&2; return 2; }
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
-
170
- local name impl
171
- name="$(tp_provider_name)"
172
- impl="$(tp_providers_dir)/${name}.sh"
173
-
174
- if [ ! -f "$impl" ]; then
175
- echo "tp: unknown ticket provider '$name' (no $impl)" >&2
176
- return 2
177
- fi
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
-
188
- TICKET_PROVIDER="$name" sh "$impl" "$op" "$@"
189
- }
190
-
191
- # Normalized states the engine reasons in. Adapters map these to provider terms.
192
- TP_STATES="backlog unstarted started in_review completed cancelled"
193
-
194
- tp_is_valid_state() {
195
- case " $TP_STATES " in
196
- *" $1 "*) return 0 ;;
197
- *) return 1 ;;
198
- esac
199
- }