@delorenj/pjangler 1.4.2 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +528 -0
  2. package/contracts/fleet-contract.yaml +513 -0
  3. package/dist/index.js +9333 -1509
  4. package/dist/mcp-server.js +6634 -1096
  5. package/dist/prompt.js +2 -1
  6. package/package.json +10 -4
  7. package/templates/hermes-agent/copier.yml +16 -3
  8. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
  9. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
  10. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
  11. package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
  12. package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
  13. package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
  14. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
  15. package/templates/hermes-agent/template/.scripts/70-systemd.sh +129 -15
  16. package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
  17. package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
  18. package/templates/hermes-agent/template/.scripts/_lib.sh +773 -0
  19. package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
  20. package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
  21. package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
  22. package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
  23. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
  24. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
  25. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +93 -4
  26. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
  27. package/templates/hermes-agent/template/.scripts/providers/linear.sh +138 -25
  28. package/templates/hermes-agent/template/.scripts/providers/plane.sh +408 -51
  29. package/templates/hermes-agent/template/.scripts/providers/trello.sh +54 -6
  30. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +257 -43
  31. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +142 -25
  32. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
  33. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
  34. package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +3 -1
  35. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
  36. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
  37. package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
  38. package/templates/hermes-agent/template/hermes.jinja +1 -1
  39. package/templates/hermes-agent/template/role.yaml.jinja +15 -4
package/dist/prompt.js CHANGED
@@ -241,7 +241,7 @@ function planeBase(env = process.env, home = homedir()) {
241
241
  if (fromConfig) return fromConfig.replace(/\/+$/, "");
242
242
  return DEFAULT_PLANE_BASE;
243
243
  }
244
- function planeWorkspace(provider, env, home) {
244
+ function planeWorkspace(provider, env = process.env, home = homedir()) {
245
245
  const fromManifest = provider.workspace?.trim();
246
246
  if (fromManifest) return fromManifest;
247
247
  const config = readTemplateConfig(env, home);
@@ -402,3 +402,4 @@ export {
402
402
  promptLine,
403
403
  readPromptFacts
404
404
  };
405
+ //# sourceMappingURL=prompt.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delorenj/pjangler",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
4
4
  "description": "Project subsystem bootstrapper CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -12,6 +12,8 @@
12
12
  },
13
13
  "files": [
14
14
  "dist",
15
+ "!dist/**/*.map",
16
+ "contracts",
15
17
  "templates/commonproject/copier.yml",
16
18
  "templates/commonproject/template",
17
19
  "templates/hermes-agent/copier.yml",
@@ -23,7 +25,7 @@
23
25
  "node": ">=20"
24
26
  },
25
27
  "scripts": {
26
- "build": "esbuild src/index.ts src/mcp-server.ts src/prompt.ts --bundle --packages=external --platform=node --format=esm --outdir=dist && node scripts/export-project-notebook-skill.mjs",
28
+ "build": "esbuild src/index.ts src/mcp-server.ts src/prompt.ts --bundle --packages=external --platform=node --format=esm --sourcemap --outdir=dist && node scripts/export-project-notebook-skill.mjs",
27
29
  "check:audit:prod": "npm audit --omit=dev",
28
30
  "check:lock": "node scripts/check-package-lock-parity.mjs",
29
31
  "check:submodules": "node scripts/check-submodule-contract.mjs",
@@ -33,12 +35,15 @@
33
35
  "typecheck": "tsc --noEmit",
34
36
  "test:bmad-installer-contract": "node tests/bmad-installer-contract-regressions.mjs",
35
37
  "test:pjan-77": "node tests/pjan-77-notebook-domain-regressions.mjs && node tests/pjan-77-notebook-adapter-contract.mjs && node tests/pjan-77-notebook-lifecycle-regressions.mjs && node tests/pjan-77-notebook-cli-contract.mjs && node tests/pjan-77-notebook-hooks-capture.mjs && node tests/pjan-77-notebook-security-isolation.mjs && node tests/pjan-77-notebook-release-gates.mjs",
36
- "test": "npm run check:lock && node tests/package-lock-parity-regressions.mjs && npm run check:submodules && npm run check:tracked-secrets && node tests/portable-test-paths-regressions.mjs && node tests/release-regressions.mjs && node tests/submodule-contract-regressions.mjs && node tests/secret-publication-gate-regressions.mjs && node tests/bmad-version-surface-regressions.mjs && node tests/bmad-transaction-regressions.mjs && node tests/parity-migrate-regressions.mjs && node tests/hermes-profile-inheritance-regressions.mjs && node tests/pjan-57-lifecycle-recipes-regressions.mjs && node tests/pjan-57-dogfood-regressions.mjs && node tests/generated-project-lifecycle-regressions.mjs && node tests/pack-flatten-regressions.mjs && node tests/pack-flatten-cross-engine-regressions.mjs && node tests/registry-cache-parity-regressions.mjs && node tests/registry-root-ladder-regressions.mjs && node tests/pjan-23-regressions.mjs && node tests/pjan-24-regressions.mjs && node tests/pjan-28-regressions.mjs && node tests/pjan-30-regressions.mjs && node tests/pjan-31a-regressions.mjs && node tests/pjan-36-regressions.mjs && node tests/pjan-43-regressions.mjs && node tests/pjan-48-regressions.mjs && node tests/pjan-49-regressions.mjs && node tests/pjan-50-regressions.mjs && node tests/pjan-65-regressions.mjs && node tests/pjan-67-lifecycle-preflight-regressions.mjs && node tests/pjan-67-regressions.mjs && node tests/pjan-67-trusted-lifecycle-regressions.mjs && node tests/pjan-71-regressions.mjs && node tests/pjan-72-regressions.mjs && node tests/pjan-75-regressions.mjs && node tests/pjan-76-regressions.mjs && node tests/skillex-init-regressions.mjs && node tests/pjan-84-global-scope-regressions.mjs && node tests/pjan-84-registry-flag-regressions.mjs && node tests/pjan-84-finding-scope-regressions.mjs && node tests/pjan-84-orphan-adoption-regressions.mjs && node tests/fleet-shared-bloodbank-regressions.mjs && node tests/mcp-catalog-regressions.mjs && node tests/mcp-server-regressions.mjs && node tests/project-registry-regressions.mjs && node tests/pg-registry-regressions.mjs && node tests/momo-lifecycle-plane-regressions.mjs && npm run test:pjan-77",
38
+ "test": "node scripts/run-tests.mjs",
37
39
  "migrate:up": "node-pg-migrate --migrations-dir migrations up",
38
40
  "migrate:down": "node-pg-migrate --migrations-dir migrations down",
39
41
  "migrate:create": "node-pg-migrate --migrations-dir migrations create",
40
42
  "prepublishOnly": "npm run check:lock && npm run check:submodules -- --remote --recursive --archive --npm && npm run build && npm run check:tracked-secrets",
41
- "test:hermes-profile-inheritance": "node tests/hermes-profile-inheritance-regressions.mjs"
43
+ "test:hermes-profile-inheritance": "node tests/hermes-profile-inheritance-regressions.mjs",
44
+ "test:coverage": "npm run build && NODE_OPTIONS=--max-old-space-size=12288 c8 --exclude='**/.pjan-*/**' --reporter=json-summary --reporter=text-summary --src=src --report-dir=coverage node scripts/run-tests.mjs",
45
+ "coverage:check": "node scripts/coverage-ratchet.mjs",
46
+ "coverage:apply": "node scripts/coverage-ratchet.mjs --apply"
42
47
  },
43
48
  "keywords": [
44
49
  "cli",
@@ -60,6 +65,7 @@
60
65
  "devDependencies": {
61
66
  "@types/node": "^25.9.1",
62
67
  "@types/pg": "^8.15.0",
68
+ "c8": "^10.1.3",
63
69
  "esbuild": "^0.25.0",
64
70
  "node-pg-migrate": "^8.0.0",
65
71
  "typescript": "^5"
@@ -13,8 +13,8 @@
13
13
  # 2. Creates ignored local state at agents/hermes/<role>/runtime/ and delegates
14
14
  # named-profile wiring to `pj migrate hermes.runtime-singleton`
15
15
  # 4. Creates a Plane project in 33god workspace
16
- # 6. Prompts for a BotFather Telegram token, stores in ignored runtime state
17
- # 7. Optionally wires a dedicated Slack app+bot pair into runtime/.env
16
+ # 6. Verifies a BotFather token, stores it in 1Password, and maps an op:// reference
17
+ # 7. Optionally does the same for a dedicated Slack app+bot pair
18
18
  # 8. Records fleet-shared Bloodbank routing identity (no profile consumer)
19
19
  # 9. Installs a profile gateway + heartbeat timer (reconcile + checkpoint)
20
20
  # 10. Appends to ~/.hermes/agents-registry.yaml
@@ -34,6 +34,10 @@ _envops:
34
34
  variable_end_string: "}}"
35
35
  comment_start_string: "{#"
36
36
  comment_end_string: "#}"
37
+ _exclude:
38
+ - "**/__pycache__"
39
+ - "**/*.pyc"
40
+ - "**/*.pyo"
37
41
 
38
42
  # ─── Questions (only what we can't derive) ───────────────────────────────
39
43
 
@@ -111,6 +115,11 @@ soul_tone:
111
115
  - formal
112
116
  - terse
113
117
 
118
+ reconcile_enabled:
119
+ type: bool
120
+ help: "Run autonomous PM ticket-board reconciliation from the heartbeat timer"
121
+ default: true
122
+
114
123
  # ─── Derived (never asked) ───────────────────────────────────────────────
115
124
 
116
125
  agent_id:
@@ -169,5 +178,9 @@ _tasks:
169
178
  - "./.scripts/99-summary.sh"
170
179
 
171
180
  _skip_if_exists:
172
- - hermes
181
+ # role.yaml contains verified channel identities and explicit operator
182
+ # choices (including reconcile_enabled=false). A forced Copier refresh must
183
+ # preserve those durable values; the idempotent tasks below reconcile any
184
+ # newly-required fields and lifecycle assets around them.
185
+ - role.yaml
173
186
  - runtime
@@ -8,7 +8,7 @@ short and high-signal — anything that should ALWAYS be in context.
8
8
  You are **{{display_name}}**, the `{{role}}` agent for the `{{repo}}` repo.
9
9
  Your scope is the repo at the project root. You communicate via Telegram
10
10
  (`@{{repo}}_{{role}}_bot`) and canonical Bloodbank repo events on
11
- `bloodbank.evt.v1.repo.>`, filtered by `data.repo = {{repo}}`.
11
+ `bloodbank.evt.repo.>`, filtered by `data.repo = {{repo}}`.
12
12
 
13
13
  ## Operator
14
14
 
@@ -37,9 +37,12 @@ preambles.
37
37
 
38
38
  Always emit envelope-shaped events with `actor.agent_id = {{agent_id}}` and
39
39
  `producer = hermes-agent:{{agent_id}}` for every consequential action.
40
- Event type format: `bloodbank.v1.<domain>.<entity>.<action>` (5 tokens,
41
- lowercase, past-tense action for events). Naming spec lives at
42
- `~/code/33GOD/bloodbank/docs/event-naming.md`.
40
+ Event type format: `bloodbank.<domain>.<entity>.<action>` (4 tokens,
41
+ lowercase, past-tense action for events). The NATS subject inserts a kind
42
+ marker as segment 2 — `bloodbank.<evt|cmd|rpy>.<domain>.<entity>.<action>`
43
+ (5 tokens). There is no version token in either shape; a breaking payload
44
+ change earns a new action or entity, never a `v<n>` segment. Naming spec
45
+ lives at `~/code/33GOD/bloodbank/docs/event-naming.md`.
43
46
 
44
47
  ## Recent context
45
48
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # Create the initial named Hermes profile and sanitize cloned chat credentials.
2
+ # Create or reconcile the initial named Hermes profile without cloning secrets.
3
3
  # The directory remains REAL. Step 20 delegates final shared-vs-owned link
4
4
  # topology to `pj migrate hermes.runtime-singleton`; no template step may
5
5
  # replace the profile itself with a symlink.
@@ -13,104 +13,104 @@ fi
13
13
  source "$(dirname "$0")/_lib.sh"
14
14
  load_role_env
15
15
 
16
- already_done 10-hermes-profile && { log "[10] profile already created — skipping"; exit 0; }
16
+ PROFILE_HOME="$HOME/.hermes/profiles/$PROFILE_NAME"
17
+
18
+ # Older deployments made the named profile itself a symlink into project
19
+ # state. Following that link here would make the cleanup below delete files
20
+ # from the link target before the singleton-runtime migration can preserve
21
+ # them. Refuse the legacy topology before any profile mutation.
22
+ if [[ -L "$PROFILE_HOME" ]]; then
23
+ die "legacy named profile symlink detected at $PROFILE_HOME; refusing mutation. Run: pj migrate hermes.runtime-singleton '$(project_repo_path 2>/dev/null || printf '%s' "$ROLE_DIR")'"
24
+ fi
25
+ PROFILE_DELTA_SEEDER="$ROLE_DIR/.scripts/lib/profile-config-seed.py"
26
+ [[ -f "$PROFILE_DELTA_SEEDER" && ! -L "$PROFILE_DELTA_SEEDER" ]] \
27
+ || die "trusted profile config seed helper is unavailable: $PROFILE_DELTA_SEEDER"
28
+
29
+ already_done 10-hermes-profile \
30
+ && log "[10] profile marker found — revalidating required profile contract"
31
+
32
+ # Skillex projects the canonical global catalog into ~/.agents/skills. These
33
+ # six skills are the immutable deployed PM contract, not optional suggestions.
34
+ # Validate the complete set before any profile mutation so a partial/missing
35
+ # projection cannot warn and then be falsely reported as provisioned.
36
+ CANONICAL_SKILLS_DIR="${CANONICAL_SKILLS_DIR:-$(config_get fleet.canonical_skills_dir "$HOME/.agents/skills")}"
37
+ CORE_RUNTIME_SKILLS=(
38
+ 33god-projects
39
+ delonet-conventions
40
+ delonet-dotenv
41
+ hermes-pm-template-maintenance
42
+ hindsight
43
+ subagent-driven-development
44
+ )
45
+ OPTIONAL_RUNTIME_SKILLS_TEXT="${SYMLINKED_RUNTIME_SKILLS:-$(config_get fleet.symlinked_runtime_skills '')}"
46
+ read -r -a OPTIONAL_RUNTIME_SKILLS <<< "$OPTIONAL_RUNTIME_SKILLS_TEXT"
47
+ SYMLINKED_RUNTIME_SKILLS=("${CORE_RUNTIME_SKILLS[@]}")
48
+ for skill_name in "${OPTIONAL_RUNTIME_SKILLS[@]}"; do
49
+ [[ -n "$skill_name" ]] || continue
50
+ skill_present=0
51
+ for required_name in "${SYMLINKED_RUNTIME_SKILLS[@]}"; do
52
+ [[ "$required_name" == "$skill_name" ]] && { skill_present=1; break; }
53
+ done
54
+ [[ $skill_present -eq 1 ]] || SYMLINKED_RUNTIME_SKILLS+=("$skill_name")
55
+ done
56
+ missing_runtime_skills=()
57
+ for skill_name in "${SYMLINKED_RUNTIME_SKILLS[@]}"; do
58
+ [[ -f "$CANONICAL_SKILLS_DIR/$skill_name/SKILL.md" ]] \
59
+ || missing_runtime_skills+=("$skill_name")
60
+ done
61
+ if [[ ${#missing_runtime_skills[@]} -gt 0 ]]; then
62
+ clear_done 10-hermes-profile
63
+ die "required Skillex projection missing SKILL.md for: ${missing_runtime_skills[*]} (run the global Skillex sync, then rerun)"
64
+ fi
65
+ log "[10] required Skillex skills validated: ${SYMLINKED_RUNTIME_SKILLS[*]}"
17
66
 
18
67
  log "[10] creating hermes profile: $PROFILE_NAME"
19
- PROFILE_HOME="$HOME/.hermes/profiles/$PROFILE_NAME"
20
68
 
21
69
  if [[ -d "$PROFILE_HOME" ]]; then
22
70
  log " profile dir already exists; reusing"
23
71
  else
24
- # --clone (NOT --clone-all): copies config.yaml, .env, SOUL.md only.
25
- # --clone-all has a recursion bug it copies the entire ~/.hermes tree,
26
- # including profiles/ itself, producing nested profiles/<name>/profiles/<name>/...
27
- # We do explicit skill + plugin + hooks copies below to avoid that.
28
- "$HERMES_BIN" profile create "$PROFILE_NAME" --clone --no-alias
72
+ # `--clone` copies the default profile's .env before a provisioner can
73
+ # inspect it, transiently materializing every credential in the new profile.
74
+ # Start clean; required skills and the project SOUL are installed below.
75
+ "$HERMES_BIN" profile create "$PROFILE_NAME" --no-alias
29
76
  fi
30
77
 
31
- # Manually copy the inheritable bits that --clone doesn't get.
32
- # These are content-only dirs; safe to mirror without recursion risk.
33
- log " mirroring skills, plugins, hooks from default profile"
34
- for sub in skills plugins hooks cron skins; do
35
- src="$HOME/.hermes/$sub"
36
- dst="$PROFILE_HOME/$sub"
37
- if [[ -d "$src" && "$src" != "$PROFILE_HOME"* ]]; then
38
- mkdir -p "$dst"
39
- # cp -R, dereferencing symlinks; -u to preserve newer if dst exists
40
- cp -RLu "$src/." "$dst/" 2>/dev/null || cp -RL "$src/." "$dst/" 2>/dev/null || true
41
- fi
42
- done
43
-
44
78
  # Strip any inherited gateway/runtime state so this profile boots clean.
45
79
  rm -f "$PROFILE_HOME/gateway.pid" "$PROFILE_HOME/gateway_state.json" \
46
80
  "$PROFILE_HOME/processes.json" "$PROFILE_HOME/state.db" 2>/dev/null || true
47
81
  # Belt-and-suspenders: if a profiles/ dir somehow exists, remove it
48
82
  [[ -d "$PROFILE_HOME/profiles" ]] && rm -rf "$PROFILE_HOME/profiles"
49
83
 
50
- # Strip inherited messaging-platform credentials from the cloned .env.
51
- # `profile create --clone` copies the DEFAULT profile's .env verbatim — and the
52
- # default profile is an operator's own agent (e.g. Condaleeza on Slack). Without
53
- # this, every sub-agent inherits the parent's bot token and would (a) hijack the
54
- # parent's Slack/Telegram socket if it ever connects, and (b) crash-loop: the
55
- # gateway treats an inherited-but-unusable platform as "configured", fails to
56
- # connect it, and exits non-fatal only when ZERO platforms are configured. A
57
- # sub-agent must establish its OWN identity via the Wire steps (30-telegram etc),
58
- # never borrow the parent's.
84
+ # A new profile gets an empty Hermes-created .env. Existing profiles are never
85
+ # migrated opportunistically: if a legacy deployment still has raw channel
86
+ # credentials, fail closed and leave the approval-gated migration to the fleet
87
+ # operator. Only key names are reported; values are never printed.
59
88
  PROFILE_ENV="$PROFILE_HOME/.env"
60
89
  if [[ -f "$PROFILE_ENV" ]]; then
61
- # Some Hermes versions implement `profile create --clone` by symlinking the
62
- # cloned .env back to the default profile. Never sanitize through that
63
- # symlink: doing so would delete the operator's fleet-wide provider secrets.
64
- # Break only the .env link into a private, mode-0600 copy first.
65
- if [[ -L "$PROFILE_ENV" ]]; then
66
- log " detaching cloned .env symlink before profile-local sanitization"
67
- PROFILE_ENV_COPY="$PROFILE_HOME/.env.profile-local.$$"
68
- cp -L "$PROFILE_ENV" "$PROFILE_ENV_COPY"
69
- chmod 600 "$PROFILE_ENV_COPY"
70
- mv -fT "$PROFILE_ENV_COPY" "$PROFILE_ENV"
71
- fi
72
- log " stripping inherited platform credentials from profile .env"
73
90
  python3 - "$PROFILE_ENV" <<'PYEOF'
74
- import re, sys, pathlib
91
+ import sys, pathlib
75
92
  p = pathlib.Path(sys.argv[1])
76
- # Identity-bearing platform creds that must be per-agent, not inherited.
77
93
  keys = (
78
- "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS",
79
- "SLACK_SIGNING_SECRET", "SLACK_HOME_CHANNEL",
80
- "TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOWED_USERS",
81
- "DISCORD_BOT_TOKEN", "DISCORD_ALLOWED_USERS", "DISCORD_HOME_CHANNEL",
82
- "DISCORD_HOME_CHANNEL_NAME",
94
+ "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_SIGNING_SECRET",
95
+ "TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN",
83
96
  )
84
- # Only touch *uncommented* assignments (leave the template's `# KEY=` examples).
85
- pat = re.compile(r"^\s*(?:%s)=" % "|".join(keys))
86
- lines = p.read_text().splitlines(keepends=True)
87
- kept = [ln for ln in lines if not pat.match(ln)]
88
- if len(kept) != len(lines):
89
- p.write_text("".join(kept))
97
+ found = []
98
+ for raw in p.read_text(encoding="utf-8").splitlines():
99
+ line = raw.strip()
100
+ if not line or line.startswith("#"):
101
+ continue
102
+ if line.startswith("export "):
103
+ line = line[7:].lstrip()
104
+ name, sep, value = line.partition("=")
105
+ if sep and name.strip() in keys and value.strip():
106
+ found.append(name.strip())
107
+ if found:
108
+ raise SystemExit(
109
+ "raw channel credential assignments require the approval-gated "
110
+ "1Password migration: " + ", ".join(sorted(set(found)))
111
+ )
90
112
  PYEOF
91
- chmod 600 "$PROFILE_ENV"
92
- fi
93
-
94
- if [[ "$ROLE" == "pm" ]]; then
95
- VOX_URL_VALUE="${VOX_URL:-$(config_get fleet.vox_url 'https://vox.delo.sh')}"
96
- if [[ -n "$VOX_URL_VALUE" ]]; then
97
- log " ensuring VOX_URL is present for PM voice mode"
98
- python3 - "$PROFILE_ENV" "$VOX_URL_VALUE" <<'PYEOF'
99
- import pathlib, sys
100
-
101
- path = pathlib.Path(sys.argv[1])
102
- vox_url = sys.argv[2]
103
- lines = path.read_text().splitlines() if path.exists() else []
104
- for idx, line in enumerate(lines):
105
- if line.startswith("VOX_URL="):
106
- lines[idx] = f'VOX_URL="{vox_url}"'
107
- break
108
- else:
109
- lines.append(f'VOX_URL="{vox_url}"')
110
- path.write_text("\n".join(lines) + "\n")
111
- PYEOF
112
- chmod 600 "$PROFILE_ENV"
113
- fi
113
+ [[ -L "$PROFILE_ENV" ]] || chmod 600 "$PROFILE_ENV"
114
114
  fi
115
115
 
116
116
  # Never persist a project-specific terminal.cwd through the named profile.
@@ -127,15 +127,13 @@ fi
127
127
  # Seed an EMPTY delta: a new agent should be identical to the fleet base, and
128
128
  # every line here is an override someone must justify later.
129
129
  PROFILE_DELTA="$PROFILE_HOME/config.delta.yaml"
130
- if [[ ! -f "$PROFILE_DELTA" ]]; then
130
+ profile_delta_seed_result="$(
131
+ python3 -I "$PROFILE_DELTA_SEEDER" --profile "$PROFILE_HOME"
132
+ )" || die "config.delta.yaml seed reconciliation failed"
133
+ if [[ "$profile_delta_seed_result" == "seeded" ]]; then
131
134
  log " seeding empty config.delta.yaml (override-only SSOT)"
132
- cat > "$PROFILE_DELTA" <<'DELTA_EOF'
133
- # Override-only delta for this Hermes profile.
134
- # Merged over ~/.hermes/config.yaml to produce config.yaml (which is GENERATED).
135
- # Empty == identical to the fleet base. Add ONLY what must differ.
136
- {}
137
- DELTA_EOF
138
- chmod 600 "$PROFILE_DELTA"
135
+ elif [[ "$profile_delta_seed_result" != "exists" ]]; then
136
+ die "profile config seed helper returned an invalid result"
139
137
  fi
140
138
 
141
139
  # Pin the identity-memory bank explicitly rather than relying on the fleet
@@ -166,7 +164,6 @@ else
166
164
  fi
167
165
 
168
166
  # Canonical shared-skill source of truth + local PM fallback sync.
169
- CANONICAL_SKILLS_DIR="${CANONICAL_SKILLS_DIR:-$(config_get fleet.canonical_skills_dir '/home/delorenj/.agents/skills')}"
170
167
  CANONICAL_PM_SKILL_SRC="$CANONICAL_SKILLS_DIR/subagent-driven-development"
171
168
  LOCAL_PM_SKILL_DST="$PROFILE_HOME/skills/software-development/subagent-driven-development"
172
169
 
@@ -186,17 +183,14 @@ if [[ -d "$CANONICAL_SKILLS_DIR" ]]; then
186
183
 
187
184
  # Ensure key PM/local-ops skills are symlinked into runtime/profile skills root.
188
185
  # This preserves canonical ownership and keeps updates instant across agents.
189
- read -r -a SYMLINKED_RUNTIME_SKILLS <<< "${SYMLINKED_RUNTIME_SKILLS:-$(config_get fleet.symlinked_runtime_skills 'delonet-conventions delonet-dotenv hermes-pm-template-maintenance hindsight subagent-driven-development')}"
190
186
  mkdir -p "$PROFILE_HOME/skills"
191
187
 
192
188
  for skill_name in "${SYMLINKED_RUNTIME_SKILLS[@]}"; do
193
189
  src="$CANONICAL_SKILLS_DIR/$skill_name"
194
190
  dst="$PROFILE_HOME/skills/$skill_name"
195
191
 
196
- if [[ ! -f "$src/SKILL.md" ]]; then
197
- warn " skipping runtime skill symlink (missing SKILL.md): $src"
198
- continue
199
- fi
192
+ [[ -f "$src/SKILL.md" ]] \
193
+ || die "required Skillex skill disappeared during provisioning: $src/SKILL.md"
200
194
 
201
195
  if [[ -L "$dst" && "$(readlink "$dst")" == "$src" ]]; then
202
196
  log " runtime skill symlink already set: $dst -> $src"
@@ -208,7 +202,7 @@ if [[ -d "$CANONICAL_SKILLS_DIR" ]]; then
208
202
  log " symlinked runtime skill: $dst -> $src"
209
203
  done
210
204
  else
211
- warn " canonical skills dir missing: $CANONICAL_SKILLS_DIR"
205
+ die "canonical Skillex projection directory disappeared during provisioning: $CANONICAL_SKILLS_DIR"
212
206
  fi
213
207
 
214
208
  if [[ -f "$CANONICAL_PM_SKILL_SRC/SKILL.md" ]]; then
@@ -216,7 +210,7 @@ if [[ -f "$CANONICAL_PM_SKILL_SRC/SKILL.md" ]]; then
216
210
  mkdir -p "$LOCAL_PM_SKILL_DST"
217
211
  cp -f "$CANONICAL_PM_SKILL_SRC/SKILL.md" "$LOCAL_PM_SKILL_DST/SKILL.md"
218
212
  else
219
- warn " canonical PM skill missing: $CANONICAL_PM_SKILL_SRC/SKILL.md"
213
+ die "required canonical PM skill disappeared during provisioning: $CANONICAL_PM_SKILL_SRC/SKILL.md"
220
214
  fi
221
215
 
222
216
  # Install the project's SOUL.md into the profile so the agent loads it.
@@ -70,7 +70,25 @@ PYEOF
70
70
  # Never let a literal secret reach the runtime. The scaffold is rendered from
71
71
  # templates, so a leaked credential shows up here before anything is copied.
72
72
  python3 "$(dirname "$0")/secret-scan.py" "$TMP"
73
- cp -an "$TMP/." "$RUNTIME_LOCAL/"
73
+ python3 - "$TMP" "$RUNTIME_LOCAL" <<'PYEOF'
74
+ import os, pathlib, shutil, sys
75
+
76
+ source = pathlib.Path(sys.argv[1])
77
+ target = pathlib.Path(sys.argv[2])
78
+ for current in sorted(source.rglob("*")):
79
+ relative = current.relative_to(source)
80
+ destination = target / relative
81
+ if current.is_dir():
82
+ destination.mkdir(parents=True, exist_ok=True)
83
+ continue
84
+ if os.path.lexists(destination):
85
+ continue
86
+ destination.parent.mkdir(parents=True, exist_ok=True)
87
+ if current.is_symlink():
88
+ destination.symlink_to(os.readlink(current))
89
+ else:
90
+ shutil.copy2(current, destination, follow_symlinks=False)
91
+ PYEOF
74
92
 
75
93
  # Seed mutable identity/config only when no local value exists.
76
94
  if [[ "$ROLE" == "reporter" ]]; then
@@ -148,29 +166,34 @@ log " singleton profile verified by pj migrate hermes.runtime-singleton: $PRO
148
166
  # config.yaml is fleet-shared in the singleton topology. The manual and
149
167
  # service launchers pass TERMINAL_CWD process-locally for this role instead.
150
168
 
151
- profile_config_set() {
152
- local key="$1"
153
- [[ -x "$HERMES_BIN" ]] \
154
- || die "Hermes CLI is not executable; cannot configure named profile: $HERMES_BIN"
155
- if ! env HERMES_HOME="$PROFILE_HOME" "$HERMES_BIN" config set "$@" >/dev/null; then
156
- die "required Hermes config write failed for named profile $PROFILE_NAME: $key"
157
- fi
158
- }
159
-
160
169
  if [[ "$ROLE" == "pm" ]]; then
161
- VOXXY_PLUGIN_DIR="${VOXXY_PLUGIN_DIR:-$(config_get fleet.voxxy_plugin_dir "$HOME/code/voxxy/plugins/tts/voxxy")}"
162
- if [[ -d "$VOXXY_PLUGIN_DIR" ]]; then
163
- mkdir -p "$RUNTIME_LOCAL/plugins/tts"
164
- ln -sfn "$VOXXY_PLUGIN_DIR" "$RUNTIME_LOCAL/plugins/tts/voxxy"
165
- log " linked Voxxy plugin into runtime"
170
+ VOX_PLUGIN_NAME="${VOX_PLUGIN_NAME:-$(config_get fleet.vox_plugin_name 'vox')}"
171
+ VOX_VOICE="${VOX_VOICE:-$(config_get fleet.vox_voice 'carlin')}"
172
+ VOX_PLUGIN_DIR="${VOX_PLUGIN_DIR:-$(config_get fleet.vox_plugin_dir "$HOME/code/voxxy/plugins/tts/$VOX_PLUGIN_NAME")}"
173
+ if [[ -d "$VOX_PLUGIN_DIR" ]]; then
174
+ mkdir -p "$PROFILE_HOME/plugins/tts"
175
+ ln -sfn "$VOX_PLUGIN_DIR" "$PROFILE_HOME/plugins/tts/$VOX_PLUGIN_NAME"
176
+ profile_voice_contract_set "$PROFILE_HOME" "$VOX_PLUGIN_NAME" "$VOX_VOICE" \
177
+ || die "could not write the canonical PM voice contract to config.delta.yaml"
178
+ python3 - "$PROFILE_HOME/config.yaml" "$VOX_PLUGIN_NAME" "$VOX_VOICE" <<'PYEOF' \
179
+ || die "PM voice config is not the canonical vox/carlin contract"
180
+ import pathlib, sys
181
+ try:
182
+ import yaml
183
+ except ImportError:
184
+ raise SystemExit("PyYAML is required to validate PM voice config")
185
+ config = yaml.safe_load(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) or {}
186
+ plugin, voice = sys.argv[2:4]
187
+ enabled = ((config.get("plugins") or {}).get("enabled") or [])
188
+ tts = config.get("tts") or {}
189
+ actual_voice = ((tts.get(plugin) or {}).get("voice") or tts.get("voice") or "")
190
+ if f"tts/{plugin}" not in enabled or tts.get("provider") != plugin or actual_voice != voice:
191
+ raise SystemExit(1)
192
+ PYEOF
193
+ log " PM voice verified: provider=$VOX_PLUGIN_NAME voice=$VOX_VOICE"
166
194
  else
167
- warn " Voxxy plugin dir missing: $VOXXY_PLUGIN_DIR"
195
+ log " optional Vox plugin not installed; PM voice activation deferred"
168
196
  fi
169
-
170
- profile_config_set plugins.enabled.0 tts/voxxy
171
- profile_config_set tts.provider voxxy
172
- profile_config_set tts.voice rick
173
- log " set PM named-profile TTS provider -> voxxy"
174
197
  fi
175
198
 
176
199
  mark_done 20-runtime-repo