@event4u/agent-config 3.0.0 → 3.1.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 (207) hide show
  1. package/.agent-src/commands/install-via-agent.md +129 -0
  2. package/.agent-src/commands/video/from-script.md +1 -1
  3. package/.agent-src/commands/video.md +1 -1
  4. package/.agent-src/contexts/execution/cheap-question-mechanics.md +81 -0
  5. package/.agent-src/rules/caveman-speak.md +2 -2
  6. package/.agent-src/rules/context-hygiene.md +36 -0
  7. package/.agent-src/rules/engineering-safety-floor.md +102 -0
  8. package/.agent-src/rules/finance-safety-floor.md +114 -0
  9. package/.agent-src/rules/git-history-discipline.md +1 -1
  10. package/.agent-src/rules/no-cheap-questions.md +34 -32
  11. package/.agent-src/rules/provider-lifecycle-discipline.md +4 -4
  12. package/.agent-src/rules/strategy-safety-floor.md +114 -0
  13. package/.agent-src/skills/agents-md-thin-root/SKILL.md +15 -9
  14. package/.agent-src/skills/async-python-patterns/SKILL.md +1 -1
  15. package/.agent-src/skills/project-analysis-node-express/SKILL.md +1 -1
  16. package/.agent-src/skills/readme-reviewer/SKILL.md +52 -3
  17. package/.agent-src/skills/readme-writing/SKILL.md +52 -4
  18. package/.agent-src/skills/readme-writing-package/SKILL.md +48 -5
  19. package/.agent-src/skills/systematic-debugging/SKILL.md +41 -0
  20. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  21. package/.agent-src/templates/hooks/pre-commit-frontmatter +66 -0
  22. package/.agent-src/templates/hooks/pre-commit-roadmap-progress +78 -39
  23. package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +4 -1
  24. package/.agent-src/templates/scripts/work_engine/orchestration.py +25 -11
  25. package/.claude-plugin/marketplace.json +2 -1
  26. package/AGENTS.md +10 -8
  27. package/CHANGELOG.md +223 -125
  28. package/README.md +165 -553
  29. package/config/agent-settings.template.yml +0 -7
  30. package/config/discovery/packs.yml +20 -0
  31. package/config/discovery/unassigned-artefacts.yml +2 -0
  32. package/config/gitignore-block.txt +19 -3
  33. package/dist/cli/commands/uiServe.js +13 -4
  34. package/dist/cli/commands/uiServe.js.map +1 -1
  35. package/dist/cli/registry.js +2 -0
  36. package/dist/cli/registry.js.map +1 -1
  37. package/dist/discovery/deprecation-report.md +7 -0
  38. package/dist/discovery/discovery-manifest.json +2107 -1409
  39. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  40. package/dist/discovery/discovery-manifest.summary.md +9 -9
  41. package/dist/discovery/orphan-report.md +10 -0
  42. package/dist/discovery/packs.json +1002 -0
  43. package/dist/discovery/trust-report.md +26 -0
  44. package/dist/discovery/workspaces.json +705 -0
  45. package/dist/mcp/registry-manifest.json +4 -4
  46. package/dist/router.json +1623 -0
  47. package/dist/server/app.js +11 -3
  48. package/dist/server/app.js.map +1 -1
  49. package/dist/server/io/atomicMultiWrite.js +3 -1
  50. package/dist/server/io/atomicMultiWrite.js.map +1 -1
  51. package/dist/server/io/yamlIO.js +22 -0
  52. package/dist/server/io/yamlIO.js.map +1 -1
  53. package/dist/server/routes/ping.js +8 -0
  54. package/dist/server/routes/ping.js.map +1 -1
  55. package/dist/server/routes/schema.js +2 -2
  56. package/dist/server/routes/schema.js.map +1 -1
  57. package/dist/server/routes/settings.js +104 -23
  58. package/dist/server/routes/settings.js.map +1 -1
  59. package/dist/server/routes/userMd.js +37 -27
  60. package/dist/server/routes/userMd.js.map +1 -1
  61. package/dist/server/routes/wizard.js +256 -20
  62. package/dist/server/routes/wizard.js.map +1 -1
  63. package/dist/server/schemas/settings.js +0 -1
  64. package/dist/server/schemas/settings.js.map +1 -1
  65. package/dist/server/token.js +10 -3
  66. package/dist/server/token.js.map +1 -1
  67. package/dist/server/writeRoot.js +28 -11
  68. package/dist/server/writeRoot.js.map +1 -1
  69. package/dist/server/writeRoot.test.js +22 -4
  70. package/dist/server/writeRoot.test.js.map +1 -1
  71. package/dist/shared/userMd/formAdapter.js +29 -51
  72. package/dist/shared/userMd/formAdapter.js.map +1 -1
  73. package/dist/shared/userMd/schema.js +32 -104
  74. package/dist/shared/userMd/schema.js.map +1 -1
  75. package/dist/shared/userMd/utils.js +64 -50
  76. package/dist/shared/userMd/utils.js.map +1 -1
  77. package/dist/ui/assets/index-D-DY1ywI.js +35 -0
  78. package/dist/ui/assets/index-D-DY1ywI.js.map +1 -0
  79. package/dist/ui/index.html +1 -1
  80. package/docs/adrs/router/0001-three-tier-routing.md +5 -5
  81. package/docs/adrs/smoke/0001-per-tier-smoke-scripts.md +1 -1
  82. package/docs/architecture.md +3 -3
  83. package/docs/archive/CHANGELOG-pre-3.1.0.md +167 -0
  84. package/docs/catalog.md +30 -26
  85. package/docs/contracts/CHANGELOG-conventions.md +1 -1
  86. package/docs/contracts/agent-user-schema.md +6 -9
  87. package/docs/contracts/consumer-bridge.md +79 -0
  88. package/docs/contracts/discovery-manifest.md +209 -0
  89. package/docs/contracts/discovery-manifest.schema.json +77 -4
  90. package/docs/contracts/explain-trace.schema.json +1 -1
  91. package/docs/contracts/file-ownership-matrix.json +197 -13
  92. package/docs/contracts/frontmatter-contract.md +140 -0
  93. package/docs/contracts/gui-wizard.md +223 -0
  94. package/docs/contracts/installer-agent-mode.md +137 -0
  95. package/docs/contracts/kernel-membership.md +1 -1
  96. package/docs/contracts/mcp-tool-inventory.md +9 -9
  97. package/docs/contracts/namespace.md +6 -6
  98. package/docs/contracts/provider-lifecycle.md +5 -5
  99. package/docs/contracts/rule-router.md +4 -4
  100. package/docs/contracts/settings-api.md +53 -6
  101. package/docs/contracts/smoke-contracts.md +3 -3
  102. package/docs/contracts/trust-and-safety.md +144 -0
  103. package/docs/customization.md +2 -2
  104. package/docs/decisions/ADR-007-agent-discovery-scopes.md +12 -0
  105. package/docs/decisions/ADR-013-discovery-frontmatter-contract.md +24 -0
  106. package/docs/decisions/ADR-015-discovery-manifest-contract.md +146 -0
  107. package/docs/decisions/ADR-016-installer-architecture.md +189 -0
  108. package/docs/decisions/ADR-017-monorepo-physical-layout.md +261 -0
  109. package/docs/decisions/ADR-018-trust-and-safety-layer.md +159 -0
  110. package/docs/decisions/ADR-019-router-json-dist-location.md +124 -0
  111. package/docs/decisions/ADR-020-global-only-consumer-scope.md +123 -0
  112. package/docs/decisions/ADR-021-deployment-shape.md +153 -0
  113. package/docs/decisions/INDEX.md +7 -0
  114. package/docs/deploy/connector-setup.md +129 -0
  115. package/docs/deploy/env-vars.md +70 -0
  116. package/docs/deploy/policy-cookbook.md +130 -0
  117. package/docs/deploy/quickstart.md +112 -0
  118. package/docs/distribution/public-install-smoke.md +68 -0
  119. package/docs/distribution/registries.md +55 -0
  120. package/docs/distribution/telemetry-privacy.md +128 -0
  121. package/docs/distribution/telemetry-schema.md +174 -0
  122. package/docs/featured-skills.md +95 -0
  123. package/docs/getting-started-by-role.md +19 -1
  124. package/docs/getting-started.md +2 -2
  125. package/docs/guidelines/agent-infra/installed-tools-manifest.md +11 -8
  126. package/docs/guidelines/docs/readme-size-and-splitting.md +53 -1
  127. package/docs/installation.md +27 -14
  128. package/docs/maintainers/dev-mode.md +105 -0
  129. package/docs/setup/per-ide/claude-desktop.md +3 -2
  130. package/docs/wizard.md +39 -4
  131. package/package.json +18 -1
  132. package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
  133. package/scripts/_cli/cmd_doctor.py +150 -2
  134. package/scripts/_cli/cmd_explain.py +2 -1
  135. package/scripts/_cli/cmd_migrate_to_global.py +415 -0
  136. package/scripts/_cli/cmd_settings_migrate.py +146 -0
  137. package/scripts/_cli/explain_last/route.py +2 -1
  138. package/scripts/_dispatch.bash +36 -3
  139. package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
  140. package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
  141. package/scripts/_lib/agent_settings.py +4 -1
  142. package/scripts/_lib/agent_src.py +157 -0
  143. package/scripts/agent-config +17 -6
  144. package/scripts/audit_skill_descriptions.py +18 -6
  145. package/scripts/build_discovery_manifest.py +373 -17
  146. package/scripts/check_artefact_checksums.py +104 -0
  147. package/scripts/check_cluster_patterns.py +20 -4
  148. package/scripts/check_command_count_messaging.py +33 -14
  149. package/scripts/check_council_references.py +43 -4
  150. package/scripts/check_overlay_cascade_subdirs.py +7 -3
  151. package/scripts/check_references.py +5 -2
  152. package/scripts/check_reply_consistency.py +32 -9
  153. package/scripts/check_template_pin_drift.py +24 -7
  154. package/scripts/check_token_optimizer_freshness.py +18 -3
  155. package/scripts/compile_router.py +34 -2
  156. package/scripts/compress.py +162 -44
  157. package/scripts/config/presets.py +19 -1
  158. package/scripts/config/profiles.py +16 -1
  159. package/scripts/discovery_stats.py +70 -0
  160. package/scripts/expected_perms.json +47 -0
  161. package/scripts/generate_index.py +78 -46
  162. package/scripts/generate_ownership_matrix.py +98 -43
  163. package/scripts/generate_pack_manifests.py +183 -0
  164. package/scripts/install +18 -1
  165. package/scripts/install.py +934 -59
  166. package/scripts/install.sh +27 -9
  167. package/scripts/lint_agents_layout.py +93 -13
  168. package/scripts/lint_agents_md.py +1 -1
  169. package/scripts/lint_archived_skills.py +32 -16
  170. package/scripts/lint_bench_corpus.py +14 -2
  171. package/scripts/lint_command_tiers.py +15 -2
  172. package/scripts/lint_featured_skills.py +139 -0
  173. package/scripts/lint_framework_leakage.py +33 -6
  174. package/scripts/lint_global_paths.py +147 -0
  175. package/scripts/lint_orchestration_dsl.py +6 -3
  176. package/scripts/lint_pack_boundaries.py +147 -0
  177. package/scripts/lint_pack_first_win.py +103 -0
  178. package/scripts/lint_readme_jargon.py +131 -0
  179. package/scripts/lint_readme_size.py +33 -0
  180. package/scripts/lint_rule_interactions.py +23 -5
  181. package/scripts/lint_rule_tiers.py +12 -3
  182. package/scripts/lint_trust_coherence.py +212 -0
  183. package/scripts/measure_rule_budget.py +22 -4
  184. package/scripts/move_artefact.py +143 -0
  185. package/scripts/new_skill.py +148 -0
  186. package/scripts/plan_physical_move.py +353 -0
  187. package/scripts/refine_ticket_detect.py +30 -7
  188. package/scripts/schemas/command.schema.json +4 -0
  189. package/scripts/skill_linter.py +248 -118
  190. package/scripts/skill_trigger_eval.py +28 -8
  191. package/scripts/smoke/kernel.sh +1 -1
  192. package/scripts/smoke/router.sh +24 -5
  193. package/scripts/smoke/skills.sh +15 -7
  194. package/scripts/smoke_quickstart.py +11 -2
  195. package/scripts/snapshot_agent_outputs.py +144 -0
  196. package/scripts/update_counts.py +45 -17
  197. package/scripts/validate_decision_engine.py +9 -1
  198. package/scripts/validate_discovery_manifest.py +94 -0
  199. package/scripts/validate_frontmatter.py +39 -20
  200. package/scripts/verify_physical_move.py +185 -0
  201. package/templates/agent-user.md +0 -1
  202. package/templates/agent-user.yml +21 -0
  203. package/templates/minimal/agents-overrides-readme.md +46 -0
  204. package/templates/minimal/overrides-gitkeep +2 -0
  205. package/dist/ui/assets/index-BTRcKDlB.js +0 -39
  206. package/dist/ui/assets/index-BTRcKDlB.js.map +0 -1
  207. package/templates/minimal/agents-gitkeep +0 -2
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env python3
2
+ """Permissions-audit entry-gate for the global install tree.
3
+
4
+ Phase 5.0 / amendment A7 of road-to-global-only-install. Runs BEFORE
5
+ any legacy snapshot write so a perms leak cannot be created by the
6
+ migration itself: `agent-config migrate-to-global` is expected to call
7
+ this script first, abort on any failure, and only then proceed with
8
+ the copy → verify → move → bridge sequence.
9
+
10
+ Policy source: scripts/expected_perms.json (parameterised so the policy
11
+ can evolve without code changes).
12
+
13
+ Exit codes:
14
+ 0 — all checks pass.
15
+ 1 — at least one finding (printed to stdout, one finding per line).
16
+ 2 — bad invocation (missing policy, JSON parse error, etc).
17
+
18
+ Usage:
19
+ python3 scripts/lint_global_paths.py
20
+ python3 scripts/lint_global_paths.py --policy scripts/expected_perms.json
21
+ python3 scripts/lint_global_paths.py --quiet
22
+
23
+ The script is intentionally read-only — no fixups, no chmod, no creates.
24
+ The migration owns side effects.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import json
31
+ import os
32
+ import stat
33
+ import sys
34
+ from pathlib import Path
35
+
36
+ DEFAULT_POLICY = Path(__file__).resolve().parent / "expected_perms.json"
37
+
38
+
39
+ def _expand(p: str) -> Path:
40
+ return Path(os.path.expanduser(p))
41
+
42
+
43
+ def _mode_str(mode: int) -> str:
44
+ return f"0{stat.S_IMODE(mode):03o}"
45
+
46
+
47
+ def _check_mode(path: Path, expected: str, kind: str) -> str | None:
48
+ """Return finding text or None when path is clean."""
49
+ if not path.exists():
50
+ return None # missing optional paths are silent — checked by `required`
51
+ try:
52
+ actual = _mode_str(path.stat().st_mode)
53
+ except OSError as exc:
54
+ return f"{path}: stat failed ({exc})"
55
+ if actual != expected:
56
+ return f"{path}: {kind} mode {actual} (expected {expected})"
57
+ return None
58
+
59
+
60
+ def _check_symlinks(root: Path) -> list[str]:
61
+ """All symlinks under `root` must resolve to paths still under `root`."""
62
+ findings: list[str] = []
63
+ if not root.exists():
64
+ return findings
65
+ root_resolved = root.resolve()
66
+ for entry in root.rglob("*"):
67
+ if not entry.is_symlink():
68
+ continue
69
+ try:
70
+ target = entry.resolve(strict=False)
71
+ except OSError as exc:
72
+ findings.append(f"{entry}: symlink resolve failed ({exc})")
73
+ continue
74
+ try:
75
+ target.relative_to(root_resolved)
76
+ except ValueError:
77
+ findings.append(f"{entry}: symlink escapes global root → {target}")
78
+ return findings
79
+
80
+
81
+ def _check_glob(root: Path, glob: str, expected_mode: str, required: bool, kind: str) -> list[str]:
82
+ findings: list[str] = []
83
+ # Globs anchored at ~ are pre-expanded; reduce them to a root-relative pattern.
84
+ home = Path.home()
85
+ pattern_path = Path(os.path.expanduser(glob))
86
+ try:
87
+ rel = pattern_path.relative_to(home)
88
+ except ValueError:
89
+ rel = pattern_path
90
+ matches = list(home.glob(str(rel)))
91
+ if not matches and required:
92
+ findings.append(f"{glob}: required {kind} missing")
93
+ return findings
94
+ for match in matches:
95
+ finding = _check_mode(match, expected_mode, kind)
96
+ if finding:
97
+ findings.append(finding)
98
+ return findings
99
+
100
+
101
+ def lint(policy_path: Path, quiet: bool = False) -> int:
102
+ try:
103
+ policy = json.loads(policy_path.read_text(encoding="utf-8"))
104
+ except (OSError, json.JSONDecodeError) as exc:
105
+ print(f"error: policy load failed: {exc}", file=sys.stderr)
106
+ return 2
107
+
108
+ findings: list[str] = []
109
+
110
+ root_spec = policy.get("global_root") or {}
111
+ root_path = _expand(root_spec.get("path", "~/.event4u/agent-config"))
112
+ if root_path.exists():
113
+ finding = _check_mode(root_path, root_spec.get("expected_mode", "0700"), "directory")
114
+ if finding:
115
+ findings.append(finding)
116
+ findings.extend(_check_symlinks(root_path))
117
+
118
+ for spec in policy.get("files", []):
119
+ findings.extend(_check_glob(
120
+ root_path, spec["glob"], spec["expected_mode"],
121
+ spec.get("required", False), "file",
122
+ ))
123
+ for spec in policy.get("directories", []):
124
+ findings.extend(_check_glob(
125
+ root_path, spec["glob"], spec["expected_mode"],
126
+ spec.get("required", False), "directory",
127
+ ))
128
+
129
+ if not findings:
130
+ if not quiet:
131
+ print(f"✅ global paths clean ({root_path})")
132
+ return 0
133
+ for f in findings:
134
+ print(f"❌ {f}")
135
+ return 1
136
+
137
+
138
+ def main() -> int:
139
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
140
+ ap.add_argument("--policy", type=Path, default=DEFAULT_POLICY)
141
+ ap.add_argument("--quiet", action="store_true")
142
+ args = ap.parse_args()
143
+ return lint(args.policy, quiet=args.quiet)
144
+
145
+
146
+ if __name__ == "__main__":
147
+ sys.exit(main())
@@ -33,6 +33,9 @@ import sys
33
33
  from pathlib import Path
34
34
 
35
35
  REPO_ROOT = Path(__file__).resolve().parent.parent
36
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
37
+ from _lib.agent_src import resolve_logical # noqa: E402
38
+
36
39
  DEFAULT_DIR = REPO_ROOT / ".agent-config" / "orchestrations"
37
40
 
38
41
  NAME_RE = re.compile(r"^[a-z][a-z0-9-]*$")
@@ -62,11 +65,11 @@ def _load_yaml(path: Path) -> object:
62
65
 
63
66
  def _ref_exists(kind: str, ref: str) -> bool:
64
67
  if kind == "skill":
65
- return (REPO_ROOT / ".agent-src.uncompressed" / "skills" / ref / "SKILL.md").is_file()
68
+ return resolve_logical(f"skills/{ref}/SKILL.md") is not None
66
69
  if kind == "command":
67
- return (REPO_ROOT / ".agent-src.uncompressed" / "commands" / f"{ref}.md").is_file()
70
+ return resolve_logical(f"commands/{ref}.md") is not None
68
71
  if kind == "persona":
69
- return (REPO_ROOT / ".agent-src.uncompressed" / "personas" / f"{ref}.md").is_file()
72
+ return resolve_logical(f"personas/{ref}.md") is not None
70
73
  if kind == "subagent":
71
74
  return ref in SUBAGENT_MODES
72
75
  return False
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env python3
2
+ """Enforce cross-pack reference boundaries.
3
+
4
+ Phase 4.4 of the monorepo migration (ADR-017). Walks every markdown
5
+ link in every artefact under ``packages/*/.agent-src.uncompressed/``
6
+ and verifies the link target's pack is either the same pack, ``core``
7
+ (always allowed), or listed in the source pack's ``requires``.
8
+
9
+ Reports every violation with ``source -> target`` plus the offending
10
+ pack edge. Exits non-zero if any are found.
11
+
12
+ CLI:
13
+ --format text|json default text
14
+ --quiet suppress per-file noise; only print violations
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import re
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import yaml
26
+
27
+ ROOT = Path(__file__).resolve().parents[1]
28
+ PACKAGES = ROOT / "packages"
29
+
30
+ LINK_RE = re.compile(r"\[[^\]]*\]\(([^)#?]+)(?:[#?][^)]*)?\)")
31
+
32
+
33
+ def _load_pack_meta(pkg_dir: Path) -> dict[str, Any]:
34
+ pack_yaml = pkg_dir / "pack.yaml"
35
+ if not pack_yaml.exists():
36
+ return {}
37
+ data = yaml.safe_load(pack_yaml.read_text(encoding="utf-8"))
38
+ return data if isinstance(data, dict) else {}
39
+
40
+
41
+ def _build_artefact_index() -> dict[str, str]:
42
+ """Map repo-relative POSIX artefact path -> pack id."""
43
+ index: dict[str, str] = {}
44
+ if not PACKAGES.exists():
45
+ return index
46
+ for pkg in sorted(PACKAGES.iterdir()):
47
+ if not pkg.is_dir():
48
+ continue
49
+ src_root = pkg / ".agent-src.uncompressed"
50
+ if not src_root.is_dir():
51
+ continue
52
+ pid = _load_pack_meta(pkg).get("id") or pkg.name.removeprefix("pack-")
53
+ for p in src_root.rglob("*.md"):
54
+ if p.is_file():
55
+ index[p.relative_to(ROOT).as_posix()] = pid
56
+ return index
57
+
58
+
59
+ def _resolve_link(source_file: Path, raw: str) -> Path | None:
60
+ """Resolve a markdown link target to a repo-relative path, or None."""
61
+ target = raw.strip()
62
+ if not target or target.startswith(("http://", "https://", "mailto:", "ftp://")):
63
+ return None
64
+ if target.startswith("/"):
65
+ return None # absolute web paths, ignored
66
+ try:
67
+ resolved = (source_file.parent / target).resolve()
68
+ except OSError:
69
+ return None
70
+ try:
71
+ return resolved.relative_to(ROOT)
72
+ except ValueError:
73
+ return None
74
+
75
+
76
+ def _scan_file(path: Path) -> list[str]:
77
+ try:
78
+ text = path.read_text(encoding="utf-8")
79
+ except UnicodeDecodeError:
80
+ return []
81
+ return LINK_RE.findall(text)
82
+
83
+
84
+ def _is_allowed(source_pack: str, target_pack: str, requires: list[str]) -> bool:
85
+ if source_pack == target_pack:
86
+ return True
87
+ if target_pack == "core":
88
+ return True
89
+ return target_pack in (requires or [])
90
+
91
+
92
+ def main() -> int:
93
+ ap = argparse.ArgumentParser(description=__doc__)
94
+ ap.add_argument("--format", choices=["text", "json"], default="text")
95
+ ap.add_argument("--quiet", action="store_true")
96
+ args = ap.parse_args()
97
+
98
+ artefact_pack = _build_artefact_index()
99
+ if not artefact_pack:
100
+ print("no packages/ tree to lint — skipping", file=sys.stderr)
101
+ return 0
102
+
103
+ pack_requires: dict[str, list[str]] = {}
104
+ for pkg in sorted(PACKAGES.iterdir()):
105
+ if pkg.is_dir():
106
+ meta = _load_pack_meta(pkg)
107
+ pid = meta.get("id") or pkg.name.removeprefix("pack-")
108
+ pack_requires[pid] = list(meta.get("requires") or [])
109
+
110
+ violations: list[dict[str, str]] = []
111
+ for rel_path, src_pack in artefact_pack.items():
112
+ source_file = ROOT / rel_path
113
+ for raw in _scan_file(source_file):
114
+ target_rel = _resolve_link(source_file, raw)
115
+ if target_rel is None:
116
+ continue
117
+ target_key = target_rel.as_posix()
118
+ target_pack = artefact_pack.get(target_key)
119
+ if target_pack is None:
120
+ continue # link to docs/, scripts/, root files — not pack-scoped
121
+ if _is_allowed(src_pack, target_pack, pack_requires.get(src_pack, [])):
122
+ continue
123
+ violations.append({
124
+ "source_pack": src_pack,
125
+ "target_pack": target_pack,
126
+ "source": rel_path,
127
+ "target": target_key,
128
+ "link": raw,
129
+ })
130
+
131
+ if args.format == "json":
132
+ json.dump({"violations": violations, "count": len(violations)}, sys.stdout, indent=2)
133
+ sys.stdout.write("\n")
134
+ else:
135
+ if not args.quiet:
136
+ print(f"lint_pack_boundaries: scanned {len(artefact_pack)} artefacts across {len(pack_requires)} packs")
137
+ for v in violations:
138
+ print(f" ✗ {v['source_pack']} -> {v['target_pack']} : {v['source']} → {v['target']} (link: {v['link']})")
139
+ if violations:
140
+ print(f"\n{len(violations)} cross-pack violation(s) — declare 'requires' in pack.yaml or move the artefact")
141
+ elif not args.quiet:
142
+ print("OK — no cross-pack drift")
143
+ return 1 if violations else 0
144
+
145
+
146
+ if __name__ == "__main__":
147
+ sys.exit(main())
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env python3
2
+ """Lint: every featured pack ships FIRST_WIN.md + onboarding: block.
3
+
4
+ Phase 4 Step 4 of road-to-role-first-onboarding.md.
5
+
6
+ A pack is "featured" when its id appears in the
7
+ FEATURED_PACK_IDS set below — currently the five role-first packs
8
+ listed in docs/featured-skills.md.
9
+
10
+ Each featured pack MUST have:
11
+ - packages/pack-<id>/FIRST_WIN.md (file present, > 0 bytes)
12
+ - packages/pack-<id>/pack.yaml with an `onboarding:` block carrying
13
+ `first_win_doc`, `example_workflow`, `time_to_first_value_minutes`
14
+
15
+ Exits non-zero on any violation. Stdlib-only (no PyYAML — uses simple
16
+ YAML scan since pack.yaml is generator-controlled flat shape).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ REPO_ROOT = Path(__file__).resolve().parents[1]
24
+ PACKAGES = REPO_ROOT / "packages"
25
+
26
+ FEATURED_PACK_IDS = {
27
+ "founder-strategy",
28
+ "finance-basic",
29
+ "gtm-sales",
30
+ "ops-people",
31
+ "ai-video",
32
+ }
33
+
34
+ REQUIRED_ONBOARDING_KEYS = (
35
+ "first_win_doc",
36
+ "example_workflow",
37
+ "time_to_first_value_minutes",
38
+ )
39
+
40
+
41
+ def _has_onboarding_block(pack_yaml: Path) -> tuple[bool, list[str]]:
42
+ """Return (ok, missing_keys). Uses a tiny scanner — pack.yaml is
43
+ generator-controlled, so we only check for the literal `onboarding:`
44
+ parent key and the three required child keys nested under it."""
45
+ if not pack_yaml.exists():
46
+ return False, list(REQUIRED_ONBOARDING_KEYS)
47
+ lines = pack_yaml.read_text(encoding="utf-8").splitlines()
48
+ in_block = False
49
+ found: set[str] = set()
50
+ for raw in lines:
51
+ if raw.startswith("onboarding:"):
52
+ in_block = True
53
+ continue
54
+ if in_block:
55
+ if raw and not raw.startswith((" ", "\t")):
56
+ break
57
+ stripped = raw.strip()
58
+ for key in REQUIRED_ONBOARDING_KEYS:
59
+ if stripped.startswith(f"{key}:"):
60
+ found.add(key)
61
+ if not in_block:
62
+ return False, list(REQUIRED_ONBOARDING_KEYS)
63
+ missing = [k for k in REQUIRED_ONBOARDING_KEYS if k not in found]
64
+ return not missing, missing
65
+
66
+
67
+ def main() -> int:
68
+ errors: list[str] = []
69
+ for pid in sorted(FEATURED_PACK_IDS):
70
+ pack_dir = PACKAGES / f"pack-{pid}"
71
+ if not pack_dir.is_dir():
72
+ errors.append(f"missing pack dir: {pack_dir.relative_to(REPO_ROOT)}")
73
+ continue
74
+ first_win = pack_dir / "FIRST_WIN.md"
75
+ if not first_win.exists() or first_win.stat().st_size == 0:
76
+ errors.append(
77
+ f"missing or empty: {first_win.relative_to(REPO_ROOT)}"
78
+ )
79
+ ok, missing = _has_onboarding_block(pack_dir / "pack.yaml")
80
+ if not ok:
81
+ errors.append(
82
+ f"{pack_dir.name}/pack.yaml: onboarding block missing "
83
+ f"key(s) {missing!r}"
84
+ )
85
+ if errors:
86
+ print("❌ pack first-win lint failed:", file=sys.stderr)
87
+ for e in errors:
88
+ print(f" - {e}", file=sys.stderr)
89
+ print(
90
+ " fix: add FIRST_WIN.md to the pack root and the onboarding "
91
+ "block to config/discovery/packs.yml, then re-run "
92
+ "`task generate-pack-manifests`",
93
+ file=sys.stderr,
94
+ )
95
+ return 1
96
+ print(
97
+ f"✅ pack first-win lint OK — {len(FEATURED_PACK_IDS)} featured packs"
98
+ )
99
+ return 0
100
+
101
+
102
+ if __name__ == "__main__":
103
+ sys.exit(main())
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env python3
2
+ """CI guard for README.md above-fold jargon density.
3
+
4
+ The role-first-onboarding roadmap (Phase 2 Step 3) targets non-developer
5
+ readers above the fold. Lines 1..ABOVE_FOLD_LINES of README.md MUST
6
+ contain at most MAX_HITS occurrences of the watchlist terms below
7
+ (case-insensitive, whole-word matched).
8
+
9
+ Watchlist comes from feedback8 — words that read fine to a maintainer
10
+ but bounce a Founder or Creator off the page within five seconds:
11
+
12
+ kernel · contract · iron law · projection · manifest · lint ·
13
+ ADR · soak · drift · gate · harness
14
+
15
+ Counting rules:
16
+ - Case-insensitive.
17
+ - Whole-word match (no partial hits inside other words).
18
+ - Skip fenced code blocks (```...```), HTML comments, and link URLs.
19
+ - Each match counts once at its location; multi-line lints stay
20
+ deterministic.
21
+
22
+ Exit codes:
23
+ 0 — above-fold jargon hits <= MAX_HITS.
24
+ 1 — above-fold jargon hits > MAX_HITS (print line + match summary).
25
+
26
+ Invocation:
27
+ python3 scripts/lint_readme_jargon.py
28
+ python3 scripts/lint_readme_jargon.py --quiet
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import re
34
+ import sys
35
+ from pathlib import Path
36
+
37
+ README = Path("README.md")
38
+ ABOVE_FOLD_LINES = 120
39
+ MAX_HITS = 3
40
+
41
+ WATCHLIST = (
42
+ "kernel",
43
+ "contract",
44
+ "iron law",
45
+ "projection",
46
+ "manifest",
47
+ "lint",
48
+ "ADR",
49
+ "soak",
50
+ "drift",
51
+ "gate",
52
+ "harness",
53
+ )
54
+
55
+
56
+ def _strip_noise(lines: list[str]) -> list[str]:
57
+ """Return per-line content with fences / HTML comments / URLs removed.
58
+
59
+ Order matters: drop URLs first (they may sit inside fences), then
60
+ blank out fenced code regions so word-boundary matches don't trip
61
+ on stack-trace or shell tokens.
62
+ """
63
+ url_re = re.compile(r"https?://\S+|\(\.[\w./-]+\)")
64
+ cleaned: list[str] = []
65
+ in_fence = False
66
+ in_html = False
67
+ for raw in lines:
68
+ line = raw
69
+ if "<!--" in line and "-->" not in line:
70
+ in_html = True
71
+ if in_html:
72
+ cleaned.append("")
73
+ if "-->" in line:
74
+ in_html = False
75
+ continue
76
+ stripped = line.strip()
77
+ if stripped.startswith("```"):
78
+ in_fence = not in_fence
79
+ cleaned.append("")
80
+ continue
81
+ if in_fence:
82
+ cleaned.append("")
83
+ continue
84
+ cleaned.append(url_re.sub(" ", line))
85
+ return cleaned
86
+
87
+
88
+ def main() -> int:
89
+ quiet = "--quiet" in sys.argv
90
+ if not README.exists():
91
+ print(f"error: {README} not found", file=sys.stderr)
92
+ return 1
93
+
94
+ all_lines = README.read_text(encoding="utf-8").splitlines()
95
+ head = _strip_noise(all_lines[:ABOVE_FOLD_LINES])
96
+
97
+ patterns = [
98
+ (term, re.compile(r"(?<![A-Za-z0-9])" + re.escape(term) + r"(?![A-Za-z0-9])", re.IGNORECASE))
99
+ for term in WATCHLIST
100
+ ]
101
+
102
+ hits: list[tuple[int, str, str]] = []
103
+ for idx, content in enumerate(head, start=1):
104
+ for term, pat in patterns:
105
+ for m in pat.finditer(content):
106
+ hits.append((idx, term, m.group(0)))
107
+
108
+ if len(hits) > MAX_HITS:
109
+ print(
110
+ f"FAIL {README}: {len(hits)} jargon hits above the fold "
111
+ f"(lines 1..{ABOVE_FOLD_LINES}, limit {MAX_HITS})."
112
+ )
113
+ for line_no, term, match in hits:
114
+ print(f" L{line_no:>3} {term:<10} -> {match!r}")
115
+ print(
116
+ "\nFix: rewrite the line in role-first language. Move the "
117
+ "term below line "
118
+ f"{ABOVE_FOLD_LINES + 1} (architecture / contracts section)."
119
+ )
120
+ return 1
121
+
122
+ if not quiet:
123
+ print(
124
+ f"OK {README}: {len(hits)} jargon hits above the fold "
125
+ f"(limit {MAX_HITS})."
126
+ )
127
+ return 0
128
+
129
+
130
+ if __name__ == "__main__":
131
+ sys.exit(main())
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env python3
2
+ """CI guard for README.md line budget.
3
+
4
+ The role-first-onboarding roadmap (Phase 2 Step 6) freezes README at
5
+ its current length: replace, do not grow. Every line added above the
6
+ fold must displace an existing line. Budget: 750 lines max.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ README = Path("README.md")
15
+ LIMIT = 750
16
+
17
+
18
+ def main() -> int:
19
+ quiet = "--quiet" in sys.argv
20
+ if not README.exists():
21
+ print(f"error: {README} not found", file=sys.stderr)
22
+ return 1
23
+ n = sum(1 for _ in README.read_text(encoding="utf-8").splitlines())
24
+ if n > LIMIT:
25
+ print(f"FAIL {README}: {n} lines (limit {LIMIT}). Trim before merge.")
26
+ return 1
27
+ if not quiet:
28
+ print(f"OK {README}: {n} lines (limit {LIMIT}).")
29
+ return 0
30
+
31
+
32
+ if __name__ == "__main__":
33
+ sys.exit(main())
@@ -25,8 +25,27 @@ import yaml
25
25
  QUIET = "--quiet" in sys.argv
26
26
 
27
27
  ROOT = Path(__file__).resolve().parent.parent
28
+ sys.path.insert(0, str(ROOT / "scripts"))
29
+ from _lib.agent_src import resolve_logical, strip_source_prefix # noqa: E402
30
+
28
31
  MATRIX = ROOT / "docs" / "contracts" / "rule-interactions.yml"
29
- RULES_DIR = ROOT / ".agent-src.uncompressed" / "rules"
32
+
33
+
34
+ def _rule_exists(slug: str) -> bool:
35
+ return resolve_logical(f"rules/{slug}.md") is not None
36
+
37
+
38
+ def _evidence_exists(file_part: str) -> bool:
39
+ """Return True if the evidence path resolves under any source root.
40
+
41
+ Accepts legacy ``.agent-src.uncompressed/...`` citations and resolves
42
+ them through the multi-root layout; falls back to a literal repo
43
+ path check for non-source citations (docs/, agents/, ...).
44
+ """
45
+ logical = strip_source_prefix(file_part)
46
+ if logical is not None:
47
+ return resolve_logical(logical) is not None
48
+ return (ROOT / file_part).exists()
30
49
 
31
50
  ALLOWED_RELATIONS = {
32
51
  "overrides",
@@ -77,9 +96,8 @@ def main() -> int:
77
96
  if not isinstance(slug, str):
78
97
  errors.append(f"rule slug not a string: {slug!r}")
79
98
  continue
80
- rule_path = RULES_DIR / f"{slug}.md"
81
- if not rule_path.exists():
82
- errors.append(f"rule slug `{slug}` has no file at {rule_path.relative_to(ROOT)}")
99
+ if not _rule_exists(slug):
100
+ errors.append(f"rule slug `{slug}` has no file under any source root (rules/{slug}.md)")
83
101
 
84
102
  pairs = data.get("pairs") or []
85
103
  if not isinstance(pairs, list) or not pairs:
@@ -124,7 +142,7 @@ def main() -> int:
124
142
  errors.append(f"pair `{pid}` evidence item not a string: {citation!r}")
125
143
  continue
126
144
  file_part = citation.split("#", 1)[0]
127
- if not (ROOT / file_part).exists():
145
+ if not _evidence_exists(file_part):
128
146
  errors.append(f"pair `{pid}` evidence path does not exist: {file_part}")
129
147
 
130
148
  # Anchor coverage check
@@ -20,7 +20,12 @@ from pathlib import Path
20
20
  QUIET = "--quiet" in sys.argv
21
21
 
22
22
  REPO = Path(__file__).resolve().parents[1]
23
- RULES_DIR = REPO / ".agent-src.uncompressed" / "rules"
23
+
24
+ # Rules live under every artefact root post-monorepo Phase 4.
25
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
26
+ from _lib.agent_src import artefact_roots # noqa: E402
27
+
28
+ RULES_DIRS = [root / "rules" for root in artefact_roots() if (root / "rules").is_dir()]
24
29
 
25
30
  VALID_TIERS = frozenset({"1", "2a", "2b", "3", "safety-floor", "mechanical-already"})
26
31
 
@@ -41,9 +46,13 @@ def parse_tier(text: str) -> str | None:
41
46
 
42
47
 
43
48
  def main() -> int:
44
- rules = sorted(RULES_DIR.glob("*.md"))
49
+ rules: list[Path] = []
50
+ for rules_dir in RULES_DIRS:
51
+ rules.extend(rules_dir.glob("*.md"))
52
+ rules.sort()
45
53
  if not rules:
46
- print(f"lint_rule_tiers: no rules found under {RULES_DIR}", file=sys.stderr)
54
+ roots_label = ", ".join(str(d) for d in RULES_DIRS) or "<no rules root>"
55
+ print(f"lint_rule_tiers: no rules found under {roots_label}", file=sys.stderr)
47
56
  return 1
48
57
 
49
58
  missing: list[str] = []