@event4u/agent-config 1.14.0 → 1.15.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 (106) hide show
  1. package/.agent-src/commands/agent-handoff.md +1 -1
  2. package/.agent-src/commands/bug-fix.md +2 -2
  3. package/.agent-src/commands/chat-history-checkpoint.md +2 -2
  4. package/.agent-src/commands/chat-history-clear.md +1 -1
  5. package/.agent-src/commands/chat-history-resume.md +2 -2
  6. package/.agent-src/commands/chat-history.md +2 -2
  7. package/.agent-src/commands/check-current-md.md +43 -32
  8. package/.agent-src/commands/commit-in-chunks.md +43 -23
  9. package/.agent-src/commands/compress.md +34 -2
  10. package/.agent-src/commands/feature-roadmap.md +2 -2
  11. package/.agent-src/commands/fix-portability.md +2 -2
  12. package/.agent-src/commands/onboard.md +14 -5
  13. package/.agent-src/commands/optimize-augmentignore.md +9 -0
  14. package/.agent-src/commands/refine-ticket.md +9 -7
  15. package/.agent-src/commands/review-changes.md +35 -8
  16. package/.agent-src/commands/roadmap-create.md +13 -2
  17. package/.agent-src/commands/roadmap-execute.md +9 -7
  18. package/.agent-src/commands/set-cost-profile.md +8 -0
  19. package/.agent-src/commands/sync-agent-settings.md +9 -0
  20. package/.agent-src/commands/tests-execute.md +2 -3
  21. package/.agent-src/rules/artifact-engagement-recording.md +1 -1
  22. package/.agent-src/rules/augment-portability.md +56 -37
  23. package/.agent-src/rules/chat-history-cadence.md +109 -0
  24. package/.agent-src/rules/chat-history-ownership.md +123 -0
  25. package/.agent-src/rules/chat-history-visibility.md +96 -0
  26. package/.agent-src/rules/cli-output-handling.md +1 -1
  27. package/.agent-src/rules/command-suggestion.md +3 -2
  28. package/.agent-src/rules/commit-policy.md +44 -34
  29. package/.agent-src/rules/direct-answers.md +1 -1
  30. package/.agent-src/rules/language-and-tone.md +19 -15
  31. package/.agent-src/rules/non-destructive-by-default.md +18 -18
  32. package/.agent-src/rules/roadmap-progress-sync.md +133 -74
  33. package/.agent-src/rules/role-mode-adherence.md +1 -1
  34. package/.agent-src/rules/size-enforcement.md +2 -1
  35. package/.agent-src/rules/user-interaction.md +28 -4
  36. package/.agent-src/scripts/update_roadmap_progress.py +56 -4
  37. package/.agent-src/skills/blade-ui/SKILL.md +29 -10
  38. package/.agent-src/skills/command-writing/SKILL.md +15 -4
  39. package/.agent-src/skills/existing-ui-audit/SKILL.md +24 -9
  40. package/.agent-src/skills/fe-design/SKILL.md +20 -15
  41. package/.agent-src/skills/file-editor/SKILL.md +9 -0
  42. package/.agent-src/skills/livewire/SKILL.md +26 -7
  43. package/.agent-src/skills/refine-ticket/SKILL.md +30 -24
  44. package/.agent-src/skills/roadmap-management/SKILL.md +22 -16
  45. package/.agent-src/skills/skill-writing/SKILL.md +3 -3
  46. package/.agent-src/skills/upstream-contribute/SKILL.md +2 -2
  47. package/.agent-src/templates/agent-settings.md +1 -1
  48. package/.agent-src/templates/roadmaps.md +9 -8
  49. package/.agent-src/templates/scripts/memory_lookup.py +1 -1
  50. package/.agent-src/templates/scripts/work_engine/__init__.py +2 -2
  51. package/.agent-src/templates/scripts/work_engine/cli.py +64 -461
  52. package/.agent-src/templates/scripts/work_engine/cli_args.py +116 -0
  53. package/.agent-src/templates/scripts/work_engine/delivery_state.py +3 -3
  54. package/.agent-src/templates/scripts/work_engine/directives/backend/__init__.py +1 -1
  55. package/.agent-src/templates/scripts/work_engine/directives/backend/implement.py +1 -1
  56. package/.agent-src/templates/scripts/work_engine/directives/backend/memory.py +1 -1
  57. package/.agent-src/templates/scripts/work_engine/directives/backend/plan.py +1 -1
  58. package/.agent-src/templates/scripts/work_engine/directives/backend/report.py +1 -1
  59. package/.agent-src/templates/scripts/work_engine/dispatcher.py +1 -1
  60. package/.agent-src/templates/scripts/work_engine/emitters.py +43 -0
  61. package/.agent-src/templates/scripts/work_engine/errors.py +19 -0
  62. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +76 -0
  63. package/.agent-src/templates/scripts/work_engine/input_builders.py +163 -0
  64. package/.agent-src/templates/scripts/work_engine/migration/v0_to_v1.py +34 -2
  65. package/.agent-src/templates/scripts/work_engine/persona_policy.py +1 -1
  66. package/.agent-src/templates/scripts/work_engine/resolvers/prompt.py +1 -1
  67. package/.agent-src/templates/scripts/work_engine/state_io.py +202 -0
  68. package/.claude-plugin/marketplace.json +1 -1
  69. package/AGENTS.md +6 -4
  70. package/CHANGELOG.md +83 -8
  71. package/README.md +24 -23
  72. package/docs/MIGRATION.md +122 -0
  73. package/docs/architecture.md +83 -34
  74. package/docs/contracts/STABILITY.md +95 -0
  75. package/docs/contracts/adr-chat-history-split.md +132 -0
  76. package/docs/contracts/adr-command-suggestion.md +146 -0
  77. package/docs/contracts/adr-implement-ticket-runtime.md +122 -0
  78. package/docs/contracts/adr-product-ui-track.md +384 -0
  79. package/docs/contracts/adr-prompt-driven-execution.md +187 -0
  80. package/docs/contracts/agent-memory-contract.md +149 -0
  81. package/docs/contracts/artifact-engagement-flow.md +262 -0
  82. package/docs/contracts/command-clusters.md +126 -0
  83. package/docs/contracts/command-suggestion-flow.md +148 -0
  84. package/docs/contracts/implement-ticket-flow.md +628 -0
  85. package/docs/contracts/linear-ai-rules-inclusion.md +143 -0
  86. package/docs/contracts/linear-ai-three-layers.md +131 -0
  87. package/docs/contracts/rule-interactions.md +107 -0
  88. package/docs/contracts/rule-interactions.yml +142 -0
  89. package/docs/contracts/ui-stack-extension.md +236 -0
  90. package/docs/contracts/ui-track-flow.md +338 -0
  91. package/docs/getting-started.md +2 -2
  92. package/docs/installation.md +42 -6
  93. package/docs/migrations/commands-1.15.0.md +112 -0
  94. package/docs/ui-track-mental-model.md +121 -0
  95. package/package.json +1 -1
  96. package/scripts/build_linear_digest.py +4 -4
  97. package/scripts/check_portability.py +2 -0
  98. package/scripts/check_public_links.py +185 -0
  99. package/scripts/check_references.py +1 -0
  100. package/scripts/lint_no_new_atomic_commands.py +179 -0
  101. package/scripts/lint_rule_interactions.py +149 -0
  102. package/scripts/memory_lookup.py +1 -1
  103. package/scripts/release.py +297 -64
  104. package/scripts/skill_linter.py +14 -0
  105. package/scripts/update_counts.py +10 -0
  106. package/.agent-src/rules/chat-history.md +0 -200
@@ -0,0 +1,112 @@
1
+ # Command migration — 1.15.0
2
+
3
+ > **Audience:** consumers of `event4u/agent-config` upgrading from
4
+ > `1.14.x` to `1.15.0`.
5
+ > **Authoritative spec:** [`docs/contracts/command-clusters.md`](../contracts/command-clusters.md).
6
+
7
+ `1.15.0` collapses 15 atomic commands into 3 verb clusters
8
+ (`fix`, `optimize`, `feature`) to reduce command-palette
9
+ fragmentation. **Old commands keep working through the entire
10
+ 1.15.x cycle**; they emit a one-line deprecation warning on
11
+ invocation and are removed in `1.16.0`.
12
+
13
+ ## Summary table
14
+
15
+ | Old command | New invocation | Removed in |
16
+ |---|---|---|
17
+ | `/fix-ci` | `/fix ci` | 1.16.0 |
18
+ | `/fix-pr-comments` | `/fix pr` | 1.16.0 |
19
+ | `/fix-pr-bot-comments` | `/fix pr-bots` | 1.16.0 |
20
+ | `/fix-pr-developer-comments` | `/fix pr-developers` | 1.16.0 |
21
+ | `/fix-portability` | `/fix portability` | 1.16.0 |
22
+ | `/fix-references` | `/fix refs` | 1.16.0 |
23
+ | `/fix-seeder` | `/fix seeder` | 1.16.0 |
24
+ | `/optimize-agents` | `/optimize agents` | 1.16.0 |
25
+ | `/optimize-augmentignore` | `/optimize augmentignore` | 1.16.0 |
26
+ | `/optimize-rtk-filters` | `/optimize rtk` | 1.16.0 |
27
+ | `/optimize-skills` | `/optimize skills` | 1.16.0 |
28
+ | `/feature-explore` | `/feature explore` | 1.16.0 |
29
+ | `/feature-plan` | `/feature plan` | 1.16.0 |
30
+ | `/feature-refactor` | `/feature refactor` | 1.16.0 |
31
+ | `/feature-roadmap` | `/feature roadmap` | 1.16.0 |
32
+
33
+ ## What changes for you
34
+
35
+ **Nothing breaks in 1.15.x.** Old slugs continue to work — the agent
36
+ recognises them, dispatches to the new cluster command, and prints
37
+ one warning line at the top of the reply:
38
+
39
+ ```
40
+ ⚠️ /fix-ci is deprecated; use /fix ci instead.
41
+ ```
42
+
43
+ **Your existing custom skills, rules, or agents/roadmaps that
44
+ reference old slugs do not need to change in 1.15.x.** Update at
45
+ your own pace.
46
+
47
+ ## What to update before 1.16.0
48
+
49
+ Search your project for any direct references to the old slugs and
50
+ swap them for the new invocation:
51
+
52
+ ```bash
53
+ # Find references in agents/, docs/, README, custom rules
54
+ rg -n '/(fix|optimize|feature)-[a-z-]+' \
55
+ agents/ docs/ README.md AGENTS.md .github/ 2>/dev/null
56
+ ```
57
+
58
+ Common spots:
59
+
60
+ - `agents/roadmaps/*.md` — roadmap steps that name commands
61
+ - `agents/contexts/*.md` — context docs cross-linking commands
62
+ - Custom skills/rules under `.augment/` overrides
63
+ - Internal team docs / runbooks / onboarding pages
64
+
65
+ ## Why this changed
66
+
67
+ The atomic-command surface had grown to 77 commands by 1.14.0. Three
68
+ prefix families (`fix-*`, `optimize-*`, `feature-*`) accounted for 15
69
+ of those — same verb, same invocation pattern, only the noun
70
+ differed. Collapsing the families into clusters:
71
+
72
+ - Reduces palette noise (15 → 3 top-level entries).
73
+ - Makes new sub-commands additive (`/fix tests` ships without a new
74
+ top-level slug).
75
+ - Holds the line: `scripts/lint_no_new_atomic_commands.py` fails CI
76
+ if a new atomic command lands without a `cluster:` field declared
77
+ in [`docs/contracts/command-clusters.md`](../contracts/command-clusters.md).
78
+
79
+ ## Phase 2 (deferred)
80
+
81
+ A second wave of collapses (`chat-history`, `agents`, `memory`,
82
+ `roadmap`, `module`, `tests`, `context`, `override`, `copilot-agents`,
83
+ `commit`, `judge`, `create-pr`) is scheduled after 1.15.0 ships and
84
+ the deprecation cycle for Phase 1 closes. Tracked in
85
+ [`agents/roadmaps/road-to-governance-cleanup.md`](../../agents/roadmaps/road-to-governance-cleanup.md)
86
+ § F2.
87
+
88
+ ## Related rule split — `chat-history` (post-1.15.0)
89
+
90
+ The monolithic `rules/chat-history.md` was split into three sibling
91
+ `always` rules in the post-1.15.0 optimization phase:
92
+
93
+ - [`chat-history-ownership`](../../.agent-src/rules/chat-history-ownership.md) — sole owner of file I/O + first-turn handshake.
94
+ - [`chat-history-cadence`](../../.agent-src/rules/chat-history-cadence.md) — when to persist (SessionStart / StepEnd / append boundaries).
95
+ - [`chat-history-visibility`](../../.agent-src/rules/chat-history-visibility.md) — heartbeat marker contract for user-facing reporting.
96
+
97
+ Decision record: [`docs/contracts/adr-chat-history-split.md`](../contracts/adr-chat-history-split.md).
98
+ Cross-references in commands and contexts now point to
99
+ `chat-history-ownership` as the entry point.
100
+
101
+ ## Rollback
102
+
103
+ If a deprecation warning blocks tooling that screen-scrapes agent
104
+ output, suppress it for the 1.15.x cycle by setting
105
+ `commands.deprecation_warnings: false` in `.agent-settings.yml`.
106
+ The setting disappears in 1.16.0 along with the shims.
107
+
108
+ ## See also
109
+
110
+ - [`docs/contracts/command-clusters.md`](../contracts/command-clusters.md) — locked cluster spec.
111
+ - [`docs/contracts/STABILITY.md`](../contracts/STABILITY.md) — public-surface stability tiers.
112
+ - [`agents/roadmaps/archive/road-to-post-pr29-optimize.md`](../../agents/roadmaps/archive/road-to-post-pr29-optimize.md) — P0.8 anchor for this migration.
@@ -0,0 +1,121 @@
1
+ ---
2
+ stability: stable
3
+ ---
4
+
5
+ # UI Track — Mental Model (1 page)
6
+
7
+ > **Audience:** anyone driving `/work` or `/implement-ticket` on a UI-shaped
8
+ > prompt, or reading code that touches `state.directive_set`.
9
+ > **Not a contract.** For shapes, schemas, and slot wiring see
10
+ > [`ui-track-flow.md`](contracts/ui-track-flow.md) and
11
+ > [`adr-product-ui-track.md`](contracts/adr-product-ui-track.md).
12
+ > **Not a roadmap.** Phased delivery lives in
13
+ > [`road-to-product-ui-track.md`](../agents/roadmaps/archive/road-to-product-ui-track.md)
14
+ > and [`road-to-visual-review-loop.md`](../agents/roadmaps/archive/road-to-visual-review-loop.md).
15
+ > This page is the picture you keep in your head while the engine runs.
16
+
17
+ ## The three (+1) directive sets
18
+
19
+ | Set | Shape | Halt budget |
20
+ |---|---|---:|
21
+ | `backend` | `refine → memory → analyze → plan → implement → test → verify → report` | depends on confidence band |
22
+ | `ui` | `audit → design → apply → review → polish → report` | **2** (audit pick + design sign-off) |
23
+ | `ui-trivial` | `refine → apply → test → report` | **0** on the happy path |
24
+ | `mixed` | `refine → memory → analyze → contract → ui → stitch → verify → report` | inherits both |
25
+
26
+ The first three are sibling routes; `mixed` stitches `backend` and `ui`
27
+ in one envelope. The dispatcher picks one and refuses to switch
28
+ mid-flight.
29
+
30
+ ## When to pick which
31
+
32
+ | Signal | Set |
33
+ |---|---|
34
+ | No UI keywords, no UI envelope, prompt edits services / migrations / tests / config | `backend` |
35
+ | New component / screen / partial; "improve this dashboard"; "build a settings panel"; major edit to a screen | `ui` |
36
+ | Bounded edit, **provably** ≤ 1 file, ≤ 5 changed lines, no new component, no new state, no new dependency | `ui-trivial` |
37
+ | One prompt that adds a backend endpoint **and** the screen that consumes it | `mixed` |
38
+
39
+ The classifier picks; the agent does not override the pick silently.
40
+ A misclassified `ui-trivial` that grows during edit must reclassify
41
+ **loudly** (stop, restart at `audit`) — never quietly upgrade in place.
42
+
43
+ ## What the agent must never do
44
+
45
+ 1. **Skip the audit.** No new component, screen, or partial without
46
+ `state.ui_audit` populated (or `greenfield_decision` recorded).
47
+ Defense-in-depth: dispatcher refuses *and* `ui-audit-before-build`
48
+ refuses, even when the engine is not in the loop.
49
+ 2. **Render the UI.** The engine never opens a browser, never takes a
50
+ screenshot, never runs axe. Rendering and a11y scanning happen
51
+ out-of-process; the engine consumes the `preview_envelope` /
52
+ `a11y` envelope written by the skill.
53
+ 3. **Edit microcopy.** The design brief is **locked**. `apply` reads
54
+ strings verbatim. `<placeholder>`, `lorem`, `todo:`, `tbd`, `xxx`
55
+ are rejected at the producer (design) **and** the consumer (apply).
56
+ 4. **Loop polish indefinitely.** Hard ceiling is 2 rounds, +1 with
57
+ the explicit Extend pick (one-shot, never returns). Round 4 is
58
+ rejected on disk regardless of flags.
59
+ 5. **Confuse "ship as-is" with "review clean".** `review_clean=False`
60
+ plus `findings=[]` is a malformed envelope, not a green light.
61
+
62
+ ## Where each set stops
63
+
64
+ | Set | Stops cleanly when … | Stops with a halt when … |
65
+ |---|---|---|
66
+ | `ui` | `report` written; design brief + audit + apply + review + polish all `SUCCESS` | audit ambiguous · greenfield decision missing · design unconfirmed · review dirty at ceiling · a11y violation un-accepted · preview render failed |
67
+ | `ui-trivial` | `report` written; classifier preconditions held throughout | preconditions fail mid-flight → reclassify to `ui` (loud halt) |
68
+ | `mixed` | `stitch` joins both subtrees; `verify` + `report` clean | either subtree halts → mixed waits, never auto-completes the other |
69
+
70
+ ## What "audit" actually means
71
+
72
+ A non-empty `state.ui_audit` carrying **at least one of**:
73
+
74
+ - `components_found` — `[{path, name, kind, similarity?}]` from
75
+ [`existing-ui-audit`](../.agent-src/skills/existing-ui-audit/SKILL.md).
76
+ - `greenfield: true` plus `greenfield_decision ∈ {scaffold, bare, external_reference}`.
77
+ - Legacy `components` alias — same shape.
78
+
79
+ Empty dict, `null`, or a dict without those keys is **not** an audit.
80
+ The gate fires; the dispatcher emits `@agent-directive: existing-ui-audit`
81
+ instead of advancing.
82
+
83
+ ## What "design locked" actually means
84
+
85
+ The brief carries `layout`, `components`, `states`, `microcopy`,
86
+ `a11y`. State coverage requires `empty`, `loading`, `error`, `success`,
87
+ `disabled`. `apply` does not re-decide microcopy — it copies strings.
88
+ "The button label feels off" is a **new** design pass, not a polish
89
+ fix.
90
+
91
+ ## Polish termination — subjective vs objective
92
+
93
+ | Findings at ceiling | Halt branch | User options |
94
+ |---|---|---|
95
+ | Non-a11y only | `polish_ceiling_reached` | ship as-is · abort · hand off |
96
+ | Includes a11y violation | `polish_a11y_blocking` | extend (one-shot, sets `extension_used=True`) · accept (rule ids land in `accepted_violations`) · abort |
97
+
98
+ Pre-existing a11y violations recorded in `state.ui_audit.a11y_baseline`
99
+ stay informational and never block.
100
+
101
+ ## Stack dispatch (apply / review / polish)
102
+
103
+ `state.stack.frontend` decides which skill bundle runs:
104
+
105
+ | Stack | Skill bundle |
106
+ |---|---|
107
+ | `blade-livewire-flux` | `flux` + `livewire` + `blade-ui` |
108
+ | `react-shadcn` | `react-shadcn-ui` |
109
+ | `vue` | `ui-apply-vue` |
110
+ | `plain` (or unknown) | `blade-ui` + Tailwind base |
111
+
112
+ The directive set stays `ui`; only the skill changes. Adding a new
113
+ stack ships as a new skill bundle and a recipe — see
114
+ [`ui-stack-extension.md`](contracts/ui-stack-extension.md).
115
+
116
+ ## See also
117
+
118
+ - [`adr-product-ui-track.md`](contracts/adr-product-ui-track.md) — *why* this shape.
119
+ - [`ui-track-flow.md`](contracts/ui-track-flow.md) — slot-by-slot contract.
120
+ - [`ui-stack-extension.md`](contracts/ui-stack-extension.md) — adding a stack.
121
+ - [`ui-audit-before-build`](../.agent-src/rules/ui-audit-before-build.md) — the always-on rule that mirrors the dispatcher gate.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "Shared agent configuration \u2014 skills, rules, commands, guidelines, and templates for AI coding tools",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -10,7 +10,7 @@ Concatenates a curated set of cloud-safe rules from
10
10
  personal.md — empty stub for individual preferences
11
11
 
12
12
  Per-rule inclusion + mode is the source of truth in
13
- `agents/contexts/linear-ai-rules-inclusion.md`. This script encodes the
13
+ `docs/contracts/linear-ai-rules-inclusion.md`. This script encodes the
14
14
  same lists so a drift between the two surfaces is caught by the digest
15
15
  audit (Phase 3 Step 4) — the markdown doc is the human-readable spec,
16
16
  this script is the executable.
@@ -44,7 +44,7 @@ from pathlib import Path
44
44
  ROOT = Path(__file__).resolve().parent.parent
45
45
  # Compressed source is the shipped form — denser, sharper section
46
46
  # structure; better fit for a guidance field than the verbose authoring
47
- # layer. The inclusion list at agents/contexts/linear-ai-rules-inclusion.md
47
+ # layer. The inclusion list at docs/contracts/linear-ai-rules-inclusion.md
48
48
  # remains the human-readable spec.
49
49
  SOURCE = ROOT / ".agent-src" / "rules"
50
50
  OUT_DIR = ROOT / "dist" / "linear"
@@ -64,7 +64,7 @@ class RuleEntry:
64
64
 
65
65
 
66
66
  # Workspace digest — universal coding posture. Maps 1:1 to the
67
- # "Workspace digest" table in agents/contexts/linear-ai-rules-inclusion.md.
67
+ # "Workspace digest" table in docs/contracts/linear-ai-rules-inclusion.md.
68
68
  WORKSPACE: list[RuleEntry] = [
69
69
  RuleEntry("ask-when-uncertain"),
70
70
  RuleEntry("commit-conventions"),
@@ -169,7 +169,7 @@ def render_digest(layer: str, entries: list[RuleEntry]) -> tuple[str, dict]:
169
169
  parts.append(
170
170
  "> Auto-generated by `scripts/build_linear_digest.py` from "
171
171
  "`.agent-src/rules/` (compressed source) plus the inclusion list "
172
- "at `agents/contexts/linear-ai-rules-inclusion.md`. Do not edit "
172
+ "at `docs/contracts/linear-ai-rules-inclusion.md`. Do not edit "
173
173
  "this file by hand — re-run `task build-linear-digest` to "
174
174
  "regenerate.\n"
175
175
  )
@@ -157,6 +157,8 @@ ALLOWLIST = [
157
157
  r"agent-config", # refers to the package concept, not a specific project
158
158
  r"shared.*package", # "shared package" concept
159
159
  r"package repository", # "package repository" concept
160
+ r"scripts/mcp_server/", # MCP server module path (road-to-mcp-server.md Phase 1)
161
+ r"scripts\.mcp_server", # MCP server Python module entrypoint
160
162
  ]
161
163
 
162
164
  # Directories to scan (only package files, not project-specific agents/)
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Public-link checker for the agent-config public surface.
4
+
5
+ Scans the public-surface files (README.md, AGENTS.md, docs/architecture.md)
6
+ for markdown links into `docs/contracts/`, then validates each link against
7
+ the `stability:` frontmatter declared by the target file (per
8
+ `docs/contracts/STABILITY.md`).
9
+
10
+ Rules:
11
+ - target stability=stable → OK (no marker required).
12
+ - target stability=beta → OK; warns if surrounding text has no
13
+ visible "(beta)" marker.
14
+ - target stability=experimental → ERROR. Public surface MUST NOT link
15
+ to experimental contracts.
16
+ - target outside docs/contracts/ but referenced for contract-shaped
17
+ intent (links into agents/contexts/*.md from public files) → ERROR.
18
+ - target file missing → ERROR.
19
+ - target file under docs/contracts/ without `stability:` frontmatter
20
+ (except STABILITY.md itself) → ERROR.
21
+
22
+ Exit codes: 0 = clean, 1 = violations found, 3 = internal error.
23
+
24
+ Usage:
25
+ python3 scripts/check_public_links.py
26
+ python3 scripts/check_public_links.py --list # list contracts + levels
27
+ python3 scripts/check_public_links.py --json # machine-readable
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import json
34
+ import re
35
+ import sys
36
+ from dataclasses import dataclass, asdict
37
+ from pathlib import Path
38
+
39
+ ROOT = Path(__file__).resolve().parent.parent
40
+ PUBLIC_FILES = [Path("README.md"), Path("AGENTS.md"), Path("docs/architecture.md")]
41
+ CONTRACTS_DIR = Path("docs/contracts")
42
+ STABILITY_FILE = CONTRACTS_DIR / "STABILITY.md"
43
+
44
+ LINK_RE = re.compile(r"\[(?P<text>[^\]]+)\]\((?P<href>[^)\s]+)(?:\s+\"[^\"]*\")?\)")
45
+ FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
46
+ STABILITY_RE = re.compile(r"^stability:\s*(\w+)\s*$", re.MULTILINE)
47
+
48
+
49
+ @dataclass
50
+ class Violation:
51
+ file: str
52
+ line: int
53
+ href: str
54
+ reason: str
55
+ severity: str # "error" | "warning"
56
+
57
+
58
+ def read_stability(path: Path) -> str | None:
59
+ if not path.exists():
60
+ return None
61
+ txt = path.read_text(encoding="utf-8")
62
+ m = FRONTMATTER_RE.match(txt)
63
+ if not m:
64
+ return None
65
+ sm = STABILITY_RE.search(m.group(1))
66
+ return sm.group(1) if sm else None
67
+
68
+
69
+ def collect_contracts() -> dict[Path, str | None]:
70
+ out: dict[Path, str | None] = {}
71
+ for p in sorted((ROOT / CONTRACTS_DIR).glob("*.md")):
72
+ rel = p.relative_to(ROOT)
73
+ out[rel] = read_stability(p)
74
+ return out
75
+
76
+
77
+ def resolve(public_file: Path, href: str) -> Path | None:
78
+ href = href.split("#", 1)[0]
79
+ if not href or href.startswith(("http://", "https://", "mailto:", "tel:")):
80
+ return None
81
+ if href.startswith("/"):
82
+ return Path(href.lstrip("/"))
83
+ return (public_file.parent / href).resolve().relative_to(ROOT.resolve())
84
+
85
+
86
+ def scan_file(public_file: Path, contracts: dict[Path, str | None]) -> list[Violation]:
87
+ abs_path = ROOT / public_file
88
+ if not abs_path.exists():
89
+ return []
90
+ violations: list[Violation] = []
91
+ for lineno, line in enumerate(abs_path.read_text(encoding="utf-8").splitlines(), 1):
92
+ for m in LINK_RE.finditer(line):
93
+ href = m.group("href")
94
+ text = m.group("text")
95
+ try:
96
+ target = resolve(public_file, href)
97
+ except ValueError:
98
+ continue
99
+ if target is None:
100
+ continue
101
+ if target.parts[:2] == ("agents", "contexts") and target.suffix == ".md":
102
+ violations.append(Violation(str(public_file), lineno, href,
103
+ "public surface MUST NOT link into agents/contexts/ — move target to docs/contracts/",
104
+ "error"))
105
+ continue
106
+ if target.parts[:2] != ("docs", "contracts") or target.suffix != ".md":
107
+ continue
108
+ if target == STABILITY_FILE:
109
+ continue
110
+ if target not in contracts:
111
+ violations.append(Violation(str(public_file), lineno, href,
112
+ f"target not found: {target}", "error"))
113
+ continue
114
+ level = contracts[target]
115
+ if level is None:
116
+ violations.append(Violation(str(public_file), lineno, href,
117
+ f"target missing 'stability:' frontmatter: {target}", "error"))
118
+ continue
119
+ if level == "experimental":
120
+ violations.append(Violation(str(public_file), lineno, href,
121
+ f"public surface MUST NOT link to experimental contract: {target}",
122
+ "error"))
123
+ continue
124
+ if level == "beta":
125
+ window = line.lower()
126
+ if "(beta)" not in window and "[beta]" not in window:
127
+ violations.append(Violation(str(public_file), lineno, href,
128
+ f"link to beta contract '{target}' lacks visible (beta) marker",
129
+ "warning"))
130
+ return violations
131
+
132
+
133
+ def main() -> int:
134
+ ap = argparse.ArgumentParser()
135
+ ap.add_argument("--list", action="store_true", help="list contracts + stability levels")
136
+ ap.add_argument("--json", action="store_true", help="machine-readable output")
137
+ ap.add_argument("--strict", action="store_true",
138
+ help="fail on warnings as well as errors (default: errors only)")
139
+ args = ap.parse_args()
140
+
141
+ contracts = collect_contracts()
142
+ if args.list:
143
+ for p, lvl in contracts.items():
144
+ print(f" {lvl or '(no frontmatter)':14} {p}")
145
+ return 0
146
+
147
+ missing_fm = [p for p, lvl in contracts.items() if lvl is None and p != STABILITY_FILE]
148
+ violations: list[Violation] = []
149
+ for p in missing_fm:
150
+ violations.append(Violation(str(p), 0, "(self)",
151
+ "missing 'stability:' frontmatter required by docs/contracts/STABILITY.md",
152
+ "error"))
153
+ for f in PUBLIC_FILES:
154
+ violations.extend(scan_file(f, contracts))
155
+
156
+ if args.json:
157
+ print(json.dumps([asdict(v) for v in violations], indent=2))
158
+ else:
159
+ errors = [v for v in violations if v.severity == "error"]
160
+ warnings = [v for v in violations if v.severity == "warning"]
161
+ for v in violations:
162
+ icon = "❌" if v.severity == "error" else "⚠️ "
163
+ loc = f"{v.file}:{v.line}" if v.line else v.file
164
+ print(f"{icon} {loc} {v.href}\n → {v.reason}")
165
+ if not violations:
166
+ print(f"✅ public-link check clean — {len(contracts)} contracts scanned, "
167
+ f"{len(PUBLIC_FILES)} public files clean")
168
+ else:
169
+ print(f"\nsummary: {len(errors)} error(s), {len(warnings)} warning(s)")
170
+
171
+ has_errors = any(v.severity == "error" for v in violations)
172
+ has_warnings = any(v.severity == "warning" for v in violations)
173
+ if has_errors:
174
+ return 1
175
+ if has_warnings and args.strict:
176
+ return 1
177
+ return 0
178
+
179
+
180
+ if __name__ == "__main__":
181
+ try:
182
+ sys.exit(main())
183
+ except Exception as e:
184
+ print(f"❌ internal error: {e}", file=sys.stderr)
185
+ sys.exit(3)
@@ -78,6 +78,7 @@ EXAMPLE_PATH_PATTERNS = [
78
78
  re.compile(r"agents/overrides/"), # override examples
79
79
  re.compile(r"commands/old-cmd"), # example placeholder
80
80
  re.compile(r"agents/README"), # README reference (may not exist in package)
81
+ re.compile(r"agents/index[\w.-]*\.md"), # planned auto-generated artefact index (F5)
81
82
  re.compile(r"agents/docs/"), # project-specific docs (not in package)
82
83
  re.compile(r"agents/contexts/"), # project-specific contexts (not in package)
83
84
  re.compile(r"agents/gates"), # project-specific policy docs
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Atomic-command linter for the command-collapse policy.
4
+
5
+ Reads the locked verb clusters from `docs/contracts/command-clusters.md`,
6
+ finds every command file under `.agent-src.uncompressed/commands/` that
7
+ was **added** since `--baseline` (default: `main`), and requires each
8
+ new file to declare either:
9
+
10
+ - `cluster: <locked-name>` (file is a cluster entry or sub-command), or
11
+ - `superseded_by: <slug>` (file is a deprecation shim).
12
+
13
+ Modifications to pre-existing files are NOT flagged — only additions.
14
+ This stops the atomic surface from growing without forcing every existing
15
+ command into a Phase 1 cluster (most aren't in Phase 1).
16
+
17
+ Exit codes: 0 = clean, 1 = violations found, 3 = internal error.
18
+
19
+ Usage:
20
+ python3 scripts/lint_no_new_atomic_commands.py
21
+ python3 scripts/lint_no_new_atomic_commands.py --baseline origin/main
22
+ python3 scripts/lint_no_new_atomic_commands.py --all # ignore baseline
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import re
29
+ import subprocess
30
+ import sys
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+
34
+ ROOT = Path(__file__).resolve().parent.parent
35
+ COMMANDS_DIR = Path(".agent-src.uncompressed/commands")
36
+ CLUSTER_CONTRACT = Path("docs/contracts/command-clusters.md")
37
+
38
+
39
+ @dataclass
40
+ class Violation:
41
+ file: str
42
+ reason: str
43
+
44
+
45
+ def load_locked_clusters() -> set[str]:
46
+ """Parse the Phase 1 cluster table from the locked contract."""
47
+ text = (ROOT / CLUSTER_CONTRACT).read_text(encoding="utf-8")
48
+ # Locate the Phase 1 table; cluster names sit in backticks in column 1.
49
+ in_phase_1 = False
50
+ clusters: set[str] = set()
51
+ for line in text.splitlines():
52
+ if line.startswith("## Phase 1 clusters"):
53
+ in_phase_1 = True
54
+ continue
55
+ if in_phase_1 and line.startswith("## "):
56
+ break
57
+ if in_phase_1:
58
+ m = re.match(r"\|\s*`([a-z][a-z0-9-]*)`\s*\|", line)
59
+ if m:
60
+ clusters.add(m.group(1))
61
+ if not clusters:
62
+ print(
63
+ f"❌ Could not parse Phase 1 cluster table from {CLUSTER_CONTRACT}",
64
+ file=sys.stderr,
65
+ )
66
+ sys.exit(3)
67
+ return clusters
68
+
69
+
70
+ def added_command_files(baseline: str) -> list[Path]:
71
+ """Files under commands/ added (status A) since baseline."""
72
+ try:
73
+ result = subprocess.run(
74
+ ["git", "diff", "--name-only", "--diff-filter=A",
75
+ f"{baseline}...HEAD", "--", str(COMMANDS_DIR)],
76
+ capture_output=True, text=True, cwd=ROOT, timeout=15,
77
+ )
78
+ except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
79
+ print(f"❌ git diff failed: {exc}", file=sys.stderr)
80
+ sys.exit(3)
81
+ if result.returncode != 0:
82
+ print(f"❌ git diff exit {result.returncode}: {result.stderr}",
83
+ file=sys.stderr)
84
+ sys.exit(3)
85
+ files = [Path(p) for p in result.stdout.splitlines()
86
+ if p.endswith(".md") and p != ""]
87
+ # Also include untracked (newly added, uncommitted) files.
88
+ try:
89
+ wt = subprocess.run(
90
+ ["git", "status", "--porcelain", "--", str(COMMANDS_DIR)],
91
+ capture_output=True, text=True, cwd=ROOT, timeout=10,
92
+ )
93
+ for line in wt.stdout.splitlines():
94
+ if len(line) < 4:
95
+ continue
96
+ status = line[:2]
97
+ if status.strip() not in ("A", "??", "AM"):
98
+ continue
99
+ path = line[3:].strip().split(" -> ")[-1]
100
+ if path.endswith(".md"):
101
+ p = Path(path)
102
+ if p not in files:
103
+ files.append(p)
104
+ except (FileNotFoundError, subprocess.TimeoutExpired):
105
+ pass
106
+ return files
107
+
108
+
109
+ def all_command_files() -> list[Path]:
110
+ return sorted((ROOT / COMMANDS_DIR).glob("*.md"))
111
+
112
+
113
+ def parse_frontmatter(path: Path) -> dict[str, str]:
114
+ text = path.read_text(encoding="utf-8")
115
+ if not text.startswith("---"):
116
+ return {}
117
+ end = text.find("\n---", 3)
118
+ if end == -1:
119
+ return {}
120
+ fm: dict[str, str] = {}
121
+ for line in text[3:end].splitlines():
122
+ if ":" in line:
123
+ k, _, v = line.partition(":")
124
+ fm[k.strip()] = v.strip()
125
+ return fm
126
+
127
+
128
+ def check_file(path: Path, clusters: set[str]) -> Violation | None:
129
+ abs_path = path if path.is_absolute() else ROOT / path
130
+ if not abs_path.exists():
131
+ return None # deleted file, nothing to check
132
+ fm = parse_frontmatter(abs_path)
133
+ if "superseded_by" in fm:
134
+ return None # shim — exempt
135
+ cluster = fm.get("cluster")
136
+ if not cluster:
137
+ return Violation(str(path),
138
+ "missing `cluster:` frontmatter "
139
+ f"(allowed: {sorted(clusters)})")
140
+ if cluster not in clusters:
141
+ return Violation(str(path),
142
+ f"`cluster: {cluster}` is not a locked cluster "
143
+ f"(allowed: {sorted(clusters)})")
144
+ return None
145
+
146
+
147
+ def main() -> int:
148
+ ap = argparse.ArgumentParser(description=__doc__)
149
+ ap.add_argument("--baseline", default="main",
150
+ help="git ref to diff against (default: main)")
151
+ ap.add_argument("--all", action="store_true",
152
+ help="check every command file, not just changed ones")
153
+ args = ap.parse_args()
154
+
155
+ clusters = load_locked_clusters()
156
+ targets = (all_command_files() if args.all
157
+ else added_command_files(args.baseline))
158
+ if not targets:
159
+ print(f"✅ No new commands added under {COMMANDS_DIR} "
160
+ f"(baseline: {args.baseline}).")
161
+ return 0
162
+
163
+ violations = [v for v in (check_file(p, clusters) for p in targets)
164
+ if v is not None]
165
+ if violations:
166
+ print(f"❌ {len(violations)} newly-added atomic command(s) violate "
167
+ f"the command-cluster policy:")
168
+ for v in violations:
169
+ print(f" • {v.file} — {v.reason}")
170
+ print(f"\nSee docs/contracts/command-clusters.md for the locked "
171
+ f"cluster names and frontmatter contract.")
172
+ return 1
173
+ print(f"✅ {len(targets)} newly-added command(s) all declare a valid "
174
+ f"`cluster:` (or `superseded_by:`).")
175
+ return 0
176
+
177
+
178
+ if __name__ == "__main__":
179
+ sys.exit(main())