@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/dist/index.js +642 -291
- package/dist/mcp-server.js +649 -298
- package/package.json +1 -1
- package/templates/commonproject/copier.yml +3 -4
- package/templates/commonproject/template/.agents/hooks/README.md +13 -26
- package/templates/commonproject/template/.agents/hooks/lib/local-config.sh +4 -14
- package/templates/commonproject/template/.agents/hooks/sync.py +5 -6
- package/templates/commonproject/template/.agents/local.example.json +2 -8
- package/templates/commonproject/template/.agents/skills.json +6 -0
- package/templates/commonproject/template/mise.toml.jinja +9 -16
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +11 -0
- package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +137 -0
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +21 -0
- package/templates/hermes-agent/template/SOUL.md.jinja +34 -13
- package/templates/commonproject/template/.mise/scripts/link-project-skills-to-clis.sh +0 -110
- package/templates/commonproject/template/.mise/scripts/unlink-project-skills-from-clis.sh +0 -45
package/package.json
CHANGED
|
@@ -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
|
|
94
|
-
#
|
|
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
|
-
-
|
|
86
|
+
## Skill fan-out — `.agents/skills.json` → each CLI
|
|
87
87
|
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
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/
|
|
36
|
-
#
|
|
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 '.
|
|
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`
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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"))
|
|
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
|
|
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.
|
|
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
|
}
|
|
@@ -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
|
|
21
|
-
`
|
|
22
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|