@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.
- package/.agent-src/commands/install-via-agent.md +129 -0
- package/.agent-src/commands/video/from-script.md +1 -1
- package/.agent-src/commands/video.md +1 -1
- package/.agent-src/contexts/execution/cheap-question-mechanics.md +81 -0
- package/.agent-src/rules/caveman-speak.md +2 -2
- package/.agent-src/rules/context-hygiene.md +36 -0
- package/.agent-src/rules/engineering-safety-floor.md +102 -0
- package/.agent-src/rules/finance-safety-floor.md +114 -0
- package/.agent-src/rules/git-history-discipline.md +1 -1
- package/.agent-src/rules/no-cheap-questions.md +34 -32
- package/.agent-src/rules/provider-lifecycle-discipline.md +4 -4
- package/.agent-src/rules/strategy-safety-floor.md +114 -0
- package/.agent-src/skills/agents-md-thin-root/SKILL.md +15 -9
- package/.agent-src/skills/async-python-patterns/SKILL.md +1 -1
- package/.agent-src/skills/project-analysis-node-express/SKILL.md +1 -1
- package/.agent-src/skills/readme-reviewer/SKILL.md +52 -3
- package/.agent-src/skills/readme-writing/SKILL.md +52 -4
- package/.agent-src/skills/readme-writing-package/SKILL.md +48 -5
- package/.agent-src/skills/systematic-debugging/SKILL.md +41 -0
- package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/.agent-src/templates/hooks/pre-commit-frontmatter +66 -0
- package/.agent-src/templates/hooks/pre-commit-roadmap-progress +78 -39
- package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +4 -1
- package/.agent-src/templates/scripts/work_engine/orchestration.py +25 -11
- package/.claude-plugin/marketplace.json +2 -1
- package/AGENTS.md +10 -8
- package/CHANGELOG.md +223 -125
- package/README.md +165 -553
- package/config/agent-settings.template.yml +0 -7
- package/config/discovery/packs.yml +20 -0
- package/config/discovery/unassigned-artefacts.yml +2 -0
- package/config/gitignore-block.txt +19 -3
- package/dist/cli/commands/uiServe.js +13 -4
- package/dist/cli/commands/uiServe.js.map +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/discovery/deprecation-report.md +7 -0
- package/dist/discovery/discovery-manifest.json +2107 -1409
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +9 -9
- package/dist/discovery/orphan-report.md +10 -0
- package/dist/discovery/packs.json +1002 -0
- package/dist/discovery/trust-report.md +26 -0
- package/dist/discovery/workspaces.json +705 -0
- package/dist/mcp/registry-manifest.json +4 -4
- package/dist/router.json +1623 -0
- package/dist/server/app.js +11 -3
- package/dist/server/app.js.map +1 -1
- package/dist/server/io/atomicMultiWrite.js +3 -1
- package/dist/server/io/atomicMultiWrite.js.map +1 -1
- package/dist/server/io/yamlIO.js +22 -0
- package/dist/server/io/yamlIO.js.map +1 -1
- package/dist/server/routes/ping.js +8 -0
- package/dist/server/routes/ping.js.map +1 -1
- package/dist/server/routes/schema.js +2 -2
- package/dist/server/routes/schema.js.map +1 -1
- package/dist/server/routes/settings.js +104 -23
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/userMd.js +37 -27
- package/dist/server/routes/userMd.js.map +1 -1
- package/dist/server/routes/wizard.js +256 -20
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/server/schemas/settings.js +0 -1
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/server/token.js +10 -3
- package/dist/server/token.js.map +1 -1
- package/dist/server/writeRoot.js +28 -11
- package/dist/server/writeRoot.js.map +1 -1
- package/dist/server/writeRoot.test.js +22 -4
- package/dist/server/writeRoot.test.js.map +1 -1
- package/dist/shared/userMd/formAdapter.js +29 -51
- package/dist/shared/userMd/formAdapter.js.map +1 -1
- package/dist/shared/userMd/schema.js +32 -104
- package/dist/shared/userMd/schema.js.map +1 -1
- package/dist/shared/userMd/utils.js +64 -50
- package/dist/shared/userMd/utils.js.map +1 -1
- package/dist/ui/assets/index-D-DY1ywI.js +35 -0
- package/dist/ui/assets/index-D-DY1ywI.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/adrs/router/0001-three-tier-routing.md +5 -5
- package/docs/adrs/smoke/0001-per-tier-smoke-scripts.md +1 -1
- package/docs/architecture.md +3 -3
- package/docs/archive/CHANGELOG-pre-3.1.0.md +167 -0
- package/docs/catalog.md +30 -26
- package/docs/contracts/CHANGELOG-conventions.md +1 -1
- package/docs/contracts/agent-user-schema.md +6 -9
- package/docs/contracts/consumer-bridge.md +79 -0
- package/docs/contracts/discovery-manifest.md +209 -0
- package/docs/contracts/discovery-manifest.schema.json +77 -4
- package/docs/contracts/explain-trace.schema.json +1 -1
- package/docs/contracts/file-ownership-matrix.json +197 -13
- package/docs/contracts/frontmatter-contract.md +140 -0
- package/docs/contracts/gui-wizard.md +223 -0
- package/docs/contracts/installer-agent-mode.md +137 -0
- package/docs/contracts/kernel-membership.md +1 -1
- package/docs/contracts/mcp-tool-inventory.md +9 -9
- package/docs/contracts/namespace.md +6 -6
- package/docs/contracts/provider-lifecycle.md +5 -5
- package/docs/contracts/rule-router.md +4 -4
- package/docs/contracts/settings-api.md +53 -6
- package/docs/contracts/smoke-contracts.md +3 -3
- package/docs/contracts/trust-and-safety.md +144 -0
- package/docs/customization.md +2 -2
- package/docs/decisions/ADR-007-agent-discovery-scopes.md +12 -0
- package/docs/decisions/ADR-013-discovery-frontmatter-contract.md +24 -0
- package/docs/decisions/ADR-015-discovery-manifest-contract.md +146 -0
- package/docs/decisions/ADR-016-installer-architecture.md +189 -0
- package/docs/decisions/ADR-017-monorepo-physical-layout.md +261 -0
- package/docs/decisions/ADR-018-trust-and-safety-layer.md +159 -0
- package/docs/decisions/ADR-019-router-json-dist-location.md +124 -0
- package/docs/decisions/ADR-020-global-only-consumer-scope.md +123 -0
- package/docs/decisions/ADR-021-deployment-shape.md +153 -0
- package/docs/decisions/INDEX.md +7 -0
- package/docs/deploy/connector-setup.md +129 -0
- package/docs/deploy/env-vars.md +70 -0
- package/docs/deploy/policy-cookbook.md +130 -0
- package/docs/deploy/quickstart.md +112 -0
- package/docs/distribution/public-install-smoke.md +68 -0
- package/docs/distribution/registries.md +55 -0
- package/docs/distribution/telemetry-privacy.md +128 -0
- package/docs/distribution/telemetry-schema.md +174 -0
- package/docs/featured-skills.md +95 -0
- package/docs/getting-started-by-role.md +19 -1
- package/docs/getting-started.md +2 -2
- package/docs/guidelines/agent-infra/installed-tools-manifest.md +11 -8
- package/docs/guidelines/docs/readme-size-and-splitting.md +53 -1
- package/docs/installation.md +27 -14
- package/docs/maintainers/dev-mode.md +105 -0
- package/docs/setup/per-ide/claude-desktop.md +3 -2
- package/docs/wizard.md +39 -4
- package/package.json +18 -1
- package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
- package/scripts/_cli/cmd_doctor.py +150 -2
- package/scripts/_cli/cmd_explain.py +2 -1
- package/scripts/_cli/cmd_migrate_to_global.py +415 -0
- package/scripts/_cli/cmd_settings_migrate.py +146 -0
- package/scripts/_cli/explain_last/route.py +2 -1
- package/scripts/_dispatch.bash +36 -3
- package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
- package/scripts/_lib/agent_settings.py +4 -1
- package/scripts/_lib/agent_src.py +157 -0
- package/scripts/agent-config +17 -6
- package/scripts/audit_skill_descriptions.py +18 -6
- package/scripts/build_discovery_manifest.py +373 -17
- package/scripts/check_artefact_checksums.py +104 -0
- package/scripts/check_cluster_patterns.py +20 -4
- package/scripts/check_command_count_messaging.py +33 -14
- package/scripts/check_council_references.py +43 -4
- package/scripts/check_overlay_cascade_subdirs.py +7 -3
- package/scripts/check_references.py +5 -2
- package/scripts/check_reply_consistency.py +32 -9
- package/scripts/check_template_pin_drift.py +24 -7
- package/scripts/check_token_optimizer_freshness.py +18 -3
- package/scripts/compile_router.py +34 -2
- package/scripts/compress.py +162 -44
- package/scripts/config/presets.py +19 -1
- package/scripts/config/profiles.py +16 -1
- package/scripts/discovery_stats.py +70 -0
- package/scripts/expected_perms.json +47 -0
- package/scripts/generate_index.py +78 -46
- package/scripts/generate_ownership_matrix.py +98 -43
- package/scripts/generate_pack_manifests.py +183 -0
- package/scripts/install +18 -1
- package/scripts/install.py +934 -59
- package/scripts/install.sh +27 -9
- package/scripts/lint_agents_layout.py +93 -13
- package/scripts/lint_agents_md.py +1 -1
- package/scripts/lint_archived_skills.py +32 -16
- package/scripts/lint_bench_corpus.py +14 -2
- package/scripts/lint_command_tiers.py +15 -2
- package/scripts/lint_featured_skills.py +139 -0
- package/scripts/lint_framework_leakage.py +33 -6
- package/scripts/lint_global_paths.py +147 -0
- package/scripts/lint_orchestration_dsl.py +6 -3
- package/scripts/lint_pack_boundaries.py +147 -0
- package/scripts/lint_pack_first_win.py +103 -0
- package/scripts/lint_readme_jargon.py +131 -0
- package/scripts/lint_readme_size.py +33 -0
- package/scripts/lint_rule_interactions.py +23 -5
- package/scripts/lint_rule_tiers.py +12 -3
- package/scripts/lint_trust_coherence.py +212 -0
- package/scripts/measure_rule_budget.py +22 -4
- package/scripts/move_artefact.py +143 -0
- package/scripts/new_skill.py +148 -0
- package/scripts/plan_physical_move.py +353 -0
- package/scripts/refine_ticket_detect.py +30 -7
- package/scripts/schemas/command.schema.json +4 -0
- package/scripts/skill_linter.py +248 -118
- package/scripts/skill_trigger_eval.py +28 -8
- package/scripts/smoke/kernel.sh +1 -1
- package/scripts/smoke/router.sh +24 -5
- package/scripts/smoke/skills.sh +15 -7
- package/scripts/smoke_quickstart.py +11 -2
- package/scripts/snapshot_agent_outputs.py +144 -0
- package/scripts/update_counts.py +45 -17
- package/scripts/validate_decision_engine.py +9 -1
- package/scripts/validate_discovery_manifest.py +94 -0
- package/scripts/validate_frontmatter.py +39 -20
- package/scripts/verify_physical_move.py +185 -0
- package/templates/agent-user.md +0 -1
- package/templates/agent-user.yml +21 -0
- package/templates/minimal/agents-overrides-readme.md +46 -0
- package/templates/minimal/overrides-gitkeep +2 -0
- package/dist/ui/assets/index-BTRcKDlB.js +0 -39
- package/dist/ui/assets/index-BTRcKDlB.js.map +0 -1
- 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 (
|
|
68
|
+
return resolve_logical(f"skills/{ref}/SKILL.md") is not None
|
|
66
69
|
if kind == "command":
|
|
67
|
-
return (
|
|
70
|
+
return resolve_logical(f"commands/{ref}.md") is not None
|
|
68
71
|
if kind == "persona":
|
|
69
|
-
return (
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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] = []
|