@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,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())
@@ -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 `agents/contexts/agent-memory-contract.md` synthesises
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
@@ -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 is intentionally limited: this script mutates git state, so
24
- re-running after a partial failure needs a clean tree. Each step prints
25
- what it's about to do before doing it, so a crash leaves a recoverable
26
- trail.
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
- if branch != MAIN_BRANCH:
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
- tags = git("tag", "-l", target, capture=True).splitlines()
392
- if target in tags:
393
- die(f"tag {target!r} already exists; nothing to release")
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(plan: Plan, *, wait_for_checks: bool, dry_run: bool) -> None:
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
- _step(1, total, f"Create branch {branch}")
448
- run("git", "checkout", "-b", branch)
449
-
450
- _step(2, total, "Bump package.json + marketplace.json, prepend CHANGELOG")
451
- set_package_version(PACKAGE_JSON, plan.target)
452
- set_marketplace_version(MARKETPLACE_JSON, plan.target)
453
- prepend_changelog(CHANGELOG, plan.changelog_entry)
454
-
455
- _step(3, total, f"Commit `release: {plan.target}`")
456
- run("git", "add", str(PACKAGE_JSON), str(MARKETPLACE_JSON), str(CHANGELOG))
457
- run("git", "commit", "-m", f"release: {plan.target}")
458
-
459
- _step(4, total, f"Push {branch} to {REMOTE}")
460
- run("git", "push", "-u", REMOTE, branch)
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
- _step(5, total, "Open pull request")
463
- pr_body = (
464
- f"Release {plan.target}.\n\n"
465
- f"{plan.changelog_body}\n\n"
466
- "Created by `scripts/release.py`."
467
- )
468
- run(
469
- "gh", "pr", "create",
470
- "--base", MAIN_BRANCH,
471
- "--head", branch,
472
- "--title", f"release: {plan.target}",
473
- "--body", pr_body,
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
- if wait_for_checks:
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
- _step(7, total, "Merge pull request (merge commit) and delete branch")
483
- run("gh", "pr", "merge", "--merge", "--delete-branch")
484
-
485
- _step(8, total, f"Fast-forward {MAIN_BRANCH}, tag merge commit, push tag")
486
- run("git", "checkout", MAIN_BRANCH)
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
- run("git", "tag", plan.target)
489
- run("git", "push", REMOTE, plan.target)
490
-
491
- _step(9, total, "Create GitHub Release (triggers publish-npm on the tag)")
492
- notes = plan.changelog_body or f"Release {plan.target}"
493
- run(
494
- "gh", "release", "create", plan.target,
495
- "--title", plan.target,
496
- "--notes", notes,
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
- target = args.explicit or bump_version(current, bump)
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(plan, wait_for_checks=not args.no_wait, dry_run=False)
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
 
@@ -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)"))
@@ -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