@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.
- package/.agent-src/commands/agent-handoff.md +1 -1
- package/.agent-src/commands/bug-fix.md +2 -2
- package/.agent-src/commands/chat-history-checkpoint.md +2 -2
- package/.agent-src/commands/chat-history-clear.md +1 -1
- package/.agent-src/commands/chat-history-resume.md +2 -2
- package/.agent-src/commands/chat-history.md +2 -2
- package/.agent-src/commands/check-current-md.md +43 -32
- package/.agent-src/commands/commit-in-chunks.md +43 -23
- package/.agent-src/commands/compress.md +34 -2
- package/.agent-src/commands/feature-roadmap.md +2 -2
- package/.agent-src/commands/fix-portability.md +2 -2
- package/.agent-src/commands/onboard.md +14 -5
- package/.agent-src/commands/optimize-augmentignore.md +9 -0
- package/.agent-src/commands/refine-ticket.md +9 -7
- package/.agent-src/commands/review-changes.md +35 -8
- package/.agent-src/commands/roadmap-create.md +13 -2
- package/.agent-src/commands/roadmap-execute.md +9 -7
- package/.agent-src/commands/set-cost-profile.md +8 -0
- package/.agent-src/commands/sync-agent-settings.md +9 -0
- package/.agent-src/commands/tests-execute.md +2 -3
- package/.agent-src/rules/artifact-engagement-recording.md +1 -1
- package/.agent-src/rules/augment-portability.md +56 -37
- package/.agent-src/rules/chat-history-cadence.md +109 -0
- package/.agent-src/rules/chat-history-ownership.md +123 -0
- package/.agent-src/rules/chat-history-visibility.md +96 -0
- package/.agent-src/rules/cli-output-handling.md +1 -1
- package/.agent-src/rules/command-suggestion.md +3 -2
- package/.agent-src/rules/commit-policy.md +44 -34
- package/.agent-src/rules/direct-answers.md +1 -1
- package/.agent-src/rules/language-and-tone.md +19 -15
- package/.agent-src/rules/non-destructive-by-default.md +18 -18
- package/.agent-src/rules/roadmap-progress-sync.md +133 -74
- package/.agent-src/rules/role-mode-adherence.md +1 -1
- package/.agent-src/rules/size-enforcement.md +2 -1
- package/.agent-src/rules/user-interaction.md +28 -4
- package/.agent-src/scripts/update_roadmap_progress.py +56 -4
- package/.agent-src/skills/blade-ui/SKILL.md +29 -10
- package/.agent-src/skills/command-writing/SKILL.md +15 -4
- package/.agent-src/skills/existing-ui-audit/SKILL.md +24 -9
- package/.agent-src/skills/fe-design/SKILL.md +20 -15
- package/.agent-src/skills/file-editor/SKILL.md +9 -0
- package/.agent-src/skills/livewire/SKILL.md +26 -7
- package/.agent-src/skills/refine-ticket/SKILL.md +30 -24
- package/.agent-src/skills/roadmap-management/SKILL.md +22 -16
- package/.agent-src/skills/skill-writing/SKILL.md +3 -3
- package/.agent-src/skills/upstream-contribute/SKILL.md +2 -2
- package/.agent-src/templates/agent-settings.md +1 -1
- package/.agent-src/templates/roadmaps.md +9 -8
- package/.agent-src/templates/scripts/memory_lookup.py +1 -1
- package/.agent-src/templates/scripts/work_engine/__init__.py +2 -2
- package/.agent-src/templates/scripts/work_engine/cli.py +64 -461
- package/.agent-src/templates/scripts/work_engine/cli_args.py +116 -0
- package/.agent-src/templates/scripts/work_engine/delivery_state.py +3 -3
- package/.agent-src/templates/scripts/work_engine/directives/backend/__init__.py +1 -1
- package/.agent-src/templates/scripts/work_engine/directives/backend/implement.py +1 -1
- package/.agent-src/templates/scripts/work_engine/directives/backend/memory.py +1 -1
- package/.agent-src/templates/scripts/work_engine/directives/backend/plan.py +1 -1
- package/.agent-src/templates/scripts/work_engine/directives/backend/report.py +1 -1
- package/.agent-src/templates/scripts/work_engine/dispatcher.py +1 -1
- package/.agent-src/templates/scripts/work_engine/emitters.py +43 -0
- package/.agent-src/templates/scripts/work_engine/errors.py +19 -0
- package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +76 -0
- package/.agent-src/templates/scripts/work_engine/input_builders.py +163 -0
- package/.agent-src/templates/scripts/work_engine/migration/v0_to_v1.py +34 -2
- package/.agent-src/templates/scripts/work_engine/persona_policy.py +1 -1
- package/.agent-src/templates/scripts/work_engine/resolvers/prompt.py +1 -1
- package/.agent-src/templates/scripts/work_engine/state_io.py +202 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/AGENTS.md +6 -4
- package/CHANGELOG.md +83 -8
- package/README.md +24 -23
- package/docs/MIGRATION.md +122 -0
- package/docs/architecture.md +83 -34
- package/docs/contracts/STABILITY.md +95 -0
- package/docs/contracts/adr-chat-history-split.md +132 -0
- package/docs/contracts/adr-command-suggestion.md +146 -0
- package/docs/contracts/adr-implement-ticket-runtime.md +122 -0
- package/docs/contracts/adr-product-ui-track.md +384 -0
- package/docs/contracts/adr-prompt-driven-execution.md +187 -0
- package/docs/contracts/agent-memory-contract.md +149 -0
- package/docs/contracts/artifact-engagement-flow.md +262 -0
- package/docs/contracts/command-clusters.md +126 -0
- package/docs/contracts/command-suggestion-flow.md +148 -0
- package/docs/contracts/implement-ticket-flow.md +628 -0
- package/docs/contracts/linear-ai-rules-inclusion.md +143 -0
- package/docs/contracts/linear-ai-three-layers.md +131 -0
- package/docs/contracts/rule-interactions.md +107 -0
- package/docs/contracts/rule-interactions.yml +142 -0
- package/docs/contracts/ui-stack-extension.md +236 -0
- package/docs/contracts/ui-track-flow.md +338 -0
- package/docs/getting-started.md +2 -2
- package/docs/installation.md +42 -6
- package/docs/migrations/commands-1.15.0.md +112 -0
- package/docs/ui-track-mental-model.md +121 -0
- package/package.json +1 -1
- package/scripts/build_linear_digest.py +4 -4
- package/scripts/check_portability.py +2 -0
- package/scripts/check_public_links.py +185 -0
- package/scripts/check_references.py +1 -0
- package/scripts/lint_no_new_atomic_commands.py +179 -0
- package/scripts/lint_rule_interactions.py +149 -0
- package/scripts/memory_lookup.py +1 -1
- package/scripts/release.py +297 -64
- package/scripts/skill_linter.py +14 -0
- package/scripts/update_counts.py +10 -0
- package/.agent-src/rules/chat-history.md +0 -200
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Lint docs/contracts/rule-interactions.yml.
|
|
3
|
+
|
|
4
|
+
Validates:
|
|
5
|
+
- Schema (required fields per pair)
|
|
6
|
+
- All rule slugs in `rules:` exist as `.agent-src.uncompressed/rules/<slug>.md`
|
|
7
|
+
- Every pair references rules listed in the top-level `rules:` block
|
|
8
|
+
- `relation` is one of the allowed values
|
|
9
|
+
- All `evidence:` entries point at real files (anchors are advisory, not checked)
|
|
10
|
+
- Pair `id`s are unique
|
|
11
|
+
- The anchor pair from `road-to-post-pr29-optimize.md` Phase 2 is present:
|
|
12
|
+
`non-destructive-by-default` × {autonomous-execution, scope-control,
|
|
13
|
+
commit-policy, ask-when-uncertain, verify-before-complete}.
|
|
14
|
+
|
|
15
|
+
Exits non-zero on any failure. Used in CI via Taskfile target
|
|
16
|
+
`lint-rule-interactions`.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
26
|
+
MATRIX = ROOT / "docs" / "contracts" / "rule-interactions.yml"
|
|
27
|
+
RULES_DIR = ROOT / ".agent-src.uncompressed" / "rules"
|
|
28
|
+
|
|
29
|
+
ALLOWED_RELATIONS = {
|
|
30
|
+
"overrides",
|
|
31
|
+
"narrows",
|
|
32
|
+
"defers_to",
|
|
33
|
+
"restates",
|
|
34
|
+
"gates",
|
|
35
|
+
"complements",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
REQUIRED_PAIR_FIELDS = {"id", "rules", "relation", "conflict", "resolution", "evidence"}
|
|
39
|
+
|
|
40
|
+
ANCHOR_PARTNERS = {
|
|
41
|
+
"autonomous-execution",
|
|
42
|
+
"scope-control",
|
|
43
|
+
"commit-policy",
|
|
44
|
+
"ask-when-uncertain",
|
|
45
|
+
"verify-before-complete",
|
|
46
|
+
}
|
|
47
|
+
ANCHOR_RULE = "non-destructive-by-default"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fail(errors: list[str]) -> None:
|
|
51
|
+
print(f"❌ rule-interactions.yml — {len(errors)} issue(s):")
|
|
52
|
+
for e in errors:
|
|
53
|
+
print(f" • {e}")
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main() -> int:
|
|
58
|
+
if not MATRIX.exists():
|
|
59
|
+
fail([f"{MATRIX.relative_to(ROOT)} is missing"])
|
|
60
|
+
|
|
61
|
+
data = yaml.safe_load(MATRIX.read_text())
|
|
62
|
+
errors: list[str] = []
|
|
63
|
+
|
|
64
|
+
if not isinstance(data, dict):
|
|
65
|
+
fail(["top-level YAML must be a mapping"])
|
|
66
|
+
|
|
67
|
+
if data.get("version") != 1:
|
|
68
|
+
errors.append("version must be 1")
|
|
69
|
+
|
|
70
|
+
declared_rules = data.get("rules") or []
|
|
71
|
+
if not isinstance(declared_rules, list) or not declared_rules:
|
|
72
|
+
errors.append("`rules:` must be a non-empty list of slugs")
|
|
73
|
+
|
|
74
|
+
for slug in declared_rules:
|
|
75
|
+
if not isinstance(slug, str):
|
|
76
|
+
errors.append(f"rule slug not a string: {slug!r}")
|
|
77
|
+
continue
|
|
78
|
+
rule_path = RULES_DIR / f"{slug}.md"
|
|
79
|
+
if not rule_path.exists():
|
|
80
|
+
errors.append(f"rule slug `{slug}` has no file at {rule_path.relative_to(ROOT)}")
|
|
81
|
+
|
|
82
|
+
pairs = data.get("pairs") or []
|
|
83
|
+
if not isinstance(pairs, list) or not pairs:
|
|
84
|
+
errors.append("`pairs:` must be a non-empty list")
|
|
85
|
+
|
|
86
|
+
seen_ids: set[str] = set()
|
|
87
|
+
declared_set = set(declared_rules) if isinstance(declared_rules, list) else set()
|
|
88
|
+
anchor_partners_seen: set[str] = set()
|
|
89
|
+
|
|
90
|
+
for idx, pair in enumerate(pairs):
|
|
91
|
+
if not isinstance(pair, dict):
|
|
92
|
+
errors.append(f"pair[{idx}] is not a mapping")
|
|
93
|
+
continue
|
|
94
|
+
missing = REQUIRED_PAIR_FIELDS - set(pair)
|
|
95
|
+
if missing:
|
|
96
|
+
errors.append(f"pair[{idx}] missing fields: {sorted(missing)}")
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
pid = pair["id"]
|
|
100
|
+
if pid in seen_ids:
|
|
101
|
+
errors.append(f"duplicate pair id: {pid}")
|
|
102
|
+
seen_ids.add(pid)
|
|
103
|
+
|
|
104
|
+
rules_pair = pair["rules"]
|
|
105
|
+
if not (isinstance(rules_pair, list) and len(rules_pair) == 2):
|
|
106
|
+
errors.append(f"pair `{pid}` rules must be a 2-element list")
|
|
107
|
+
continue
|
|
108
|
+
for r in rules_pair:
|
|
109
|
+
if r not in declared_set:
|
|
110
|
+
errors.append(f"pair `{pid}` references undeclared rule `{r}`")
|
|
111
|
+
|
|
112
|
+
if pair["relation"] not in ALLOWED_RELATIONS:
|
|
113
|
+
errors.append(
|
|
114
|
+
f"pair `{pid}` relation `{pair['relation']}` not in {sorted(ALLOWED_RELATIONS)}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
evidence = pair.get("evidence") or []
|
|
118
|
+
if not isinstance(evidence, list) or not evidence:
|
|
119
|
+
errors.append(f"pair `{pid}` evidence must be a non-empty list")
|
|
120
|
+
for citation in evidence:
|
|
121
|
+
if not isinstance(citation, str):
|
|
122
|
+
errors.append(f"pair `{pid}` evidence item not a string: {citation!r}")
|
|
123
|
+
continue
|
|
124
|
+
file_part = citation.split("#", 1)[0]
|
|
125
|
+
if not (ROOT / file_part).exists():
|
|
126
|
+
errors.append(f"pair `{pid}` evidence path does not exist: {file_part}")
|
|
127
|
+
|
|
128
|
+
# Anchor coverage check
|
|
129
|
+
if ANCHOR_RULE in rules_pair:
|
|
130
|
+
partner = next((r for r in rules_pair if r != ANCHOR_RULE), None)
|
|
131
|
+
if partner in ANCHOR_PARTNERS:
|
|
132
|
+
anchor_partners_seen.add(partner)
|
|
133
|
+
|
|
134
|
+
missing_anchors = ANCHOR_PARTNERS - anchor_partners_seen
|
|
135
|
+
if missing_anchors:
|
|
136
|
+
errors.append(
|
|
137
|
+
f"anchor pairs missing for `{ANCHOR_RULE}` × {sorted(missing_anchors)} "
|
|
138
|
+
"(required by road-to-post-pr29-optimize.md P2.2)"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if errors:
|
|
142
|
+
fail(errors)
|
|
143
|
+
|
|
144
|
+
print(f"✅ rule-interactions.yml clean — {len(declared_rules)} rules, {len(pairs)} pairs.")
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
sys.exit(main())
|
package/scripts/memory_lookup.py
CHANGED
|
@@ -246,7 +246,7 @@ def _apply_conflict_rule(
|
|
|
246
246
|
# says retrieval should route through `@event4u/agent-memory`. The package
|
|
247
247
|
# CLI is purely **semantic** (`memory retrieve <query> --type T …`); the
|
|
248
248
|
# shared `retrieve(types, keys, …)` API is **key-based**. The hybrid
|
|
249
|
-
# resolution agreed in `
|
|
249
|
+
# resolution agreed in `docs/contracts/agent-memory-contract.md` synthesises
|
|
250
250
|
# `keys` into a single natural-language query for the package call, while
|
|
251
251
|
# the file fallback continues to do glob/substring matching on the same
|
|
252
252
|
# keys. Both legs land in the same `Hit` shape so the conflict rule can
|
package/scripts/release.py
CHANGED
|
@@ -20,10 +20,13 @@ Pipeline:
|
|
|
20
20
|
push the tag (this triggers publish-npm.yml).
|
|
21
21
|
9. GitHub Release — `gh release create X.Y.Z --notes <changelog>`.
|
|
22
22
|
|
|
23
|
-
Idempotency
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
Idempotency: pass `--resume` to recover from a partial failure. Each
|
|
24
|
+
step then probes existing state (branch, commit, PR, tag, GitHub
|
|
25
|
+
Release) and skips work that is already done, instead of erroring out.
|
|
26
|
+
Without `--resume` the pipeline still mutates git/network state, so
|
|
27
|
+
re-running on a dirty tree needs `--resume` (or a manual cleanup).
|
|
28
|
+
Each step prints what it's about to do before doing it, so a crash
|
|
29
|
+
leaves a recoverable trail.
|
|
27
30
|
|
|
28
31
|
Stdlib-only (Python 3.10+). No third-party runtime dependencies.
|
|
29
32
|
"""
|
|
@@ -183,6 +186,61 @@ def have(bin: str) -> bool:
|
|
|
183
186
|
)
|
|
184
187
|
|
|
185
188
|
|
|
189
|
+
# ─── resume-mode state probes ────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _branch_exists_local(branch: str) -> bool:
|
|
193
|
+
r = run(
|
|
194
|
+
"git", "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}",
|
|
195
|
+
check=False, capture=True,
|
|
196
|
+
)
|
|
197
|
+
return r.returncode == 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _branch_exists_remote(branch: str) -> bool:
|
|
201
|
+
r = run(
|
|
202
|
+
"git", "ls-remote", "--exit-code", "--heads", REMOTE, branch,
|
|
203
|
+
check=False, capture=True,
|
|
204
|
+
)
|
|
205
|
+
return r.returncode == 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _tag_exists_local(tag: str) -> bool:
|
|
209
|
+
return tag in git("tag", "-l", tag, capture=True).splitlines()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _tag_exists_remote(tag: str) -> bool:
|
|
213
|
+
r = run(
|
|
214
|
+
"git", "ls-remote", "--exit-code", "--tags", REMOTE, tag,
|
|
215
|
+
check=False, capture=True,
|
|
216
|
+
)
|
|
217
|
+
return r.returncode == 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _pr_for_branch(branch: str) -> dict | None:
|
|
221
|
+
"""Most recent PR (any state) with `release/X.Y.Z` as head, or None."""
|
|
222
|
+
r = run(
|
|
223
|
+
"gh", "pr", "list",
|
|
224
|
+
"--head", branch,
|
|
225
|
+
"--state", "all",
|
|
226
|
+
"--json", "number,state,url",
|
|
227
|
+
"--limit", "1",
|
|
228
|
+
check=False, capture=True,
|
|
229
|
+
)
|
|
230
|
+
if r.returncode != 0:
|
|
231
|
+
return None
|
|
232
|
+
try:
|
|
233
|
+
items = json.loads(r.stdout or "[]")
|
|
234
|
+
except json.JSONDecodeError:
|
|
235
|
+
return None
|
|
236
|
+
return items[0] if items else None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _release_exists(tag: str) -> bool:
|
|
240
|
+
r = run("gh", "release", "view", tag, check=False, capture=True)
|
|
241
|
+
return r.returncode == 0
|
|
242
|
+
|
|
243
|
+
|
|
186
244
|
# ─── version math ─────────────────────────────────────────────────────────────
|
|
187
245
|
|
|
188
246
|
|
|
@@ -353,8 +411,21 @@ def set_marketplace_version(path: Path, version: str) -> None:
|
|
|
353
411
|
# ─── preflight ────────────────────────────────────────────────────────────────
|
|
354
412
|
|
|
355
413
|
|
|
356
|
-
def preflight(target: str) -> None:
|
|
357
|
-
"""Fail fast on conditions that would break the release mid-flight.
|
|
414
|
+
def preflight(target: str, *, resume: bool = False) -> None:
|
|
415
|
+
"""Fail fast on conditions that would break the release mid-flight.
|
|
416
|
+
|
|
417
|
+
In ``--resume`` mode two invariants are relaxed:
|
|
418
|
+
|
|
419
|
+
* The starting branch may be ``release/{target}`` in addition to
|
|
420
|
+
``main`` — both are valid resume positions (mid-pipeline crash
|
|
421
|
+
after step 1 leaves you on the release branch).
|
|
422
|
+
* The target-tag-exists check is dropped — execute() probes for
|
|
423
|
+
existing tags/releases and skips them.
|
|
424
|
+
|
|
425
|
+
Tree cleanliness, gh auth, and ``main`` in-sync with origin are
|
|
426
|
+
still enforced, so resuming has the same starting posture as a
|
|
427
|
+
fresh run; only step-level outcomes differ.
|
|
428
|
+
"""
|
|
358
429
|
for b in ("git", "gh"):
|
|
359
430
|
if not have(b):
|
|
360
431
|
die(f"{b!r} not found on PATH")
|
|
@@ -368,7 +439,14 @@ def preflight(target: str) -> None:
|
|
|
368
439
|
die("gh is not authenticated; run `gh auth login` first")
|
|
369
440
|
|
|
370
441
|
branch = git("rev-parse", "--abbrev-ref", "HEAD", capture=True)
|
|
371
|
-
|
|
442
|
+
release_branch = f"release/{target}"
|
|
443
|
+
allowed = {MAIN_BRANCH, release_branch} if resume else {MAIN_BRANCH}
|
|
444
|
+
if branch not in allowed:
|
|
445
|
+
if resume:
|
|
446
|
+
die(
|
|
447
|
+
f"resume must run from {MAIN_BRANCH!r} or {release_branch!r}, "
|
|
448
|
+
f"currently on {branch!r}"
|
|
449
|
+
)
|
|
372
450
|
die(f"release must run from {MAIN_BRANCH!r}, currently on {branch!r}")
|
|
373
451
|
|
|
374
452
|
porcelain = git("status", "--porcelain", capture=True)
|
|
@@ -380,17 +458,24 @@ def preflight(target: str) -> None:
|
|
|
380
458
|
# about to create a new tag anyway — local drift (e.g. from renamed
|
|
381
459
|
# release-please tags) should not block the fetch.
|
|
382
460
|
run("git", "fetch", REMOTE, "--tags", "--prune", "--force", capture=True)
|
|
383
|
-
local = git("rev-parse", "HEAD", capture=True)
|
|
384
|
-
remote = git("rev-parse", f"{REMOTE}/{MAIN_BRANCH}", capture=True)
|
|
385
|
-
if local != remote:
|
|
386
|
-
die(
|
|
387
|
-
f"local {MAIN_BRANCH} is not in sync with {REMOTE}/{MAIN_BRANCH}; "
|
|
388
|
-
"pull or push first"
|
|
389
|
-
)
|
|
390
461
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
462
|
+
# The local-in-sync-with-origin check only applies to main; if we're
|
|
463
|
+
# already on the release branch in resume mode, the relevant invariant
|
|
464
|
+
# is "main hasn't moved beyond what release/X.Y.Z branched off", which
|
|
465
|
+
# `git pull --ff-only` enforces in step 8 anyway.
|
|
466
|
+
if branch == MAIN_BRANCH:
|
|
467
|
+
local = git("rev-parse", "HEAD", capture=True)
|
|
468
|
+
remote = git("rev-parse", f"{REMOTE}/{MAIN_BRANCH}", capture=True)
|
|
469
|
+
if local != remote:
|
|
470
|
+
die(
|
|
471
|
+
f"local {MAIN_BRANCH} is not in sync with "
|
|
472
|
+
f"{REMOTE}/{MAIN_BRANCH}; pull or push first"
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
if not resume:
|
|
476
|
+
tags = git("tag", "-l", target, capture=True).splitlines()
|
|
477
|
+
if target in tags:
|
|
478
|
+
die(f"tag {target!r} already exists; nothing to release")
|
|
394
479
|
|
|
395
480
|
|
|
396
481
|
# ─── plan ─────────────────────────────────────────────────────────────────────
|
|
@@ -436,7 +521,13 @@ def _step(n: int, total: int, msg: str) -> None:
|
|
|
436
521
|
print(f"[{n}/{total}] {msg}")
|
|
437
522
|
|
|
438
523
|
|
|
439
|
-
def execute(
|
|
524
|
+
def execute(
|
|
525
|
+
plan: Plan,
|
|
526
|
+
*,
|
|
527
|
+
wait_for_checks: bool,
|
|
528
|
+
dry_run: bool,
|
|
529
|
+
resume: bool = False,
|
|
530
|
+
) -> None:
|
|
440
531
|
branch = f"release/{plan.target}"
|
|
441
532
|
total = 9
|
|
442
533
|
|
|
@@ -444,57 +535,129 @@ def execute(plan: Plan, *, wait_for_checks: bool, dry_run: bool) -> None:
|
|
|
444
535
|
print("(dry-run) no git/gh mutations will be performed.")
|
|
445
536
|
return
|
|
446
537
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
538
|
+
# Probe the world once at the top so each step skip-decision is cheap.
|
|
539
|
+
pr_info = _pr_for_branch(branch) if resume else None
|
|
540
|
+
pr_state = (pr_info or {}).get("state")
|
|
541
|
+
pr_merged = pr_state == "MERGED"
|
|
542
|
+
|
|
543
|
+
# ─── 1. branch ──────────────────────────────────────────────────────────
|
|
544
|
+
if pr_merged:
|
|
545
|
+
_step(1, total, f"PR for {branch} already merged — staying on {MAIN_BRANCH}")
|
|
546
|
+
if git("rev-parse", "--abbrev-ref", "HEAD", capture=True) != MAIN_BRANCH:
|
|
547
|
+
run("git", "checkout", MAIN_BRANCH)
|
|
548
|
+
run("git", "pull", "--ff-only", REMOTE, MAIN_BRANCH)
|
|
549
|
+
elif resume and _branch_exists_local(branch):
|
|
550
|
+
_step(1, total, f"Branch {branch} exists locally — checkout")
|
|
551
|
+
run("git", "checkout", branch)
|
|
552
|
+
elif resume and _branch_exists_remote(branch):
|
|
553
|
+
_step(1, total, f"Branch {branch} exists on {REMOTE} — fetch + checkout")
|
|
554
|
+
run("git", "fetch", REMOTE, branch)
|
|
555
|
+
run("git", "checkout", "-b", branch, f"{REMOTE}/{branch}")
|
|
556
|
+
else:
|
|
557
|
+
_step(1, total, f"Create branch {branch}")
|
|
558
|
+
run("git", "checkout", "-b", branch)
|
|
461
559
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
560
|
+
# ─── 2. file mutations ──────────────────────────────────────────────────
|
|
561
|
+
if pr_merged:
|
|
562
|
+
_step(2, total, "PR already merged — skip file bumps")
|
|
563
|
+
else:
|
|
564
|
+
current_pkg = json.loads(PACKAGE_JSON.read_text(encoding="utf-8")).get("version")
|
|
565
|
+
if resume and current_pkg == plan.target:
|
|
566
|
+
_step(2, total, f"Files already at {plan.target} — skip bump")
|
|
567
|
+
else:
|
|
568
|
+
_step(2, total, "Bump package.json + marketplace.json, prepend CHANGELOG")
|
|
569
|
+
set_package_version(PACKAGE_JSON, plan.target)
|
|
570
|
+
set_marketplace_version(MARKETPLACE_JSON, plan.target)
|
|
571
|
+
prepend_changelog(CHANGELOG, plan.changelog_entry)
|
|
572
|
+
|
|
573
|
+
# ─── 3. commit ──────────────────────────────────────────────────────────
|
|
574
|
+
if pr_merged:
|
|
575
|
+
_step(3, total, "PR already merged — skip commit")
|
|
576
|
+
else:
|
|
577
|
+
last_msg = git("log", "-1", "--format=%s", capture=True)
|
|
578
|
+
porcelain = git("status", "--porcelain", capture=True)
|
|
579
|
+
if resume and last_msg == f"release: {plan.target}" and not porcelain:
|
|
580
|
+
_step(3, total, f"Last commit already `release: {plan.target}` and tree clean — skip")
|
|
581
|
+
else:
|
|
582
|
+
_step(3, total, f"Commit `release: {plan.target}`")
|
|
583
|
+
run("git", "add", str(PACKAGE_JSON), str(MARKETPLACE_JSON), str(CHANGELOG))
|
|
584
|
+
run("git", "commit", "-m", f"release: {plan.target}")
|
|
585
|
+
|
|
586
|
+
# ─── 4. push ────────────────────────────────────────────────────────────
|
|
587
|
+
if pr_merged:
|
|
588
|
+
_step(4, total, "PR already merged — skip push")
|
|
589
|
+
else:
|
|
590
|
+
# `git push -u` is naturally idempotent — it prints "Everything
|
|
591
|
+
# up-to-date" when remote already matches. No probe needed.
|
|
592
|
+
_step(4, total, f"Push {branch} to {REMOTE}")
|
|
593
|
+
run("git", "push", "-u", REMOTE, branch)
|
|
594
|
+
|
|
595
|
+
# ─── 5. PR ──────────────────────────────────────────────────────────────
|
|
596
|
+
if pr_merged:
|
|
597
|
+
_step(5, total, f"PR #{pr_info.get('number')} already merged — skip")
|
|
598
|
+
elif resume and pr_state == "OPEN":
|
|
599
|
+
_step(5, total, f"PR already open: {pr_info.get('url')}")
|
|
600
|
+
else:
|
|
601
|
+
_step(5, total, "Open pull request")
|
|
602
|
+
pr_body = (
|
|
603
|
+
f"Release {plan.target}.\n\n"
|
|
604
|
+
f"{plan.changelog_body}\n\n"
|
|
605
|
+
"Created by `scripts/release.py`."
|
|
606
|
+
)
|
|
607
|
+
run(
|
|
608
|
+
"gh", "pr", "create",
|
|
609
|
+
"--base", MAIN_BRANCH,
|
|
610
|
+
"--head", branch,
|
|
611
|
+
"--title", f"release: {plan.target}",
|
|
612
|
+
"--body", pr_body,
|
|
613
|
+
)
|
|
475
614
|
|
|
476
|
-
|
|
615
|
+
# ─── 6. wait for checks ─────────────────────────────────────────────────
|
|
616
|
+
if pr_merged:
|
|
617
|
+
_step(6, total, "PR already merged — skip checks wait")
|
|
618
|
+
elif wait_for_checks:
|
|
477
619
|
_step(6, total, "Wait for PR checks")
|
|
478
620
|
watch_pr_checks()
|
|
479
621
|
else:
|
|
480
622
|
_step(6, total, "Skip waiting for checks (--no-wait)")
|
|
481
623
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
624
|
+
# ─── 7. merge ───────────────────────────────────────────────────────────
|
|
625
|
+
if pr_merged:
|
|
626
|
+
_step(7, total, f"PR #{pr_info.get('number')} already merged — skip")
|
|
627
|
+
else:
|
|
628
|
+
_step(7, total, "Merge pull request (merge commit) and delete branch")
|
|
629
|
+
run("gh", "pr", "merge", "--merge", "--delete-branch")
|
|
630
|
+
|
|
631
|
+
# ─── 8. tag main + push tag ─────────────────────────────────────────────
|
|
632
|
+
# Always idempotent — even outside resume mode this prevents a mid-flight
|
|
633
|
+
# crash on step 9 from leaving a half-tagged release that subsequent
|
|
634
|
+
# `task release` invocations can't recover from without `--resume`.
|
|
635
|
+
if git("rev-parse", "--abbrev-ref", "HEAD", capture=True) != MAIN_BRANCH:
|
|
636
|
+
run("git", "checkout", MAIN_BRANCH)
|
|
487
637
|
run("git", "pull", "--ff-only", REMOTE, MAIN_BRANCH)
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
638
|
+
|
|
639
|
+
if _tag_exists_local(plan.target):
|
|
640
|
+
if _tag_exists_remote(plan.target):
|
|
641
|
+
_step(8, total, f"Tag {plan.target} already on {REMOTE} — skip")
|
|
642
|
+
else:
|
|
643
|
+
_step(8, total, f"Tag {plan.target} exists locally — push only")
|
|
644
|
+
run("git", "push", REMOTE, plan.target)
|
|
645
|
+
else:
|
|
646
|
+
_step(8, total, f"Tag merge commit and push {plan.target}")
|
|
647
|
+
run("git", "tag", plan.target)
|
|
648
|
+
run("git", "push", REMOTE, plan.target)
|
|
649
|
+
|
|
650
|
+
# ─── 9. GitHub Release ──────────────────────────────────────────────────
|
|
651
|
+
if _release_exists(plan.target):
|
|
652
|
+
_step(9, total, f"GitHub Release {plan.target} already exists — skip")
|
|
653
|
+
else:
|
|
654
|
+
_step(9, total, "Create GitHub Release (triggers publish-npm on the tag)")
|
|
655
|
+
notes = plan.changelog_body or f"Release {plan.target}"
|
|
656
|
+
run(
|
|
657
|
+
"gh", "release", "create", plan.target,
|
|
658
|
+
"--title", plan.target,
|
|
659
|
+
"--notes", notes,
|
|
660
|
+
)
|
|
498
661
|
|
|
499
662
|
print()
|
|
500
663
|
print(f"✅ Released {plan.target}")
|
|
@@ -535,6 +698,15 @@ def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|
|
535
698
|
"--no-wait", action="store_true",
|
|
536
699
|
help="Merge immediately without waiting for PR checks to pass.",
|
|
537
700
|
)
|
|
701
|
+
p.add_argument(
|
|
702
|
+
"--resume", action="store_true",
|
|
703
|
+
help=(
|
|
704
|
+
"Recover from a partial run. Each step probes existing state "
|
|
705
|
+
"(branch, commit, PR, tag, GitHub Release) and skips work that "
|
|
706
|
+
"is already done. Use this when an earlier `task release` "
|
|
707
|
+
"crashed mid-pipeline."
|
|
708
|
+
),
|
|
709
|
+
)
|
|
538
710
|
return p.parse_args(argv)
|
|
539
711
|
|
|
540
712
|
|
|
@@ -545,6 +717,49 @@ def resolve_bump(override: str | None, commits: list[Commit]) -> str:
|
|
|
545
717
|
return infer_bump(commits)
|
|
546
718
|
|
|
547
719
|
|
|
720
|
+
_RELEASE_BRANCH_RE = re.compile(r"^release/(\d+\.\d+\.\d+)$")
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _detect_in_flight_target() -> str | None:
|
|
724
|
+
"""Find the in-flight release target from existing release branches.
|
|
725
|
+
|
|
726
|
+
Resume mode needs to know which `release/X.Y.Z` is being recovered,
|
|
727
|
+
not what the next bump would be. The release branch name is the
|
|
728
|
+
canonical anchor: it was committed by step 1 of an earlier run and
|
|
729
|
+
is the only state guaranteed to survive a partial pipeline.
|
|
730
|
+
|
|
731
|
+
Local branches win over remote, current-branch wins over both — if
|
|
732
|
+
you ran `git checkout release/1.15.0`, that's the target. Returns
|
|
733
|
+
None if no release branch exists; caller falls back to the regular
|
|
734
|
+
bump-inference path.
|
|
735
|
+
"""
|
|
736
|
+
head = git("rev-parse", "--abbrev-ref", "HEAD", capture=True)
|
|
737
|
+
m = _RELEASE_BRANCH_RE.match(head)
|
|
738
|
+
if m:
|
|
739
|
+
return m.group(1)
|
|
740
|
+
|
|
741
|
+
local_raw = git("for-each-ref", "--format=%(refname:short)", "refs/heads/release/", capture=True)
|
|
742
|
+
candidates = [
|
|
743
|
+
m.group(1)
|
|
744
|
+
for line in local_raw.splitlines()
|
|
745
|
+
if (m := _RELEASE_BRANCH_RE.match(line.strip()))
|
|
746
|
+
]
|
|
747
|
+
remote_raw = git(
|
|
748
|
+
"for-each-ref", "--format=%(refname:short)",
|
|
749
|
+
f"refs/remotes/{REMOTE}/release/", capture=True,
|
|
750
|
+
)
|
|
751
|
+
for line in remote_raw.splitlines():
|
|
752
|
+
bare = line.strip().removeprefix(f"{REMOTE}/")
|
|
753
|
+
if (m := _RELEASE_BRANCH_RE.match(bare)):
|
|
754
|
+
candidates.append(m.group(1))
|
|
755
|
+
|
|
756
|
+
if not candidates:
|
|
757
|
+
return None
|
|
758
|
+
# Sort semver-aware so 1.10.0 > 1.9.0 (lexicographic would lose).
|
|
759
|
+
candidates.sort(key=parse_version)
|
|
760
|
+
return candidates[-1]
|
|
761
|
+
|
|
762
|
+
|
|
548
763
|
def main(argv: list[str] | None = None) -> int:
|
|
549
764
|
args = _parse_args(list(sys.argv[1:] if argv is None else argv))
|
|
550
765
|
|
|
@@ -554,11 +769,22 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
554
769
|
prev = latest_tag()
|
|
555
770
|
commits = commits_since(prev)
|
|
556
771
|
bump = resolve_bump(args.bump_override, commits)
|
|
557
|
-
|
|
772
|
+
|
|
773
|
+
# Resume mode: prefer an existing `release/X.Y.Z` over computed bump,
|
|
774
|
+
# so we don't accidentally start a 1.16.0 release while 1.15.0 is
|
|
775
|
+
# still in flight. Explicit --version still wins.
|
|
776
|
+
in_flight = _detect_in_flight_target() if args.resume else None
|
|
777
|
+
if args.explicit:
|
|
778
|
+
target = args.explicit
|
|
779
|
+
elif in_flight:
|
|
780
|
+
target = in_flight
|
|
781
|
+
print(f"(resume) detected in-flight release branch release/{in_flight}")
|
|
782
|
+
else:
|
|
783
|
+
target = bump_version(current, bump)
|
|
558
784
|
parse_version(target)
|
|
559
785
|
|
|
560
786
|
if not args.dry_run:
|
|
561
|
-
preflight(target)
|
|
787
|
+
preflight(target, resume=args.resume)
|
|
562
788
|
|
|
563
789
|
today = _date.today().isoformat()
|
|
564
790
|
full, body = render_changelog_entry(target, prev, commits, today)
|
|
@@ -572,6 +798,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
572
798
|
changelog_entry=full,
|
|
573
799
|
)
|
|
574
800
|
print_preview(plan)
|
|
801
|
+
if args.resume:
|
|
802
|
+
print("(resume) probing existing state — completed steps will be skipped.")
|
|
575
803
|
|
|
576
804
|
if args.dry_run:
|
|
577
805
|
return 0
|
|
@@ -580,7 +808,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
580
808
|
print("aborted.")
|
|
581
809
|
return 1
|
|
582
810
|
|
|
583
|
-
execute(
|
|
811
|
+
execute(
|
|
812
|
+
plan,
|
|
813
|
+
wait_for_checks=not args.no_wait,
|
|
814
|
+
dry_run=False,
|
|
815
|
+
resume=args.resume,
|
|
816
|
+
)
|
|
584
817
|
return 0
|
|
585
818
|
|
|
586
819
|
|
package/scripts/skill_linter.py
CHANGED
|
@@ -867,6 +867,20 @@ def lint_command(path: Path, text: str) -> LintResult:
|
|
|
867
867
|
# suggestion block (road-to-context-aware-command-suggestion Phase 2)
|
|
868
868
|
issues.extend(_lint_command_suggestion_block(text))
|
|
869
869
|
|
|
870
|
+
# deprecation-shim warning line (P0.8b — command-clusters contract)
|
|
871
|
+
if "superseded_by:" in frontmatter:
|
|
872
|
+
shim_warning = re.search(
|
|
873
|
+
r"⚠️\s+/[a-z][a-z0-9-]*\s+is deprecated;\s+use\s+/[a-z][a-z0-9 -]+\s+instead",
|
|
874
|
+
text,
|
|
875
|
+
)
|
|
876
|
+
if not shim_warning:
|
|
877
|
+
issues.append(Issue(
|
|
878
|
+
"error", "shim_missing_warning",
|
|
879
|
+
"Deprecation shim must contain a one-line warning matching "
|
|
880
|
+
"'⚠️ /<old-name> is deprecated; use /<cluster> <sub> instead.'"
|
|
881
|
+
" (see docs/contracts/command-clusters.md § Deprecation shim contract)"
|
|
882
|
+
))
|
|
883
|
+
|
|
870
884
|
# --- Structure checks ---
|
|
871
885
|
if not H1_PATTERN.search(text):
|
|
872
886
|
issues.append(Issue("error", "missing_h1", "Command is missing an H1 heading (# Title)"))
|
package/scripts/update_counts.py
CHANGED
|
@@ -72,6 +72,16 @@ TARGETS: list[tuple[str, list[tuple[str, str]]]] = [
|
|
|
72
72
|
(r"(Browse all )(\d+)( commands\])", "commands"),
|
|
73
73
|
],
|
|
74
74
|
),
|
|
75
|
+
(
|
|
76
|
+
"docs/architecture.md",
|
|
77
|
+
[
|
|
78
|
+
# "What's inside" table: | **Skills** | NNN | … |
|
|
79
|
+
(r"(\| \*\*Skills\*\* \| )(\d+)( \|)", "skills"),
|
|
80
|
+
(r"(\| \*\*Rules\*\* \| )(\d+)( \|)", "rules"),
|
|
81
|
+
(r"(\| \*\*Commands\*\* \| )(\d+)( \|)", "commands"),
|
|
82
|
+
(r"(\| \*\*Guidelines\*\* \| )(\d+)( \|)", "guidelines"),
|
|
83
|
+
],
|
|
84
|
+
),
|
|
75
85
|
# Note: ``agents/roadmaps/road-to-stronger-skills.md`` was previously
|
|
76
86
|
# tracked here with a living ``baseline N as of`` pattern. The roadmap
|
|
77
87
|
# was moved to ``skipped/`` on 2026-04-23 (Q35 superseded), so its
|