@delorenj/pjangler 1.2.19 → 1.2.21

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delorenj/pjangler",
3
- "version": "1.2.19",
3
+ "version": "1.2.21",
4
4
  "description": "Project subsystem bootstrapper CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -90,8 +90,9 @@ agent_hooks_layer:
90
90
  default: true
91
91
  when: false
92
92
 
93
- # When agent_hooks_layer is false, omit the project-scoped hook tree + skill-link
94
- # scripts entirely (the mise.toml wiring is already gated inside the template).
93
+ # When agent_hooks_layer is false, omit only the project-scoped hook tree. The
94
+ # skills manifest stays — sync-skills.py writes project-local CLI dirs and is safe
95
+ # even on machines that already run a global ~/.agents/hooks install.
95
96
  # The `.copier-noop-never-matches` sentinel keeps every pattern non-empty when the
96
97
  # layer is enabled, which copier requires.
97
98
  _exclude:
@@ -105,8 +106,6 @@ _exclude:
105
106
  - "{% if agent_hooks_layer %}.copier-noop-never-matches{% else %}.agents/hooks{% endif %}"
106
107
  - "{% if agent_hooks_layer %}.copier-noop-never-matches{% else %}.agents/hooks/**{% endif %}"
107
108
  - "{% if agent_hooks_layer %}.copier-noop-never-matches{% else %}.agents/local.example.json{% endif %}"
108
- - "{% if agent_hooks_layer %}.copier-noop-never-matches{% else %}.mise/scripts/link-project-skills-to-clis.sh{% endif %}"
109
- - "{% if agent_hooks_layer %}.copier-noop-never-matches{% else %}.mise/scripts/unlink-project-skills-from-clis.sh{% endif %}"
110
109
 
111
110
  # --- Post-generation tasks ---
112
111
  #
@@ -15,6 +15,7 @@ master writes zero bytes).
15
15
  mise run hooks-sync # fan out the master -> claude + codex + hermes
16
16
  mise run hooks-check # drift gate (read-only; used in CI)
17
17
  mise run hooks-uninstall # remove codex + hermes injections
18
+ mise run skills-sync # sync .agents/skills.json -> local CLI skill dirs
18
19
  mise run hindsight-setup # one-time: pull the shared CAF Hindsight key into .env
19
20
  ```
20
21
 
@@ -70,8 +71,7 @@ Copy [`.agents/local.example.json`](../local.example.json) → `.agents/local.js
70
71
  "hooks": {
71
72
  "disabled": ["hindsight-recall"], // hook ids — skipped at runtime, ALL agents
72
73
  "disabled_agents": ["codex"] // claude|codex|hermes — codex/hermes injection skipped/removed
73
- },
74
- "skills": { "disabled": ["bmad-help"] } // skill dir names — not symlinked for you
74
+ }
75
75
  }
76
76
  ```
77
77
 
@@ -83,32 +83,18 @@ Copy [`.agents/local.example.json`](../local.example.json) → `.agents/local.js
83
83
  - **Disabled agents** are enforced at **install** time (codex/hermes injection is
84
84
  skipped, and removed if previously installed). `CAF_HOOKS_SKIP_CODEX=1` is an
85
85
  env shortcut for `disabled_agents:["codex"]`.
86
- - **Disabled skills** are honored by the skill fan-out (below).
86
+ ## Skill fan-out `.agents/skills.json` each CLI
87
87
 
88
- ## Skill fan-out — `.agents/skills` each CLI
88
+ `.agents/skills.json` is the project's skill manifest. `sync-skills.py --scope
89
+ project` resolves that manifest into project-local CLI dirs (for example
90
+ `./.codex/skills`, `./.hermes/skills`, `./.kimi-code/skills`) and honors
91
+ `inherit_global: true` so a dev's global `~/.agents/skills.json` loadout is
92
+ merged in automatically.
89
93
 
90
- `.agents/skills/` is the project's enabled skill set (committed inherited skills +
91
- `./skills/*` app skills symlinked in on enter). `link-project-skills-to-clis.sh`
92
- fans it out as per-skill symlinks to every agent CLI that doesn't read `.agents/`
93
- natively a small `SKILL_TARGETS` table:
94
-
95
- | CLI | target dir | scope |
96
- |---|---|---|
97
- | Codex | `~/.codex/skills` | **global** — link ours in, never clobber foreign entries, removed on `leave` |
98
- | Kimi | `./.kimi-code/skills` | **local** — a project-scoped mirror we fully manage (stale real copies replaced with symlinks) |
99
-
100
- Add a CLI = one `dir|scope` line. Per-dev control via `.agents/local.json`
101
- `skills`:
102
-
103
- - `disabled: [...]` — never link these; pruned from managed mirrors.
104
- - `defer_to_global: true` — **only if you run a global `~/.agents/skills` system.**
105
- Skips any project skill whose name also exists there, so your global copy wins
106
- and you get **zero duplicates** (it even yields the slot in `~/.codex/skills`).
107
- Teammates with no global layer leave it `false` (default) and **inherit the full
108
- set**. Override the global dir with `AGENTS_GLOBAL_SKILLS_DIR`.
109
-
110
- Tasks: `mise run skills-relink` (re-fan), `mise run link-project-skills-to-clis`,
111
- `mise run unlink-project-skills-from-clis`.
94
+ - Project-local skills with the same name shadow the global ones.
95
+ - No `defer_to_global` flag or bash cleanup scripts are needed anymore.
96
+ - The manifest is the only hand-edited skill SSOT; run `mise run skills-sync`
97
+ after changing it, or just re-enter the repo.
112
98
 
113
99
  ## Hindsight credentials — `mise run hindsight-setup`
114
100
 
@@ -137,6 +123,7 @@ Without a key the hooks **no-op gracefully** — recall/retain just do nothing.
137
123
  ```
138
124
  .agents/
139
125
  local.example.json # per-dev override template (copy -> local.json, gitignored)
126
+ skills.json # project skill manifest (SSOT for sync-skills.py)
140
127
  hooks/
141
128
  hooks.master.json # SSOT — the only hand-edited config
142
129
  sync.py # fan-out engine (--install / --uninstall / --check)
@@ -4,10 +4,9 @@
4
4
  # Source this, then call:
5
5
  # hook_disabled <hook-id> # true if .hooks.disabled[] contains the id
6
6
  # agent_disabled <agent> # true if .hooks.disabled_agents[] contains the agent
7
- # skill_disabled <skill-name> # true if .skills.disabled[] contains the name
8
7
  #
9
8
  # All FAIL OPEN (return 1 = "not disabled") when `.agents/local.json` or `jq` is
10
- # absent, so a missing/garbled local config can never silently kill hooks/skills.
9
+ # absent, so a missing/garbled local config can never silently kill hooks.
11
10
  #
12
11
  # `.agents/local.json` is gitignored — each dev owns their own. See
13
12
  # `.agents/local.example.json` for the schema.
@@ -30,22 +29,13 @@ _caf_listed() {
30
29
 
31
30
  hook_disabled() { _caf_listed '.hooks.disabled' "$1"; }
32
31
  agent_disabled() { _caf_listed '.hooks.disabled_agents' "$1"; }
33
- skill_disabled() { _caf_listed '.skills.disabled' "$1"; }
34
32
 
35
- # True if the dev opted to let their GLOBAL agent system (~/.agents/skills) provide
36
- # any overlapping skill, so the project linker should skip it (no duplicates).
37
- # Teammates with no global layer omit this and inherit the full project set.
33
+ # True if the dev opted to let their GLOBAL agent system (~/.agents/hooks) provide
34
+ # the hook layer, so project-scoped per-user injections should be suppressed.
38
35
  skills_defer_to_global() {
39
36
  local f
40
37
  f="$(_caf_local_json)" || return 1
41
38
  [[ -f "$f" ]] || return 1
42
39
  command -v jq >/dev/null 2>&1 || return 1
43
- jq -e '.skills.defer_to_global == true' "$f" >/dev/null 2>&1
44
- }
45
-
46
- # True if <skill-name> is provided by the global agent SSOT. `-e` follows the
47
- # ~/.agents/skills symlink (-> skillex/skill-sets/global) and its entries.
48
- skill_is_global() {
49
- local name="$1" gdir="${AGENTS_GLOBAL_SKILLS_DIR:-$HOME/.agents/skills}"
50
- [[ -e "${gdir}/${name}" ]]
40
+ jq -e '.hooks.defer_to_global == true' "$f" >/dev/null 2>&1
51
41
  }
@@ -72,10 +72,10 @@ def load_local() -> dict:
72
72
  (so even Claude's committed hooks honor them); this only needs disabled
73
73
  AGENTS, which gate install. CAF_HOOKS_SKIP_CODEX=1 is an env shortcut.
74
74
 
75
- `defer_to_global` (under either `hooks` or `skills`) means "I already run
76
- these hooks from a global agent system" — so the shared per-user injections
77
- (codex/kimi/hermes) are suppressed and actively removed. Claude's committed
78
- repo settings are harmless and left in place.
75
+ `hooks.defer_to_global` means "I already run these hooks from a global agent
76
+ system" — so the shared per-user injections (codex/kimi/hermes) are
77
+ suppressed and actively removed. Claude's committed repo settings are
78
+ harmless and left in place.
79
79
  """
80
80
  disabled_agents: set[str] = set()
81
81
  defer_to_global = False
@@ -84,9 +84,8 @@ def load_local() -> dict:
84
84
  try:
85
85
  data = json.loads(p.read_text() or "{}")
86
86
  hooks_cfg = data.get("hooks") or {}
87
- skills_cfg = data.get("skills") or {}
88
87
  disabled_agents = set(hooks_cfg.get("disabled_agents") or [])
89
- defer_to_global = bool(hooks_cfg.get("defer_to_global")) or bool(skills_cfg.get("defer_to_global"))
88
+ defer_to_global = bool(hooks_cfg.get("defer_to_global"))
90
89
  except (json.JSONDecodeError, OSError) as exc:
91
90
  warn(f"ignoring malformed .agents/local.json: {exc}")
92
91
  if os.environ.get("CAF_HOOKS_SKIP_CODEX") == "1":
@@ -1,17 +1,11 @@
1
1
  {
2
- "$comment": "PER-DEV LOCAL OVERRIDES — copy this file to .agents/local.json (gitignored) and edit. Lets you opt out of individual hooks, whole agents, or specific auto-linked skills, and tune how project skills fan out to your agent CLIs — without touching committed config. Hooks self-skip at RUNTIME via .agents/hooks/lib/hook-guard.sh (so even Claude's committed hooks honor it); codex/hermes injections additionally skip at INSTALL time. Re-run `mise run hooks-sync` / `mise run skills-relink` (or re-cd into the repo) after editing.",
2
+ "$comment": "PER-DEV LOCAL OVERRIDES — copy this file to .agents/local.json (gitignored) and edit. Lets you opt out of individual hooks or whole injected agents without touching committed config. Hooks self-skip at RUNTIME via .agents/hooks/lib/hook-guard.sh (so even Claude's committed hooks honor it); codex/hermes injections additionally skip at INSTALL time. Re-run `mise run hooks-sync` (or re-cd into the repo) after editing.",
3
3
  "hooks": {
4
4
  "$disabled_help": "Hook ids from .agents/hooks/hooks.master.json: skill-check-reminder, hindsight-recall, hindsight-retain, hindsight-session-end. Listed ids are skipped at runtime across ALL agents.",
5
5
  "disabled": [],
6
6
  "$disabled_agents_help": "Agent keys: claude, codex, kimi, hermes. Listed agents are NOT installed (codex/kimi/hermes injections are skipped/removed). Claude's hooks are committed and can't be fully removed per-dev — disable them individually via `disabled` instead.",
7
7
  "disabled_agents": [],
8
- "$defer_to_global_help": "Set true if you already run these hooks from a GLOBAL agent system (~/.agents/hooks). When true, sync.py suppresses AND removes the shared per-user injections (codex/kimi/hermes) so a project never double-injects into ~/.codex / ~/.kimi-code; Claude's committed repo settings are left in place. Implied by skills.defer_to_global. On a machine with a global install, pjangler skips scaffolding this whole layer, so you rarely need to set this by hand.",
9
- "defer_to_global": false
10
- },
11
- "skills": {
12
- "$disabled_help": "Skill directory names under .agents/skills/. Listed skills are NOT symlinked into any agent CLI dir for you, and are pruned from your managed mirrors.",
13
- "disabled": [],
14
- "$defer_to_global_help": "Set true ONLY if you run a global agent system at ~/.agents/skills. When true, the linker skips any project skill whose name also exists in your global SSOT, so your global copy wins and you get ZERO duplicates/conflicts (and yields the slot in shared dirs like ~/.codex/skills). Teammates with no global layer leave this false (the default) and inherit the FULL project skill set. Override the global dir with the AGENTS_GLOBAL_SKILLS_DIR env var.",
8
+ "$defer_to_global_help": "Set true if you already run these hooks from a GLOBAL agent system (~/.agents/hooks). When true, sync.py suppresses AND removes the shared per-user injections (codex/kimi/hermes) so a project never double-injects into ~/.codex / ~/.kimi-code; Claude's committed repo settings are left in place. On a machine with a global install, pjangler skips scaffolding this whole layer, so you rarely need to set this by hand.",
15
9
  "defer_to_global": false
16
10
  }
17
11
  }
@@ -0,0 +1,6 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/skillex/schemas/main/skills.schema.json",
3
+ "inherit_global": true,
4
+ "registry": "https://github.com/delorenj/skillex.git",
5
+ "skills": []
6
+ }
@@ -20,23 +20,24 @@ script = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/link-agentfiles.sh
20
20
  [[hooks.enter]]
21
21
  script = "op inject -i .env.op > .env"
22
22
  [[hooks.enter]]
23
+ script = "sync-skills.py --scope project"
24
+ [[hooks.enter]]
23
25
  script = "[ -f '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/codegraph.sh' ] && '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/codegraph.sh' || true"
24
26
  {%- if agent_hooks_layer %}
25
27
  [[hooks.enter]]
26
- script = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/link-project-skills-to-clis.sh'"
27
- [[hooks.enter]]
28
28
  script = "'{% raw %}{{config_root}}{% endraw %}/.agents/hooks/sync.py' --install --quiet"
29
29
  {%- endif %}
30
30
  {%- if agent_hooks_layer %}
31
31
  [[hooks.leave]]
32
- script = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/unlink-project-skills-from-clis.sh'"
33
- [[hooks.leave]]
34
32
  script = "'{% raw %}{{config_root}}{% endraw %}/.agents/hooks/sync.py' --uninstall --quiet"
35
33
  {%- endif %}
36
34
 
37
35
  [[watch_files]]
38
36
  patterns = ["AGENTS.md"]
39
37
  task = "link-agentfiles"
38
+ [[watch_files]]
39
+ patterns = [".agents/skills.json"]
40
+ task = "skills-sync"
40
41
  {% if agent_hooks_layer %}
41
42
  # Re-fan-out the agent hooks whenever the single source of truth changes.
42
43
  [[watch_files]]
@@ -46,6 +47,10 @@ task = "hooks-sync"
46
47
  [tasks.link-agentfiles]
47
48
  description = "Symlink all agent files to AGENTS.md"
48
49
  run = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/link-agentfiles.sh'"
50
+
51
+ [tasks.skills-sync]
52
+ description = "Sync skills from manifest to local CLI dirs"
53
+ run = "sync-skills.py --scope project"
49
54
  {% if agent_hooks_layer %}
50
55
  # --- Project-scoped agent hooks + skill fan-out (see .agents/hooks/README.md) ---
51
56
 
@@ -60,18 +65,6 @@ run = "'{% raw %}{{config_root}}{% endraw %}/.agents/hooks/sync.py' --check"
60
65
  [tasks.hooks-uninstall]
61
66
  description = "Remove per-user agent-hook injections (codex/kimi/hermes)"
62
67
  run = "'{% raw %}{{config_root}}{% endraw %}/.agents/hooks/sync.py' --uninstall"
63
-
64
- [tasks.link-project-skills-to-clis]
65
- description = "Fan .agents/skills out to each agent CLI; honors local.json defer_to_global + disabled"
66
- run = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/link-project-skills-to-clis.sh'"
67
-
68
- [tasks.unlink-project-skills-from-clis]
69
- description = "Remove project skill symlinks from shared per-CLI dirs"
70
- run = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/unlink-project-skills-from-clis.sh'"
71
-
72
- [tasks.skills-relink]
73
- description = "Re-fan the project skill set to all CLIs"
74
- run = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/link-project-skills-to-clis.sh'"
75
68
  {% endif %}
76
69
  [tasks.hindsight-setup]
77
70
  description = "Provision this dev's shared project Hindsight key from 1Password into .env (op inject)"
@@ -229,6 +229,17 @@ state.update({"source":"hermes-continuous-ticket-sentinel","agent_id":agent_id,"
229
229
  tmp = path.with_suffix(path.suffix + ".tmp"); tmp.write_text(json.dumps(state, indent=2, sort_keys=True)+"\n"); tmp.replace(path)
230
230
  PYEOF
231
231
 
232
+ # Coexistence WIP=1 lease (momo E2/S2.3): don't full-drive if the human-drivable
233
+ # Momo holds it — it's driving the same board. A crashed holder's lease expires
234
+ # (ttl) so the board is never wedged. Release on any exit.
235
+ WIP_LOCK="$RUNTIME/wip-driver.lock"
236
+ if ! python3 "$ROLE_DIR/.scripts/momo-wip-lock.py" acquire "$WIP_LOCK" "hermes:$AGENT_ID" --ttl 3600 >/dev/null 2>&1; then
237
+ printf '[heartbeat] WIP lease held by Momo — skipping full reconcile pass this tick\n'
238
+ maybe_checkpoint
239
+ exit 0
240
+ fi
241
+ trap 'python3 "$ROLE_DIR/.scripts/momo-wip-lock.py" release "$WIP_LOCK" "hermes:$AGENT_ID" >/dev/null 2>&1 || true' EXIT
242
+
232
243
  prompt="$(<"$PROMPT_FILE")"
233
244
  set +e
234
245
  env HERMES_HOME="$RUNTIME" "$HERMES_BIN" chat -Q --source cron --max-turns 90 -q "$prompt"
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env python3
2
+ """momo-wip-lock — shared WIP=1 driver lease so interactive Momo and the Hermes
3
+ sentinel never double-drive one board (momo E2/S2.3, the coexistence gate).
4
+
5
+ Both drivers acquire the SAME advisory lease (a JSON file, conventionally
6
+ <runtime>/wip-driver.lock) before a board-driving pass. The lease PERSISTS across
7
+ processes — it is deliberately NOT a held flock, because a Momo pass spans many
8
+ separate tool-call/bash invocations over minutes. flock is used only to serialize
9
+ the check-and-set so two acquirers can't race. A lease is respected while its
10
+ heartbeat is fresh (now - heartbeat_at < ttl); a stale lease (holder died without
11
+ releasing) can be stolen after it expires.
12
+
13
+ Owners are free strings by convention: "momo" (interactive) or
14
+ "hermes:<agent_id>" (the sentinel). WIP=1 is per BOARD == per runtime, so there
15
+ is one lease file per runtime.
16
+
17
+ Protocol:
18
+ * Driver start: acquire <lock> <me> -> exit 0 → drive; exit 1 → HELD, back off.
19
+ * While driving: refresh <lock> <me> periodically (< ttl) so the lease stays fresh.
20
+ * Driver end: release <lock> <me>.
21
+ * A holder that crashed leaves a lease that expires after `ttl`; the next driver
22
+ acquires it normally (or `--steal` to take an explicitly stale one immediately).
23
+
24
+ Commands (exit 0 = you hold it; 1 = someone else holds it fresh; 2 = usage/error):
25
+ acquire <lockfile> <owner> [--ttl S] [--steal]
26
+ refresh <lockfile> <owner>
27
+ release <lockfile> <owner>
28
+ status <lockfile> # always exit 0
29
+ """
30
+ from __future__ import annotations
31
+ import argparse, fcntl, json, os, socket, sys, tempfile, time
32
+
33
+
34
+ def _read(path: str):
35
+ try:
36
+ with open(path) as f:
37
+ return json.load(f)
38
+ except Exception:
39
+ return None
40
+
41
+
42
+ def _fresh(lease: dict | None, now: float) -> bool:
43
+ return bool(lease) and (now - lease.get("heartbeat_at", 0)) < lease.get("ttl", 300)
44
+
45
+
46
+ def _write_atomic(path: str, data: dict) -> None:
47
+ d = os.path.dirname(os.path.abspath(path)) or "."
48
+ fd, tmp = tempfile.mkstemp(dir=d)
49
+ with os.fdopen(fd, "w") as f:
50
+ json.dump(data, f)
51
+ os.replace(tmp, path)
52
+
53
+
54
+ def _guard(lockfile: str):
55
+ """Sibling .flock file held only for the read-modify-write critical section."""
56
+ g = open(lockfile + ".flock", "a")
57
+ fcntl.flock(g, fcntl.LOCK_EX)
58
+ return g
59
+
60
+
61
+ def acquire(lockfile: str, owner: str, ttl: int, steal: bool) -> int:
62
+ now = time.time(); g = _guard(lockfile)
63
+ try:
64
+ cur = _read(lockfile)
65
+ if cur and cur.get("owner") != owner and _fresh(cur, now) and not steal:
66
+ print(f"HELD by {cur['owner']} (fresh, {int(now - cur['heartbeat_at'])}s ago) — back off")
67
+ return 1
68
+ started = cur["started_at"] if (cur and cur.get("owner") == owner and "started_at" in cur) else now
69
+ _write_atomic(lockfile, {
70
+ "owner": owner, "pid": os.getpid(), "host": socket.gethostname(),
71
+ "started_at": started, "heartbeat_at": now, "ttl": ttl,
72
+ })
73
+ print(f"ACQUIRED by {owner}" + (" (stole stale lease)" if (cur and cur.get('owner') != owner) else ""))
74
+ return 0
75
+ finally:
76
+ fcntl.flock(g, fcntl.LOCK_UN); g.close()
77
+
78
+
79
+ def refresh(lockfile: str, owner: str) -> int:
80
+ now = time.time(); g = _guard(lockfile)
81
+ try:
82
+ cur = _read(lockfile)
83
+ if not cur or cur.get("owner") != owner:
84
+ print(f"NOT OWNER (held by {cur.get('owner') if cur else 'nobody'}) — cannot refresh")
85
+ return 1
86
+ cur["heartbeat_at"] = now
87
+ _write_atomic(lockfile, cur); print("REFRESHED"); return 0
88
+ finally:
89
+ fcntl.flock(g, fcntl.LOCK_UN); g.close()
90
+
91
+
92
+ def release(lockfile: str, owner: str) -> int:
93
+ g = _guard(lockfile)
94
+ try:
95
+ cur = _read(lockfile)
96
+ if cur and cur.get("owner") != owner:
97
+ print(f"NOT OWNER (held by {cur['owner']}) — not releasing"); return 1
98
+ try:
99
+ os.remove(lockfile)
100
+ except FileNotFoundError:
101
+ pass
102
+ print("RELEASED"); return 0
103
+ finally:
104
+ fcntl.flock(g, fcntl.LOCK_UN); g.close()
105
+
106
+
107
+ def status(lockfile: str) -> int:
108
+ now = time.time(); cur = _read(lockfile)
109
+ if not cur:
110
+ print("FREE (no lease)"); return 0
111
+ state = "fresh" if _fresh(cur, now) else "STALE"
112
+ print(f"{cur['owner']} — {state} (heartbeat {int(now - cur.get('heartbeat_at', 0))}s ago, "
113
+ f"ttl {cur.get('ttl')}s, pid {cur.get('pid')}@{cur.get('host')})")
114
+ return 0
115
+
116
+
117
+ def main() -> int:
118
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
119
+ ap.add_argument("cmd", choices=["acquire", "refresh", "release", "status"])
120
+ ap.add_argument("lockfile")
121
+ ap.add_argument("owner", nargs="?")
122
+ ap.add_argument("--ttl", type=int, default=300, help="freshness window in seconds (default 300)")
123
+ ap.add_argument("--steal", action="store_true", help="take an explicitly stale lease immediately")
124
+ a = ap.parse_args()
125
+ if a.cmd in ("acquire", "refresh", "release") and not a.owner:
126
+ print("owner required", file=sys.stderr); return 2
127
+ if a.cmd == "acquire":
128
+ return acquire(a.lockfile, a.owner, a.ttl, a.steal)
129
+ if a.cmd == "refresh":
130
+ return refresh(a.lockfile, a.owner)
131
+ if a.cmd == "release":
132
+ return release(a.lockfile, a.owner)
133
+ return status(a.lockfile)
134
+
135
+
136
+ if __name__ == "__main__":
137
+ raise SystemExit(main())
@@ -31,6 +31,12 @@ on Linear, Plane, or Trello.
31
31
 
32
32
  ## Pass
33
33
 
34
+ **Trigger.** This pass runs both on the cheap heartbeat timer AND on a live
35
+ **Plane board event** — a `bloodbank.evt.v1.repo.<repo>.ticket_*` event delivered
36
+ by the `plane-webhook-bridge`. If a specific ticket event triggered you, FIRST
37
+ `tp get_issue <that ticket>`, read the change, and react to it (triage / refine /
38
+ comment / transition per the lifecycle) before the general reconcile below.
39
+
34
40
  1. Run or explicitly follow the project session-start ritual if one exists.
35
41
  2. Reconcile: active milestone (`tp active_milestone`), issues (`tp list_issues`),
36
42
  local evidence under `_bmad-output/implementation-artifacts/issue-evidence/`,
@@ -85,6 +91,21 @@ on Linear, Plane, or Trello.
85
91
  as done, move on), **held** (back to active), or a genuine **out-of-scope
86
92
  blocker** (recorded and waited on). There is no fourth "waiting for the
87
93
  operator's sign-off" state.
94
+
95
+ **Before going idle — always run this protocol (this is the point of the pass):**
96
+
97
+ - **(a) Unclaimed-work sweep.** If ANY ticket is claimable (`ready`/`unstarted`
98
+ in the active milestone, no active worker) and WIP allows, run the delegation
99
+ cycle (step 4) now — never go idle with claimable work sitting on the board.
100
+ - **(b) In-progress status sweep.** For EVERY `started` / `in_review` ticket:
101
+ request a status update — verify the worker is alive (its zellij session /
102
+ worktree / recent git activity) and post or refresh a concise status note on
103
+ the ticket (`tp comment`) with progress + next step. If a ticket is
104
+ **blocked**, actively try to UNBLOCK it: identify the blocker and act on
105
+ anything agent-doable (missing AC, a dependency you can drive, a stale worker
106
+ to re-drive); escalate ONLY a genuine external blocker (step 7). A silent or
107
+ stalled worker is re-driven or flagged — never left silent.
108
+
88
109
  9. Update `runtime/continuous-ticket-sentinel-state.json`: `active` /
89
110
  `blocked` / `idle` / `stalled` with the required fields (`source`, `agent_id`,
90
111
  `repo`, `ticket_provider`, `status`, `summary`, `reason`, `updated_at`,
@@ -17,9 +17,10 @@ You are **{{ display_name }}** — a Hermes agent provisioned to work inside the
17
17
 
18
18
  You operate **only** within the working directory of `{{ target_repo }}`. You do
19
19
  not touch files outside this repo unless the operator explicitly approves it.
20
- Your HERMES_HOME is the submodule at `./runtime/` (a separate git repo named
21
- `{{ runtime_repo_owner }}/{{ runtime_repo }}`); everything you change there is
22
- auto-checkpointed hourly + on session end.
20
+ Your HERMES_HOME is the local runtime at `./runtime/`; Hermes loads its
21
+ `config.yaml` directly. Secrets, SOUL, skills, sessions, and gateway state live
22
+ local to that runtime (pure-local state; durable memory is the shared Hindsight
23
+ bank — see Memory hygiene).
23
24
 
24
25
  ## Tone
25
26
 
@@ -53,12 +54,24 @@ read it before publishing a type you haven't published before.
53
54
  ## Role-specific behavior
54
55
 
55
56
  {% if role == "pm" -%}
56
- You are the **project manager**. You triage incoming requests from Telegram /
57
- Bloodbank command lanes, decompose them into discrete tasks on the
58
- Plane board, and route work to other agents in the fleet (e.g. the dev role
59
- on `bloodbank.cmd.v1.agent.task.assign` with
60
- `data.target_agent_id = {{ target_repo }}-dev`). You do not write application
61
- code. You do not approve merges.
57
+ You are the **project-manager ORCHESTRATOR** the autonomous Hermes carrier of
58
+ Momo, and the twin of the human-drivable Momo. You share ONE board and ONE
59
+ Hindsight bank with it; stay attributable and never split-brain the state. You
60
+ triage incoming requests from Telegram / Bloodbank command lanes, decompose them
61
+ into discrete tasks on the Plane board, and route work to other agents (e.g. the
62
+ dev role on `bloodbank.cmd.v1.agent.task.assign` with
63
+ `data.target_agent_id = {{ target_repo }}-dev`).
64
+
65
+ **Prime directives (non-negotiable):**
66
+ - **Never mutate code** — every code change flows through a delegated worker.
67
+ - **WIP = 1**, shared with the human-drivable Momo via the driver lease
68
+ (`.scripts/momo-wip-lock.py` → `runtime/wip-driver.lock`) — acquire before driving,
69
+ back off if Momo holds it fresh; never double-drive one board. (The heartbeat
70
+ enforces this automatically for the reconcile pass.)
71
+ - **Reviewer ≠ implementer** — independent adversarial review is the normal path.
72
+ - **Evidence over status** — a board column is a claim; repo evidence is proof.
73
+ - **Anti-stall** — never park a pass on operator sign-off.
74
+ - You do not write application code. You do not approve merges.
62
75
 
63
76
  Default execution workflow for implementation delivery: use
64
77
  `subagent-driven-development` in kanban-orchestrated codex mode
@@ -114,7 +127,15 @@ declared` event so the fleet knows what to route to you.
114
127
 
115
128
  ## Memory hygiene
116
129
 
117
- Your memory is the submodule at `./runtime/memories/`. Use Hindsight for
118
- durable cross-session facts (`hindsight memory retain {{ target_repo }} "…"
119
- --context conventions`). Edit `memories/MEMORY.md` directly for the
120
- condensed mental-model summary the gateway loads on every session.
130
+ Your durable memory is the shared **Hindsight bank `{{ target_repo }}`** — one
131
+ bank per PROJECT, shared with the human-drivable Momo twin. Honcho and the
132
+ per-agent `runtime/memories/` store are **neutralized** (see `config.yaml`
133
+ `memory.provider: ""`): do not rely on `MEMORY.md`/`USER.md`. Retain with
134
+ `hindsight memory retain {{ target_repo }} "…" --context <cat>`; recall with
135
+ `hindsight memory recall {{ target_repo }} "…"`.
136
+
137
+ ## Doctrine
138
+
139
+ Decide on the operator's behalf using **`~/code/33GOD/momo/PILLARS.md`**
140
+ (canonical, priority-ordered). This soul **references** that file; it does not
141
+ copy it. Cite the pillar(s) that drove a consequential call in its decision event.
@@ -1,110 +0,0 @@
1
- #!/bin/bash
2
- # Fan the project's enabled skill set out to every agent CLI that does NOT read
3
- # .agents/ natively, as per-skill symlinks.
4
- #
5
- # SSOT: .agents/skills/ (committed inherited skills + on-enter ./skills/* links)
6
- # Targets: a table of per-CLI skill dirs, each with a SCOPE:
7
- # global -> a shared per-user dir (e.g. ~/.codex/skills): link ours in, never
8
- # clobber foreign entries, removed on leave (unlink-*-from-clis.sh).
9
- # local -> a project-scoped mirror dir (e.g. ./.kimi-code/skills) that is fully
10
- # ours: stale real copies / foreign symlinks are replaced, and
11
- # disabled/deferred skills are pruned. Persists across leave.
12
- #
13
- # Per-dev controls — .agents/local.json (see .agents/local.example.json):
14
- # skills.disabled[] never link these
15
- # skills.defer_to_global if true, skip any skill that ALSO exists in your global
16
- # SSOT (~/.agents/skills) so your global copy wins and you
17
- # get zero duplicates. Teammates omit it → inherit all.
18
- #
19
- # Add a CLI: append one "dir|scope" line to SKILL_TARGETS. That's the whole change.
20
- set -euo pipefail
21
-
22
- script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
23
- project_root="$(cd "${script_dir}/../.." && pwd)"
24
- agents_skills="${project_root}/.agents/skills"
25
-
26
- SKILL_TARGETS=(
27
- "${CODEX_HOME:-$HOME/.codex}/skills|global"
28
- "${project_root}/.kimi-code/skills|local"
29
- )
30
-
31
- local_config="${project_root}/.agents/hooks/lib/local-config.sh"
32
- if [ -f "$local_config" ]; then
33
- # shellcheck source=/dev/null
34
- source "$local_config"
35
- else
36
- skill_disabled() { return 1; }
37
- skills_defer_to_global() { return 1; }
38
- skill_is_global() { return 1; }
39
- fi
40
-
41
- if [ ! -d "$agents_skills" ]; then
42
- echo "No .agents/skills/ found. Nothing to fan out."
43
- exit 0
44
- fi
45
-
46
- defer=0
47
- skills_defer_to_global && defer=1
48
-
49
- # Should this skill be skipped (disabled, or deferred to the dev's global system)?
50
- is_skipped() {
51
- local name="$1"
52
- skill_disabled "$name" && return 0
53
- [ "$defer" = "1" ] && skill_is_global "$name" && return 0
54
- return 1
55
- }
56
-
57
- # readlink with any trailing slash stripped (older links were stored with one).
58
- _readlink_norm() {
59
- local l; l="$(readlink "$1" 2>/dev/null || echo "")"
60
- printf '%s' "${l%/}"
61
- }
62
-
63
- link_into() {
64
- local dir="$1" scope="$2" linked=0 skipped=0 pruned=0
65
- mkdir -p "$dir"
66
- for skill_path in "$agents_skills"/*; do
67
- [ -d "$skill_path" ] || continue
68
- local name target
69
- name="$(basename "$skill_path")"
70
- [[ "$name" == ".system" ]] && continue
71
- target="${dir}/${name}"
72
-
73
- if is_skipped "$name"; then
74
- # Yield the slot: remove a link WE previously made (so the dev's global copy
75
- # can claim it), and prune stale entries from our fully-managed mirror.
76
- if [ -L "$target" ] && [ "$(_readlink_norm "$target")" = "$skill_path" ]; then
77
- rm -f "$target"
78
- pruned=$((pruned + 1))
79
- elif [ "$scope" = "local" ] && { [ -L "$target" ] || [ -e "$target" ]; }; then
80
- rm -rf "$target"
81
- pruned=$((pruned + 1))
82
- fi
83
- skipped=$((skipped + 1))
84
- continue
85
- fi
86
-
87
- if [ -L "$target" ] && [ "$(_readlink_norm "$target")" = "$skill_path" ]; then
88
- linked=$((linked + 1))
89
- continue
90
- fi
91
-
92
- if [ -e "$target" ] || [ -L "$target" ]; then
93
- if [ "$scope" = "local" ]; then
94
- rm -rf "$target" # our mirror: replace stale copy / foreign link
95
- else
96
- skipped=$((skipped + 1)) # shared dir: leave foreign entries untouched
97
- continue
98
- fi
99
- fi
100
-
101
- ln -s "$skill_path" "$target"
102
- linked=$((linked + 1))
103
- done
104
- echo " ${dir} (${scope}): ${linked} linked, ${skipped} skipped, ${pruned} pruned"
105
- }
106
-
107
- echo "Fanning .agents/skills -> agent CLIs (defer_to_global=${defer})"
108
- for entry in "${SKILL_TARGETS[@]}"; do
109
- link_into "${entry%%|*}" "${entry##*|}"
110
- done