@event4u/agent-config 2.16.0 → 2.18.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 (96) hide show
  1. package/.agent-src/commands/ghostwriter/delete.md +118 -0
  2. package/.agent-src/commands/ghostwriter/fetch.md +185 -0
  3. package/.agent-src/commands/ghostwriter/list.md +102 -0
  4. package/.agent-src/commands/ghostwriter/show.md +113 -0
  5. package/.agent-src/commands/ghostwriter/write.md +160 -0
  6. package/.agent-src/commands/ghostwriter.md +96 -0
  7. package/.agent-src/commands/post-as/ghostwriter.md +66 -0
  8. package/.agent-src/commands/post-as/me.md +124 -0
  9. package/.agent-src/commands/post-as.md +58 -0
  10. package/.agent-src/ghostwriter/README.md +61 -0
  11. package/.agent-src/ghostwriter/fictional-fixture-v1.md +94 -0
  12. package/.agent-src/personas/README.md +8 -0
  13. package/.agent-src/rules/domain-safety-disclaimer-consulting.md +52 -0
  14. package/.agent-src/rules/domain-safety-disclaimer-financial.md +54 -0
  15. package/.agent-src/rules/domain-safety-disclaimer-legal.md +49 -0
  16. package/.agent-src/rules/domain-safety-disclaimer-medical.md +56 -0
  17. package/.agent-src/rules/domain-safety-export-redact.md +65 -0
  18. package/.agent-src/rules/domain-safety-logging-pii-floor.md +55 -0
  19. package/.agent-src/rules/domain-safety-pii-finance.md +57 -0
  20. package/.agent-src/rules/domain-safety-pii-marketing.md +60 -0
  21. package/.agent-src/rules/domain-safety-pii-recruiting.md +56 -0
  22. package/.agent-src/rules/domain-safety-pii-support.md +57 -0
  23. package/.agent-src/rules/domain-safety-retention-finance.md +48 -0
  24. package/.agent-src/rules/domain-safety-retention-support.md +55 -0
  25. package/.agent-src/skills/api-design/SKILL.md +3 -0
  26. package/.agent-src/skills/authz-review/SKILL.md +3 -0
  27. package/.agent-src/skills/competitive-moat-analysis/SKILL.md +3 -0
  28. package/.agent-src/skills/competitive-positioning/SKILL.md +3 -0
  29. package/.agent-src/skills/content-funnel-design/SKILL.md +3 -0
  30. package/.agent-src/skills/contracts-cognition/SKILL.md +3 -0
  31. package/.agent-src/skills/dashboard-design/SKILL.md +3 -0
  32. package/.agent-src/skills/data-handling-judgment/SKILL.md +3 -0
  33. package/.agent-src/skills/dcf-modeling/SKILL.md +3 -0
  34. package/.agent-src/skills/deal-qualification-meddic/SKILL.md +3 -0
  35. package/.agent-src/skills/discovery-interview/SKILL.md +3 -0
  36. package/.agent-src/skills/editorial-calendar/SKILL.md +3 -0
  37. package/.agent-src/skills/forecast-accuracy/SKILL.md +3 -0
  38. package/.agent-src/skills/forecasting/SKILL.md +3 -0
  39. package/.agent-src/skills/fundraising-narrative/SKILL.md +3 -0
  40. package/.agent-src/skills/gtm-launch/SKILL.md +3 -0
  41. package/.agent-src/skills/incident-commander/SKILL.md +3 -0
  42. package/.agent-src/skills/launch-readiness/SKILL.md +3 -0
  43. package/.agent-src/skills/messaging-architecture/SKILL.md +3 -0
  44. package/.agent-src/skills/okr-tree-modeling/SKILL.md +3 -0
  45. package/.agent-src/skills/pipeline-strategy/SKILL.md +3 -0
  46. package/.agent-src/skills/playwright-architect/SKILL.md +3 -0
  47. package/.agent-src/skills/privacy-review/SKILL.md +4 -1
  48. package/.agent-src/skills/quality-tools/SKILL.md +3 -0
  49. package/.agent-src/skills/release-comms/SKILL.md +3 -0
  50. package/.agent-src/skills/runway-cognition/SKILL.md +3 -0
  51. package/.agent-src/skills/scenario-modeling/SKILL.md +3 -0
  52. package/.agent-src/skills/secrets-management/SKILL.md +3 -0
  53. package/.agent-src/skills/tech-debt-tracker/SKILL.md +3 -0
  54. package/.agent-src/skills/unit-economics-modeling/SKILL.md +3 -0
  55. package/.agent-src/skills/voc-extract/SKILL.md +3 -0
  56. package/.agent-src/skills/voice-and-tone-design/SKILL.md +3 -0
  57. package/.agent-src/templates/agents/agent-project-settings.example.yml +16 -1
  58. package/.claude-plugin/marketplace.json +10 -1
  59. package/CHANGELOG.md +98 -0
  60. package/README.md +44 -23
  61. package/config/agent-settings.template.yml +7 -0
  62. package/config/gitignore-block.txt +8 -0
  63. package/docs/announcements/2026-05-non-dev-launch.md +79 -0
  64. package/docs/architecture.md +2 -2
  65. package/docs/case-studies/_template.md +60 -0
  66. package/docs/catalog.md +25 -4
  67. package/docs/contracts/adr-install-user-type-axis.md +107 -0
  68. package/docs/contracts/agent-user-schema.md +1 -0
  69. package/docs/contracts/command-clusters.md +2 -0
  70. package/docs/contracts/file-ownership-matrix.json +490 -0
  71. package/docs/contracts/ghostwriter-schema.md +337 -0
  72. package/docs/contracts/init-telemetry.md +132 -0
  73. package/docs/contracts/router-blending.md +71 -0
  74. package/docs/contracts/universal-skills.md +92 -0
  75. package/docs/contracts/write-engine.md +142 -0
  76. package/docs/getting-started-by-role.md +89 -0
  77. package/docs/getting-started-laravel.md +72 -0
  78. package/docs/getting-started.md +2 -2
  79. package/docs/safety.md +30 -0
  80. package/package.json +1 -1
  81. package/scripts/audit_user_type_axis.py +140 -0
  82. package/scripts/bench_runner.py +158 -0
  83. package/scripts/check_role_doc_links.py +110 -0
  84. package/scripts/compress.py +11 -0
  85. package/scripts/ghostwriter_fixture_allowlist.txt +16 -0
  86. package/scripts/install +9 -1
  87. package/scripts/install.py +214 -8
  88. package/scripts/install.sh +7 -0
  89. package/scripts/lint_ghostwriter_source.py +240 -0
  90. package/scripts/mcp_server/prompts.py +134 -2
  91. package/scripts/measure_skill_reduction.py +102 -0
  92. package/scripts/schemas/rule.schema.json +5 -0
  93. package/scripts/schemas/skill.schema.json +6 -0
  94. package/scripts/schemas/user-type-axis.schema.json +56 -0
  95. package/scripts/sync_agent_settings.py +6 -0
  96. package/scripts/update-github-metadata.sh +84 -0
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env python3
2
+ """Verify every skill link in role-based docs resolves to a real file.
3
+
4
+ Part of step-12 Phase 2. Runs in `task ci` to catch link rot when a
5
+ skill is renamed or removed but the role docs still reference it.
6
+
7
+ Scans `docs/getting-started-by-role.md` and `docs/getting-started-laravel.md`
8
+ for markdown links of the form `../.agent-src/skills/<name>/SKILL.md`
9
+ (relative to docs/) and checks that the target file exists on disk.
10
+
11
+ Exit codes:
12
+ 0 — every link resolves
13
+ 1 — at least one broken link; prints the offending file:line:url tuples
14
+ 2 — usage error (one of the role doc files missing)
15
+
16
+ Usage:
17
+ python3 scripts/check_role_doc_links.py
18
+ python3 scripts/check_role_doc_links.py --quiet
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import re
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ ROOT = Path(__file__).resolve().parent.parent
28
+ DOCS_DIR = ROOT / "docs"
29
+
30
+ # (display-path, on-disk path, link-anchor) — anchor is the relative
31
+ # prefix that identifies a skill link from inside docs/.
32
+ ROLE_DOCS = [
33
+ DOCS_DIR / "getting-started-by-role.md",
34
+ DOCS_DIR / "getting-started-laravel.md",
35
+ ]
36
+
37
+ # Markdown link: [label](path). We only check the (path) part. The
38
+ # regex tolerates trailing #anchor fragments and ignores absolute URLs.
39
+ LINK_RE = re.compile(r"\]\(([^)\s]+)\)")
40
+
41
+ # Anchors we know how to resolve. Each tuple is (prefix, base_dir).
42
+ ANCHORS: list[tuple[str, Path]] = [
43
+ ("../.agent-src/skills/", ROOT / ".agent-src" / "skills"),
44
+ ("../.agent-src/commands/", ROOT / ".agent-src" / "commands"),
45
+ ("../.agent-src/rules/", ROOT / ".agent-src" / "rules"),
46
+ ("../agents/", ROOT / "agents"),
47
+ ("contracts/", DOCS_DIR / "contracts"),
48
+ ("guidelines/", DOCS_DIR / "guidelines"),
49
+ ]
50
+
51
+
52
+ def resolve(url: str, doc_path: Path) -> Path | None:
53
+ """Return the on-disk target path for a relative link, or None if external."""
54
+ if url.startswith(("http://", "https://", "mailto:")):
55
+ return None
56
+ bare = url.split("#", 1)[0]
57
+ if not bare:
58
+ return None
59
+ # Relative to the doc's own directory.
60
+ target = (doc_path.parent / bare).resolve()
61
+ return target
62
+
63
+
64
+ def scan(doc_path: Path) -> list[tuple[int, str]]:
65
+ """Return list of (line_no, url) tuples for every non-external link."""
66
+ if not doc_path.is_file():
67
+ print(f"error: missing role doc: {doc_path}", file=sys.stderr)
68
+ sys.exit(2)
69
+ links: list[tuple[int, str]] = []
70
+ for i, line in enumerate(doc_path.read_text(encoding="utf-8").splitlines(), 1):
71
+ for m in LINK_RE.finditer(line):
72
+ url = m.group(1)
73
+ if url.startswith(("http://", "https://", "mailto:")):
74
+ continue
75
+ links.append((i, url))
76
+ return links
77
+
78
+
79
+ def main() -> int:
80
+ p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
81
+ p.add_argument("--quiet", action="store_true", help="Suppress success summary.")
82
+ args = p.parse_args()
83
+
84
+ failures: list[tuple[Path, int, str]] = []
85
+ checked = 0
86
+
87
+ for doc in ROLE_DOCS:
88
+ for line_no, url in scan(doc):
89
+ target = resolve(url, doc)
90
+ if target is None:
91
+ continue
92
+ checked += 1
93
+ if not target.exists():
94
+ failures.append((doc, line_no, url))
95
+
96
+ if failures:
97
+ print("Broken links in role docs:", file=sys.stderr)
98
+ for doc, line_no, url in failures:
99
+ rel = doc.relative_to(ROOT)
100
+ print(f" {rel}:{line_no} -> {url}", file=sys.stderr)
101
+ print(f"\n{len(failures)} broken / {checked} checked", file=sys.stderr)
102
+ return 1
103
+
104
+ if not args.quiet:
105
+ print(f"check_role_doc_links: {checked} links OK across {len(ROLE_DOCS)} files")
106
+ return 0
107
+
108
+
109
+ if __name__ == "__main__":
110
+ sys.exit(main())
@@ -77,6 +77,11 @@ def _tool_active(tool_id: str) -> bool:
77
77
  # Files to copy as-is even if .md (not compressed by agent)
78
78
  COPY_AS_IS = {"README.md"}
79
79
 
80
+ # Directories (relative to SOURCE_DIR) whose .md content is data, not prose,
81
+ # and must be copied verbatim. Ghostwriter fixtures carry voice_samples that
82
+ # would be destroyed by caveman compression.
83
+ COPY_AS_IS_DIRS = frozenset({"ghostwriter"})
84
+
80
85
 
81
86
  def _read_augment_rules_use_symlinks() -> bool:
82
87
  """Read augment.rules_use_symlinks from .agent-settings.yml.
@@ -235,6 +240,12 @@ def should_compress(filepath: Path) -> bool:
235
240
  return False
236
241
  if filepath.name in COPY_AS_IS:
237
242
  return False
243
+ try:
244
+ rel_parts = filepath.relative_to(SOURCE_DIR).parts
245
+ except ValueError:
246
+ rel_parts = filepath.parts
247
+ if rel_parts and rel_parts[0] in COPY_AS_IS_DIRS:
248
+ return False
238
249
  return True
239
250
 
240
251
 
@@ -0,0 +1,16 @@
1
+ # Ghostwriter fixture allowlist
2
+ #
3
+ # One file stem per line (without the .md extension).
4
+ # Every file under .agent-src.uncompressed/ghostwriter/ whose stem is
5
+ # NOT on this list will fail `task lint-ghostwriter-source`.
6
+ #
7
+ # Adding a new fixture requires:
8
+ # 1. Adding the stem here.
9
+ # 2. Setting `fictional: true` in the file's frontmatter.
10
+ # 3. Reviewer sign-off on the allowlist change.
11
+ #
12
+ # README.md is exempt (the lint skips it).
13
+ #
14
+ # See docs/contracts/ghostwriter-schema.md § Lint enforcement.
15
+
16
+ fictional-fixture-v1
package/scripts/install CHANGED
@@ -15,6 +15,10 @@
15
15
  # --source <dir> Package source directory (default: auto-detect)
16
16
  # --target <dir> Target project root (default: cwd)
17
17
  # --profile <name> Cost profile for bridges (minimal|balanced|full)
18
+ # --user-type <id> Primary user-type for skill filtering (step-9 axis).
19
+ # Valid ids: consultant | creator | developer | finance
20
+ # | founder | gtm | ops. Default: empty (no filter).
21
+ # Written to personal.user_type in .agent-settings.yml.
18
22
  # --tools <list> Comma-separated tool IDs to install (default: all).
19
23
  # Valid: claude-code,claude-desktop,cursor,windsurf,
20
24
  # cline,gemini-cli,copilot,augment,aider,codex,
@@ -82,12 +86,13 @@ SCOPE=""
82
86
  CUSTOM_PATH=""
83
87
  OFFLINE=false
84
88
  MINIMAL=false
89
+ USER_TYPE=""
85
90
 
86
91
  # Single source of truth for valid tool IDs (also referenced by install.sh / install.py).
87
92
  VALID_TOOLS="claude-code claude-desktop cursor windsurf cline gemini-cli copilot augment aider codex roocode continue kilocode zed jetbrains kiro all"
88
93
 
89
94
  show_help() {
90
- sed -n '3,54p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
95
+ sed -n '3,58p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
91
96
  }
92
97
 
93
98
  list_tools() {
@@ -146,6 +151,8 @@ while [[ $# -gt 0 ]]; do
146
151
  --target=*) TARGET_DIR="${1#*=}"; shift ;;
147
152
  --profile) PROFILE="$2"; shift 2 ;;
148
153
  --profile=*) PROFILE="${1#*=}"; shift ;;
154
+ --user-type) USER_TYPE="$2"; shift 2 ;;
155
+ --user-type=*) USER_TYPE="${1#*=}"; shift ;;
149
156
  --tools) TOOLS="${TOOLS:+$TOOLS,}$2"; TOOLS_EXPLICIT=true; shift 2 ;;
150
157
  --tools=*) TOOLS="${TOOLS:+$TOOLS,}${1#*=}"; TOOLS_EXPLICIT=true; shift ;;
151
158
  --ai) TOOLS="${TOOLS:+$TOOLS,}$2"; TOOLS_EXPLICIT=true; shift 2 ;;
@@ -309,6 +316,7 @@ run_bridges() {
309
316
 
310
317
  local args=(--project "$TARGET_DIR" --package "$SOURCE_DIR")
311
318
  [[ -n "$PROFILE" ]] && args+=(--profile="$PROFILE")
319
+ [[ -n "$USER_TYPE" ]] && args+=(--user-type="$USER_TYPE")
312
320
  $FORCE && args+=(--force)
313
321
  $QUIET && args+=(--quiet)
314
322
  $GLOBAL && args+=(--global)
@@ -45,6 +45,8 @@ except ImportError: # pragma: no cover — alt sys.path layout
45
45
  DEFAULT_PROFILE = "balanced"
46
46
  SUPPORTED_PROFILES = ("minimal", "balanced", "full")
47
47
  COST_PROFILE_PLACEHOLDER = "__COST_PROFILE__"
48
+ USER_TYPE_PLACEHOLDER = "__USER_TYPE__"
49
+ USER_TYPES_DIR = "user-types"
48
50
 
49
51
  # Env-var equivalent of --force for CI / scripted installs (P3.4).
50
52
  # When set to "1" the install run treats every conflict as
@@ -781,7 +783,44 @@ def _render_template(template: str, profile_values: "dict[str, str]") -> str:
781
783
  return body
782
784
 
783
785
 
784
- def ensure_agent_settings(project_root: Path, package_root: Path, profile: str, force: bool) -> None:
786
+ def _load_valid_user_types(package_root: Path) -> list[str]:
787
+ """Return the sorted user-type slugs shipped under ``user-types/``.
788
+
789
+ Maps `user-types/<id>.yml` → `<id>`. The ``README.md`` is skipped.
790
+ Empty list when the directory is absent (older package payloads).
791
+ """
792
+ directory = package_root / USER_TYPES_DIR
793
+ if not directory.is_dir():
794
+ return []
795
+ return sorted(p.stem for p in directory.glob("*.yml"))
796
+
797
+
798
+ def _validate_user_type(package_root: Path, value: str) -> str:
799
+ """Return the validated user-type slug (empty string allowed → no filter)."""
800
+ cleaned = (value or "").strip()
801
+ if not cleaned:
802
+ return ""
803
+ valid = _load_valid_user_types(package_root)
804
+ if not valid:
805
+ fail(
806
+ f"--user-type={cleaned} requested but no user-types/*.yml present "
807
+ f"under {package_root}"
808
+ )
809
+ if cleaned not in valid:
810
+ fail(
811
+ f"Unknown --user-type={cleaned}. Valid: {', '.join(valid)} "
812
+ "(empty string disables the filter)."
813
+ )
814
+ return cleaned
815
+
816
+
817
+ def ensure_agent_settings(
818
+ project_root: Path,
819
+ package_root: Path,
820
+ profile: str,
821
+ force: bool,
822
+ user_type: str = "",
823
+ ) -> None:
785
824
  target = project_root / SETTINGS_FILE
786
825
  profile_source = package_root / "config" / "profiles" / f"{profile}.ini"
787
826
  template_source = package_root / "config" / "agent-settings.template.yml"
@@ -794,12 +833,16 @@ def ensure_agent_settings(project_root: Path, package_root: Path, profile: str,
794
833
  template = template_source.read_text(encoding="utf-8")
795
834
  if COST_PROFILE_PLACEHOLDER not in template:
796
835
  fail(f"Template is missing placeholder {COST_PROFILE_PLACEHOLDER}")
836
+ if USER_TYPE_PLACEHOLDER not in template:
837
+ fail(f"Template is missing placeholder {USER_TYPE_PLACEHOLDER}")
797
838
  profile_values = _parse_profile_ini(profile_source)
798
839
  if profile_values.get("cost_profile") != profile:
799
840
  fail(
800
841
  f"Profile preset {profile_source.name} has cost_profile="
801
842
  f"{profile_values.get('cost_profile')!r} but --profile={profile}"
802
843
  )
844
+ # Inject runtime-only values (not part of the .ini profile presets).
845
+ profile_values["user_type"] = _validate_user_type(package_root, user_type)
803
846
  template_body = _render_template(template, profile_values)
804
847
 
805
848
  legacy_target = project_root / LEGACY_SETTINGS_FILE
@@ -822,7 +865,9 @@ def ensure_agent_settings(project_root: Path, package_root: Path, profile: str,
822
865
  return
823
866
 
824
867
  write_file(target, template_body)
825
- success(f"{SETTINGS_FILE} created (cost_profile={profile})")
868
+ user_type_value = profile_values.get("user_type", "")
869
+ suffix = f", user_type={user_type_value}" if user_type_value else ""
870
+ success(f"{SETTINGS_FILE} created (cost_profile={profile}{suffix})")
826
871
 
827
872
 
828
873
  def ensure_vscode_bridge(project_root: Path, package_type: str, force: bool) -> None:
@@ -3130,6 +3175,17 @@ def parse_options(argv: list[str]) -> argparse.Namespace:
3130
3175
  default=DEFAULT_PROFILE,
3131
3176
  help=f"cost_profile value ({'|'.join(SUPPORTED_PROFILES)}, default: {DEFAULT_PROFILE})",
3132
3177
  )
3178
+ parser.add_argument(
3179
+ "--user-type",
3180
+ dest="user_type",
3181
+ default="",
3182
+ help=(
3183
+ "primary user-type for skill filtering (step-9 axis). "
3184
+ "Valid ids: consultant | creator | developer | finance | "
3185
+ "founder | gtm | ops. Default: empty (no filter, every skill "
3186
+ "surfaces). Written to personal.user_type in .agent-settings.yml."
3187
+ ),
3188
+ )
3133
3189
  parser.add_argument("--force", action="store_true", help="overwrite existing files")
3134
3190
  parser.add_argument("--skip-bridges", action="store_true", help="only create .agent-settings.yml")
3135
3191
  parser.add_argument(
@@ -3236,6 +3292,18 @@ def parse_options(argv: list[str]) -> argparse.Namespace:
3236
3292
  "guard). See docs/installation.md → Minimal init."
3237
3293
  ),
3238
3294
  )
3295
+ parser.add_argument(
3296
+ "--interactive",
3297
+ action="store_true",
3298
+ help=(
3299
+ "after the install completes, run a short prompt to capture "
3300
+ "user-type / stack / verbosity and write `.agent-config.local.json` "
3301
+ "(forward-compatible stub for step-9 user-types axis — runtime "
3302
+ "skill filtering activates once that axis ships). TTY-only; "
3303
+ "no-op without an interactive stdin. See "
3304
+ "docs/contracts/universal-skills.md for the always-loaded set."
3305
+ ),
3306
+ )
3239
3307
  opts = parser.parse_args(argv)
3240
3308
  opts.tools = _merge_tools_aliases(opts.tools, opts.ai)
3241
3309
  if opts.scope == "global" and opts.custom_path:
@@ -3342,7 +3410,7 @@ def _write_install_mode_marker(project_root: Path, mode: str) -> None:
3342
3410
  pass
3343
3411
 
3344
3412
 
3345
- def install_minimal(target_root: Path, force: bool) -> int:
3413
+ def install_minimal(target_root: Path, force: bool, user_type: str = "") -> int:
3346
3414
  """Bootstrap the project-local override layer only (D2-compliant).
3347
3415
 
3348
3416
  Writes:
@@ -3409,8 +3477,16 @@ def install_minimal(target_root: Path, force: bool) -> int:
3409
3477
  if settings_dst.exists() and not force:
3410
3478
  skip(f"{SETTINGS_FILE} already exists (use --force to overwrite)")
3411
3479
  else:
3412
- settings_dst.write_text(settings_src.read_text(encoding="utf-8"), encoding="utf-8")
3413
- success(f"Wrote {SETTINGS_FILE}")
3480
+ body = settings_src.read_text(encoding="utf-8")
3481
+ if user_type:
3482
+ body = body.rstrip() + (
3483
+ "\n\n# --- Personal (step-9 user-type axis) ---\n"
3484
+ "personal:\n"
3485
+ f" user_type: {user_type}\n"
3486
+ )
3487
+ settings_dst.write_text(body, encoding="utf-8")
3488
+ suffix = f" (user_type={user_type})" if user_type else ""
3489
+ success(f"Wrote {SETTINGS_FILE}{suffix}")
3414
3490
 
3415
3491
  # 3. install-mode marker (Step 8 A5) — authoritative state for
3416
3492
  # doctor --context and future install-aware tooling. Written even
@@ -3437,6 +3513,120 @@ def install_minimal(target_root: Path, force: bool) -> int:
3437
3513
  return 0
3438
3514
 
3439
3515
 
3516
+ # --- Interactive init (step-12 Phase 3, forward-compatible stub) ---
3517
+
3518
+ _INTERACTIVE_USER_TYPES: tuple[tuple[str, str], ...] = (
3519
+ ("creator", "Content / writing / publishing"),
3520
+ ("founder", "Early-stage company building"),
3521
+ ("consultant", "Advisory / strategy / discovery"),
3522
+ ("gtm", "Sales / marketing / revenue ops"),
3523
+ ("finance", "Finance / FP&A / unit economics"),
3524
+ ("ops", "Operations / incident / compliance"),
3525
+ ("developer", "Engineering / code-heavy work"),
3526
+ )
3527
+
3528
+ _INTERACTIVE_STACKS: tuple[tuple[str, str], ...] = (
3529
+ ("none", "No code project / pure content"),
3530
+ ("laravel", "PHP / Laravel"),
3531
+ ("nextjs", "TypeScript / Next.js / React"),
3532
+ ("python", "Python / FastAPI / Django"),
3533
+ ("symfony", "PHP / Symfony"),
3534
+ ("generic", "Other / mixed stack"),
3535
+ )
3536
+
3537
+ _INTERACTIVE_VERBOSITIES: tuple[tuple[str, str], ...] = (
3538
+ ("quiet", "Caveman / minimal output"),
3539
+ ("normal", "Default verbosity"),
3540
+ ("verbose", "Full intent announcements + play-by-play"),
3541
+ )
3542
+
3543
+ _LOCAL_CONFIG_FILE = ".agent-config.local.json"
3544
+
3545
+
3546
+ def _interactive_prompt_choice(label: str, options: tuple[tuple[str, str], ...]) -> str:
3547
+ """Render a numbered list and return the chosen id. Defaults to option 1 on empty input."""
3548
+ print()
3549
+ print(f" {label}")
3550
+ for idx, (key, blurb) in enumerate(options, start=1):
3551
+ print(f" {idx}. {key} — {blurb}")
3552
+ print()
3553
+ while True:
3554
+ try:
3555
+ raw = input(f" Choice [1-{len(options)}, default 1]: ").strip()
3556
+ except EOFError:
3557
+ return options[0][0]
3558
+ if not raw:
3559
+ return options[0][0]
3560
+ if raw.isdigit():
3561
+ i = int(raw)
3562
+ if 1 <= i <= len(options):
3563
+ return options[i - 1][0]
3564
+ # Allow typing the slug directly.
3565
+ for key, _ in options:
3566
+ if raw.lower() == key:
3567
+ return key
3568
+ print(f" ⚠️ Pick a number 1-{len(options)} or one of: {', '.join(k for k, _ in options)}.")
3569
+
3570
+
3571
+ def run_interactive_init(project_root: Path, force: bool) -> int:
3572
+ """Write ``.agent-config.local.json`` based on three TTY prompts.
3573
+
3574
+ Forward-compatible stub for [`step-9-user-types-axis`](../agents/roadmaps/step-9-user-types-axis.md):
3575
+ runtime skill filtering activates once that axis ships its
3576
+ ``user-types/`` directory and ``--user-type`` flag. Until then,
3577
+ this file is metadata-only — read by ``doctor --context`` and the
3578
+ upcoming ``agent-config skills`` listing command.
3579
+
3580
+ Universal-skills allowlist (see
3581
+ ``docs/contracts/universal-skills.md``) loads regardless of the
3582
+ captured ``user_type`` — the contract guarantees these 15 skills
3583
+ are never filtered out.
3584
+
3585
+ Returns 0 on success, 1 on collision without ``--force``. No-op
3586
+ (returns 0) when stdin is not a TTY.
3587
+ """
3588
+ if not sys.stdin.isatty():
3589
+ warn(
3590
+ "--interactive requested but stdin is not a TTY; skipping the "
3591
+ f"prompt. Re-run interactively or hand-edit {_LOCAL_CONFIG_FILE}."
3592
+ )
3593
+ return 0
3594
+
3595
+ target = project_root / _LOCAL_CONFIG_FILE
3596
+ if target.exists() and not force:
3597
+ warn(
3598
+ f"{_LOCAL_CONFIG_FILE} already exists; re-run with --force to "
3599
+ "overwrite. Skipping interactive init."
3600
+ )
3601
+ return 0
3602
+
3603
+ print()
3604
+ info("Interactive init — captures user-type / stack / verbosity")
3605
+ info("(forward-compatible stub; runtime filtering activates with step-9)")
3606
+
3607
+ user_type = _interactive_prompt_choice("Primary user type:", _INTERACTIVE_USER_TYPES)
3608
+ stack = _interactive_prompt_choice("Project stack:", _INTERACTIVE_STACKS)
3609
+ verbosity = _interactive_prompt_choice("Verbosity profile:", _INTERACTIVE_VERBOSITIES)
3610
+
3611
+ payload: dict[str, Any] = {
3612
+ "$schema": "https://github.com/event4u-app/agent-config/scripts/schemas/local-config.schema.json",
3613
+ "version": 1,
3614
+ "user_type": user_type,
3615
+ "stack": stack,
3616
+ "verbosity": verbosity,
3617
+ "universal_skills_contract": "docs/contracts/universal-skills.md",
3618
+ }
3619
+
3620
+ try:
3621
+ target.write_text(json.dumps(payload, indent=2, sort_keys=False) + "\n", encoding="utf-8")
3622
+ except OSError as exc:
3623
+ warn(f"Could not write {target}: {exc}")
3624
+ return 1
3625
+
3626
+ success(f"Wrote {target.relative_to(project_root)} ({user_type} / {stack} / {verbosity})")
3627
+ return 0
3628
+
3629
+
3440
3630
  # --- Main ---
3441
3631
 
3442
3632
  def main(argv: list[str]) -> int:
@@ -3466,7 +3656,13 @@ def main(argv: list[str]) -> int:
3466
3656
  target_root = Path(
3467
3657
  opts.custom_path or opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()
3468
3658
  ).resolve()
3469
- return install_minimal(target_root, opts.force)
3659
+ # Validate --user-type early so the minimal short-circuit fails
3660
+ # fast on a bogus slug instead of writing a half-formed stub.
3661
+ # _minimal_templates_root() returns <package_root>/templates/minimal;
3662
+ # walk two parents up to reach the package root where user-types/ lives.
3663
+ minimal_package_root = _minimal_templates_root().parent.parent
3664
+ validated_user_type = _validate_user_type(minimal_package_root, opts.user_type)
3665
+ return install_minimal(target_root, opts.force, validated_user_type)
3470
3666
 
3471
3667
  # Multi-signal scope detection (Phase 1.3) + scope resolution
3472
3668
  # (Phase 1.4). Order of precedence (highest first):
@@ -3503,7 +3699,13 @@ def main(argv: list[str]) -> int:
3503
3699
 
3504
3700
  project_root = custom_path or Path(opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()).resolve()
3505
3701
  is_first_run = not (project_root / SETTINGS_FILE).exists()
3506
- return _main_project_install(opts, project_root, parsed_tools, is_first_run)
3702
+ rc = _main_project_install(opts, project_root, parsed_tools, is_first_run)
3703
+ # Interactive post-install prompt (step-12 Phase 3, forward-compatible
3704
+ # stub). Runs only after a successful install so the local config
3705
+ # never ships ahead of the bridge files it parameterizes.
3706
+ if rc == 0 and getattr(opts, "interactive", False):
3707
+ run_interactive_init(project_root, opts.force)
3708
+ return rc
3507
3709
  except ConflictAbort as exc:
3508
3710
  warn(exc.message)
3509
3711
  return 1
@@ -3538,9 +3740,13 @@ def _main_project_install(
3538
3740
  info(f"Package: {package_root}")
3539
3741
  info(f"Type: {package_type}")
3540
3742
  info(f"Profile: {opts.profile}")
3743
+ if opts.user_type:
3744
+ info(f"UserType: {opts.user_type}")
3541
3745
  print()
3542
3746
 
3543
- ensure_agent_settings(project_root, package_root, opts.profile, opts.force)
3747
+ ensure_agent_settings(
3748
+ project_root, package_root, opts.profile, opts.force, opts.user_type
3749
+ )
3544
3750
 
3545
3751
  # Install-mode marker (Step 8 A5) — full path flips any prior
3546
3752
  # minimal marker to "full" so doctor --context reflects the
@@ -79,6 +79,13 @@ parse_args() {
79
79
  --skip-gitignore) SKIP_GITIGNORE=true; shift ;;
80
80
  --tools) TOOLS="$2"; shift 2 ;;
81
81
  --tools=*) TOOLS="${1#*=}"; shift ;;
82
+ # --user-type is consumed by install.py (settings persistence).
83
+ # Accepted here so direct `bash scripts/install.sh --user-type=...`
84
+ # invocations from the `install` wrapper / standalone users do not
85
+ # trip the "Unknown argument" guard. Value is intentionally unused
86
+ # by the payload-sync stage.
87
+ --user-type) shift 2 ;;
88
+ --user-type=*) shift ;;
82
89
  --minimal|--settings-only) MINIMAL=true; shift ;;
83
90
  --help|-h) show_help; exit 0 ;;
84
91
  *) log_error "Unknown argument: $1"; show_help; exit 1 ;;