@bompus/house-rules 0.0.0-stage → 0.11.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 (191) hide show
  1. package/CONTRIBUTING.md +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +347 -2
  4. package/THIRD_PARTY_NOTICES.md +188 -0
  5. package/compose.mjs +119 -0
  6. package/composition.mjs +353 -0
  7. package/config-view.mjs +194 -0
  8. package/config.mjs +320 -0
  9. package/docs/assets/banner-dark.png +0 -0
  10. package/docs/assets/banner-light.png +0 -0
  11. package/docs/assets/overview.png +0 -0
  12. package/docs/configuration.md +193 -0
  13. package/docs/npm-release.md +45 -0
  14. package/docs/skill-names.md +50 -0
  15. package/examples/person/house-rules.json +15 -0
  16. package/examples/person/rules/10-tooling.md +8 -0
  17. package/examples/person/rules/20-reporting.md +9 -0
  18. package/examples/project/AGENTS.md +18 -0
  19. package/package.json +40 -4
  20. package/rule-references.mjs +91 -0
  21. package/rules/core.md +431 -0
  22. package/rules/modifiers/coded-offers.md +40 -0
  23. package/rules/modifiers/effort-estimates.md +41 -0
  24. package/rules/modifiers/land-when-done.md +12 -0
  25. package/rules/modifiers/low-quota-handoff.md +19 -0
  26. package/rules/modifiers/multi-agent.md +34 -0
  27. package/rules/modifiers/no-attribution.md +10 -0
  28. package/rules/modifiers/plan-files.md +50 -0
  29. package/rules/modifiers/question-cards.md +48 -0
  30. package/rules/modifiers/release-batching.md +13 -0
  31. package/rules/modifiers/scratch-on-disk.md +15 -0
  32. package/rules/modifiers/shared-host-load.md +48 -0
  33. package/rules/modifiers/solo-operator.md +13 -0
  34. package/rules/modifiers/squash-landing.md +22 -0
  35. package/rules/modifiers/swarmail.md +12 -0
  36. package/rules/references/github-actions.md +45 -0
  37. package/rules/references/landing.md +39 -0
  38. package/rules/references/multi-agent.md +47 -0
  39. package/rules/references/release-batching.md +43 -0
  40. package/rules/references/squash-landing.md +44 -0
  41. package/setup.mjs +375 -0
  42. package/skills/hr-agent-guidance-audit/LICENSE +21 -0
  43. package/skills/hr-agent-guidance-audit/SKILL.md +86 -0
  44. package/skills/hr-agent-guidance-audit/agents/openai.yaml +2 -0
  45. package/skills/hr-agent-guidance-audit/references/records-and-lessons.md +60 -0
  46. package/skills/hr-agent-guidance-refresh/LICENSE +21 -0
  47. package/skills/hr-agent-guidance-refresh/SKILL.md +68 -0
  48. package/skills/hr-agent-guidance-refresh/agents/openai.yaml +2 -0
  49. package/skills/hr-agent-guidance-refresh/references/verification.md +47 -0
  50. package/skills/hr-api-exposure-check/SKILL.md +159 -0
  51. package/skills/hr-api-exposure-check/agents/openai.yaml +3 -0
  52. package/skills/hr-audit-choices/SKILL.md +67 -0
  53. package/skills/hr-audit-choices/agents/openai.yaml +2 -0
  54. package/skills/hr-benchmarking/NOTICE.md +36 -0
  55. package/skills/hr-benchmarking/SKILL.md +316 -0
  56. package/skills/hr-benchmarking/agents/openai.yaml +2 -0
  57. package/skills/hr-benchmarking/references/javascript.md +17 -0
  58. package/skills/hr-benchmarking/references/priority-findings-20261006.md +115 -0
  59. package/skills/hr-benchmarking/references/tools.md +160 -0
  60. package/skills/hr-benchmarking/scripts/process-attribution.ps1 +20 -0
  61. package/skills/hr-benchmarking/scripts/process-attribution.py +107 -0
  62. package/skills/hr-better-accessibility/LICENSE +22 -0
  63. package/skills/hr-better-accessibility/NOTICE.md +46 -0
  64. package/skills/hr-better-accessibility/SKILL.md +119 -0
  65. package/skills/hr-better-accessibility/agents/openai.yaml +3 -0
  66. package/skills/hr-better-accessibility/focus-and-keyboard.md +130 -0
  67. package/skills/hr-better-accessibility/forms.md +82 -0
  68. package/skills/hr-better-accessibility/hit-areas.md +98 -0
  69. package/skills/hr-better-accessibility/motion-and-zoom.md +81 -0
  70. package/skills/hr-better-accessibility/screen-readers.md +101 -0
  71. package/skills/hr-better-accessibility/semantics-and-aria.md +84 -0
  72. package/skills/hr-better-colors/LICENSE +21 -0
  73. package/skills/hr-better-colors/NOTICE.md +37 -0
  74. package/skills/hr-better-colors/SKILL.md +114 -0
  75. package/skills/hr-better-colors/agents/openai.yaml +3 -0
  76. package/skills/hr-better-colors/color-formats.md +90 -0
  77. package/skills/hr-better-colors/color-usage.md +140 -0
  78. package/skills/hr-better-colors/contrast.md +80 -0
  79. package/skills/hr-better-colors/palette-generation.md +104 -0
  80. package/skills/hr-better-colors/palette-structure.md +76 -0
  81. package/skills/hr-better-colors/token-naming.md +97 -0
  82. package/skills/hr-better-layout/LICENSE +22 -0
  83. package/skills/hr-better-layout/NOTICE.md +48 -0
  84. package/skills/hr-better-layout/SKILL.md +92 -0
  85. package/skills/hr-better-layout/agents/openai.yaml +3 -0
  86. package/skills/hr-better-layout/grouping-and-alignment.md +144 -0
  87. package/skills/hr-better-layout/spacing-and-adaptivity.md +215 -0
  88. package/skills/hr-better-typography/LICENSE +22 -0
  89. package/skills/hr-better-typography/NOTICE.md +46 -0
  90. package/skills/hr-better-typography/SKILL.md +172 -0
  91. package/skills/hr-better-typography/agents/openai.yaml +3 -0
  92. package/skills/hr-better-typography/choosing-fonts.md +69 -0
  93. package/skills/hr-better-typography/css-cheat-sheet.md +65 -0
  94. package/skills/hr-better-typography/details-and-accessibility.md +119 -0
  95. package/skills/hr-better-typography/spacing-and-sizing.md +131 -0
  96. package/skills/hr-better-typography/variable-fonts-and-opentype.md +105 -0
  97. package/skills/hr-better-typography/wrapping-and-punctuation.md +57 -0
  98. package/skills/hr-better-writing/LICENSE +22 -0
  99. package/skills/hr-better-writing/NOTICE.md +46 -0
  100. package/skills/hr-better-writing/SKILL.md +122 -0
  101. package/skills/hr-better-writing/agents/openai.yaml +3 -0
  102. package/skills/hr-change-impact/SKILL.md +113 -0
  103. package/skills/hr-change-impact/agents/openai.yaml +5 -0
  104. package/skills/hr-code-review/LICENSE +21 -0
  105. package/skills/hr-code-review/NOTICE.md +26 -0
  106. package/skills/hr-code-review/SKILL.md +147 -0
  107. package/skills/hr-code-review/agents/openai.yaml +3 -0
  108. package/skills/hr-code-review/references/evidence.md +57 -0
  109. package/skills/hr-design-exploration/SKILL.md +82 -0
  110. package/skills/hr-design-exploration/agents/openai.yaml +4 -0
  111. package/skills/hr-diagnosing-bugs/NOTICE.md +45 -0
  112. package/skills/hr-diagnosing-bugs/SKILL.md +108 -0
  113. package/skills/hr-diagnosing-bugs/agents/openai.yaml +3 -0
  114. package/skills/hr-diagnosing-bugs/references/reporting.md +72 -0
  115. package/skills/hr-explain-code/NOTICE.md +37 -0
  116. package/skills/hr-explain-code/SKILL.md +110 -0
  117. package/skills/hr-explain-code/agents/openai.yaml +5 -0
  118. package/skills/hr-explain-code/references/helper-brief.md +39 -0
  119. package/skills/hr-extract-shared-steps/SKILL.md +134 -0
  120. package/skills/hr-extract-shared-steps/agents/openai.yaml +3 -0
  121. package/skills/hr-handoff/SKILL.md +91 -0
  122. package/skills/hr-handoff/agents/openai.yaml +5 -0
  123. package/skills/hr-handoff/assets/ledger.example.json +10 -0
  124. package/skills/hr-handoff/assets/receipt.example.json +76 -0
  125. package/skills/hr-handoff/assets/source.example.json +20 -0
  126. package/skills/hr-handoff/references/reconciliation.md +105 -0
  127. package/skills/hr-handoff/scripts/check-reconciliation.mjs +282 -0
  128. package/skills/hr-house-rules-setup/SKILL.md +100 -0
  129. package/skills/hr-house-rules-setup/agents/openai.yaml +5 -0
  130. package/skills/hr-issue-tracker-setup/SKILL.md +49 -0
  131. package/skills/hr-issue-tracker-setup/agents/openai.yaml +5 -0
  132. package/skills/hr-lean-plan/SKILL.md +142 -0
  133. package/skills/hr-lean-plan/agents/openai.yaml +5 -0
  134. package/skills/hr-maintainability-review/SKILL.md +176 -0
  135. package/skills/hr-maintainability-review/agents/openai.yaml +5 -0
  136. package/skills/hr-manual-qa/NOTICE.md +36 -0
  137. package/skills/hr-manual-qa/SKILL.md +37 -0
  138. package/skills/hr-manual-qa/agents/openai.yaml +3 -0
  139. package/skills/hr-navigation-retro/LICENSE +21 -0
  140. package/skills/hr-navigation-retro/NOTICE.md +5 -0
  141. package/skills/hr-navigation-retro/SKILL.md +86 -0
  142. package/skills/hr-navigation-retro/agents/openai.yaml +4 -0
  143. package/skills/hr-navigation-retro/references/inputs.md +67 -0
  144. package/skills/hr-navigation-retro/scripts/retro.py +552 -0
  145. package/skills/hr-navigation-retro/scripts/transcripts.py +205 -0
  146. package/skills/hr-ordering-tests/SKILL.md +51 -0
  147. package/skills/hr-ordering-tests/agents/openai.yaml +2 -0
  148. package/skills/hr-plain-prose/SKILL.md +261 -0
  149. package/skills/hr-plain-prose/agents/openai.yaml +3 -0
  150. package/skills/hr-plain-prose/scripts/check.mjs +331 -0
  151. package/skills/hr-pr-followup/LICENSE +21 -0
  152. package/skills/hr-pr-followup/NOTICE.md +9 -0
  153. package/skills/hr-pr-followup/SKILL.md +71 -0
  154. package/skills/hr-pr-followup/agents/openai.yaml +2 -0
  155. package/skills/hr-pr-followup/references/review-helpers.md +90 -0
  156. package/skills/hr-pr-followup/scripts/check-pr-review.ts +510 -0
  157. package/skills/hr-pr-followup/scripts/resolve-pr-thread.ts +219 -0
  158. package/skills/hr-progress-report/SKILL.md +49 -0
  159. package/skills/hr-progress-report/agents/openai.yaml +5 -0
  160. package/skills/hr-read-reddit/SKILL.md +44 -0
  161. package/skills/hr-read-reddit/agents/openai.yaml +2 -0
  162. package/skills/hr-read-reddit/scripts/reddit.ts +252 -0
  163. package/skills/hr-read-x-links/SKILL.md +42 -0
  164. package/skills/hr-read-x-links/agents/openai.yaml +2 -0
  165. package/skills/hr-read-x-links/scripts/x-post.ts +264 -0
  166. package/skills/hr-split-to-prs/LICENSE +21 -0
  167. package/skills/hr-split-to-prs/NOTICE.md +9 -0
  168. package/skills/hr-split-to-prs/SKILL.md +61 -0
  169. package/skills/hr-split-to-prs/agents/openai.yaml +2 -0
  170. package/skills/hr-stock-ui-audit/SKILL.md +132 -0
  171. package/skills/hr-stock-ui-audit/agents/openai.yaml +3 -0
  172. package/skills/hr-stock-ui-audit/scripts/scan.mjs +619 -0
  173. package/skills/hr-tdd/LICENSE +21 -0
  174. package/skills/hr-tdd/NOTICE.md +10 -0
  175. package/skills/hr-tdd/SKILL.md +40 -0
  176. package/skills/hr-tdd/agents/openai.yaml +3 -0
  177. package/skills/hr-tdd/mocking.md +39 -0
  178. package/skills/hr-tdd/tests.md +77 -0
  179. package/skills/hr-test-audit/SKILL.md +146 -0
  180. package/skills/hr-test-audit/agents/openai.yaml +3 -0
  181. package/skills/hr-test-audit/scripts/floor-guard.ts +137 -0
  182. package/skills/hr-what-next/SKILL.md +61 -0
  183. package/skills/hr-what-next/agents/openai.yaml +4 -0
  184. package/skills/hr-what-next/references/reporting.md +68 -0
  185. package/skills/hr-writing-for-agents/NOTICE.md +46 -0
  186. package/skills/hr-writing-for-agents/SKILL-MECHANICS.md +83 -0
  187. package/skills/hr-writing-for-agents/SKILL.md +108 -0
  188. package/skills/hr-writing-for-agents/agents/openai.yaml +3 -0
  189. package/skills/hr-writing-pr/NOTICE.md +48 -0
  190. package/skills/hr-writing-pr/SKILL.md +104 -0
  191. package/skills/hr-writing-pr/agents/openai.yaml +2 -0
@@ -0,0 +1,552 @@
1
+ #!/usr/bin/env python3
2
+ """Navigation analysis of explicitly selected coding-agent transcripts.
3
+
4
+ retro.py rank --repo-root NAME=ROOT ... [--since-days 30] [--repo NAME ...]
5
+ [--top 8] [--subagents] [--json PATH] TRANSCRIPT ...
6
+ retro.py timeline <transcript.jsonl> [--width 240]
7
+
8
+ `rank` reads only the explicit TRANSCRIPT files, counts events within the
9
+ requested timestamp window, and prints one summary row per
10
+ repository (with at least --min-sessions sessions), then the highest-scoring
11
+ sessions per repository with their transcript path. `timeline` prints one
12
+ transcript as prompts, assistant text, numbered tool calls and `!!` failed or
13
+ empty results, so a session can be read without loading the raw JSONL.
14
+
15
+ Per-session metrics:
16
+ tools tool calls
17
+ nav navigation calls: Read/Grep/Glob/LS, codegraph_explore, or a shell
18
+ command that starts with grep, rg, find, ls, cat, sed -n, head, tail,
19
+ tree, git grep/log/show, fd, wc, awk or codegraph explore
20
+ navEdit navigation calls before the first edit (Edit/Write/apply_patch)
21
+ misses navigation that found nothing: "No matches/No files found", or a
22
+ missing-file error. `.codegraph/*` globs, `opencode*` patterns and
23
+ ToolSearch are excluded as noise.
24
+ widen a miss within 4 calls of the previous miss (a search being widened)
25
+ rereads files read 3+ times (Read, or cat/sed -n/head/tail on a path)
26
+ stale unverified assistant text matches about stale, moved or wrong paths
27
+ score misses + 2*widen + 2*rereads (heuristic weights; stale text matches
28
+ remain visible but do not contribute to ranking)
29
+ rate score per 100 tool calls. The top list sorts by rate (or --sort
30
+ score) among sessions with at least --min-tools calls, so a long
31
+ session does not outrank a short one by length alone.
32
+
33
+ Limits: Claude/Codex JSONL, OpenCode JSON exports, Cursor stream JSON and ACP v1
34
+ recordings. All recorded events are included unless --since-days is requested;
35
+ missing or malformed timestamps are excluded from dated rank. Repository names
36
+ come from explicit root mappings and each command's working directory, falling
37
+ back to session metadata. Unmapped paths remain unknown.
38
+ One batched call counts once in each participating repository, so summed rows
39
+ can exceed outer calls. Mixed-repository results and remarks are ambiguous and
40
+ are not counted as misses or stale remarks. Dynamic command directories remain
41
+ unknown. Literal leading cd and normalized absolute read paths are supported;
42
+ complex shell state and JavaScript expressions are not evaluated.
43
+ Outputs contain raw session text and filesystem paths, without redaction.
44
+ Keep them local. --json overwrites its output, but cannot overwrite an input.
45
+ """
46
+ import argparse
47
+ import collections
48
+ import json
49
+ import os
50
+ import re
51
+ import sys
52
+ import time
53
+ import shlex
54
+ import math
55
+ from datetime import datetime, timezone
56
+ from transcripts import records, opencode, acp_update, acp_calls, acp, cursor
57
+
58
+ if sys.version_info < (3, 11):
59
+ sys.exit("retro.py requires Python 3.11 or newer")
60
+
61
+ HOME = os.path.expanduser("~")
62
+ REPO_ROOTS = []
63
+ DEFAULT_CWD = None
64
+ NAV_TOOLS = {"Read", "Grep", "Glob", "LS"}
65
+ EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit", "apply_patch"}
66
+ NAV_SHELL = re.compile(
67
+ r"^\s*(?:cd [^;&]+(?:&&|;)\s*)?(grep|rg|find|ls|cat|sed -n|head|tail|tree|git grep|git log|git show|fd|wc|awk|codegraph explore)\b",
68
+ re.M,
69
+ )
70
+ EMPTY_SEARCH = re.compile(r"\s*(No matches found|No files found)")
71
+ MISSING_FILE = re.compile(r"No such file or directory|File does not exist|does not exist|ENOENT")
72
+ NOISE = re.compile(r"\.codegraph/\*|opencode\*|^select:")
73
+ READ_PATH = re.compile(r"(?:sed -n \S+ |cat |head (?:-n ?\d+ |-\d+ )?|tail (?:-n ?\d+ |-\d+ )?)((?:[A-Za-z]:)?[~\w./\\-]+\.\w+)")
74
+ STALE = re.compile(
75
+ r"out of date|out-of-date|\bstale\b|outdated|no longer (?:exists?|there|true|accurate)"
76
+ r"|doesn'?t (?:match|exist)|does not (?:match|exist)|wrong path|moved to"
77
+ r"|(?:docs?|README|AGENTS\.md|CLAUDE\.md|plan|guidance|skill) (?:says|said|claims|still says)"
78
+ r"|(?:was|has been) (?:removed|renamed|moved)",
79
+ re.I,
80
+ )
81
+
82
+
83
+ def repo_of(cwd):
84
+ if not isinstance(cwd, str) or not cwd or not os.path.isabs(cwd):
85
+ return "?"
86
+ path = os.path.normcase(os.path.normpath(cwd))
87
+ for name, root in REPO_ROOTS:
88
+ try:
89
+ if os.path.commonpath([path, root]) == root:
90
+ return name
91
+ except ValueError: # Different filesystem drives.
92
+ continue
93
+ return "?"
94
+
95
+
96
+ def text_of(content):
97
+ if isinstance(content, list):
98
+ return " ".join(c.get("text", "") for c in content if isinstance(c, dict))
99
+ return content if isinstance(content, str) else json.dumps(content) if content else ""
100
+
101
+
102
+ JS_STRING = r'''"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`'''
103
+ JS_COMMAND = re.compile(r'exec_command\(\s*(\{(?:' + JS_STRING + r'|[^{}"\'`])*\})')
104
+ JS_FIELD = re.compile(JS_STRING + r'|(?P<key>\b(?:cmd|workdir))\s*:')
105
+
106
+
107
+ def shell_commands(name, arg, cwd):
108
+ """Shell commands run by a call: Claude Bash, Codex exec_command, or each
109
+ tools.exec_command in a Codex `exec` code cell. Empty for other tools."""
110
+ if name == "Bash":
111
+ return [(arg, cwd)]
112
+ if name == "exec":
113
+ commands = []
114
+ for obj in JS_COMMAND.findall(arg):
115
+ fields = {}
116
+ seen = set()
117
+ for match in JS_FIELD.finditer(obj):
118
+ key = match.group('key')
119
+ rest = obj[match.end():].lstrip()
120
+ if not key:
121
+ # Quoted property names are valid; quoted values are not keys.
122
+ token = match[0]
123
+ key = token[1:-1]
124
+ if key not in ('cmd', 'workdir') or not rest.startswith(':'):
125
+ continue
126
+ rest = rest[1:].lstrip()
127
+ seen.add(key)
128
+ fields.pop(key, None) # A later unresolved value replaces an earlier literal.
129
+ value = re.match(JS_STRING, rest)
130
+ if not value or not rest[value.end():].lstrip().startswith((',', '}')):
131
+ continue
132
+ literal = value[0]
133
+ if '${' in literal or '`' in literal[1:-1]:
134
+ continue
135
+ try:
136
+ fields[key] = json.loads(literal) if literal[0] == '"' else literal[1:-1]
137
+ except ValueError:
138
+ continue
139
+ syntax = re.sub(JS_STRING, '""', obj)
140
+ unresolved = '...' in syntax or re.search(r'[,{}]\s*workdir\s*[,}]', syntax)
141
+ workdir = None if unresolved else fields.get('workdir') if 'workdir' in seen else cwd
142
+ commands.append((fields.get('cmd'), workdir))
143
+ if len(re.findall(r'\bexec_command\s*\(', arg)) > len(commands):
144
+ commands.append((None, None)) # An unparsed child keeps the result ambiguous.
145
+ return commands
146
+ if name in ("exec_command", "shell", "local_shell"):
147
+ try:
148
+ parsed = json.loads(arg)
149
+ except ValueError:
150
+ return [(arg, cwd)]
151
+ value = parsed.get("cmd") or parsed.get("command") if isinstance(parsed, dict) else None
152
+ if isinstance(value, list):
153
+ if not all(isinstance(part, str) for part in value):
154
+ return [(None, parsed.get('workdir', cwd))]
155
+ command = value[-1] if len(value) >= 3 and value[1] in ('-c', '-lc') else ' '.join(value)
156
+ return [(command, parsed.get('workdir', cwd))]
157
+ return [(value if isinstance(value, str) else arg, parsed.get('workdir', cwd) if isinstance(parsed, dict) else cwd)]
158
+ return []
159
+
160
+
161
+ def physical_path(path, cwd):
162
+ if not isinstance(path, str) or not path or '$' in path or '`' in path:
163
+ return None
164
+ if path == '~' or path.startswith('~/'):
165
+ path = HOME + path[1:]
166
+ if not os.path.isabs(path):
167
+ if not cwd:
168
+ return None
169
+ path = os.path.join(cwd, path)
170
+ return os.path.normpath(path)
171
+
172
+
173
+ def command_context(command, cwd):
174
+ if not isinstance(command, str):
175
+ return None, cwd
176
+ match = re.match(r'^\s*cd\s+([^;&\n]+)\s*(?:&&|;)\s*', command)
177
+ if match:
178
+ try:
179
+ parts = shlex.split(match[1])
180
+ except ValueError:
181
+ parts = []
182
+ cwd = physical_path(parts[0], cwd) if len(parts) == 1 else None
183
+ command = command[match.end():]
184
+ return command, cwd
185
+
186
+
187
+ def timestamp(value):
188
+ try:
189
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
190
+ return (value / 1000 if value > 100_000_000_000 else value) if math.isfinite(value) else None
191
+ date = datetime.fromisoformat(value)
192
+ return date.replace(tzinfo=timezone.utc).timestamp() if date.tzinfo is None else date.timestamp()
193
+ except (TypeError, ValueError, OverflowError):
194
+ return None
195
+
196
+
197
+ def raw_events(path):
198
+ """Normalized events from a Claude or Codex transcript.
199
+
200
+ ("meta", cwd, title, sub) | ("context", cwd) | ("user", text) | ("assistant", text)
201
+ | ("call", name, arg, id) | ("result", id, text, is_error), each followed
202
+ by its record timestamp in seconds, or None when unavailable.
203
+ """
204
+ if DEFAULT_CWD:
205
+ yield ('meta', DEFAULT_CWD, '', False, None)
206
+ calls, seen, finished, cursor_seen = None, set(), set(), set()
207
+ session_ids = set()
208
+ for record in records(path):
209
+ if isinstance(record.get('messages'), list) and isinstance(record.get('info'), dict):
210
+ yield from opencode(record, timestamp)
211
+ continue
212
+ session, update = acp_update(record)
213
+ if isinstance(update, dict):
214
+ if isinstance(session, str) and session:
215
+ session_ids.add(session)
216
+ if len(session_ids) > 1:
217
+ raise ValueError('select one ACP session per transcript')
218
+ if calls is None:
219
+ calls = acp_calls(path)
220
+ yield from acp(record, calls, seen, finished, timestamp)
221
+ continue
222
+ if record.get('method') in ('session/new', 'session/load', 'session/resume'):
223
+ params = record.get('params')
224
+ if isinstance(params, dict) and isinstance(params.get('cwd'), str):
225
+ yield ('context', params['cwd'], timestamp(record.get('timestamp')))
226
+ continue
227
+ if record.get('type') == 'tool_call' and isinstance(record.get('tool_call'), dict):
228
+ yield from cursor(record, cursor_seen, timestamp)
229
+ continue
230
+ payload = record.get("payload")
231
+ stamp = timestamp(record.get('timestamp_ms')) or timestamp(record.get('timestamp'))
232
+ emit = lambda event: (*event, stamp)
233
+ if isinstance(payload, dict):
234
+ kind = payload.get("type")
235
+ if record.get("type") == "session_meta":
236
+ sub = payload.get("source") == "exec" or payload.get("originator") == "codex_exec"
237
+ yield emit(("meta", payload.get("cwd"), "", sub))
238
+ elif record.get('type') == 'turn_context':
239
+ yield emit(('context', payload.get('cwd')))
240
+ elif kind == "message":
241
+ role = payload.get("role")
242
+ if role in ('user', 'assistant'):
243
+ yield emit((role, text_of(payload.get("content"))))
244
+ elif kind in ("function_call", "custom_tool_call"):
245
+ arg = payload.get("arguments") or payload.get("input") or ""
246
+ yield emit(("call", payload.get("name") or "", arg if isinstance(arg, str) else json.dumps(arg), payload.get("call_id")))
247
+ elif kind in ("function_call_output", "custom_tool_call_output"):
248
+ yield emit(("result", payload.get("call_id"), text_of(payload.get("output")), False))
249
+ continue
250
+ if record.get("type") == "ai-title":
251
+ yield emit(("meta", None, record.get("aiTitle", ""), False))
252
+ if record.get("cwd"):
253
+ yield emit(("meta", record["cwd"], "", False))
254
+ message = record.get("message")
255
+ if not isinstance(message, dict):
256
+ continue
257
+ content = message.get("content")
258
+ role = message.get('role', record.get('type'))
259
+ if isinstance(content, str):
260
+ if role in ('user', 'assistant'):
261
+ yield emit((role, content))
262
+ continue
263
+ for part in content if isinstance(content, list) else []:
264
+ if not isinstance(part, dict):
265
+ continue
266
+ kind = part.get("type")
267
+ if kind == "text":
268
+ if role in ('user', 'assistant'):
269
+ yield emit((role, part.get("text", "")))
270
+ elif kind == "tool_use":
271
+ inp = part.get("input")
272
+ if not isinstance(inp, dict):
273
+ continue
274
+ arg = inp.get("command") or inp.get("file_path") or inp.get("pattern") or inp.get("query") or inp.get("prompt") or json.dumps(inp)
275
+ yield emit(("call", part.get("name") or "", str(arg), part.get("id")))
276
+ elif kind == "tool_result":
277
+ yield emit(("result", part.get("tool_use_id"), text_of(part.get("content")), bool(part.get("is_error"))))
278
+
279
+
280
+ def events(path):
281
+ chunks = []
282
+ role, stamp = None, None
283
+ for event in raw_events(path):
284
+ kind = event[0]
285
+ if kind in ('user_chunk', 'assistant_chunk'):
286
+ next_role = kind.removesuffix('_chunk')
287
+ if role and next_role != role:
288
+ yield (role, ''.join(chunks), stamp)
289
+ chunks = []
290
+ if not chunks:
291
+ role, stamp = next_role, event[-1]
292
+ chunks.append(event[1])
293
+ continue
294
+ if chunks:
295
+ yield (role, ''.join(chunks), stamp)
296
+ chunks, role = [], None
297
+ yield event
298
+ if chunks:
299
+ yield (role, ''.join(chunks), stamp)
300
+
301
+
302
+ def missing_path(text, is_error=False):
303
+ """Inspect complete result diagnostics, including serialized shell outputs."""
304
+ lines = text.splitlines()
305
+ decoder = json.JSONDecoder()
306
+ fence = None
307
+ for line in lines:
308
+ rest = line.lstrip()
309
+ marker = re.match(r'(`{3,}|~{3,})(.*)$', rest)
310
+ if fence:
311
+ if marker and marker[1][0] == fence[0] and len(marker[1]) >= len(fence) and not marker[2].strip():
312
+ fence = None
313
+ continue
314
+ if marker:
315
+ fence = marker[1]
316
+ continue
317
+ while rest.startswith('{'):
318
+ try:
319
+ result, end = decoder.raw_decode(rest)
320
+ except ValueError:
321
+ break
322
+ if isinstance(result, dict):
323
+ if is_error and result.get('code') == 'ENOENT':
324
+ return 'ENOENT: ' + str(result.get('message', 'missing path'))
325
+ if ('chunk_id' in result or 'wall_time_seconds' in result) and isinstance(result.get('output'), str):
326
+ lines.extend(result['output'].splitlines())
327
+ rest = rest[end:].lstrip()
328
+ if re.match(r'^(?:cat|rg|grep|find|ls|sed|head|tail|bash|sh|Error|FileNotFoundError):', rest) or re.match(
329
+ r'^(?:No such file or directory|File does not exist|does not exist|ENOENT)\b', rest
330
+ ):
331
+ if MISSING_FILE.search(rest):
332
+ return rest
333
+ return None
334
+
335
+
336
+ def scan(path, sub=False, cutoff=None):
337
+ rows = {}
338
+ cwd, title, prompt, index = None, '', '', 0
339
+ last_repos = set()
340
+ pending = {}
341
+ total_calls, undated = 0, 0
342
+ def state(repo):
343
+ if repo not in rows:
344
+ rows[repo] = dict(path=path, repo=repo, sub=sub, title=title, prompt=prompt,
345
+ tools=0, nav=0, navEdit=None, misses=0, widen=0,
346
+ rereads=0, stale=0, missExamples=[], staleExamples=[],
347
+ rereadFiles=[], reads=collections.Counter(), last_miss=None)
348
+ return rows[repo]
349
+ for stamped in events(path):
350
+ *event, stamp = stamped
351
+ kind = event[0]
352
+ if kind == "context":
353
+ cwd = event[1] or cwd
354
+ last_repos = set()
355
+ continue
356
+ if kind == "meta":
357
+ _, new_cwd, new_title, is_sub = event
358
+ if new_cwd and new_cwd != cwd:
359
+ last_repos = set()
360
+ cwd = new_cwd or cwd
361
+ title = title or new_title
362
+ sub = sub or is_sub
363
+ continue
364
+ if kind == 'call':
365
+ index += 1 # Same full-transcript numbering as timeline.
366
+ total_calls += 1
367
+ undated += stamp is None
368
+ if cutoff is not None and (stamp is None or stamp < cutoff):
369
+ continue
370
+ elif kind == "user":
371
+ text = event[1].strip()
372
+ if not prompt and text and not text.startswith(("<", "#")):
373
+ prompt = re.sub(r"\s+", " ", text)[:160]
374
+ elif kind == "assistant":
375
+ if len(last_repos) > 1:
376
+ continue # Do not duplicate an ambiguous batch's remarks.
377
+ s = state(next(iter(last_repos), repo_of(cwd)))
378
+ for match in STALE.finditer(event[1]):
379
+ s["stale"] += 1
380
+ if len(s["staleExamples"]) < 10:
381
+ snippet = event[1][max(0, match.start() - 140): match.end() + 140]
382
+ s["staleExamples"].append([index, re.sub(r"\s+", " ", snippet)])
383
+ elif kind == "call":
384
+ _, name, arg, call_id = event
385
+ commands = [command_context(c, physical_path(d, cwd)) for c, d in shell_commands(name, arg, cwd)]
386
+ targets = {}
387
+ for command, directory in commands:
388
+ repo = repo_of(directory)
389
+ paths = [physical_path(m[1], directory) for m in READ_PATH.finditer(command or '')]
390
+ targets.setdefault(repo, [False, []])
391
+ targets[repo][0] |= bool(command and NAV_SHELL.search(command))
392
+ targets[repo][1].extend(p for p in paths if p)
393
+ if not targets:
394
+ file = physical_path(arg, cwd) if name == 'Read' else None
395
+ targets[repo_of(file or cwd)] = [name in NAV_TOOLS or 'codegraph_explore' in name, [file] if file else []]
396
+ is_edit = name in EDIT_TOOLS or (name == "exec" and "apply_patch" in arg) or "*** Begin Patch" in arg[:200]
397
+ for repo, (is_nav, paths) in targets.items():
398
+ s = state(repo)
399
+ s['tools'] += 1
400
+ if is_edit and s['navEdit'] is None:
401
+ s['navEdit'] = s['nav']
402
+ s['nav'] += is_nav
403
+ s['reads'].update(paths)
404
+ pending[call_id] = (name, arg, targets, index)
405
+ last_repos = set(targets)
406
+ elif kind == "result":
407
+ _, call_id, text, is_error = event
408
+ name, arg, targets, call_index = pending.get(call_id, ('', '', {}, 0))
409
+ if len(targets) != 1 or NOISE.search(arg) or name == 'ToolSearch':
410
+ continue
411
+ repo = next(iter(targets))
412
+ if not targets[repo][0]:
413
+ continue
414
+ miss = (name in ("Grep", "Glob") and EMPTY_SEARCH.match(text)) or (
415
+ (is_error or name not in NAV_TOOLS) and missing_path(text, is_error)
416
+ )
417
+ if miss:
418
+ s = state(repo)
419
+ s["misses"] += 1
420
+ if s['last_miss'] is not None and 0 <= call_index - s['last_miss'] <= 4:
421
+ s["widen"] += 1
422
+ s['last_miss'] = call_index
423
+ if len(s["missExamples"]) < 8:
424
+ s["missExamples"].append([call_index, name, arg[:160]])
425
+ if not total_calls:
426
+ print(f'warning: no supported tool-call evidence in {path}', file=sys.stderr)
427
+ if undated:
428
+ action = 'excluded from dated ranking' if cutoff is not None else 'included without date filtering'
429
+ print(f'warning: {undated} undated tool calls {action} in {path}', file=sys.stderr)
430
+ for s in rows.values():
431
+ reads = s.pop('reads')
432
+ s.pop('last_miss')
433
+ s.update(title=title, prompt=prompt, sub=sub)
434
+ s["rereadFiles"] = [[f.replace(HOME, '~', 1), n] for f, n in reads.most_common() if n >= 3][:8]
435
+ s["rereads"] = sum(n >= 3 for n in reads.values())
436
+ s["score"] = s["misses"] + 2 * s["widen"] + 2 * s["rereads"]
437
+ s["rate"] = round(100 * s["score"] / max(s["tools"], 1), 1)
438
+ return list(rows.values())
439
+
440
+
441
+ def rank(args):
442
+ cutoff = time.time() - args.since_days * 86400 if args.since_days is not None else None
443
+ sessions = [row for path in args.transcripts
444
+ for row in scan(path, "/subagents/" in path.replace("\\", "/"), cutoff)]
445
+ sessions = [s for s in sessions if s["tools"] and (args.subagents or not s["sub"])]
446
+ if args.repo:
447
+ sessions = [s for s in sessions if s["repo"] in args.repo]
448
+ if args.json:
449
+ with open(args.json, "w") as out:
450
+ json.dump(sessions, out)
451
+ by_repo = collections.defaultdict(list)
452
+ for s in sessions:
453
+ by_repo[s["repo"]].append(s)
454
+ repos = sorted((r for r, rows in by_repo.items() if len(rows) >= args.min_sessions), key=lambda r: -len(by_repo[r]))
455
+ hidden = sum(len(rows) for r, rows in by_repo.items() if r not in repos)
456
+ print(f"{'repo':32} {'sessions':>8} {'tools':>7} {'nav%':>5} {'navEdit':>7} {'misses':>6} {'widen':>5} {'rereads':>7} {'stale':>5}")
457
+ for repo in repos:
458
+ rows = by_repo[repo]
459
+ tools = sum(s["tools"] for s in rows)
460
+ before_edit = sorted(s["navEdit"] for s in rows if s["navEdit"] is not None)
461
+ median = before_edit[len(before_edit) // 2] if before_edit else "-"
462
+ nav_share = 100 * sum(s["nav"] for s in rows) // max(tools, 1)
463
+ print(f"{repo[:32]:32} {len(rows):8} {tools:7} {nav_share:4}% {median!s:>7} "
464
+ + " ".join(f"{sum(s[k] for s in rows):{w}}" for k, w in (("misses", 6), ("widen", 5), ("rereads", 7), ("stale", 5))))
465
+ if hidden:
466
+ print(f"({hidden} sessions omitted: fewer than --min-sessions)")
467
+ for repo in repos:
468
+ print(f"\n== {repo}: top sessions by {args.sort}")
469
+ ranked = sorted((s for s in by_repo[repo] if s["tools"] >= args.min_tools), key=lambda s: -s[args.sort])
470
+ for s in ranked[: args.top]:
471
+ print(f" rate={s['rate']} score={s['score']} misses={s['misses']} widen={s['widen']} rereads={s['rereads']} "
472
+ f"stale={s['stale']} tools={s['tools']} | {(s['title'] or s['prompt'])[:70]!r}\n {s['path']}")
473
+
474
+
475
+ def timeline(args):
476
+ def short(value, width=args.width):
477
+ return re.sub(r"\s+", " ", str(value))[:width]
478
+
479
+ index = 0
480
+ for event in events(args.transcript):
481
+ kind = event[0]
482
+ if kind in ("user", "assistant") and event[1].strip() and not event[1].lstrip().startswith("<"):
483
+ print(f"[{kind}] {short(event[1])}")
484
+ elif kind == "call":
485
+ index += 1
486
+ print(f" #{index} {event[1]}: {short(event[2], 200)}")
487
+ elif kind == "result":
488
+ text = event[2]
489
+ diagnostic = missing_path(text, event[3])
490
+ if event[3] or EMPTY_SEARCH.match(text) or diagnostic or not text.strip() \
491
+ or re.search(r"Process exited with code [1-9]", text[:800]):
492
+ print(f" !! {short(diagnostic or text or '(empty)', 160)}")
493
+
494
+
495
+ def main():
496
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
497
+ commands = parser.add_subparsers(dest="command", required=True)
498
+ r = commands.add_parser("rank", help="summarize repositories and rank sessions by friction")
499
+ r.add_argument("--since-days", type=float, help="rank only this recent window; excludes undated events (default: all recorded events)")
500
+ r.add_argument("--repo", action="append", help="filter output by repository name; does not limit input access")
501
+ r.add_argument("--repo-root", action="append", required=True, metavar="NAME=ROOT",
502
+ help="repository name and absolute recorded root; repeat for multiple checkouts")
503
+ r.add_argument("transcripts", nargs="+", metavar="TRANSCRIPT", help="explicit JSON/JSONL inputs; no store discovery")
504
+ r.add_argument("--top", type=int, default=8)
505
+ r.add_argument("--min-sessions", type=int, default=3)
506
+ r.add_argument("--min-tools", type=int, default=100, help="shortest session the top list ranks")
507
+ r.add_argument("--sort", choices=("rate", "score"), default="rate")
508
+ r.add_argument("--subagents", action="store_true", help="include transcripts identified as subagent sessions")
509
+ r.add_argument("--json", help="also write every session's metrics to this file")
510
+ t = commands.add_parser("timeline", help="print one transcript compactly")
511
+ t.add_argument("transcript")
512
+ t.add_argument("--width", type=int, default=240)
513
+ for command in (r, t):
514
+ command.add_argument('--cwd', help='fallback absolute recorded session cwd when the transcript omits it')
515
+ args = parser.parse_args()
516
+ if args.command == 'rank' and args.since_days is not None and (not math.isfinite(args.since_days) or args.since_days < 0):
517
+ parser.error('--since-days requires a finite nonnegative number')
518
+ global DEFAULT_CWD
519
+ if args.cwd:
520
+ if not os.path.isabs(args.cwd):
521
+ parser.error('--cwd requires an absolute recorded session directory')
522
+ DEFAULT_CWD = args.cwd
523
+ if args.command == "rank":
524
+ roots = {}
525
+ for mapping in args.repo_root:
526
+ name, separator, root = mapping.partition("=")
527
+ root = os.path.expanduser(root)
528
+ if not separator or not name or not os.path.isabs(root):
529
+ parser.error("--repo-root requires NAME=absolute-root")
530
+ root = os.path.normcase(os.path.normpath(root))
531
+ if root in roots and roots[root] != name:
532
+ parser.error("one repository root cannot have conflicting names")
533
+ roots[root] = name
534
+ global REPO_ROOTS
535
+ REPO_ROOTS = sorted(((name, root) for root, name in roots.items()),
536
+ key=lambda pair: len(pair[1]), reverse=True)
537
+ paths = list(dict.fromkeys(os.path.realpath(path) for path in args.transcripts))
538
+ for path in paths:
539
+ if not os.path.isfile(path):
540
+ parser.error("transcript is not a regular file: " + path)
541
+ if args.json and (os.path.realpath(args.json) == path or
542
+ (os.path.exists(args.json) and os.path.samefile(args.json, path))):
543
+ parser.error("--json cannot overwrite an input transcript")
544
+ args.transcripts = paths
545
+ try:
546
+ (rank if args.command == "rank" else timeline)(args)
547
+ except (OSError, ValueError) as error:
548
+ parser.error(str(error))
549
+
550
+
551
+ if __name__ == "__main__":
552
+ sys.exit(main())