@delorenj/pjangler 1.2.33 → 1.3.7

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 (38) hide show
  1. package/README.md +72 -0
  2. package/dist/index.js +4754 -2564
  3. package/dist/mcp-server.js +4516 -2141
  4. package/dist/prompt.js +404 -0
  5. package/package.json +7 -5
  6. package/templates/commonproject/copier.yml +6 -1
  7. package/templates/commonproject/template/.agents/hooks/README.md +10 -10
  8. package/templates/commonproject/template/.agents/hooks/hooks.master.json +1 -1
  9. package/templates/commonproject/template/.agents/hooks/sync.py +4 -4
  10. package/templates/commonproject/template/.agents/local.example.json +1 -1
  11. package/templates/commonproject/template/.mise/scripts/hindsight-setup.sh +1 -1
  12. package/templates/commonproject/template/.mise/scripts/provision-packs.py +14 -50
  13. package/templates/commonproject/template/mise.toml.jinja +11 -11
  14. package/templates/hermes-agent/copier.yml +34 -14
  15. package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +44 -0
  16. package/templates/hermes-agent/template/.runtime-scaffold/README.md +11 -10
  17. package/templates/hermes-agent/template/.scripts/01-config.sh +13 -4
  18. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +18 -28
  19. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +73 -14
  20. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +37 -32
  21. package/templates/hermes-agent/template/.scripts/30-telegram.sh +18 -4
  22. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +18 -1
  23. package/templates/hermes-agent/template/.scripts/70-systemd.sh +114 -24
  24. package/templates/hermes-agent/template/.scripts/80-registry.sh +19 -3
  25. package/templates/hermes-agent/template/.scripts/_lib.sh +74 -11
  26. package/templates/hermes-agent/template/.scripts/checkpoint.sh +29 -1
  27. package/templates/hermes-agent/template/.scripts/config.example.toml +7 -4
  28. package/templates/hermes-agent/template/.scripts/credential-launch.sh +81 -0
  29. package/templates/hermes-agent/template/.scripts/heartbeat.sh +15 -2
  30. package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +202 -0
  31. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +734 -0
  32. package/templates/hermes-agent/template/.scripts/lifecycle.sh +126 -0
  33. package/templates/hermes-agent/template/.scripts/providers/plane.sh +31 -5
  34. package/templates/hermes-agent/template/.scripts/secret-scan.py +82 -16
  35. package/templates/hermes-agent/template/SOUL.md.jinja +48 -12
  36. package/templates/hermes-agent/template/hermes.jinja +51 -10
  37. package/templates/hermes-agent/template/momo.jinja +177 -0
  38. package/templates/hermes-agent/template/role.yaml.jinja +31 -21
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env bash
2
+ # Lifecycle helper — resolves the CANONICAL Krebs ticket lifecycle for this role.
3
+ #
4
+ # The state machine is NOT defined here. It lives in
5
+ # krebs/spec/lifecycle.v1.yaml, which is the contract between ticket providers
6
+ # and fleet orchestrators. This script is a reader: it answers "which tp band
7
+ # does phase X map to" and "is this ticket stale" without any repo growing its
8
+ # own private copy of the phase list. A second copy is how a PM ends up
9
+ # transitioning against labels the board no longer uses.
10
+ #
11
+ # Provider LABELS are deliberately absent — those belong to the tp adapter's
12
+ # normalized-state map (.scripts/providers/<provider>.sh). This layer speaks
13
+ # only phases and the five tp bands.
14
+ #
15
+ # Usage:
16
+ # lifecycle.sh phases list every phase with band + staleness
17
+ # lifecycle.sh band <phase> print the tp band for a phase
18
+ # lifecycle.sh staleness <phase> print staleness minutes ("-" if none)
19
+ # lifecycle.sh is-terminal <phase> exit 0 when the phase is terminal
20
+ # lifecycle.sh stale <phase> <iso8601> exit 0 when that timestamp is stale
21
+ # lifecycle.sh spec print the resolved spec path
22
+ set -euo pipefail
23
+
24
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
25
+
26
+ # Resolution order: explicit override, the 33GOD checkout, then a sibling walk
27
+ # for hosts that keep the platform elsewhere.
28
+ resolve_spec() {
29
+ if [ -n "${KREBS_LIFECYCLE_SPEC:-}" ] && [ -r "$KREBS_LIFECYCLE_SPEC" ]; then
30
+ printf '%s\n' "$KREBS_LIFECYCLE_SPEC"; return 0
31
+ fi
32
+ local candidates=(
33
+ "$HOME/code/33GOD/krebs/spec/lifecycle.v1.yaml"
34
+ "$SCRIPT_DIR/../../../../krebs/spec/lifecycle.v1.yaml"
35
+ "$SCRIPT_DIR/../../../../../krebs/spec/lifecycle.v1.yaml"
36
+ )
37
+ local c
38
+ for c in "${candidates[@]}"; do
39
+ [ -r "$c" ] && { printf '%s\n' "$c"; return 0; }
40
+ done
41
+ return 1
42
+ }
43
+
44
+ SPEC="$(resolve_spec || true)"
45
+
46
+ need_spec() {
47
+ if [ -z "$SPEC" ]; then
48
+ echo "lifecycle: canonical spec not found (set KREBS_LIFECYCLE_SPEC)" >&2
49
+ echo "lifecycle: expected krebs/spec/lifecycle.v1.yaml in the 33GOD checkout" >&2
50
+ exit 2
51
+ fi
52
+ if ! command -v python3 >/dev/null 2>&1; then
53
+ echo "lifecycle: python3 required to read $SPEC" >&2
54
+ exit 2
55
+ fi
56
+ }
57
+
58
+ # One python entrypoint for every query keeps YAML parsing in exactly one place.
59
+ query() {
60
+ need_spec
61
+ python3 - "$SPEC" "$@" <<'PY'
62
+ import sys
63
+ try:
64
+ import yaml
65
+ except ImportError:
66
+ sys.exit("lifecycle: PyYAML required to read the lifecycle spec")
67
+
68
+ spec_path, op = sys.argv[1], sys.argv[2]
69
+ arg = sys.argv[3] if len(sys.argv) > 3 else None
70
+ arg2 = sys.argv[4] if len(sys.argv) > 4 else None
71
+
72
+ spec = yaml.safe_load(open(spec_path)) or {}
73
+ states = {s["phase"]: s for s in (spec.get("states") or []) if s.get("phase")}
74
+ if not states:
75
+ sys.exit(f"lifecycle: no states in {spec_path}")
76
+
77
+ def need_phase(p):
78
+ if p not in states:
79
+ sys.exit(f"lifecycle: unknown phase {p!r}; known: {', '.join(states)}")
80
+ return states[p]
81
+
82
+ if op == "phases":
83
+ print(f"{'phase':<14} {'tp_band':<12} {'terminal':<9} stale_after")
84
+ for name, s in states.items():
85
+ stale = s.get("staleness_minutes")
86
+ print(f"{name:<14} {str(s.get('tp_band')):<12} "
87
+ f"{str(bool(s.get('terminal'))).lower():<9} "
88
+ f"{(str(stale) + 'm') if stale else '-'}")
89
+ elif op == "band":
90
+ print(need_phase(arg)["tp_band"])
91
+ elif op == "staleness":
92
+ print(need_phase(arg).get("staleness_minutes") or "-")
93
+ elif op == "is-terminal":
94
+ sys.exit(0 if need_phase(arg).get("terminal") else 1)
95
+ elif op == "stale":
96
+ from datetime import datetime, timezone
97
+ mins = need_phase(arg).get("staleness_minutes")
98
+ if not mins:
99
+ sys.exit(1) # phase has no staleness budget -> never stale
100
+ try:
101
+ ts = datetime.fromisoformat(arg2.replace("Z", "+00:00"))
102
+ except Exception:
103
+ sys.exit(f"lifecycle: unparseable timestamp {arg2!r} (want ISO 8601)")
104
+ if ts.tzinfo is None:
105
+ ts = ts.replace(tzinfo=timezone.utc)
106
+ age = (datetime.now(timezone.utc) - ts).total_seconds() / 60
107
+ sys.exit(0 if age > mins else 1)
108
+ else:
109
+ sys.exit(f"lifecycle: unknown operation {op!r}")
110
+ PY
111
+ }
112
+
113
+ case "${1:-phases}" in
114
+ phases) query phases ;;
115
+ band) query band "${2:?phase required}" ;;
116
+ staleness) query staleness "${2:?phase required}" ;;
117
+ is-terminal) query is-terminal "${2:?phase required}" ;;
118
+ stale) query stale "${2:?phase required}" "${3:?iso8601 timestamp required}" ;;
119
+ spec) need_spec; printf '%s\n' "$SPEC" ;;
120
+ --help|-h|help)
121
+ sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' ;;
122
+ *)
123
+ echo "lifecycle: unknown operation ${1}" >&2
124
+ echo "try: phases | band <phase> | staleness <phase> | is-terminal <phase> | stale <phase> <ts> | spec" >&2
125
+ exit 2 ;;
126
+ esac
@@ -22,10 +22,6 @@ ROLE_YAML="$ROLE_DIR/role.yaml"
22
22
  BASE="${PLANE_BASE:-https://plane.delo.sh}"
23
23
 
24
24
  FLEET_ENV="${HERMES_FLEET_ENV:-$HOME/.hermes/fleet.env}"
25
- if [ -f "$FLEET_ENV" ]; then
26
- # shellcheck disable=SC1090
27
- . "$FLEET_ENV"
28
- fi
29
25
 
30
26
  die() { echo "plane: $*" >&2; exit 1; }
31
27
  need_key() { [ -n "${PLANE_API_KEY:-}" ] || die "PLANE_API_KEY is not set"; }
@@ -36,6 +32,33 @@ workspace_key() {
36
32
  printf 'PLANE_%s_API_KEY' "$key"
37
33
  }
38
34
 
35
+ # dotenv_value FILE KEY — read one exact dotenv assignment as inert data.
36
+ # Never source the shared fleet file: it may contain unrelated command
37
+ # substitutions or credential helpers that this provider must not execute.
38
+ dotenv_value() {
39
+ python3 - "$1" "$2" <<'PY'
40
+ import pathlib, sys
41
+
42
+ path = pathlib.Path(sys.argv[1])
43
+ key = sys.argv[2]
44
+ value = ""
45
+ for raw in path.read_text(encoding="utf-8").splitlines():
46
+ line = raw.strip()
47
+ if not line or line.startswith("#"):
48
+ continue
49
+ if line.startswith("export "):
50
+ line = line[7:].lstrip()
51
+ name, sep, candidate = line.partition("=")
52
+ if sep and name.strip() == key:
53
+ candidate = candidate.strip()
54
+ if len(candidate) >= 2 and candidate[0] == candidate[-1] and candidate[0] in "'\"":
55
+ candidate = candidate[1:-1]
56
+ value = candidate
57
+ break
58
+ print(value, end="")
59
+ PY
60
+ }
61
+
39
62
  tp_cfg() {
40
63
  [ -f "$ROLE_YAML" ] || return 0
41
64
  python3 - "$ROLE_YAML" "$1" <<'PY'
@@ -76,7 +99,10 @@ API="$BASE/api/v1/workspaces/$WS"
76
99
 
77
100
  if [ -z "${PLANE_API_KEY:-}" ]; then
78
101
  KEY="$(workspace_key "$WS")"
79
- eval "PLANE_API_KEY=\${$KEY:-}"
102
+ PLANE_API_KEY="$(printenv "$KEY" 2>/dev/null || true)"
103
+ if [ -z "${PLANE_API_KEY:-}" ] && [ -f "$FLEET_ENV" ]; then
104
+ PLANE_API_KEY="$(dotenv_value "$FLEET_ENV" "$KEY")"
105
+ fi
80
106
  export PLANE_API_KEY
81
107
  fi
82
108
 
@@ -8,45 +8,111 @@ import re
8
8
  from pathlib import Path
9
9
 
10
10
  KEY_VALUE = re.compile(
11
- r"(?im)^\s*[\"']?(?:api[_-]?key|token|secret|password|authorization|cookie|client[_-]?secret|private[_-]?key)[\"']?\s*[:=]\s*[\"']?([^\s\"'#][^\r\n#]*)"
11
+ r"(?im)^\s*[\"']?(?P<key>api[_-]?key|token|secret|password|authorization|cookie|client[_-]?secret|private[_-]?key)[\"']?\s*[:=]\s*(?P<value>[^\r\n#]*)"
12
12
  )
13
13
  TOKEN = re.compile(
14
14
  r"(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|tvly-[A-Za-z0-9_-]{10,}|fc-[A-Za-z0-9_-]{10,}|[0-9]{6,}:[A-Za-z0-9_-]{20,})"
15
15
  )
16
16
  PRIVATE_KEY = re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")
17
- SAFE_VALUES = {"openrouter", "openai", "anthropic", "auto", "none", "null", "false", "true"}
18
- SKIP_PARTS = {".git", "__pycache__"}
17
+ SAFE_VALUES = {
18
+ "openrouter",
19
+ "openai",
20
+ "anthropic",
21
+ "auto",
22
+ "none",
23
+ "null",
24
+ "false",
25
+ "true",
26
+ }
27
+ SAFE_PREFIXES = ("${", "$", "op://", "env:", "[REDACTED]", "{{", "{%")
28
+ COMPUTED_VALUE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*\s*(?:\(|\[)")
29
+ INTERPOLATED_VALUE = re.compile(
30
+ r"(?:\$\{[A-Za-z_][A-Za-z0-9_]*[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_.]*\})"
31
+ )
32
+ ENV_ASSIGNMENT = re.compile(
33
+ r"(?m)^\s*(?:export\s+)?(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?P<value>[^\r\n#]*)"
34
+ )
35
+ SENSITIVE_ENV_KEY = re.compile(
36
+ r"(?i)(?:^|_)(?:api_key|token|secret|password|authorization|cookie|client_secret|private_key)(?:_|$)"
37
+ )
38
+ ALLOWED_ENV_FILES = {".env.example", ".env.op"}
39
+ SKIP_PARTS = {
40
+ ".cache",
41
+ ".direnv",
42
+ ".eggs",
43
+ ".git",
44
+ ".mypy_cache",
45
+ ".nox",
46
+ ".npm",
47
+ ".pnpm-store",
48
+ ".pytest_cache",
49
+ ".ruff_cache",
50
+ ".tox",
51
+ ".turbo",
52
+ ".uv-cache",
53
+ ".venv",
54
+ ".yarn",
55
+ "__pycache__",
56
+ "dist-packages",
57
+ "node_modules",
58
+ "site-packages",
59
+ "venv",
60
+ }
61
+
62
+
63
+ def safe_assignment(raw_value: str) -> bool:
64
+ """Return true only for an explicit reference, sentinel, or computation."""
65
+
66
+ value = raw_value.strip().rstrip(",").strip().strip("\"'")
67
+ if not value or value.lower() in SAFE_VALUES:
68
+ return True
69
+ if value.startswith(SAFE_PREFIXES) or value.endswith("_ENV"):
70
+ return True
71
+ if INTERPOLATED_VALUE.search(value):
72
+ return True
73
+ # Source code commonly binds credentials from a runtime provider. A bare
74
+ # literal does not have a call/index boundary and remains fail-closed.
75
+ return COMPUTED_VALUE.match(value) is not None
76
+
77
+
78
+ def safe_env_reference(raw_value: str) -> bool:
79
+ """Keep a tracked .env.op declarative: sensitive values stay references."""
80
+
81
+ value = raw_value.strip().rstrip(",").strip().strip("\"'")
82
+ return not value or value.startswith(SAFE_PREFIXES)
19
83
 
20
84
 
21
85
  def findings(root: Path) -> list[str]:
22
86
  result: list[str] = []
23
87
  for path in root.rglob("*"):
24
- if not path.is_file() or any(part in SKIP_PARTS for part in path.parts):
88
+ relative = path.relative_to(root)
89
+ if not path.is_file() or any(part in SKIP_PARTS for part in relative.parts):
25
90
  continue
26
91
  if path.name == "secret-scan.py":
27
92
  continue
28
- if path.name.startswith(".env") and path.name != ".env.example":
29
- result.append(f"forbidden secret file: {path.relative_to(root)}")
93
+ if path.name.startswith(".env") and path.name not in ALLOWED_ENV_FILES:
94
+ result.append(f"forbidden secret file: {relative}")
30
95
  continue
31
96
  if path.name in {"auth.json", "auth.lock"} or path.suffix in {".pem", ".key"}:
32
- result.append(f"forbidden credential file: {path.relative_to(root)}")
97
+ result.append(f"forbidden credential file: {relative}")
33
98
  continue
34
99
  try:
35
100
  text = path.read_text(encoding="utf-8")
36
101
  except (UnicodeDecodeError, OSError):
37
102
  continue
38
103
  if TOKEN.search(text) or PRIVATE_KEY.search(text):
39
- result.append(f"credential token pattern: {path.relative_to(root)}")
104
+ result.append(f"credential token pattern: {relative}")
105
+ if path.name == ".env.op":
106
+ for match in ENV_ASSIGNMENT.finditer(text):
107
+ if SENSITIVE_ENV_KEY.search(
108
+ match.group("key")
109
+ ) and not safe_env_reference(match.group("value")):
110
+ result.append(f"literal credential assignment: {relative}")
111
+ break
40
112
  for match in KEY_VALUE.finditer(text):
41
- value = match.group(1).strip().strip("\"'").rstrip(",")
42
- if (
43
- not value
44
- or value.lower() in SAFE_VALUES
45
- or value.startswith(("${", "$", "op://", "env:", "[REDACTED]"))
46
- or value.endswith("_ENV")
47
- ):
113
+ if safe_assignment(match.group("value")):
48
114
  continue
49
- result.append(f"literal credential assignment: {path.relative_to(root)}")
115
+ result.append(f"literal credential assignment: {relative}")
50
116
  break
51
117
  return sorted(set(result))
52
118
 
@@ -15,10 +15,10 @@ You are **{{ display_name }}** — a Hermes agent provisioned to work inside the
15
15
 
16
16
  ## Scope
17
17
 
18
- Your HERMES_HOME is the local runtime at `./runtime/`; Hermes loads its
19
- `config.yaml` directly. Secrets, SOUL, skills, sessions, and gateway state live
20
- local to that runtime (pure-local state; durable memory is the shared Hindsight
21
- bank see Memory hygiene).
18
+ Your HERMES_HOME is the real named profile under `~/.hermes/profiles/`. Shared
19
+ config/auth/skills link to fleet truth; your SOUL, sessions, memory, and other
20
+ owned state link into the ignored local `./runtime/`. Only PJangler may repair
21
+ that wiring (`pj migrate hermes.runtime-singleton`).
22
22
 
23
23
  ## Tone
24
24
 
@@ -147,14 +147,50 @@ declared` event so the fleet knows what to route to you.
147
147
  via Cloudflare Tunnel), `localhost` for same-host, Docker network service
148
148
  names for container-to-container, Tailscale for private machine-to-machine.
149
149
 
150
- ## Memory hygiene
151
-
152
- Your durable memory is the shared **Hindsight bank `{{ target_repo }}`** — one
153
- bank per PROJECT, shared with the human-drivable Momo twin. Honcho and the
154
- per-agent `runtime/memories/` store are **neutralized** (see `config.yaml`
155
- `memory.provider: ""`): do not rely on `MEMORY.md`/`USER.md`. Retain with
156
- `hindsight memory retain {{ target_repo }} "…" --context <cat>`; recall with
157
- `hindsight memory recall {{ target_repo }} "…"`.
150
+ ## Memory: two namespaces, two questions
151
+
152
+ You have **two** memory stores. They do not compete they answer opposite
153
+ questions, and you are expected to use both and play them off each other.
154
+
155
+ | | **Identity memory** | **Project memory** |
156
+ | --- | --- | --- |
157
+ | Bank | `agent-{{ agent_id }}` | `{{ target_repo }}` |
158
+ | Anchored to | **who you are** | **which repo** |
159
+ | Follows you across repos | yes | no |
160
+ | Written by | the runtime, automatically | you, explicitly |
161
+ | Read by | you alone | every agent on this repo |
162
+ | Answers | "which projects have I worked on, and how do I work?" | "what is true about this repo, and which agent learned it?" |
163
+
164
+ **Identity memory** is wired to the Hermes memory provider
165
+ (`memory.bank_id_template: agent-{profile}`), so it accrues on its own from
166
+ your turns. It is keyed to your profile name, **never** to a repo or working
167
+ directory — change directories, change projects, it follows you. Treat it as
168
+ self-referential: your capabilities, your recurring mistakes and the
169
+ corrections that stuck, operator preferences you have learned, and the shape of
170
+ the projects you have touched. Do not put repo facts here; they would be
171
+ invisible to every other agent working that repo.
172
+
173
+ **Project memory** is the shared, temporally-sequenced record of a repository,
174
+ queried by many agents including the human-drivable Momo twin. Write it
175
+ explicitly, and always carry provenance — name yourself in the content so a
176
+ later reader can answer *which agent experienced this*:
177
+
178
+ ```bash
179
+ hindsight memory retain {{ target_repo }} "{{ agent_id }}: <fact>" --context <cat>
180
+ hindsight memory recall {{ target_repo }} "<question>"
181
+ ```
182
+
183
+ **The synergy.** Before starting work in a repo you have not touched lately,
184
+ recall from BOTH: project memory tells you the state of the code; identity
185
+ memory tells you how *you* previously failed or succeeded here and what the
186
+ operator asked you to do differently. When you learn something, route it by
187
+ asking one question — *would another agent on this repo need this?* If yes it
188
+ is project memory; if it is only true of you, it is identity memory. A fact
189
+ about the operator's preferences is identity memory; a fact about the build is
190
+ project memory.
191
+
192
+ `MEMORY.md` / `USER.md` are live again and are fed by the provider — they are a
193
+ projection of identity memory, not a separate store to hand-maintain.
158
194
 
159
195
  ## Doctrine
160
196
 
@@ -1,17 +1,46 @@
1
1
  #!/usr/bin/env bash
2
- # Launcher for {{ agent_id }}. Resolves HERMES_HOME to the local runtime
2
+ # Launcher for {{ agent_id }}. Resolves HERMES_HOME to the agent's NAMED PROFILE
3
3
  # and execs the shared fleet Hermes binary configured in ~/.hermes/fleet.env.
4
4
 
5
5
  set -euo pipefail
6
6
 
7
7
  ROLE_DIR="$(cd "$(dirname "$0")" && pwd)"
8
- HERMES_HOME="$ROLE_DIR/runtime"
8
+ RUNTIME_HOME="$ROLE_DIR/runtime"
9
9
 
10
10
  FLEET_ENV="${HERMES_FLEET_ENV:-$HOME/.hermes/fleet.env}"
11
- if [[ -f "$FLEET_ENV" ]]; then
12
- # shellcheck disable=SC1090
13
- source "$FLEET_ENV"
11
+ FLEET_ENV_LIBRARY="$ROLE_DIR/.scripts/lib/fleet-env.sh"
12
+ FLEET_ENV_PARSER="$ROLE_DIR/.scripts/lib/parse-fleet-env.py"
13
+ if [[ ! -f "$FLEET_ENV_LIBRARY" || -L "$FLEET_ENV_LIBRARY" ]]; then
14
+ echo "hermes: trusted fleet environment loader unavailable" >&2
15
+ exit 1
16
+ fi
17
+ # shellcheck source=.scripts/lib/fleet-env.sh
18
+ builtin source "$FLEET_ENV_LIBRARY"
19
+ load_fleet_environment "$FLEET_ENV" "$FLEET_ENV_PARSER"
20
+
21
+ FLEET_HOME="${HERMES_FLEET_HOME:-$HOME/.hermes}"
22
+ PROFILE_NAME="${HERMES_PROFILE_NAME:-{{ agent_id }}}"
23
+
24
+ # Singleton-runtime contract: HERMES_HOME MUST be the named profile dir, never
25
+ # the raw runtime path. Hermes derives the profile identity from the UNRESOLVED
26
+ # HERMES_HOME path; any HERMES_HOME that is neither under ~/.hermes nor a child
27
+ # of a "profiles" dir makes get_active_profile_name() report "default" and
28
+ # _global_auth_file_path() return None — silently disabling shared fleet auth
29
+ # and giving the agent a divergent config.yaml. The profile dir is a REAL dir
30
+ # whose shared entries (.env, skills) symlink to the fleet root and whose owned
31
+ # entries (memories, sessions, state.db, ...) symlink into $RUNTIME_HOME.
32
+ #
33
+ # config.yaml is NOT symlinked — it is GENERATED as
34
+ # deep_merge(fleet config.yaml, <profile>/config.delta.yaml). Symlinking it
35
+ # detached the profile on the first in-agent write (Hermes' atomic write does
36
+ # os.replace, which swaps the symlink for a regular file) and left no way to
37
+ # override anything. Edit config.delta.yaml, then render.
38
+ # Provision it with: pj migrate hermes.runtime-singleton
39
+ HERMES_HOME="$FLEET_HOME/profiles/$PROFILE_NAME"
40
+ if ! REPO_ROOT="$(git -C "$ROLE_DIR" rev-parse --show-toplevel 2>/dev/null)"; then
41
+ REPO_ROOT="$ROLE_DIR"
14
42
  fi
43
+
15
44
  # Never pass an identity-bearing chat credential from shared fleet state into
16
45
  # a profile process. Hermes loads the profile's own runtime/.env itself.
17
46
  unset TELEGRAM_BOT_TOKEN SLACK_BOT_TOKEN SLACK_APP_TOKEN
@@ -47,16 +76,28 @@ HERMES_BIN="${HERMES_BIN:-${HERMES_FLEET_BIN:-}}"
47
76
  if [[ -z "$HERMES_BIN" ]]; then
48
77
  HERMES_BIN="$(config_get fleet.hermes_bin "")"
49
78
  fi
50
- HERMES_BIN="${HERMES_BIN:-$HOME/.hermes/hermes-agent/.venv/bin/hermes}"
51
- HERMES_OAUTH_FILE="${HERMES_OAUTH_FILE:-${HERMES_FLEET_OAUTH_FILE:-$(config_get fleet.oauth_file "$HOME/.hermes/auth.json")}}"
79
+ HERMES_BIN="${HERMES_BIN:-$HOME/.local/share/hermes-agent/releases/0408fec7a153e6c32c064acd2b8053917f1525f1/.venv/bin/hermes}"
52
80
  CODEX_HOME="${CODEX_HOME:-${HERMES_FLEET_CODEX_HOME:-$(config_get fleet.codex_home "$HOME/.codex")}}"
53
81
 
54
- if [[ ! -d "$HERMES_HOME" ]]; then
55
- echo "hermes: local runtime not provisioned at $HERMES_HOME" >&2
82
+ if [[ ! -d "$RUNTIME_HOME" ]]; then
83
+ echo "hermes: local runtime not provisioned at $RUNTIME_HOME" >&2
56
84
  echo " fix: run $ROLE_DIR/.scripts/20-runtime-repo.sh" >&2
57
85
  exit 1
58
86
  fi
59
87
 
88
+ if [[ ! -d "$HERMES_HOME" ]]; then
89
+ echo "hermes: profile not provisioned at $HERMES_HOME" >&2
90
+ echo " fix: pj migrate hermes.runtime-singleton" >&2
91
+ exit 1
92
+ fi
93
+
94
+ if [[ -L "$HERMES_HOME" ]]; then
95
+ echo "hermes: $HERMES_HOME is a symlink; the profile dir must be a REAL dir" >&2
96
+ echo " (a symlink breaks profile-name resolution and shared fleet auth)" >&2
97
+ echo " fix: pj migrate hermes.runtime-singleton" >&2
98
+ exit 1
99
+ fi
100
+
60
101
  if [[ ! -x "$HERMES_BIN" ]]; then
61
102
  echo "hermes: binary not executable at $HERMES_BIN" >&2
62
103
  echo " set HERMES_BIN or HERMES_FLEET_BIN (in $FLEET_ENV) to the shared Hermes binary." >&2
@@ -64,5 +105,5 @@ if [[ ! -x "$HERMES_BIN" ]]; then
64
105
  fi
65
106
 
66
107
  exec env HERMES_HOME="$HERMES_HOME" HERMES_FLEET_ENV="$FLEET_ENV" \
67
- HERMES_OAUTH_FILE="$HERMES_OAUTH_FILE" CODEX_HOME="$CODEX_HOME" \
108
+ CODEX_HOME="$CODEX_HOME" TERMINAL_CWD="$REPO_ROOT" \
68
109
  "$HERMES_BIN" "$@"
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env bash
2
+ # Momo provider dispatcher for {{ target_repo }} ({{ role }}).
3
+ #
4
+ # The stable, per-repo entrypoint Momo (and `pj audit --profile
5
+ # momo-lifecycle-plane`) uses to reach this project's ticket board. It is a thin
6
+ # seam ON PURPOSE: all real provider logic already lives in
7
+ # .scripts/lib/ticket-provider.sh, which dispatches to
8
+ # .scripts/providers/<provider>.sh based on repo-root .project.json. This file
9
+ # adds no second implementation of that — duplicating it is how a repo ends up
10
+ # with two disagreeing notions of "what board am I bound to".
11
+ #
12
+ # What this adds over calling `tp` directly:
13
+ # * one discoverable path per role (agents/hermes/<role>/momo) instead of
14
+ # "source the right lib from the right cwd"
15
+ # * --help / --smoke, so readiness is checkable without credentials
16
+ # * the canonical Krebs phase -> tp-band map, so callers reason in lifecycle
17
+ # phases rather than hardcoding provider labels
18
+ #
19
+ # Operations are forwarded verbatim to `tp`. See the contract at the top of
20
+ # .scripts/lib/ticket-provider.sh: resolve, active_milestone, list_issues,
21
+ # get_issue, comment, transition, create_board, create_issue.
22
+ set -euo pipefail
23
+
24
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
25
+ LIB="$SCRIPT_DIR/.scripts/lib/ticket-provider.sh"
26
+
27
+ # Canonical lifecycle spec. Phases are Krebs' vocabulary; tp bands are the five
28
+ # normalized states every provider adapter must map. Kept in sync with
29
+ # krebs/spec/lifecycle.v1.yaml — that file is the source of truth.
30
+ LIFECYCLE_SPEC="${KREBS_LIFECYCLE_SPEC:-$HOME/code/33GOD/krebs/spec/lifecycle.v1.yaml}"
31
+
32
+ usage() {
33
+ cat <<'MOMO_HELP'
34
+ momo — ticket-board dispatcher for this repo's Hermes role
35
+
36
+ USAGE
37
+ momo <operation> [args...] forward an operation to the bound ticket provider
38
+ momo --help show this help
39
+ momo --smoke [root|nested] verify wiring without credentials
40
+ momo --lifecycle print the canonical phase -> tp-band map
41
+ momo --provider print the resolved provider name
42
+
43
+ OPERATIONS (implemented by .scripts/providers/<provider>.sh)
44
+ resolve -> {provider, board_id, board_url}
45
+ active_milestone -> {id, name, state}
46
+ list_issues -> [ {id,key,title,state,state_type,...} ]
47
+ get_issue <id> -> {id,key,title,description,acceptance,...}
48
+ comment <id> <body> -> comment id
49
+ transition <id> <state> -> backlog|unstarted|started|in_review|completed|cancelled
50
+ create_board <name> <id> <d> -> {board_id, board_url}
51
+ create_issue [--if-absent] <title> [desc]
52
+
53
+ The provider and board binding come from repo-root .project.json
54
+ (ticket_provider.*). Credentials come from the environment; see the header of
55
+ the matching .scripts/providers/<provider>.sh.
56
+ MOMO_HELP
57
+ }
58
+
59
+ # Phase -> tp band, read from the canonical spec when present so this never
60
+ # becomes a stale second copy. The inline fallback exists only so --lifecycle
61
+ # still answers on a host without the 33GOD checkout.
62
+ print_lifecycle() {
63
+ if [ -r "$LIFECYCLE_SPEC" ] && command -v python3 >/dev/null 2>&1; then
64
+ python3 - "$LIFECYCLE_SPEC" <<'PY' && return 0
65
+ import sys
66
+ try:
67
+ import yaml
68
+ except ImportError:
69
+ sys.exit(1)
70
+ try:
71
+ spec = yaml.safe_load(open(sys.argv[1]))
72
+ except Exception:
73
+ sys.exit(1)
74
+ states = spec.get("states") or []
75
+ if not states:
76
+ sys.exit(1)
77
+ print(f"{'phase':<14} {'tp_band':<12} {'terminal':<9} stale_after")
78
+ for s in states:
79
+ stale = s.get("staleness_minutes")
80
+ print(f"{str(s.get('phase')):<14} {str(s.get('tp_band')):<12} "
81
+ f"{str(bool(s.get('terminal'))).lower():<9} "
82
+ f"{(str(stale) + 'm') if stale else '-'}")
83
+ PY
84
+ fi
85
+ # Fallback: spec unreadable or PyYAML absent.
86
+ cat <<'FALLBACK'
87
+ phase tp_band terminal stale_after
88
+ backlog backlog false -
89
+ triage unstarted false 10m
90
+ refining unstarted false 30m
91
+ ready unstarted false -
92
+ in_progress started false 120m
93
+ review in_review false 15m
94
+ qa in_review false -
95
+ FALLBACK
96
+ echo "(fallback map: could not read $LIFECYCLE_SPEC)" >&2
97
+ }
98
+
99
+ require_lib() {
100
+ if [ ! -r "$LIB" ]; then
101
+ echo "momo: ticket-provider lib missing: $LIB" >&2
102
+ echo "momo: run the role's provisioning scripts, or pj migrate hermes.pm-scaffold" >&2
103
+ return 2
104
+ fi
105
+ }
106
+
107
+ # Credential-free wiring check. Verifies the pieces a board call needs are
108
+ # present and resolvable; it deliberately does NOT hit the network, so it is
109
+ # safe in CI and in `pj audit`.
110
+ smoke() {
111
+ local scope="${1:-root}" rc=0
112
+ require_lib || return 2
113
+ # shellcheck source=/dev/null
114
+ . "$LIB"
115
+
116
+ local provider=""
117
+ provider="$(tp_provider_name 2>/dev/null || true)"
118
+ if [ -z "$provider" ]; then
119
+ echo "smoke: no ticket provider resolved (.project.json ticket_provider.type)" >&2
120
+ rc=1
121
+ else
122
+ echo "smoke: provider = $provider"
123
+ fi
124
+
125
+ local impl
126
+ impl="$(tp_providers_dir 2>/dev/null || true)/${provider}.sh"
127
+ if [ -n "$provider" ] && [ ! -f "$impl" ]; then
128
+ echo "smoke: provider adapter missing: $impl" >&2
129
+ rc=1
130
+ elif [ -n "$provider" ]; then
131
+ echo "smoke: adapter = $impl"
132
+ fi
133
+
134
+ if [ -r "$SCRIPT_DIR/../../../.project.json" ]; then
135
+ echo "smoke: binding = repo-root .project.json"
136
+ else
137
+ echo "smoke: .project.json not found from $SCRIPT_DIR" >&2
138
+ rc=1
139
+ fi
140
+
141
+ if [ "$scope" = "nested" ]; then
142
+ # Nested scope additionally asserts the normalized state vocabulary is
143
+ # intact, since that is what a nested adapter call would transition against.
144
+ local missing=""
145
+ for st in backlog unstarted started in_review completed cancelled; do
146
+ tp_is_valid_state "$st" || missing="$missing $st"
147
+ done
148
+ if [ -n "$missing" ]; then
149
+ echo "smoke: normalized states missing:$missing" >&2
150
+ rc=1
151
+ else
152
+ echo "smoke: states = ok (6 normalized)"
153
+ fi
154
+ fi
155
+
156
+ [ "$rc" -eq 0 ] && echo "smoke: ok ($scope)"
157
+ return "$rc"
158
+ }
159
+
160
+ main() {
161
+ case "${1:---help}" in
162
+ --help|-h|help) usage ;;
163
+ --lifecycle) print_lifecycle ;;
164
+ --smoke) shift; smoke "${1:-root}" ;;
165
+ --provider)
166
+ require_lib || exit 2
167
+ # shellcheck source=/dev/null
168
+ . "$LIB"; tp_provider_name ;;
169
+ *)
170
+ require_lib || exit 2
171
+ # shellcheck source=/dev/null
172
+ . "$LIB"
173
+ tp "$@" ;;
174
+ esac
175
+ }
176
+
177
+ main "$@"