@event4u/agent-config 2.23.0 → 2.25.0

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 (82) hide show
  1. package/.agent-src/commands/create-pr/description-only.md +39 -11
  2. package/.agent-src/commands/create-pr.md +59 -5
  3. package/.agent-src/commands/video/from-script.md +123 -0
  4. package/.agent-src/commands/video/scene.md +92 -0
  5. package/.agent-src/commands/video/stitch.md +83 -0
  6. package/.agent-src/commands/video/storyboard.md +95 -0
  7. package/.agent-src/commands/video.md +59 -0
  8. package/.agent-src/contexts/execution/roadmap-process-loop.md +69 -14
  9. package/.agent-src/personas/README.md +5 -1
  10. package/.agent-src/personas/ai-video-technical-director.md +81 -0
  11. package/.agent-src/personas/hollywood-director.md +99 -0
  12. package/.agent-src/profiles/content_creator.yml +5 -0
  13. package/.agent-src/rules/media-governance-routing.md +82 -0
  14. package/.agent-src/rules/persona-governance.md +90 -0
  15. package/.agent-src/rules/post-push-rewrite-discipline.md +70 -0
  16. package/.agent-src/rules/provider-lifecycle-discipline.md +75 -0
  17. package/.agent-src/rules/roadmap-ci-steps-policy.md +145 -0
  18. package/.agent-src/rules/roadmap-progress-sync.md +11 -5
  19. package/.agent-src/skills/character-consistency/SKILL.md +131 -0
  20. package/.agent-src/skills/git-workflow/SKILL.md +133 -0
  21. package/.agent-src/skills/motion-choreographer/SKILL.md +161 -0
  22. package/.agent-src/skills/pixar-storyteller/SKILL.md +120 -0
  23. package/.agent-src/skills/roadmap-writing/SKILL.md +10 -0
  24. package/.agent-src/skills/scene-expander/SKILL.md +137 -0
  25. package/.agent-src/skills/scene-expander/scene-blueprint.schema.yaml +108 -0
  26. package/.agent-src/skills/subagent-orchestration/SKILL.md +17 -15
  27. package/.agent-src/skills/video-director/SKILL.md +126 -0
  28. package/.agent-src/templates/agent-settings.md +19 -0
  29. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  30. package/.agent-src/templates/roadmaps.md +16 -0
  31. package/.claude-plugin/marketplace.json +11 -1
  32. package/CHANGELOG.md +65 -0
  33. package/README.md +7 -5
  34. package/config/agent-settings.template.yml +54 -0
  35. package/docs/adrs/caveman/0001-default-off-until-bench.md +2 -2
  36. package/docs/adrs/cost/0001-hard-stop-hook.md +1 -1
  37. package/docs/adrs/smoke/0001-per-tier-smoke-scripts.md +2 -2
  38. package/docs/architecture.md +3 -3
  39. package/docs/catalog.md +18 -5
  40. package/docs/contracts/command-clusters.md +1 -0
  41. package/docs/contracts/compression-default-kill-criterion.md +1 -1
  42. package/docs/contracts/file-ownership-matrix.json +405 -0
  43. package/docs/contracts/provider-lifecycle.md +122 -0
  44. package/docs/decisions/ADR-011-domain-pack-readiness.md +213 -0
  45. package/docs/decisions/INDEX.md +1 -0
  46. package/docs/getting-started-by-role.md +10 -0
  47. package/docs/getting-started.md +2 -2
  48. package/docs/parity/ruflo.md +3 -3
  49. package/docs/personas.md +73 -26
  50. package/docs/profiles.md +9 -4
  51. package/package.json +1 -1
  52. package/scripts/_tmp_scan_framework_leakage.py +119 -0
  53. package/scripts/ai-video/adapters/gemini-veo.sh +62 -0
  54. package/scripts/ai-video/adapters/higgsfield.sh +88 -0
  55. package/scripts/ai-video/adapters/kling.sh +59 -0
  56. package/scripts/ai-video/adapters/openai-images.sh +57 -0
  57. package/scripts/ai-video/adapters/sora.sh +60 -0
  58. package/scripts/ai-video/lib/adapter-common.sh +116 -0
  59. package/scripts/ai-video/lib/adapter-contract.md +163 -0
  60. package/scripts/ai-video/lib/fixtures/gemini-veo/result.json +1 -0
  61. package/scripts/ai-video/lib/fixtures/gemini-veo/scene-0001.mp4 +1 -0
  62. package/scripts/ai-video/lib/fixtures/higgsfield/result.json +1 -0
  63. package/scripts/ai-video/lib/fixtures/higgsfield/scene-0001.mp4 +1 -0
  64. package/scripts/ai-video/lib/fixtures/kling/result.json +1 -0
  65. package/scripts/ai-video/lib/fixtures/kling/scene-0001.mp4 +1 -0
  66. package/scripts/ai-video/lib/fixtures/openai-images/result.json +1 -0
  67. package/scripts/ai-video/lib/fixtures/openai-images/scene-0001.png +3 -0
  68. package/scripts/ai-video/lib/fixtures/sora/result.json +1 -0
  69. package/scripts/ai-video/lib/fixtures/sora/scene-0001.mp4 +1 -0
  70. package/scripts/ai-video/lib/load-config.sh +140 -0
  71. package/scripts/ai-video/lib/operator-pick.sh +119 -0
  72. package/scripts/ai-video/lib/parse-blueprint.sh +122 -0
  73. package/scripts/ai-video/lib/redact.sh +85 -0
  74. package/scripts/ai-video/lib/validate-deps.sh +132 -0
  75. package/scripts/ai-video/stitch.sh +154 -0
  76. package/scripts/ai-video/test-pipeline.sh +169 -0
  77. package/scripts/check_portability.py +6 -0
  78. package/scripts/lint_media_policy_linkage.py +140 -0
  79. package/scripts/lint_persona_governance.py +164 -0
  80. package/scripts/lint_roadmap_ci_steps.py +182 -0
  81. package/scripts/schemas/command.schema.json +8 -0
  82. package/scripts/smoke/schema.sh +1 -1
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env bash
2
+ # stitch.sh — ffmpeg-based clip concatenator for /video:* pipelines.
3
+ #
4
+ # Drives the cut from <project>/manifest.json (an ordered array of
5
+ # `{scene_id, clip_path, audio_embedded, audio_path?, duration}`).
6
+ # Pass-through for audio_embedded=true; ffmpeg mux for audio_embedded
7
+ # =false; operator-supplied bed for video-only clips.
8
+ #
9
+ # Usage:
10
+ # stitch.sh <manifest.json> <output.mp4>
11
+ # [--skip-scene <id> ...]
12
+ # [--abort-on-missing | --continue]
13
+ # [--crossfade <seconds>]
14
+ #
15
+ # Failure semantics:
16
+ # - Missing clip → fail loud with scene_id and re-render hint
17
+ # unless --skip-scene <id> drops the clip from the cut or
18
+ # --continue is passed (the dropped scenes are written to stderr).
19
+ # - --abort-on-missing is the default.
20
+ # - Adapter-failure rollback contract: a failed adapter writes
21
+ # <project>/scenes/<id>/error.json; stitch.sh checks for that
22
+ # file alongside the clip and surfaces it instead of an opaque
23
+ # ffmpeg error.
24
+
25
+ set -euo pipefail
26
+
27
+ # shellcheck source=lib/adapter-common.sh
28
+ . "$(dirname "$0")/lib/adapter-common.sh"
29
+
30
+ aiv_require_cmd jq
31
+
32
+ DRYRUN="${AIV_DRYRUN:-true}"
33
+ case "${DRYRUN}" in
34
+ false|FALSE|0|no|NO) DRYRUN_FLAG=0 ;;
35
+ *) DRYRUN_FLAG=1 ;;
36
+ esac
37
+ [ "${DRYRUN_FLAG}" -eq 0 ] && aiv_require_cmd ffmpeg
38
+
39
+ MANIFEST="${1:-}"
40
+ OUTPUT="${2:-}"
41
+ [ -n "${MANIFEST}" ] && [ -n "${OUTPUT}" ] \
42
+ || aiv_die 2 "usage: stitch.sh <manifest.json> <output.mp4> [--skip-scene <id>] [--abort-on-missing|--continue] [--crossfade <s>]"
43
+ [ -r "${MANIFEST}" ] || aiv_die 2 "manifest not readable: ${MANIFEST}"
44
+ shift 2 || true
45
+
46
+ SKIP_IDS=""
47
+ MISSING_POLICY="abort"
48
+ CROSSFADE=""
49
+ while [ "$#" -gt 0 ]; do
50
+ case "$1" in
51
+ --skip-scene) SKIP_IDS="${SKIP_IDS} ${2:-}"; shift 2 ;;
52
+ --abort-on-missing) MISSING_POLICY="abort"; shift ;;
53
+ --continue) MISSING_POLICY="continue"; shift ;;
54
+ --crossfade) CROSSFADE="${2:-}"; shift 2 ;;
55
+ *) aiv_die 2 "stitch.sh: unknown flag '$1'" ;;
56
+ esac
57
+ done
58
+
59
+ skip_match() {
60
+ local id="$1" s
61
+ for s in ${SKIP_IDS}; do
62
+ [ "${s}" = "${id}" ] && return 0
63
+ done
64
+ return 1
65
+ }
66
+
67
+ PROJECT_DIR="$(cd "$(dirname "${MANIFEST}")" && pwd)"
68
+ WORK_DIR="$(mktemp -d -t aiv-stitch-XXXXXX)"
69
+ trap 'rm -rf "${WORK_DIR}"' EXIT
70
+ CONCAT_LIST="${WORK_DIR}/concat.txt"
71
+ : > "${CONCAT_LIST}"
72
+
73
+ # Accept both bare-array manifests and the documented object shape
74
+ # (`{ "scenes": [...] }`). Field names tolerate the legacy
75
+ # `scene_id` / `clip_path` keys and the example `id` / `expected_clip`.
76
+ SCENES_JQ='if type == "array" then .[] else .scenes[] end'
77
+
78
+ count=0
79
+ missing=0
80
+ plan=""
81
+ while IFS= read -r row; do
82
+ [ -n "${row}" ] || continue
83
+ scene_id="$(printf '%s' "${row}" | jq -r '.scene_id // .id')"
84
+ clip_path="$(printf '%s' "${row}" | jq -r '.clip_path // .expected_clip')"
85
+ audio_embedded="$(printf '%s' "${row}" | jq -r '.audio_embedded // false')"
86
+ audio_path="$(printf '%s' "${row}" | jq -r '.audio_path // empty')"
87
+
88
+ case "${clip_path}" in
89
+ /*) ;;
90
+ *) clip_path="${PROJECT_DIR}/${clip_path}" ;;
91
+ esac
92
+
93
+ if skip_match "${scene_id}"; then
94
+ printf 'stitch: skip scene=%s (operator --skip-scene)\n' "${scene_id}" >&2
95
+ continue
96
+ fi
97
+
98
+ if [ "${DRYRUN_FLAG}" -eq 1 ]; then
99
+ plan="${plan}${scene_id}\taudio_embedded=${audio_embedded}\t${clip_path}\n"
100
+ count=$((count + 1))
101
+ continue
102
+ fi
103
+
104
+ if [ ! -f "${clip_path}" ]; then
105
+ err_json="${PROJECT_DIR}/scenes/${scene_id}/error.json"
106
+ if [ -f "${err_json}" ]; then
107
+ printf 'stitch: scene=%s adapter-error: ' "${scene_id}" >&2
108
+ jq -r '"adapter=\(.adapter) exit=\(.exit_code) user_action=\(.user_action)"' "${err_json}" >&2
109
+ fi
110
+ if [ "${MISSING_POLICY}" = "continue" ]; then
111
+ printf 'stitch: continue past missing scene=%s clip=%s (re-render or --skip-scene next time)\n' \
112
+ "${scene_id}" "${clip_path}" >&2
113
+ missing=$((missing + 1))
114
+ continue
115
+ fi
116
+ aiv_die 7 "missing clip for scene=${scene_id} at ${clip_path} (re-render the scene, pass --skip-scene ${scene_id}, or use --continue)"
117
+ fi
118
+
119
+ # For clips without embedded audio that ship a sibling track, mux
120
+ # via ffmpeg into a tmp file before concat. Pure pass-through for
121
+ # the embedded-audio case.
122
+ if [ "${audio_embedded}" = "false" ] && [ -n "${audio_path}" ] && [ -f "${audio_path}" ]; then
123
+ muxed="${WORK_DIR}/${scene_id}.mp4"
124
+ ffmpeg -loglevel error -y -i "${clip_path}" -i "${audio_path}" \
125
+ -c:v copy -c:a aac -shortest "${muxed}" >/dev/null \
126
+ || aiv_die 8 "ffmpeg mux failed for scene=${scene_id}"
127
+ printf "file '%s'\n" "${muxed}" >> "${CONCAT_LIST}"
128
+ else
129
+ printf "file '%s'\n" "${clip_path}" >> "${CONCAT_LIST}"
130
+ fi
131
+ count=$((count + 1))
132
+ done <<EOF
133
+ $(jq -c "${SCENES_JQ}" "${MANIFEST}")
134
+ EOF
135
+
136
+ [ "${count}" -gt 0 ] || aiv_die 7 "stitch: no usable clips after manifest scan (missing=${missing}, skipped via --skip-scene)"
137
+
138
+ if [ "${DRYRUN_FLAG}" -eq 1 ]; then
139
+ printf 'stitch: dry-run plan (output=%s, scenes=%d):\n' "${OUTPUT}" "${count}" >&2
140
+ printf '%b' "${plan}" >&2
141
+ printf '{"output":"%s","scenes":%d,"missing":0,"dry_run":true}\n' "${OUTPUT}" "${count}"
142
+ exit 0
143
+ fi
144
+
145
+ if [ -n "${CROSSFADE}" ]; then
146
+ printf 'stitch: crossfade=%ss requested — passing through ffmpeg xfade filter not yet implemented (concat path used)\n' \
147
+ "${CROSSFADE}" >&2
148
+ fi
149
+
150
+ ffmpeg -loglevel error -y -f concat -safe 0 -i "${CONCAT_LIST}" \
151
+ -c copy "${OUTPUT}" >/dev/null \
152
+ || aiv_die 8 "ffmpeg concat failed (manifest=${MANIFEST}, output=${OUTPUT})"
153
+
154
+ printf '{"output":"%s","scenes":%d,"missing":%d}\n' "${OUTPUT}" "${count}" "${missing}"
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env bash
2
+ # test-pipeline.sh — offline smoke test for /video:* against
3
+ # agents/ai-video/examples/banana-arc/. Dry-run only; no network.
4
+ #
5
+ # Asserts (per agents/roadmaps/ai-video-pipeline.md Phase 6 Step 3):
6
+ # 1. parse-blueprint.sh output matches the committed expected.json
7
+ # per scene (3 scenes, 3 tiers).
8
+ # 2. character.json descriptor tokens (silhouette, palette, wardrobe,
9
+ # prop) appear verbatim in each scene's prompt.subject — the
10
+ # load-bearing character-lock substring assertion.
11
+ # 3. audio.* branching is correct: scene 2 → enable_native_audio=true,
12
+ # scenes 1+3 → false.
13
+ # 4. native-audio-capable adapter (gemini-veo) advertises capability
14
+ # audio=native; non-audio adapter (openai-images) advertises
15
+ # audio=none; stitch.sh sees audio_embedded per scene.
16
+ # 5. stitch.sh dry-run returns the committed manifest's stitch_output
17
+ # path without invoking ffmpeg or any network.
18
+ # 6. visual regression: each scene's locked.png is non-empty + has
19
+ # PNG magic; pairwise NCC ≥ 0.95 when `compare` is available.
20
+ # When unavailable, asserts byte-identity (the three frames are
21
+ # committed identical for the offline path).
22
+ #
23
+ # Exit 0 = all assertions pass; 1 = at least one failure (counted +
24
+ # summarized at the end).
25
+
26
+ set -uo pipefail
27
+
28
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
29
+ ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
30
+ PROJECT="$ROOT/agents/ai-video/examples/banana-arc"
31
+
32
+ PASS=0
33
+ FAIL=0
34
+ FAILS=""
35
+
36
+ ok() { PASS=$((PASS+1)); printf ' ✅ %s\n' "$1"; }
37
+ fail() { FAIL=$((FAIL+1)); FAILS="$FAILS\n ❌ $1"; printf ' ❌ %s\n' "$1"; }
38
+
39
+ require() {
40
+ if command -v "$1" >/dev/null 2>&1; then return 0; fi
41
+ fail "required tool missing: $1"
42
+ return 1
43
+ }
44
+
45
+ require jq || exit 1
46
+
47
+ printf '\n== test-pipeline.sh — banana-arc golden run (offline) ==\n\n'
48
+
49
+ # ---------------------------------------------------------------- 1
50
+ printf '[1/6] parse-blueprint vs. expected.json\n'
51
+ SCENES="01-simple 02-dialogue-native-audio 03-edge-duration"
52
+ for s in $SCENES; do
53
+ actual="$(bash "$ROOT/scripts/ai-video/lib/parse-blueprint.sh" "$PROJECT/scenes/$s/blueprint.txt" 2>/dev/null \
54
+ | jq -S . 2>/dev/null || true)"
55
+ expected="$(jq -S . "$PROJECT/scenes/$s/expected.json" 2>/dev/null || true)"
56
+ if [ -z "$actual" ]; then
57
+ fail "$s: parse-blueprint produced no output"
58
+ elif [ "$actual" = "$expected" ]; then
59
+ ok "$s: parse-blueprint matches expected.json"
60
+ else
61
+ fail "$s: parse-blueprint mismatch (diff actual vs. expected.json)"
62
+ fi
63
+ done
64
+
65
+ # ---------------------------------------------------------------- 2
66
+ printf '\n[2/6] character.json descriptors verbatim in prompt.subject\n'
67
+ SILHOUETTE="$(jq -r '.characters[0].silhouette' "$PROJECT/character.json")"
68
+ PALETTE="$(jq -r '.characters[0].palette' "$PROJECT/character.json")"
69
+ WARDROBE="$(jq -r '.characters[0].wardrobe' "$PROJECT/character.json")"
70
+ PROP="$(jq -r '.characters[0].prop' "$PROJECT/character.json")"
71
+
72
+ for s in $SCENES; do
73
+ subj="$(jq -r '.prompt.subject' "$PROJECT/scenes/$s/expected.json")"
74
+ miss=""
75
+ case "$subj" in *"$SILHOUETTE"*) :;; *) miss="$miss silhouette";; esac
76
+ case "$subj" in *"$PALETTE"*) :;; *) miss="$miss palette";; esac
77
+ case "$subj" in *"$WARDROBE"*) :;; *) miss="$miss wardrobe";; esac
78
+ case "$subj" in *"$PROP"*) :;; *) miss="$miss prop";; esac
79
+ if [ -z "$miss" ]; then
80
+ ok "$s: silhouette + palette + wardrobe + prop verbatim in prompt.subject"
81
+ else
82
+ fail "$s: missing verbatim tokens in prompt.subject:$miss"
83
+ fi
84
+ done
85
+
86
+ # ---------------------------------------------------------------- 3
87
+ printf '\n[3/6] audio.* branching matches manifest\n'
88
+ for entry in 01-simple:false 02-dialogue-native-audio:true 03-edge-duration:false; do
89
+ s="${entry%%:*}"
90
+ want="${entry##*:}"
91
+ got="$(jq -r '.audio.enable_native_audio' "$PROJECT/scenes/$s/expected.json")"
92
+ manifest_got="$(jq -r --arg id "$s" '.scenes[] | select(.id==$id) | .audio_embedded' "$PROJECT/manifest.json")"
93
+ if [ "$got" = "$want" ] && [ "$manifest_got" = "$want" ]; then
94
+ ok "$s: enable_native_audio=$got, manifest.audio_embedded=$manifest_got"
95
+ else
96
+ fail "$s: audio branching drift (expected=$want, blueprint=$got, manifest=$manifest_got)"
97
+ fi
98
+ done
99
+
100
+ # ---------------------------------------------------------------- 4
101
+ printf '\n[4/6] adapter capability declarations\n'
102
+ declare_caps() {
103
+ local adapter="$1"; local expected="$2"
104
+ local out got
105
+ out="$(AIV_DRYRUN=true bash "$ROOT/scripts/ai-video/adapters/$adapter.sh" capability 2>/dev/null || true)"
106
+ got="$(printf '%s' "$out" | jq -r '.audio // empty' 2>/dev/null || true)"
107
+ if [ "$got" = "$expected" ]; then
108
+ ok "$adapter: capability.audio=$got"
109
+ else
110
+ fail "$adapter: capability mismatch (want audio=$expected, got: ${got:-<unparseable: $out>})"
111
+ fi
112
+ }
113
+ declare_caps gemini-veo "native"
114
+ declare_caps openai-images "none"
115
+ declare_caps sora "native"
116
+ declare_caps kling "none"
117
+
118
+ # ---------------------------------------------------------------- 5
119
+ printf '\n[5/6] stitch.sh dry-run returns manifest output path\n'
120
+ STITCH_OUT="$(jq -r '.stitch_output' "$PROJECT/manifest.json")"
121
+ stitch_log="$(AIV_DRYRUN=true bash "$ROOT/scripts/ai-video/stitch.sh" \
122
+ "$PROJECT/manifest.json" "$PROJECT/$STITCH_OUT" 2>&1 || true)"
123
+ case "$stitch_log" in
124
+ *"$STITCH_OUT"*) ok "stitch.sh dry-run referenced $STITCH_OUT";;
125
+ *) fail "stitch.sh dry-run did not reference $STITCH_OUT (log: $stitch_log)";;
126
+ esac
127
+
128
+ # ---------------------------------------------------------------- 6
129
+ printf '\n[6/6] visual regression (locked.png pairwise)\n'
130
+ PNG_MAGIC="$(printf '\x89PNG\r\n\x1a\n')"
131
+ prev=""
132
+ have_compare=0
133
+ command -v compare >/dev/null 2>&1 && have_compare=1
134
+ for s in $SCENES; do
135
+ f="$PROJECT/scenes/$s/fixtures/frames/locked.png"
136
+ if [ ! -s "$f" ]; then
137
+ fail "$s: locked.png missing or empty"
138
+ continue
139
+ fi
140
+ head -c 8 "$f" | od -An -c | tr -d ' \n' | grep -q '211PNG' \
141
+ && ok "$s: locked.png is a valid PNG ($(wc -c < "$f" | tr -d ' ') bytes)" \
142
+ || fail "$s: locked.png lacks PNG magic"
143
+ if [ -n "$prev" ]; then
144
+ if [ "$have_compare" -eq 1 ]; then
145
+ ncc="$(compare -metric NCC "$prev" "$f" null: 2>&1 || true)"
146
+ awk_ok="$(awk -v v="$ncc" 'BEGIN { exit !(v+0 >= 0.95) }' && echo yes || echo no)"
147
+ if [ "$awk_ok" = "yes" ]; then
148
+ ok "$s: NCC vs. previous = $ncc (≥ 0.95)"
149
+ else
150
+ fail "$s: NCC vs. previous = $ncc (< 0.95)"
151
+ fi
152
+ else
153
+ if cmp -s "$prev" "$f"; then
154
+ ok "$s: byte-identical to previous (compare unavailable; offline fallback)"
155
+ else
156
+ fail "$s: differs from previous frame and compare is unavailable"
157
+ fi
158
+ fi
159
+ fi
160
+ prev="$f"
161
+ done
162
+
163
+ # ----------------------------------------------------------------
164
+ printf '\n----------------\nresult: %d passed · %d failed\n' "$PASS" "$FAIL"
165
+ if [ "$FAIL" -gt 0 ]; then
166
+ printf '%b\n' "$FAILS"
167
+ exit 1
168
+ fi
169
+ exit 0
@@ -319,6 +319,12 @@ _TASK_DETECTOR_SKIP = (
319
319
  "rules/package-ci-checks.md",
320
320
  "contexts/communication/rules-auto/package-ci-checks-mechanics.md",
321
321
  "contexts/contracts/agents-md-anatomy.md",
322
+ # roadmap-ci-steps-policy defines the gate by listing the forbidden
323
+ # CI-shaped literals; its mechanics doc and the execution loop +
324
+ # authoring skill enumerate the same literals to detect them.
325
+ "rules/roadmap-ci-steps-policy.md",
326
+ "contexts/execution/roadmap-process-loop.md",
327
+ "skills/roadmap-writing/SKILL.md",
322
328
  )
323
329
 
324
330
 
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env python3
2
+ """Lint structural reachability of media governance policies.
3
+
4
+ Every policy file under `agents/policies/media/` (except README) must
5
+ be linked from at least one of:
6
+
7
+ * a skill SKILL.md (any .agent-src.uncompressed/skills/*/SKILL.md
8
+ or .claude/skills/*/SKILL.md),
9
+ * a routing rule under .agent-src.uncompressed/rules/, or
10
+ * a sibling policy file under agents/policies/media/.
11
+
12
+ A policy that no surface references is a silent policy and a silent
13
+ policy is a failed policy. This is the CI-side reachability guarantee
14
+ the agent-in-the-loop enforcement model rests on (see
15
+ agents/policies/media/README.md § Enforcement model).
16
+
17
+ Exit codes:
18
+ 0 all policies linked
19
+ 1 one or more orphan policies
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ QUIET = "--quiet" in sys.argv
27
+
28
+ REPO = Path(__file__).resolve().parents[1]
29
+ POLICY_DIR = REPO / "agents" / "policies" / "media"
30
+ EXEMPT_STEMS = frozenset({"README"})
31
+
32
+ # Surfaces scanned for inbound references to policy files.
33
+ SCAN_ROOTS: tuple[Path, ...] = (
34
+ REPO / ".agent-src.uncompressed" / "skills",
35
+ REPO / ".agent-src.uncompressed" / "rules",
36
+ REPO / ".agent-src.uncompressed" / "commands",
37
+ REPO / ".claude" / "skills",
38
+ REPO / "agents" / "policies" / "media",
39
+ )
40
+
41
+
42
+ def emit(msg: str) -> None:
43
+ if not QUIET:
44
+ print(msg)
45
+
46
+
47
+ def collect_policies() -> list[Path]:
48
+ if not POLICY_DIR.exists():
49
+ return []
50
+ return sorted(
51
+ p
52
+ for p in POLICY_DIR.glob("*.md")
53
+ if p.stem not in EXEMPT_STEMS
54
+ )
55
+
56
+
57
+ def collect_scan_files() -> list[Path]:
58
+ files: list[Path] = []
59
+ for root in SCAN_ROOTS:
60
+ if not root.exists():
61
+ continue
62
+ files.extend(root.rglob("*.md"))
63
+ return files
64
+
65
+
66
+ def referrers_for(policy: Path, scan_files: list[Path]) -> list[Path]:
67
+ """Return files that reference `policy` by its repo-relative name
68
+ or basename. We accept both the full path token
69
+ (`agents/policies/media/likeness.md`) and the bare basename
70
+ (`likeness.md`) inside a markdown link, because sibling policies
71
+ link via relative `[likeness.md](likeness.md)` form.
72
+ """
73
+ needles = (
74
+ f"policies/media/{policy.name}",
75
+ f"]({policy.name})",
76
+ )
77
+ referrers: list[Path] = []
78
+ for scan_file in scan_files:
79
+ # A policy can't satisfy its own linkage requirement.
80
+ if scan_file.resolve() == policy.resolve():
81
+ continue
82
+ try:
83
+ text = scan_file.read_text(encoding="utf-8", errors="replace")
84
+ except OSError:
85
+ continue
86
+ if any(n in text for n in needles):
87
+ referrers.append(scan_file)
88
+ return referrers
89
+
90
+
91
+ def main() -> int:
92
+ if not POLICY_DIR.exists():
93
+ emit(
94
+ "media-policy-linkage: agents/policies/media/ missing — "
95
+ "nothing to lint."
96
+ )
97
+ return 0
98
+
99
+ policies = collect_policies()
100
+ if not policies:
101
+ emit(
102
+ "media-policy-linkage: agents/policies/media/ has no policy "
103
+ "files — nothing to lint."
104
+ )
105
+ return 0
106
+
107
+ scan_files = collect_scan_files()
108
+ orphans: list[Path] = []
109
+ for policy in policies:
110
+ referrers = referrers_for(policy, scan_files)
111
+ rel = policy.relative_to(REPO)
112
+ if not referrers:
113
+ orphans.append(policy)
114
+ emit(f"❌ ORPHAN {rel}")
115
+ continue
116
+ emit(f"✅ {rel} ({len(referrers)} referrer(s))")
117
+
118
+ if orphans:
119
+ print(
120
+ f"\nmedia-policy-linkage: {len(orphans)} orphan policy "
121
+ f"file(s) — every policy must be linked from a skill, rule, "
122
+ f"or sibling policy.",
123
+ file=sys.stderr,
124
+ )
125
+ for o in orphans:
126
+ print(
127
+ f" - {o.relative_to(REPO)}",
128
+ file=sys.stderr,
129
+ )
130
+ return 1
131
+
132
+ emit(
133
+ f"media-policy-linkage: {len(policies)} policy file(s) — all "
134
+ f"linked."
135
+ )
136
+ return 0
137
+
138
+
139
+ if __name__ == "__main__":
140
+ sys.exit(main())
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env python3
2
+ """Lint persona governance — per-domain cap (hard) + citation floor (warn).
3
+
4
+ Enforces the mechanical checks in
5
+ `.agent-src.uncompressed/rules/persona-governance.md`:
6
+
7
+ 1. **Per-domain cap (HARD)** — ≤ 2 active specialist personas per
8
+ content domain. Core-tier personas are exempt. `status:
9
+ deprecated` rows (if any survive a transition window) are
10
+ excluded from the count; the canonical path is in-commit
11
+ deletion, no soak window.
12
+ 2. **Skill citation floor (WARN)** — every active specialist
13
+ persona SHOULD be cited by `personas: [<id>]` in at least one
14
+ skill SKILL.md under `.agent-src.uncompressed/skills/` or
15
+ `.claude/skills/`. Surfaced as a warning, never blocks CI:
16
+ the citation floor is enforced at PR time per the rule (a new
17
+ specialist MUST land with a cite); pre-existing wiring debt is
18
+ tracked as `--citation-debt` for the maintainer dashboard.
19
+
20
+ Schema conformance (check 4) is delegated to `lint-skills`.
21
+ Deprecation path (check 3) is reviewed at PR time via the table in
22
+ `docs/personas.md`.
23
+
24
+ Domain inference: persona ids → content domain via `DOMAIN_MAP`,
25
+ mirroring `persona-governance.md § Per-domain cap`. Personas absent
26
+ from the map are cross-cutting (uncapped).
27
+
28
+ Exit codes:
29
+ 0 per-domain cap clean (citation warnings non-blocking)
30
+ 1 per-domain cap violated
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import re
35
+ import sys
36
+ from collections import defaultdict
37
+ from pathlib import Path
38
+
39
+ QUIET = "--quiet" in sys.argv
40
+
41
+ REPO = Path(__file__).resolve().parents[1]
42
+ PERSONA_DIR = REPO / ".agent-src.uncompressed" / "personas"
43
+ SKILL_ROOTS: tuple[Path, ...] = (
44
+ REPO / ".agent-src.uncompressed" / "skills",
45
+ REPO / ".claude" / "skills",
46
+ )
47
+
48
+ # Per-domain cap — mirrors persona-governance.md § Per-domain cap.
49
+ # Maps persona id → content-domain bucket. Personas absent from this
50
+ # map are cross-cutting (uncapped) — typically singleton specialists
51
+ # without a domain peer (e.g. `qa`, `tech-writer`).
52
+ DOMAIN_MAP: dict[str, str] = {
53
+ "hollywood-director": "ai-video",
54
+ "ai-video-technical-director": "ai-video",
55
+ "backend-architect": "backend",
56
+ "eloquent-tamer": "backend",
57
+ "cmo": "gtm",
58
+ "revops": "gtm",
59
+ "growth-pm": "growth",
60
+ "customer-success-lead": "customer",
61
+ "discovery-lead": "customer",
62
+ "engineering-manager": "people",
63
+ "people-strategist": "people",
64
+ "finance-partner": "money",
65
+ "strategist": "money",
66
+ }
67
+ PER_DOMAIN_CAP = 2
68
+
69
+
70
+ def emit(msg: str) -> None:
71
+ if not QUIET:
72
+ print(msg)
73
+
74
+
75
+ def parse_frontmatter(path: Path) -> dict[str, str]:
76
+ text = path.read_text(encoding="utf-8", errors="replace")
77
+ if not text.startswith("---"):
78
+ return {}
79
+ end = text.find("\n---", 3)
80
+ if end == -1:
81
+ return {}
82
+ out: dict[str, str] = {}
83
+ for line in text[3:end].splitlines():
84
+ m = re.match(r"^([a-zA-Z_][\w-]*):\s*(.*)$", line)
85
+ if m:
86
+ out[m.group(1)] = m.group(2).strip().strip('"').strip("'")
87
+ return out
88
+
89
+
90
+ def collect_personas() -> list[tuple[str, str, str, Path]]:
91
+ """Return (id, tier, status, path) for every persona file."""
92
+ out: list[tuple[str, str, str, Path]] = []
93
+ if not PERSONA_DIR.exists():
94
+ return out
95
+ for path in sorted(PERSONA_DIR.glob("*.md")):
96
+ if path.stem == "README":
97
+ continue
98
+ fm = parse_frontmatter(path)
99
+ pid = fm.get("id") or path.stem
100
+ tier = fm.get("tier", "")
101
+ status = fm.get("status", "active") or "active"
102
+ out.append((pid, tier, status, path))
103
+ return out
104
+
105
+
106
+ def citations_for(persona_id: str) -> list[Path]:
107
+ pattern = re.compile(rf"(^|[\s,\[]){re.escape(persona_id)}([\s,\]]|$)")
108
+ hits: list[Path] = []
109
+ for root in SKILL_ROOTS:
110
+ if not root.exists():
111
+ continue
112
+ for skill in root.rglob("SKILL.md"):
113
+ text = skill.read_text(encoding="utf-8", errors="replace")
114
+ if text.startswith("---"):
115
+ end = text.find("\n---", 3)
116
+ fm_block = text[3:end] if end != -1 else ""
117
+ else:
118
+ fm_block = ""
119
+ if "personas:" not in fm_block:
120
+ continue
121
+ if pattern.search(fm_block):
122
+ hits.append(skill)
123
+ return hits
124
+
125
+
126
+ def main() -> int:
127
+ personas = collect_personas()
128
+ if not personas:
129
+ emit("persona-governance: no persona files found — nothing to lint.")
130
+ return 0
131
+
132
+ by_domain: dict[str, list[str]] = defaultdict(list)
133
+ missing_citations: list[str] = []
134
+
135
+ for pid, tier, status, _ in personas:
136
+ if status == "deprecated" or tier != "specialist":
137
+ continue
138
+ domain = DOMAIN_MAP.get(pid)
139
+ if domain:
140
+ by_domain[domain].append(pid)
141
+ if not citations_for(pid):
142
+ missing_citations.append(pid)
143
+
144
+ overflows = {d: ids for d, ids in by_domain.items() if len(ids) > PER_DOMAIN_CAP}
145
+ for d, ids in sorted(by_domain.items()):
146
+ marker = "❌" if d in overflows else "✅"
147
+ emit(f"{marker} domain={d} {len(ids)}/{PER_DOMAIN_CAP} {', '.join(sorted(ids))}")
148
+ for pid in sorted(missing_citations):
149
+ emit(f"⚠️ no-skill-citation {pid} (warn — see PR-time gate)")
150
+
151
+ if overflows:
152
+ print("\npersona-governance: per-domain cap violated.", file=sys.stderr)
153
+ for d, ids in sorted(overflows.items()):
154
+ print(f" - domain '{d}' has {len(ids)} specialists (cap {PER_DOMAIN_CAP}): {', '.join(sorted(ids))}", file=sys.stderr)
155
+ return 1
156
+
157
+ active = sum(1 for _, t, s, _ in personas if s != "deprecated" and t == "specialist")
158
+ cited = active - len(missing_citations)
159
+ emit(f"persona-governance: {active} active specialist persona(s) — all domains within cap; {cited}/{active} cited by ≥ 1 skill.")
160
+ return 0
161
+
162
+
163
+ if __name__ == "__main__":
164
+ sys.exit(main())