@ethlete/agent-rules 0.1.0-next.3 → 0.1.0-next.31

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 (200) hide show
  1. package/AGENTS.md +28 -0
  2. package/CHANGELOG.md +480 -0
  3. package/CLAUDE.md +1 -0
  4. package/README.md +362 -29
  5. package/THIRD-PARTY-LICENSES.md +35 -0
  6. package/content/defaults.json +1 -1
  7. package/content/git-hooks/post-checkout.sh +10 -0
  8. package/content/git-hooks/pre-push.sh +5 -0
  9. package/content/hooks/context-warning.py +872 -110
  10. package/content/hooks/subagent-model-policy.py +194 -0
  11. package/content/mods/.claude-plugin/marketplace.json +11 -0
  12. package/content/mods/plugins/e2e-progress/.claude-plugin/plugin.json +6 -0
  13. package/content/mods/plugins/e2e-progress/hooks/hooks.json +1 -0
  14. package/content/mods/plugins/e2e-progress/hooks/register.tsx +442 -0
  15. package/content/mods/plugins/e2e-progress/tsconfig.json +3 -0
  16. package/content/mods/plugins/e2e-progress/types/index.d.ts +23 -0
  17. package/content/output-styles/ste-clarity.md +131 -0
  18. package/content/rules/agent-sessions-wake-up.md +11 -0
  19. package/content/rules/app-styling.md +66 -0
  20. package/content/rules/comments.md +4 -1
  21. package/content/rules/lint-and-format.md +7 -5
  22. package/content/rules/nx-layout.md +50 -0
  23. package/content/rules/reactive-state.md +1 -1
  24. package/content/rules/styling.md +9 -7
  25. package/content/rules/subagent-models.md +39 -0
  26. package/content/skills/agent-sessions/SKILL.md +81 -0
  27. package/content/skills/agent-sessions/agent-sessions.mjs +498 -0
  28. package/content/skills/angular-patterns/SKILL.md +30 -7
  29. package/content/skills/api-source/SKILL.md +156 -0
  30. package/content/skills/app-testing/SKILL.md +141 -0
  31. package/content/skills/codex-subagent/SKILL.md +90 -0
  32. package/content/skills/codex-subagent/codex-agent.mjs +229 -0
  33. package/content/skills/design-exploration/SKILL.md +687 -0
  34. package/content/skills/design-exploration/check-story.mjs +139 -0
  35. package/content/skills/design-exploration/shoot-template.mjs +61 -0
  36. package/content/skills/domain-modeling/ADR-FORMAT.md +47 -0
  37. package/content/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  38. package/content/skills/domain-modeling/SKILL.md +80 -0
  39. package/content/skills/e2e-triage/SKILL.md +80 -0
  40. package/content/skills/figma-export/SKILL.md +191 -0
  41. package/content/skills/figma-export/dump-figma-layers.py +83 -0
  42. package/content/skills/figma-export/dump-figma-svg.py +235 -0
  43. package/content/skills/figma-export/measure-template.mjs +87 -0
  44. package/content/skills/git-commit/SKILL.md +11 -7
  45. package/content/skills/git-flow/SKILL.md +87 -0
  46. package/content/skills/grill-with-docs/SKILL.md +13 -0
  47. package/content/skills/grilling/SKILL.md +51 -0
  48. package/content/skills/handoff/SKILL.md +125 -5
  49. package/content/skills/query/SKILL.md +165 -64
  50. package/content/skills/rxjs-signals/SKILL.md +33 -4
  51. package/content/skills/sdk-docs/SKILL.md +50 -14
  52. package/content/skills/sdk-local-build/SKILL.md +26 -8
  53. package/content/skills/sdk-local-build/sdk-local-baseline.mjs +64 -0
  54. package/content/skills/sdk-source/SKILL.md +37 -34
  55. package/content/skills/sdk-update/SKILL.md +103 -0
  56. package/content/skills/story-styling/SKILL.md +10 -8
  57. package/content/skills/styleguide/SKILL.md +12 -7
  58. package/content/skills/styleguide/assets.md +54 -0
  59. package/content/skills/styleguide/changesets.md +17 -0
  60. package/content/skills/styleguide/file-structure.md +73 -0
  61. package/content/skills/styleguide/lint-rule-lookup.md +51 -0
  62. package/content/skills/styleguide/storybook-structure.md +25 -0
  63. package/content/skills/theming/SKILL.md +50 -28
  64. package/content/skills/timetrack/SKILL.md +397 -0
  65. package/content/skills/verify-in-app/SKILL.md +107 -0
  66. package/migrations/app-styling-utilities.md +63 -0
  67. package/migrations/list-state-query-form.md +20 -0
  68. package/migrations/nx-layout.md +29 -0
  69. package/migrations/sdk-components-over-hand-built-ui.md +27 -0
  70. package/migrations/search-query-field.md +52 -0
  71. package/migrations.json +48 -0
  72. package/package.json +19 -3
  73. package/src/index.js +85 -30
  74. package/src/index.js.map +1 -1
  75. package/src/lib/cli-args.d.ts +9 -0
  76. package/src/lib/cli-args.js +48 -0
  77. package/src/lib/cli-args.js.map +1 -0
  78. package/src/lib/commitlint.d.ts +10 -0
  79. package/src/lib/commitlint.js +51 -0
  80. package/src/lib/commitlint.js.map +1 -0
  81. package/src/lib/config.d.ts +41 -5
  82. package/src/lib/config.js +117 -10
  83. package/src/lib/config.js.map +1 -1
  84. package/src/lib/filter.d.ts +2 -0
  85. package/src/lib/filter.js +4 -4
  86. package/src/lib/filter.js.map +1 -1
  87. package/src/lib/frontmatter.d.ts +2 -0
  88. package/src/lib/frontmatter.js +33 -5
  89. package/src/lib/frontmatter.js.map +1 -1
  90. package/src/lib/git-flow/build.d.ts +35 -0
  91. package/src/lib/git-flow/build.js +24 -0
  92. package/src/lib/git-flow/build.js.map +1 -0
  93. package/src/lib/git-flow/config.d.ts +59 -0
  94. package/src/lib/git-flow/config.js +50 -0
  95. package/src/lib/git-flow/config.js.map +1 -0
  96. package/src/lib/git-flow/index.d.ts +6 -0
  97. package/src/lib/git-flow/index.js +23 -0
  98. package/src/lib/git-flow/index.js.map +1 -0
  99. package/src/lib/git-flow/parse.d.ts +49 -0
  100. package/src/lib/git-flow/parse.js +300 -0
  101. package/src/lib/git-flow/parse.js.map +1 -0
  102. package/src/lib/git-flow/rename.d.ts +24 -0
  103. package/src/lib/git-flow/rename.js +70 -0
  104. package/src/lib/git-flow/rename.js.map +1 -0
  105. package/src/lib/git-flow/start.d.ts +49 -0
  106. package/src/lib/git-flow/start.js +57 -0
  107. package/src/lib/git-flow/start.js.map +1 -0
  108. package/src/lib/git-flow/validate.d.ts +34 -0
  109. package/src/lib/git-flow/validate.js +72 -0
  110. package/src/lib/git-flow/validate.js.map +1 -0
  111. package/src/lib/git-flow-command.d.ts +4 -0
  112. package/src/lib/git-flow-command.js +165 -0
  113. package/src/lib/git-flow-command.js.map +1 -0
  114. package/src/lib/git-flow-repair.d.ts +17 -0
  115. package/src/lib/git-flow-repair.js +165 -0
  116. package/src/lib/git-flow-repair.js.map +1 -0
  117. package/src/lib/git-flow-start.d.ts +20 -0
  118. package/src/lib/git-flow-start.js +140 -0
  119. package/src/lib/git-flow-start.js.map +1 -0
  120. package/src/lib/git.d.ts +64 -0
  121. package/src/lib/git.js +131 -0
  122. package/src/lib/git.js.map +1 -0
  123. package/src/lib/gitlab.d.ts +35 -0
  124. package/src/lib/gitlab.js +116 -0
  125. package/src/lib/gitlab.js.map +1 -0
  126. package/src/lib/index.d.ts +2 -0
  127. package/src/lib/index.js +26 -11
  128. package/src/lib/index.js.map +1 -1
  129. package/src/lib/migrate.js +6 -1
  130. package/src/lib/migrate.js.map +1 -1
  131. package/src/lib/output-style-command.d.ts +3 -0
  132. package/src/lib/output-style-command.js +69 -0
  133. package/src/lib/output-style-command.js.map +1 -0
  134. package/src/lib/output-style.d.ts +38 -0
  135. package/src/lib/output-style.js +134 -0
  136. package/src/lib/output-style.js.map +1 -0
  137. package/src/lib/owned-paths.js +27 -3
  138. package/src/lib/owned-paths.js.map +1 -1
  139. package/src/lib/package-runner.d.ts +4 -0
  140. package/src/lib/package-runner.js +86 -0
  141. package/src/lib/package-runner.js.map +1 -0
  142. package/src/lib/plain-text.d.ts +9 -0
  143. package/src/lib/plain-text.js +22 -0
  144. package/src/lib/plain-text.js.map +1 -0
  145. package/src/lib/plan.d.ts +6 -6
  146. package/src/lib/plan.js +164 -40
  147. package/src/lib/plan.js.map +1 -1
  148. package/src/lib/prompt.d.ts +8 -0
  149. package/src/lib/prompt.js +35 -0
  150. package/src/lib/prompt.js.map +1 -0
  151. package/src/lib/render.d.ts +25 -2
  152. package/src/lib/render.js +43 -10
  153. package/src/lib/render.js.map +1 -1
  154. package/src/lib/sessions-command.d.ts +4 -0
  155. package/src/lib/sessions-command.js +16 -0
  156. package/src/lib/sessions-command.js.map +1 -0
  157. package/src/lib/sync.d.ts +0 -1
  158. package/src/lib/sync.js +30 -17
  159. package/src/lib/sync.js.map +1 -1
  160. package/src/lib/targets/agents-skills.js +1 -1
  161. package/src/lib/targets/agents-skills.js.map +1 -1
  162. package/src/lib/targets/claude-hooks.d.ts +3 -23
  163. package/src/lib/targets/claude-hooks.js +22 -86
  164. package/src/lib/targets/claude-hooks.js.map +1 -1
  165. package/src/lib/targets/claude-mods.d.ts +20 -0
  166. package/src/lib/targets/claude-mods.js +47 -0
  167. package/src/lib/targets/claude-mods.js.map +1 -0
  168. package/src/lib/targets/claude.js +2 -3
  169. package/src/lib/targets/claude.js.map +1 -1
  170. package/src/lib/targets/codex-hooks.d.ts +12 -0
  171. package/src/lib/targets/codex-hooks.js +35 -0
  172. package/src/lib/targets/codex-hooks.js.map +1 -0
  173. package/src/lib/targets/codex.js +4 -4
  174. package/src/lib/targets/codex.js.map +1 -1
  175. package/src/lib/targets/copilot.js +4 -4
  176. package/src/lib/targets/copilot.js.map +1 -1
  177. package/src/lib/targets/cursor.js +3 -3
  178. package/src/lib/targets/cursor.js.map +1 -1
  179. package/src/lib/targets/git-hooks.d.ts +25 -0
  180. package/src/lib/targets/git-hooks.js +71 -0
  181. package/src/lib/targets/git-hooks.js.map +1 -0
  182. package/src/lib/targets/hooks-shared.d.ts +60 -0
  183. package/src/lib/targets/hooks-shared.js +130 -0
  184. package/src/lib/targets/hooks-shared.js.map +1 -0
  185. package/src/lib/targets/mcp.d.ts +16 -0
  186. package/src/lib/targets/mcp.js +68 -0
  187. package/src/lib/targets/mcp.js.map +1 -0
  188. package/src/lib/targets/shared.d.ts +1 -9
  189. package/src/lib/targets/shared.js +4 -8
  190. package/src/lib/targets/shared.js.map +1 -1
  191. package/src/lib/timetrack-command.d.ts +11 -0
  192. package/src/lib/timetrack-command.js +1063 -0
  193. package/src/lib/timetrack-command.js.map +1 -0
  194. package/src/lib/timetrack-snapshot.d.ts +55 -0
  195. package/src/lib/timetrack-snapshot.js +120 -0
  196. package/src/lib/timetrack-snapshot.js.map +1 -0
  197. package/src/lib/timetrack.d.ts +561 -0
  198. package/src/lib/timetrack.js +279 -0
  199. package/src/lib/timetrack.js.map +1 -0
  200. package/content/skills/styleguide/STYLEGUIDE.md +0 -520
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: make a subagent's model an explicit choice.
3
+
4
+ A `Task` call that passes no `model` runs the subagent on the parent session's
5
+ model. A session led by an expensive model therefore spends that model on every
6
+ grep and every file read it delegates, which burns a usage limit in minutes for
7
+ work a small model does just as well.
8
+
9
+ So this hook gates the tool call rather than only advising:
10
+
11
+ * No `model` at all - denied, with the model table returned to the agent. The
12
+ agent then repeats the call with a model, so the cost of the mistake is one
13
+ tool call, not a whole subagent run.
14
+ * `model: fable` - the user is asked. Fable is the right pick for planning,
15
+ design, cross-cutting review and leading other subagents, and the wrong pick
16
+ for the twentieth mechanical lookup; only the user knows which this is.
17
+ * Any other model - allowed silently.
18
+
19
+ Two cases are left alone, because the tool's `model` would change nothing:
20
+
21
+ * `subagent_type: fork`, which always inherits the parent model.
22
+ * An agent type whose own definition under `.claude/agents/` sets a model.
23
+
24
+ Can be disabled per machine via a gitignored ethlete-agents.config.local.json at
25
+ the repo root: {"disableHooks": true} or {"disableHooks": ["subagent-model-policy"]}.
26
+
27
+ Fail-safe: any error exits 0 with no output - the hook must never block work
28
+ because it could not read its own input.
29
+ """
30
+
31
+ import json
32
+ import os
33
+ import sys
34
+
35
+ HOOK_NAME = "subagent-model-policy"
36
+ LOCAL_CONFIG_FILE = "ethlete-agents.config.local.json"
37
+
38
+ # The tool that spawns a subagent, under both names it has carried.
39
+ SUBAGENT_TOOLS = ("Task", "Agent")
40
+
41
+ POLICY = """A subagent's model is a choice, not an inheritance. This call passes no `model`, so the subagent \
42
+ would run on the model leading this session - which is how one expensive model ends up doing every small job. \
43
+ Call the tool again with `model` set:
44
+
45
+ - `haiku` - lookups and small, fully specified jobs: grep, find, read and summarize files or a CI log, run a command and report, a mechanical edit in named files.
46
+ - `sonnet` - scoped work with a clear target: a fix in a named file, a spec, a docs page, a lint cleanup, \
47
+ research that needs reasoning. Sonnet 5.5 is close to opus here and costs less.
48
+ - `opus` - hard work: a bug with no known cause, a change across many files, a review of a diff, and any task \
49
+ sonnet did not finish.
50
+ - `fable` - judgment-heavy work: planning, design, cross-cutting review, leading other subagents. The most \
51
+ expensive of the four, so choosing it asks the user first.
52
+
53
+ Effort follows the prompt, not a parameter: scope the prompt to one question, name what "done" is, and say \
54
+ "keep it brief" for a lookup. A subagent has the session's context budget on its own transcript and hands back \
55
+ at the critical tier, so size the prompt to one slice that fits; more work means a fresh subagent per slice. An agent type defined under `.claude/agents/` carries its own model and reasoning \
56
+ effort, so a call that names one needs no `model` of its own."""
57
+
58
+ FABLE_REASON = """This subagent would run on fable, the most expensive model. Approve it where the task is \
59
+ judgment-heavy - planning, design, cross-cutting review, or leading other subagents. Deny it for implementation \
60
+ (`model: "sonnet"` or `"opus"`) or for a lookup or a small, fully specified job (`model: "haiku"`), then repeat the call with that model."""
61
+
62
+
63
+ def agent_name(argv):
64
+ """The --agent value, defaulting to claude — older registrations pass no flag."""
65
+ for index, arg in enumerate(argv):
66
+ if arg == "--agent" and index + 1 < len(argv):
67
+ return argv[index + 1]
68
+ if arg.startswith("--agent="):
69
+ return arg.split("=", 1)[1]
70
+ return "claude"
71
+
72
+
73
+ def repo_root(data):
74
+ """Repo root: the env var the agent sets, else the script's own location, else the cwd.
75
+
76
+ The script always lives at <root>/.<agent>/hooks/ethlete/subagent-model-policy.py, so
77
+ walking four levels up works for any agent that has no project-dir variable of its own.
78
+ """
79
+ for variable in ("CLAUDE_PROJECT_DIR", "CODEX_PROJECT_DIR"):
80
+ value = os.environ.get(variable)
81
+ if value:
82
+ return value
83
+ here = os.path.abspath(__file__)
84
+ derived = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(here))))
85
+ if os.path.isfile(os.path.join(derived, LOCAL_CONFIG_FILE)):
86
+ return derived
87
+ cwd = data.get("cwd")
88
+ return cwd if isinstance(cwd, str) and cwd else derived
89
+
90
+
91
+ def load_local_config(root):
92
+ """Parsed ethlete-agents.config.local.json at the repo root, or {} if missing/unreadable."""
93
+ if not root:
94
+ return {}
95
+ try:
96
+ with open(os.path.join(root, LOCAL_CONFIG_FILE), encoding="utf-8") as f:
97
+ config = json.load(f)
98
+ except (OSError, ValueError, AttributeError):
99
+ return {}
100
+ return config if isinstance(config, dict) else {}
101
+
102
+
103
+ def disabled_locally(config):
104
+ """True when the local config disables this hook (or all hooks) on this machine."""
105
+ disabled = config.get("disableHooks")
106
+ return disabled is True or (isinstance(disabled, list) and HOOK_NAME in disabled)
107
+
108
+
109
+ def definition_sets_model(root, subagent_type):
110
+ """True when a project or user agent definition of that name declares its own model."""
111
+ if not subagent_type:
112
+ return False
113
+ file_name = f"{subagent_type.split(':')[-1]}.md".lower()
114
+ candidates = []
115
+ for directory in (
116
+ os.path.join(root or ".", ".claude", "agents"),
117
+ os.path.join(os.path.expanduser("~"), ".claude", "agents"),
118
+ ):
119
+ try:
120
+ entries = os.listdir(directory)
121
+ except OSError:
122
+ continue
123
+ candidates += [os.path.join(directory, entry) for entry in entries if entry.lower() == file_name]
124
+ for path in candidates:
125
+ try:
126
+ with open(path, encoding="utf-8") as f:
127
+ head = f.read(4096)
128
+ except OSError:
129
+ continue
130
+ for line in head.split("\n"):
131
+ key, separator, value = line.partition(":")
132
+ if separator and key.strip() == "model" and value.strip():
133
+ return True
134
+ return False
135
+
136
+
137
+ def decision_for(tool_input, root):
138
+ """The (decision, reason) this call needs, or None when it may run untouched."""
139
+ subagent_type = str(tool_input.get("subagent_type") or "").strip().lower()
140
+ model = str(tool_input.get("model") or "").strip().lower()
141
+
142
+ if "fable" in model:
143
+ return "ask", FABLE_REASON
144
+ if model:
145
+ return None
146
+ if subagent_type == "fork" or definition_sets_model(root, subagent_type):
147
+ return None
148
+ return "deny", POLICY
149
+
150
+
151
+ def main():
152
+ if agent_name(sys.argv[1:]) != "claude":
153
+ return
154
+
155
+ data = json.load(sys.stdin)
156
+
157
+ if data.get("tool_name") not in SUBAGENT_TOOLS:
158
+ return
159
+
160
+ tool_input = data.get("tool_input")
161
+
162
+ if not isinstance(tool_input, dict):
163
+ return
164
+
165
+ root = repo_root(data)
166
+
167
+ if disabled_locally(load_local_config(root)):
168
+ return
169
+
170
+ outcome = decision_for(tool_input, root)
171
+
172
+ if not outcome:
173
+ return
174
+
175
+ decision, reason = outcome
176
+ print(
177
+ json.dumps(
178
+ {
179
+ "hookSpecificOutput": {
180
+ "hookEventName": "PreToolUse",
181
+ "permissionDecision": decision,
182
+ "permissionDecisionReason": reason,
183
+ }
184
+ }
185
+ )
186
+ )
187
+
188
+
189
+ if __name__ == "__main__":
190
+ try:
191
+ main()
192
+ except Exception:
193
+ pass
194
+ sys.exit(0)
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "ethlete",
3
+ "owner": { "name": "ethlete" },
4
+ "plugins": [
5
+ {
6
+ "name": "e2e-progress",
7
+ "source": "./plugins/e2e-progress",
8
+ "description": "Live Playwright e2e progress from a run log, as a status line entry and a pane"
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "e2e-progress",
3
+ "version": "0.1.0-next.31",
4
+ "description": "Live Playwright e2e progress from a run log, as a status line entry and a pane",
5
+ "types": "./types/index.d.ts"
6
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }
@@ -0,0 +1,442 @@
1
+ import { atom, read, update } from 'claude-code';
2
+ import type { EngineInterface, Register, Timer } from 'claude-code';
3
+
4
+ import type { Progress } from '../types';
5
+
6
+ const PANE = 'e2e-progress';
7
+ const HANDOFFS = ['.claude/handoffs', '.agents/handoffs'];
8
+ const WORKTREES = '.claude/worktrees';
9
+ const TICK_MS = 5000;
10
+ const STALE_MS = 15_000;
11
+ const HIDE_AFTER_MS = 60_000;
12
+ const MIN_ETA_TESTS = 5;
13
+ const MIN_ETA_MS = 30_000;
14
+ const MAX_CANDIDATES = 12;
15
+ const STALLED_MS = 15 * 60_000;
16
+ const runs = atom({ plugin: 'e2e-progress', key: 'runs' } as const, []);
17
+ const pinnedPath = atom({ plugin: 'e2e-progress', key: 'path' } as const, null);
18
+
19
+ const count = (text: string, re: RegExp) => (text.match(re) ?? []).length;
20
+ const summary = (text: string, word: string) => {
21
+ const hit = text.match(new RegExp(`^\\s+(\\d+) ${word}\\b`, 'm'));
22
+ return hit ? Number(hit[1]) : undefined;
23
+ };
24
+
25
+ const doneOf = (p: Progress) => p.passed + p.failed + p.skipped;
26
+
27
+ const FAIL_LINE = /✘\s+\d+\s+\[[^\]]+\]\s+›\s+(\S+?):(\d+):\d+\s+›\s+(.*)$/;
28
+ const TEST_DECLARATION = /\btest(\.\w+)?\(/;
29
+ const indentOf = (line: string) => line.length - line.trimStart().length;
30
+
31
+ const titleOf = (rest: string) =>
32
+ (
33
+ rest
34
+ .replace(/\s+@\S+.*$/, '')
35
+ .replace(/\s+\([\d.]+m?s\)$/, '')
36
+ .split(' › ')
37
+ .pop() ?? ''
38
+ ).trim();
39
+
40
+ const declaresAt = (source: string[], index: number, title: string) => {
41
+ if (!TEST_DECLARATION.test(source[index] ?? '')) return false;
42
+ const head = source.slice(index, index + 3).join('\n');
43
+ return head.includes(title.slice(0, 25)) || head.includes('`');
44
+ };
45
+
46
+ const callsTestFail = (source: string[], index: number) => {
47
+ const declaration = source[index] ?? '';
48
+ if (/\btest\.fail\(/.test(declaration)) return true;
49
+ const indent = indentOf(declaration);
50
+ for (const line of source.slice(index + 1)) {
51
+ if (line.trim() !== '' && indentOf(line) <= indent && !/=>\s*\{\s*$/.test(line)) return false;
52
+ if (/\btest\.fail\(/.test(line)) return true;
53
+ }
54
+ return false;
55
+ };
56
+
57
+ const sourceCache = new Map<string, string[] | null>();
58
+
59
+ async function sourceOf($: EngineInterface, path: string) {
60
+ if (!sourceCache.has(path))
61
+ sourceCache.set(
62
+ path,
63
+ await $.fs.read(path).then(
64
+ (t) => t.split('\n'),
65
+ () => null,
66
+ ),
67
+ );
68
+ return sourceCache.get(path) ?? null;
69
+ }
70
+
71
+ async function rootsFor($: EngineInterface, logPath: string) {
72
+ const at = Math.max(...HANDOFFS.map((dir) => logPath.indexOf(dir)));
73
+ const own = at > 0 ? logPath.slice(0, at) : '';
74
+ const worktrees = await $.fs.list(WORKTREES).catch(() => []);
75
+ const all = [own, '', ...worktrees.filter((d) => d.kind === 'dir').map((d) => `${WORKTREES}/${d.name}/`)];
76
+ return [...new Set(all)];
77
+ }
78
+
79
+ async function expectedFailures($: EngineInterface, logPath: string, text: string) {
80
+ const expected = new Set<string>();
81
+ const failLines = text.split('\n').filter((line) => FAIL_LINE.test(line));
82
+ if (failLines.length === 0) return expected;
83
+ const roots = await rootsFor($, logPath);
84
+ for (const line of failLines) {
85
+ const [, file = '', lineNo = '0', rest = ''] = line.match(FAIL_LINE) ?? [];
86
+ const index = Number(lineNo) - 1;
87
+ const title = titleOf(rest);
88
+ for (const root of roots) {
89
+ const source = await sourceOf($, `${root}${file}`);
90
+ if (source === null || !declaresAt(source, index, title)) continue;
91
+ if (callsTestFail(source, index)) expected.add(line);
92
+ break;
93
+ }
94
+ }
95
+ return expected;
96
+ }
97
+
98
+ const parse = (
99
+ path: string,
100
+ text: string,
101
+ expected: Set<string>,
102
+ mtimeMs: number,
103
+ nowMs: number,
104
+ last: Progress | null,
105
+ ): Progress => {
106
+ const isDone = /^\s+\d+ (passed|failed|did not run)\b/m.test(text) || /^EXIT \d+/m.test(text);
107
+ const lines = text.split('\n');
108
+ const listFailures = lines
109
+ .filter((line) => line.includes('✘') && !expected.has(line))
110
+ .map((line) => line.replace(/^.*?✘\s+\d+\s+\[[^\]]+\]\s+›\s+/, '').replace(/\s+@\S+.*$/, ''));
111
+ const lineFailures = new Map<string, string>();
112
+ for (const line of lines) {
113
+ const hit = line.match(/^\s+(\d+)\) \[[^\]]+\] › (.*)$/);
114
+ if (hit?.[1] && hit[2]) lineFailures.set(hit[1], hit[2].replace(/\s+@\S+.*$/, '').replace(/\s*─+\s*$/, ''));
115
+ }
116
+ const failures = isDone && lineFailures.size > 0 ? [...lineFailures.values()] : listFailures;
117
+ const started = Math.max(0, ...[...text.matchAll(/^\[(\d+)\/\d+\]/gm)].map((m) => Number(m[1])));
118
+ const ticks = count(text, /✓/g);
119
+ const p = {
120
+ path,
121
+ total: Number(text.match(/Running (\d+) tests?/)?.[1] ?? 0),
122
+ passed: summary(text, 'passed') ?? (ticks > 0 ? ticks + expected.size : Math.max(0, started - failures.length)),
123
+ failed: summary(text, 'failed') ?? failures.length,
124
+ skipped: summary(text, 'skipped') ?? (ticks > 0 ? count(text, /^\s+-\s+\d+\s/gm) : 0),
125
+ flaky: summary(text, 'flaky') ?? 0,
126
+ didNotRun: summary(text, 'did not run') ?? 0,
127
+ workers: Number(text.match(/Running \d+ tests? using (\d+) workers?/)?.[1] ?? 1),
128
+ duration: text.match(/^\s+\d+ passed \(([^)]+)\)/m)?.[1],
129
+ expected: expected.size,
130
+ isDone,
131
+ failures,
132
+ mtimeMs,
133
+ firstSeenMs: nowMs,
134
+ firstDone: 0,
135
+ };
136
+ const isSameRun =
137
+ last !== null &&
138
+ last.path === path &&
139
+ !last.isDone &&
140
+ Number.isFinite(last.firstSeenMs) &&
141
+ Number.isFinite(last.firstDone) &&
142
+ doneOf(p) >= last.firstDone;
143
+ return isSameRun
144
+ ? { ...p, firstSeenMs: last.firstSeenMs, firstDone: last.firstDone }
145
+ : { ...p, firstDone: doneOf(p) };
146
+ };
147
+
148
+ const FAILURES_FILE_LINE = /^\[failure-reporter\] Writing failures to (.+)$/m;
149
+
150
+ type FailureRecord = { test: string; location: string; error?: string; detail?: string | null };
151
+
152
+ const recordsOf = (text: string): FailureRecord[] =>
153
+ text.split('\n').flatMap((line) => {
154
+ if (line.trim() === '') return [];
155
+ try {
156
+ const record = JSON.parse(line);
157
+ return typeof record?.test === 'string' && typeof record?.location === 'string' ? [record] : [];
158
+ } catch {
159
+ return [];
160
+ }
161
+ });
162
+
163
+ const firstErrorLine = (error: string | undefined) =>
164
+ (error ?? '')
165
+ .split('\n')
166
+ .find((line) => line.trim() !== '')
167
+ ?.replace(/^Error:\s*/, '')
168
+ .trim() ?? '';
169
+
170
+ const failuresOf = (records: FailureRecord[]) => {
171
+ const latest = new Map<string, FailureRecord>();
172
+ for (const record of records) latest.set(`${record.location} › ${record.test}`, record);
173
+ return [...latest].map(([key, record]) => {
174
+ const error = firstErrorLine(record.error);
175
+ const line = error ? `${key} — ${error}` : key;
176
+ return record.detail ? `${line} (${record.detail})` : line;
177
+ });
178
+ };
179
+
180
+ async function withRecords($: EngineInterface, p: Progress, text: string): Promise<Progress> {
181
+ const file = text.match(FAILURES_FILE_LINE)?.[1]?.trim();
182
+ if (!file) return p;
183
+ const records = await $.fs.read(file).then(recordsOf, () => undefined);
184
+ if (records === undefined) return p;
185
+ const failures = failuresOf(records);
186
+ return { ...p, failures, failed: summary(text, 'failed') ?? failures.length };
187
+ }
188
+
189
+ const remainingMs = (p: Progress, nowMs: number) => {
190
+ const doneSince = doneOf(p) - p.firstDone;
191
+ const elapsed = nowMs - p.firstSeenMs;
192
+ if (p.isDone || doneSince < MIN_ETA_TESTS || elapsed < MIN_ETA_MS) return undefined;
193
+ return ((p.total - doneOf(p)) / doneSince) * elapsed;
194
+ };
195
+
196
+ const formatEta = (ms: number | undefined) => {
197
+ if (ms === undefined) return '';
198
+ const minutes = Math.round(ms / 60_000);
199
+ return minutes < 1 ? ' ~<1m left' : ` ~${minutes}m left`;
200
+ };
201
+
202
+ type LogFile = { path: string; mtimeMs: number };
203
+
204
+ async function logsUnder($: EngineInterface, dir: string, depth: number): Promise<LogFile[]> {
205
+ const entries = await $.fs.list(dir).catch(() => []);
206
+ const files = entries
207
+ .filter((f) => f.kind === 'file' && f.name.endsWith('.log'))
208
+ .map((f) => ({ path: `${dir}/${f.name}`, mtimeMs: f.mtimeMs }));
209
+ if (depth === 0) return files;
210
+ const nested = await Promise.all(
211
+ entries.filter((d) => d.kind === 'dir').map((d) => logsUnder($, `${dir}/${d.name}`, depth - 1)),
212
+ );
213
+ return [...files, ...nested.flat()];
214
+ }
215
+
216
+ async function baseDirs($: EngineInterface) {
217
+ const bases = new Set([await $.session.root()]);
218
+ let dir = await $.session.cwd();
219
+ for (let up = 0; up <= 3 && dir !== ''; up++) {
220
+ bases.add(dir);
221
+ dir = dir.slice(0, dir.lastIndexOf('/'));
222
+ }
223
+ return [...bases];
224
+ }
225
+
226
+ async function handoffDirs($: EngineInterface) {
227
+ const perBase = await Promise.all(
228
+ (await baseDirs($)).map(async (base) => {
229
+ const worktrees = await $.fs.list(`${base}/${WORKTREES}`).catch(() => []);
230
+ const roots = [base, ...worktrees.filter((d) => d.kind === 'dir').map((d) => `${base}/${WORKTREES}/${d.name}`)];
231
+ return roots.flatMap((root) => HANDOFFS.map((dir) => `${root}/${dir}`));
232
+ }),
233
+ );
234
+ return perBase.flat();
235
+ }
236
+
237
+ const nameOf = (path: string) => (path.split('/').pop() ?? path).replace(/\.log$/, '');
238
+
239
+ const isRunLog = (text: string) => /^Running \d+ tests?/m.test(text);
240
+
241
+ const notRunLogs = new Map<string, number>();
242
+
243
+ async function candidateLogs($: EngineInterface) {
244
+ const perDir = await Promise.all((await handoffDirs($)).map((dir) => logsUnder($, dir, 2)));
245
+ const unique = new Map(perDir.flat().map((log) => [log.path, log]));
246
+ return [...unique.values()].sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, MAX_CANDIDATES);
247
+ }
248
+
249
+ async function newestLog($: EngineInterface) {
250
+ for (const log of await candidateLogs($)) {
251
+ if (isRunLog(await $.fs.read(log.path).catch(() => ''))) return log;
252
+ }
253
+ return undefined;
254
+ }
255
+
256
+ async function pinnedLog($: EngineInterface): Promise<LogFile | undefined> {
257
+ const pinned = await read($, pinnedPath);
258
+ if (pinned === null) return undefined;
259
+ const stat = await $.fs.stat(pinned).catch(() => undefined);
260
+ const newest = await newestLog($);
261
+ if (newest !== undefined && newest.path !== pinned && (stat === undefined || newest.mtimeMs > stat.mtimeMs)) {
262
+ await update($, pinnedPath, () => null);
263
+ return undefined;
264
+ }
265
+ return stat === undefined ? undefined : { path: pinned, mtimeMs: stat.mtimeMs };
266
+ }
267
+
268
+ const isVisible = (p: Progress, nowMs: number) =>
269
+ p.isDone ? nowMs - p.mtimeMs <= HIDE_AFTER_MS : nowMs - p.mtimeMs <= STALLED_MS;
270
+
271
+ let isHidden = false;
272
+ let timer: Timer | undefined;
273
+ let lastTickMs = 0;
274
+ let shownStatus: string | undefined | null = null;
275
+
276
+ function setStatus($: EngineInterface, text: string | undefined) {
277
+ if (text === shownStatus) return;
278
+ shownStatus = text;
279
+ $.ui.status(text);
280
+ }
281
+
282
+ const statusOf = (p: Progress, nowMs: number) =>
283
+ `${nameOf(p.path)} w${p.workers} ${p.isDone ? `done ${p.duration ?? ''}`.trim() : `${doneOf(p)}/${p.total}`} ✓${p.passed} ✘${p.failed}` +
284
+ `${p.flaky ? ` ~${p.flaky}` : ''}${p.skipped ? ` -${p.skipped}` : ''}${p.didNotRun ? ` ⊘${p.didNotRun}` : ''}${formatEta(remainingMs(p, nowMs))}`;
285
+
286
+ async function tick($: EngineInterface) {
287
+ const nowMs = await $.clock.now();
288
+ const pinned = await pinnedLog($);
289
+ const logs = pinned ? [pinned] : await candidateLogs($);
290
+ const last = new Map((await read($, runs)).map((p) => [p.path, p]));
291
+ const next: Progress[] = [];
292
+ for (const log of logs) {
293
+ const previous = last.get(log.path);
294
+ if (previous?.mtimeMs === log.mtimeMs) {
295
+ next.push(previous);
296
+ continue;
297
+ }
298
+ if (notRunLogs.get(log.path) === log.mtimeMs) continue;
299
+ const text = await $.fs.read(log.path).catch(() => '');
300
+ if (!isRunLog(text)) {
301
+ notRunLogs.set(log.path, log.mtimeMs);
302
+ continue;
303
+ }
304
+ sourceCache.clear();
305
+ const parsed = parse(
306
+ log.path,
307
+ text,
308
+ await expectedFailures($, log.path, text),
309
+ log.mtimeMs,
310
+ nowMs,
311
+ previous ?? null,
312
+ );
313
+ next.push(await withRecords($, parsed, text));
314
+ }
315
+ const visible = next.filter((p) => isVisible(p, nowMs)).sort((a, b) => a.path.localeCompare(b.path));
316
+ await update($, runs, () => visible);
317
+
318
+ if (visible.length === 0) {
319
+ setStatus($, undefined);
320
+ if (!isHidden) {
321
+ isHidden = true;
322
+ await $.ui.close({ id: PANE }).catch(() => undefined);
323
+ }
324
+ return;
325
+ }
326
+
327
+ isHidden = false;
328
+ setStatus($, `e2e ${visible.map((p) => statusOf(p, nowMs)).join(' │ ')}`);
329
+ }
330
+
331
+ async function run($: EngineInterface) {
332
+ lastTickMs = await $.clock.now();
333
+ try {
334
+ await tick($);
335
+ } catch (error) {
336
+ setStatus($, `e2e-progress failed: ${String(error)}`);
337
+ }
338
+ }
339
+
340
+ function start($: EngineInterface) {
341
+ timer?.cancel();
342
+ timer = $.clock.every(TICK_MS, () => run($));
343
+ }
344
+
345
+ async function revive($: EngineInterface) {
346
+ if ((await $.clock.now()) - lastTickMs < STALE_MS) return;
347
+ start($);
348
+ await run($);
349
+ }
350
+
351
+ export const register: Register = (on) => {
352
+ on('session.start', async ($, e, next) => {
353
+ await $.command.register({
354
+ name: 'e2e',
355
+ description:
356
+ 'Show e2e run progress. Optional arg: a log path to pin; "auto" removes the pin; "off" clears the status',
357
+ });
358
+ start($);
359
+ return next(e);
360
+ });
361
+
362
+ on('prompt.submit', async ($, e, next) => {
363
+ await revive($);
364
+ return next(e);
365
+ });
366
+
367
+ on('tool.call', async ($, e, next) => {
368
+ await revive($);
369
+ return next(e);
370
+ });
371
+
372
+ on('turn.complete', async ($, e, next) => {
373
+ await revive($);
374
+ return next(e);
375
+ });
376
+
377
+ on('command.run', { command: 'e2e' }, async ($, e) => {
378
+ const arg = e.args.trim();
379
+ if (arg === 'off') {
380
+ setStatus($, undefined);
381
+ return { text: 'e2e status cleared until the log changes.' };
382
+ }
383
+ if (arg === 'auto') {
384
+ await update($, pinnedPath, () => null);
385
+ start($);
386
+ await run($);
387
+ return { text: `Pin removed. Watching the newest logs under ${HANDOFFS.join(' and ')}.` };
388
+ }
389
+ if (arg !== '') await update($, pinnedPath, () => arg);
390
+ start($);
391
+ await $.ui.open({ id: PANE, title: 'E2E progress' });
392
+ return { text: arg ? `Watching ${arg}.` : 'E2E progress pane opened.' };
393
+ });
394
+
395
+ on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
396
+ const { Box, Text } = $.ui.resolve(e);
397
+ const shown = await read($, runs);
398
+ if (shown.length === 0)
399
+ return (
400
+ <Text dimColor>
401
+ No active run log under {HANDOFFS.join(' or ')}, also in {WORKTREES}/*.
402
+ </Text>
403
+ );
404
+
405
+ const nowMs = await $.clock.now();
406
+ const room = Math.max(1, Math.floor(((e.viewport?.rows ?? 24) - 6 * shown.length) / shown.length));
407
+ return (
408
+ <Box flexDirection="column">
409
+ {shown.map((p) => {
410
+ const done = doneOf(p);
411
+ return (
412
+ <Box flexDirection="column" marginBottom={1}>
413
+ <Text bold>
414
+ {nameOf(p.path)} <Text dimColor>· {p.workers} workers</Text>
415
+ </Text>
416
+ <Text dimColor wrap="truncate-start">
417
+ {p.path}
418
+ </Text>
419
+ <Text>
420
+ {p.isDone ? `finished in ${p.duration ?? '?'}` : `${done} / ${p.total}`}
421
+ {p.total > 0 && !p.isDone ? ` (${Math.round((done / p.total) * 100)}%)` : ''}
422
+ {formatEta(remainingMs(p, nowMs))}
423
+ </Text>
424
+ <Text>
425
+ <Text color="green">✓ {p.passed}</Text> <Text color="red">✘ {p.failed}</Text>{' '}
426
+ <Text dimColor>- {p.skipped} skipped</Text>
427
+ {p.didNotRun > 0 ? <Text color="yellow"> ⊘ {p.didNotRun} did not run</Text> : ''}
428
+ {p.flaky > 0 ? <Text color="yellow"> ~ {p.flaky} flaky</Text> : ''}
429
+ </Text>
430
+ {p.expected > 0 && <Text dimColor>{p.expected} expected failures (test.fail) counted as passed</Text>}
431
+ {p.failures.slice(-room).map((f) => (
432
+ <Text color="red" wrap="truncate-end">
433
+ ✘ {f}
434
+ </Text>
435
+ ))}
436
+ </Box>
437
+ );
438
+ })}
439
+ </Box>
440
+ );
441
+ });
442
+ };
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "./.claude-plugin/types/tsconfig.json"
3
+ }
@@ -0,0 +1,23 @@
1
+ export type Progress = {
2
+ path: string;
3
+ total: number;
4
+ passed: number;
5
+ failed: number;
6
+ skipped: number;
7
+ flaky: number;
8
+ didNotRun: number;
9
+ workers: number;
10
+ duration: string | undefined;
11
+ expected: number;
12
+ isDone: boolean;
13
+ failures: string[];
14
+ mtimeMs: number;
15
+ firstSeenMs: number;
16
+ firstDone: number;
17
+ };
18
+
19
+ declare module 'claude-code' {
20
+ interface PluginState {
21
+ 'e2e-progress': { runs: Progress[]; path: string | null };
22
+ }
23
+ }