proctolog 0.1.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 (228) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +178 -0
  4. data/app/controllers/proctolog/acceptances_controller.rb +29 -0
  5. data/app/controllers/proctolog/application_controller.rb +32 -0
  6. data/app/controllers/proctolog/bursts_controller.rb +34 -0
  7. data/app/controllers/proctolog/choices_controller.rb +30 -0
  8. data/app/controllers/proctolog/code_reviews_controller.rb +26 -0
  9. data/app/controllers/proctolog/decisions_controller.rb +33 -0
  10. data/app/controllers/proctolog/entries_controller.rb +14 -0
  11. data/app/controllers/proctolog/evidence_controller.rb +23 -0
  12. data/app/controllers/proctolog/freshness_controller.rb +27 -0
  13. data/app/controllers/proctolog/good_to_go_controller.rb +29 -0
  14. data/app/controllers/proctolog/memberships_controller.rb +20 -0
  15. data/app/controllers/proctolog/mentions_controller.rb +16 -0
  16. data/app/controllers/proctolog/moments_controller.rb +26 -0
  17. data/app/controllers/proctolog/note_params.rb +24 -0
  18. data/app/controllers/proctolog/notes_controller.rb +56 -0
  19. data/app/controllers/proctolog/open_pull_requests_controller.rb +59 -0
  20. data/app/controllers/proctolog/pictures_controller.rb +27 -0
  21. data/app/controllers/proctolog/prompts_controller.rb +28 -0
  22. data/app/controllers/proctolog/pull_request_scoped.rb +21 -0
  23. data/app/controllers/proctolog/pull_requests_controller.rb +34 -0
  24. data/app/controllers/proctolog/put_away_controller.rb +19 -0
  25. data/app/controllers/proctolog/recordings_controller.rb +28 -0
  26. data/app/controllers/proctolog/rendered_pieces_controller.rb +11 -0
  27. data/app/controllers/proctolog/review_data_controller.rb +12 -0
  28. data/app/controllers/proctolog/rules_controller.rb +20 -0
  29. data/app/controllers/proctolog/scenario_notes_controller.rb +26 -0
  30. data/app/controllers/proctolog/scenario_scoped.rb +21 -0
  31. data/app/controllers/proctolog/scenarios_controller.rb +67 -0
  32. data/app/controllers/proctolog/sitting_servers_controller.rb +30 -0
  33. data/app/controllers/proctolog/waiting_controller.rb +14 -0
  34. data/app/controllers/proctolog/with_decisions.rb +28 -0
  35. data/app/helpers/proctolog/bursts_helper.rb +52 -0
  36. data/app/helpers/proctolog/decisions_helper.rb +102 -0
  37. data/app/helpers/proctolog/desk_helper.rb +61 -0
  38. data/app/helpers/proctolog/mentions_helper.rb +27 -0
  39. data/app/helpers/proctolog/open_pull_requests_helper.rb +31 -0
  40. data/app/helpers/proctolog/prompts_helper.rb +21 -0
  41. data/app/helpers/proctolog/pull_requests_helper.rb +67 -0
  42. data/app/helpers/proctolog/recordings_helper.rb +30 -0
  43. data/app/helpers/proctolog/rounds_helper.rb +27 -0
  44. data/app/helpers/proctolog/scenarios_helper.rb +123 -0
  45. data/app/helpers/proctolog/standings_helper.rb +80 -0
  46. data/app/jobs/proctolog/application_job.rb +8 -0
  47. data/app/jobs/proctolog/ask_for_release_job.rb +9 -0
  48. data/app/jobs/proctolog/fetch_pictures_ahead_job.rb +36 -0
  49. data/app/jobs/proctolog/prune_fresh_profiles_job.rb +9 -0
  50. data/app/jobs/proctolog/read_mentions_ahead_job.rb +12 -0
  51. data/app/models/proctolog/acceptance.rb +47 -0
  52. data/app/models/proctolog/app.rb +92 -0
  53. data/app/models/proctolog/browser.rb +42 -0
  54. data/app/models/proctolog/burst.rb +268 -0
  55. data/app/models/proctolog/burst_notices.rb +143 -0
  56. data/app/models/proctolog/chapters.rb +42 -0
  57. data/app/models/proctolog/check.rb +122 -0
  58. data/app/models/proctolog/choices.rb +112 -0
  59. data/app/models/proctolog/code_review.rb +251 -0
  60. data/app/models/proctolog/decision_record.rb +12 -0
  61. data/app/models/proctolog/decisions.rb +199 -0
  62. data/app/models/proctolog/description.rb +165 -0
  63. data/app/models/proctolog/difference.rb +138 -0
  64. data/app/models/proctolog/doctor.rb +127 -0
  65. data/app/models/proctolog/evidence.rb +29 -0
  66. data/app/models/proctolog/failure.rb +7 -0
  67. data/app/models/proctolog/focus.rb +48 -0
  68. data/app/models/proctolog/fresh_sitting.rb +35 -0
  69. data/app/models/proctolog/github.rb +353 -0
  70. data/app/models/proctolog/handover/brief.rb +138 -0
  71. data/app/models/proctolog/handover/layers.rb +128 -0
  72. data/app/models/proctolog/handover/places.rb +98 -0
  73. data/app/models/proctolog/handover/taken.rb +41 -0
  74. data/app/models/proctolog/handover/worklist.rb +35 -0
  75. data/app/models/proctolog/handover.rb +123 -0
  76. data/app/models/proctolog/housekeeping.rb +23 -0
  77. data/app/models/proctolog/kit.rb +36 -0
  78. data/app/models/proctolog/marks.rb +130 -0
  79. data/app/models/proctolog/membership.rb +91 -0
  80. data/app/models/proctolog/mentions.rb +117 -0
  81. data/app/models/proctolog/note.rb +94 -0
  82. data/app/models/proctolog/outside.rb +45 -0
  83. data/app/models/proctolog/overlay/brief.rb +52 -0
  84. data/app/models/proctolog/overlay/written.rb +33 -0
  85. data/app/models/proctolog/overlay.rb +87 -0
  86. data/app/models/proctolog/owner.rb +16 -0
  87. data/app/models/proctolog/pick.rb +47 -0
  88. data/app/models/proctolog/picture.rb +138 -0
  89. data/app/models/proctolog/pieces_rendering.rb +40 -0
  90. data/app/models/proctolog/project.rb +118 -0
  91. data/app/models/proctolog/pull_request.rb +89 -0
  92. data/app/models/proctolog/recording.rb +194 -0
  93. data/app/models/proctolog/release.rb +59 -0
  94. data/app/models/proctolog/rendering.rb +303 -0
  95. data/app/models/proctolog/reports.rb +66 -0
  96. data/app/models/proctolog/response_files.rb +55 -0
  97. data/app/models/proctolog/rounds.rb +305 -0
  98. data/app/models/proctolog/route.rb +151 -0
  99. data/app/models/proctolog/rules.rb +121 -0
  100. data/app/models/proctolog/scenario.rb +126 -0
  101. data/app/models/proctolog/server.rb +273 -0
  102. data/app/models/proctolog/since.rb +108 -0
  103. data/app/models/proctolog/size.rb +30 -0
  104. data/app/models/proctolog/standing.rb +132 -0
  105. data/app/models/proctolog/theme.rb +40 -0
  106. data/app/models/proctolog/unopened/numbers.rb +48 -0
  107. data/app/models/proctolog/unopened/opening.rb +88 -0
  108. data/app/models/proctolog/unopened.rb +203 -0
  109. data/app/models/proctolog/visit.rb +26 -0
  110. data/app/views/layouts/proctolog/application.html.erb +58 -0
  111. data/app/views/proctolog/acceptances/new.html.erb +34 -0
  112. data/app/views/proctolog/bursts/_line.html.erb +28 -0
  113. data/app/views/proctolog/bursts/_merged.html.erb +23 -0
  114. data/app/views/proctolog/bursts/_moved.html.erb +16 -0
  115. data/app/views/proctolog/bursts/_notices.html.erb +39 -0
  116. data/app/views/proctolog/bursts/_put_away.html.erb +11 -0
  117. data/app/views/proctolog/bursts/_rules.html.erb +27 -0
  118. data/app/views/proctolog/bursts/_side.html.erb +49 -0
  119. data/app/views/proctolog/bursts/_side_stop.html.erb +10 -0
  120. data/app/views/proctolog/bursts/show.html.erb +54 -0
  121. data/app/views/proctolog/code_reviews/show.html.erb +33 -0
  122. data/app/views/proctolog/decisions/_alternative.html.erb +5 -0
  123. data/app/views/proctolog/decisions/_beneath.html.erb +21 -0
  124. data/app/views/proctolog/decisions/_box.html.erb +27 -0
  125. data/app/views/proctolog/decisions/_choices.html.erb +9 -0
  126. data/app/views/proctolog/decisions/_draft_tools.html.erb +47 -0
  127. data/app/views/proctolog/decisions/_part.html.erb +61 -0
  128. data/app/views/proctolog/decisions/_round_data.html.erb +1 -0
  129. data/app/views/proctolog/decisions/_theme.html.erb +13 -0
  130. data/app/views/proctolog/decisions/index.html.erb +75 -0
  131. data/app/views/proctolog/notes/_card.html.erb +19 -0
  132. data/app/views/proctolog/notes/_template.html.erb +41 -0
  133. data/app/views/proctolog/open_pull_requests/_side.html.erb +30 -0
  134. data/app/views/proctolog/open_pull_requests/index.html.erb +106 -0
  135. data/app/views/proctolog/prompts/show.html.erb +41 -0
  136. data/app/views/proctolog/pull_requests/_connections.html.erb +65 -0
  137. data/app/views/proctolog/pull_requests/_finish.html.erb +42 -0
  138. data/app/views/proctolog/pull_requests/_margin.html.erb +33 -0
  139. data/app/views/proctolog/pull_requests/_note.html.erb +4 -0
  140. data/app/views/proctolog/pull_requests/_outline.html.erb +26 -0
  141. data/app/views/proctolog/pull_requests/_paragraphs.html.erb +9 -0
  142. data/app/views/proctolog/pull_requests/_scenarios.html.erb +16 -0
  143. data/app/views/proctolog/pull_requests/_since.html.erb +42 -0
  144. data/app/views/proctolog/pull_requests/_standing.html.erb +51 -0
  145. data/app/views/proctolog/pull_requests/_status.html.erb +5 -0
  146. data/app/views/proctolog/pull_requests/_try.html.erb +122 -0
  147. data/app/views/proctolog/pull_requests/show.html.erb +145 -0
  148. data/app/views/proctolog/recordings/_dialog.html.erb +26 -0
  149. data/app/views/proctolog/recordings/_list.html.erb +8 -0
  150. data/app/views/proctolog/recordings/_note.html.erb +22 -0
  151. data/app/views/proctolog/recordings/index.html.erb +33 -0
  152. data/app/views/proctolog/recordings/show.html.erb +118 -0
  153. data/app/views/proctolog/scenarios/_dialog.html.erb +22 -0
  154. data/app/views/proctolog/scenarios/_note.html.erb +18 -0
  155. data/app/views/proctolog/scenarios/_open.html.erb +9 -0
  156. data/app/views/proctolog/scenarios/_scenario.html.erb +48 -0
  157. data/app/views/proctolog/scenarios/_server.html.erb +20 -0
  158. data/app/views/proctolog/scenarios/index.html.erb +62 -0
  159. data/bin/proctolog +330 -0
  160. data/completions/_proctolog +143 -0
  161. data/config/application.rb +94 -0
  162. data/config/database.yml +16 -0
  163. data/config/environment.rb +6 -0
  164. data/config/initializers/grouse.rb +113 -0
  165. data/config/initializers/grouse_notes.rb +9 -0
  166. data/config/keys.yml +6 -0
  167. data/config/routes.rb +71 -0
  168. data/config/storage.yml +4 -0
  169. data/db/migrate/20261019213542_create_grouse_tables.grouse.rb +128 -0
  170. data/db/migrate/20261019213550_create_active_storage_tables.active_storage.rb +58 -0
  171. data/docs/manual/app.md +157 -0
  172. data/docs/manual/bursts.md +129 -0
  173. data/docs/manual/deciding.md +89 -0
  174. data/docs/manual/desk.png +0 -0
  175. data/docs/manual/films.md +20 -0
  176. data/docs/manual/handover.md +93 -0
  177. data/docs/manual/pr-format.md +75 -0
  178. data/docs/manual/reading.md +233 -0
  179. data/docs/manual/unopened.md +80 -0
  180. data/docs/manual/updates.md +55 -0
  181. data/lib/proctolog/installation.rb +125 -0
  182. data/lib/proctolog/version.rb +7 -0
  183. data/public/asking.js +50 -0
  184. data/public/code-review.js +40 -0
  185. data/public/cursor.js +292 -0
  186. data/public/decision-screenshots.js +97 -0
  187. data/public/decisions.css +949 -0
  188. data/public/decisions.js +1289 -0
  189. data/public/desk.css +582 -0
  190. data/public/desk.js +126 -0
  191. data/public/favicon.png +0 -0
  192. data/public/keys.js +228 -0
  193. data/public/mentions.js +389 -0
  194. data/public/messages.js +103 -0
  195. data/public/notes.js +731 -0
  196. data/public/pick.css +99 -0
  197. data/public/plaque.png +0 -0
  198. data/public/pr-number.js +11 -0
  199. data/public/prompt.css +46 -0
  200. data/public/prompt.js +16 -0
  201. data/public/pull_request.css +805 -0
  202. data/public/recording.css +379 -0
  203. data/public/recording.js +394 -0
  204. data/public/release.js +25 -0
  205. data/public/respond.css +260 -0
  206. data/public/respond.js +95 -0
  207. data/public/review-drafts.js +949 -0
  208. data/public/review-rounds.js +1447 -0
  209. data/public/scenarios.css +253 -0
  210. data/public/scenarios.js +36 -0
  211. data/public/schemas/proctolog-response-v1.json +101 -0
  212. data/public/side.js +133 -0
  213. data/public/standing.css +478 -0
  214. data/public/terminal-apl.svg +6 -0
  215. data/public/terminal-cuneiform.svg +3 -0
  216. data/public/terminal-grain-dots.svg +6 -0
  217. data/public/terminal-grain-weave.svg +6 -0
  218. data/public/terminal-grain-wedges.svg +6 -0
  219. data/public/terminal-shades.svg +3 -0
  220. data/public/terminal.css +4426 -0
  221. data/public/toast.js +26 -0
  222. data/public/try.js +124 -0
  223. data/public/viewer.js +808 -0
  224. data/public/words.js +81 -0
  225. data/skills/proctolog-handover/SKILL.md +77 -0
  226. data/skills/proctolog-pr-writing/SKILL.md +228 -0
  227. data/skills/proctolog-pr-writing/references/example-description.md +33 -0
  228. metadata +366 -0
@@ -0,0 +1,80 @@
1
+ # Before anything is opened
2
+
3
+ Work still being shaped can be reviewed before any pull request is opened for
4
+ it. Nothing reaches GitHub: no pull request, no picture, no push. Once the
5
+ branches are opened, the review goes with them.
6
+
7
+ ## What the agent writes
8
+
9
+ An unopened pull request is what `gh pr create` would be told, written down
10
+ before anyone runs it: a branch, the branch it goes into, a title and a
11
+ description. Each lives in a folder of your store named after its branch,
12
+ which `proctolog unopened folder BRANCH` prints:
13
+
14
+ ```
15
+ ~/.local/share/proctolog/<owner>/<repo>/unopened/recovery/emails/
16
+ pull_request.md
17
+ reset-mail-before.png
18
+ reset-mail-after.png
19
+ ```
20
+
21
+ ```markdown
22
+ ---
23
+ title: A reset mail says which account it is for
24
+ base: recovery/fields
25
+ ---
26
+ ## Summary
27
+ …
28
+ ```
29
+
30
+ The description is written as any other the desk reads
31
+ ([how](pr-format.md)). Its pictures sit beside it, linked by their file names:
32
+ `![reset-mail-after](reset-mail-after.png)`. Tell your agent to "write these
33
+ for PRoctolog without opening PRs"; the `proctolog-pr-writing` skill knows the
34
+ rest. A missing title is the branch's name, and a missing base is origin's
35
+ default branch.
36
+
37
+ `proctolog unopened` lists what the desk reads, each with its number. GitHub
38
+ never writes a number with a leading zero, so the desk numbers these 01, 02
39
+ and on, and a branch keeps its number. `proctolog check recovery/emails`, or
40
+ `proctolog check 01`, checks one, pictures and all.
41
+
42
+ ## Reviewing them
43
+
44
+ The open pull requests page lists them under "Not opened", each with "Review
45
+ alone", and "Review the chain" for one that sits on another's branch. A burst
46
+ file names one by its branch, in the lists it already has:
47
+
48
+ ```yaml
49
+ name: Account recovery
50
+ repository: sartoria-llc/sartoria
51
+ tops: [recovery/emails] # followed down by base, as any chain
52
+ pull_requests: [recovery/copy] # stands alone
53
+ ```
54
+
55
+ From there it is a pull request like any other: read, decided, tried, handed
56
+ over. Its line says "not opened, on `recovery/emails`", and `G` says GitHub
57
+ has nothing of it. A chain followed down from it can end on an open pull
58
+ request's branch, and sits on it.
59
+
60
+ What differs is where things are kept:
61
+
62
+ - Its head and files are read from the branch on this Mac, each time a page
63
+ is shown: there is nothing to read again.
64
+ - Ticking accepted decisions writes its file, not GitHub.
65
+ - The desk keeps each text it reads in the folder's `.revisions/`, so a "good
66
+ to go" that the description moved past says how it changed.
67
+ - The handover tells the agent where its file is. An agent rewrites the file
68
+ in place, which needs no word from you, so a whole round stays on this Mac.
69
+
70
+ ## Opening them
71
+
72
+ When you're ready, tell the agent to open them. That is a GitHub write, and
73
+ waits for your word: it pushes the branches, uploads the pictures and opens
74
+ each pull request with its title, base and description.
75
+
76
+ On its next reading the desk finds the open pull request on each branch and
77
+ carries the review over to its new number: notes, choices, good-to-go marks,
78
+ code comments, handovers and films. The route says "opened, was #01" until
79
+ you dismiss it with `x`. From then on GitHub is the truth, and the folder is
80
+ left alone.
@@ -0,0 +1,55 @@
1
+ # Versions and updates
2
+
3
+ PRoctolog is a gem, `proctolog`, numbered the semantic way: `MAJOR.MINOR.PATCH`.
4
+
5
+ - A **patch** (0.1.0 → 0.1.1) fixes something. Nothing you do changes.
6
+ - A **minor** (0.1.1 → 0.2.0) adds something. Nothing you do changes either.
7
+ - A **major** (0.2.0 → 1.0.0) asks something new of you: a command renamed or
8
+ gone, a file that moved, a format your agent writes differently.
9
+ [CHANGELOG.md](../../CHANGELOG.md) says what.
10
+
11
+ While the number starts with 0, a minor may ask something of you too, and the
12
+ changelog says so when it does.
13
+
14
+ `proctolog version` says which one you run.
15
+
16
+ ## Being told
17
+
18
+ Once a day while the desk serves, and once as it starts, it asks RubyGems which
19
+ PRoctolog is the latest. That's the only thing it sends anywhere besides
20
+ GitHub: a request for `rubygems.org/api/v1/versions/proctolog/latest.json`,
21
+ with nothing of yours in it. It keeps the answer in
22
+ `~/.cache/proctolog/latest_release.json`.
23
+
24
+ When the latest is newer than yours, you're told three ways:
25
+
26
+ - the next page you open says so in the toast at the foot, once in each
27
+ browser for each version, and `m` shows it again after;
28
+ - `proctolog serve` says so as it starts;
29
+ - `proctolog doctor` says so, on a `--` line.
30
+
31
+ None of them waits on RubyGems: they read what the desk last kept, and say
32
+ nothing before the first answer.
33
+
34
+ ## Updating
35
+
36
+ ```sh
37
+ proctolog update
38
+ ```
39
+
40
+ It asks RubyGems, installs the newer version the way yours was installed
41
+ (`rv tool install` for a PRoctolog that `rvx` runs, `gem install` otherwise),
42
+ and runs that version's `proctolog install`, so zsh's completion and the agent
43
+ skills link to it. A checkout of PRoctolog is pulled forward instead, and its
44
+ bundle installed. A desk that's open keeps running the old one: stop it and run
45
+ `proctolog serve` again.
46
+
47
+ The old version stays installed. `gem cleanup proctolog` removes the ones
48
+ you no longer run; with rv, `rv tool list` shows them and
49
+ `rv tool uninstall proctolog` removes them all.
50
+
51
+ A checkout is pulled only when that's all it takes: no changes of your own in
52
+ it, on the branch origin calls its own, and only forward (`git pull
53
+ --ff-only`). Anything else, `update` says what's in the way and leaves it to
54
+ you. A plain `git pull` works too; run `bundle install` after it when the
55
+ Gemfile.lock changed.
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "rbconfig"
6
+ require "shellwords"
7
+
8
+ module Proctolog
9
+ # Where this PRoctolog lives, and what it puts on this Mac for zsh and the
10
+ # agents to find it: zsh's completion and the agent skills, as links into
11
+ # it, so they never fall behind it. It lives in one of three places:
12
+ #
13
+ # - a checkout, with its own bundle and a launcher in ~/.local/bin;
14
+ # - an rv tool, on a Ruby and gems of its own whatever Ruby the shell has,
15
+ # with a launcher in ~/.local/bin that asks rvx for it;
16
+ # - a gem among the others, run by RubyGems' own launcher.
17
+ #
18
+ # Plain Ruby, as bin/install runs it before any bundle.
19
+ class Installation
20
+ # Something of the same name is someone else's, and stays.
21
+ Refused = Class.new(StandardError)
22
+
23
+ SKILL_FOLDERS = %w[.claude/skills .agents/skills].freeze
24
+
25
+ def self.here = new(File.expand_path("../..", __dir__))
26
+
27
+ attr_reader :root
28
+
29
+ def initialize(root, home: Dir.home, env: ENV)
30
+ @root, @home, @env = Pathname(root).realpath, Pathname(home), env
31
+ end
32
+
33
+ # A gem ships no Gemfile: a checkout runs in a bundle of its own.
34
+ def checkout? = root.join("Gemfile").exist?
35
+
36
+ # rv gives each tool a gem home of its own, named for it and its version,
37
+ # and runs it with GEM_HOME set there.
38
+ def rv_tool? = !checkout? && gem_home&.basename.to_s.start_with?("proctolog@") && root.to_s.start_with?("#{gem_home}/")
39
+
40
+ def gem_home = @env["GEM_HOME"]&.then { Pathname(it) }
41
+
42
+ # Brings a checkout up to its origin, as `proctolog update` does: only a
43
+ # clean one, on the branch origin calls its own, and only forward.
44
+ def pull
45
+ raise Refused, "#{root} has changes of its own: commit or stash them, then run `proctolog update` again." unless git("status", "--porcelain").empty?
46
+
47
+ branch, main = git("branch", "--show-current"), origin_branch
48
+ raise Refused, "#{root} is on #{branch}, not #{main}: switch to #{main}, or pull #{branch} yourself." unless branch == main
49
+
50
+ git("pull", "--ff-only")
51
+ end
52
+
53
+ def launcher = @home.join(".local/bin/proctolog")
54
+
55
+ def completion = @home.join(".zsh/completions/_proctolog")
56
+
57
+ # What it did, a line each. Refuses before it writes anything.
58
+ def install
59
+ refuse_strangers
60
+ [*(script ? launch : shadowed), complete, *link_skills]
61
+ end
62
+
63
+ private
64
+
65
+ # What git printed, or Refused with what it said.
66
+ def git(*arguments)
67
+ output, status = Open3.capture2e("git", "-C", root.to_s, *arguments)
68
+ status.success? ? output.strip : raise(Refused, "git #{arguments.join(" ")} failed in #{root}:\n#{output}")
69
+ end
70
+
71
+ # origin's own branch, as this Mac last fetched it.
72
+ def origin_branch
73
+ git("symbolic-ref", "--short", "refs/remotes/origin/HEAD").delete_prefix("origin/")
74
+ rescue Refused
75
+ "main"
76
+ end
77
+
78
+ # The launcher's, where this PRoctolog needs one of its own on the PATH.
79
+ def script
80
+ if checkout? then "#!/bin/sh\nexec #{RbConfig.ruby.shellescape} #{root.join("bin/proctolog").to_s.shellescape} \"$@\"\n"
81
+ elsif rv_tool? then "#!/bin/sh\nexec rvx proctolog \"$@\"\n"
82
+ end
83
+ end
84
+
85
+ def refuse_strangers
86
+ if script && launcher.exist? && launcher.read != script
87
+ raise Refused, "#{launcher} runs another PRoctolog; leave it in place or remove it before installing."
88
+ end
89
+ # A copy an earlier install left is replaced by the link; anything else is someone else's.
90
+ if completion.exist? && !completion.symlink? && !completion.read.start_with?("#compdef proctolog")
91
+ raise Refused, "#{completion} is not PRoctolog's completion; leave it in place or remove it before installing."
92
+ end
93
+ end
94
+
95
+ def launch
96
+ return "#{launcher} runs this PRoctolog." if launcher.exist?
97
+
98
+ launcher.dirname.mkpath
99
+ launcher.write(script, perm: 0o755)
100
+ "Installed #{launcher}."
101
+ end
102
+
103
+ # A checkout's launcher, left from before the gem, may come first on the PATH.
104
+ def shadowed = launcher.exist? ? ["#{launcher} runs another PRoctolog: remove it, or it may answer for proctolog instead of this one."] : []
105
+
106
+ def complete
107
+ completion.dirname.mkpath
108
+ FileUtils.ln_sf(root.join("completions/_proctolog"), completion)
109
+ "Linked #{completion}."
110
+ end
111
+
112
+ # A skill folder of the same name that is no link is someone else's: it stays.
113
+ def link_skills
114
+ SKILL_FOLDERS.product(root.glob("skills/*/")).map do |folder, skill|
115
+ link = @home.join(folder, skill.basename)
116
+ next "#{link} is not a link to PRoctolog's skill; left as it is." if link.exist? && !link.symlink?
117
+
118
+ link.dirname.mkpath
119
+ FileUtils.rm_f(link)
120
+ link.make_symlink(skill.to_s.chomp("/"))
121
+ "Linked #{link}."
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # Semantic: a patch fixes, a minor adds, a major asks something new of the
5
+ # owner (a command gone, a file moved). docs/manual/updates.md says more.
6
+ VERSION = "0.1.0"
7
+ end
data/public/asking.js ADDED
@@ -0,0 +1,50 @@
1
+ // A question the owner asks at once, in a Claude Code session of its own,
2
+ // rather than in the handover. The prompt quotes each decision as the desk
3
+ // holds it, so the session needs nothing else to know what is meant, and
4
+ // puts the question last, where the owner goes on typing.
5
+
6
+ import { inventory } from "./review-drafts.js";
7
+
8
+ const { checkout, pictures } = document.querySelector("[data-review-draft]")
9
+ .dataset;
10
+
11
+ // The decision's words as a block quote, without its theme marker.
12
+ const quoted = (text) =>
13
+ text
14
+ .replace(/\s*<!-- proctolog:theme [\w-]+ -->/g, "")
15
+ .trim()
16
+ .split("\n")
17
+ .map((line) => `> ${line}`.trimEnd())
18
+ .join("\n");
19
+
20
+ // Where a decision stands, its words, and the pictures it rests on that
21
+ // the desk holds on this Mac.
22
+ const about = ({ pr, title, url, branch, head, text, pictures: shots }) => {
23
+ const here = shots.filter((picture) => picture.file);
24
+ return [
25
+ `#${pr}, "${title}" (${url}), on ${branch} at ${head.slice(0, 7)}:`,
26
+ quoted(text),
27
+ here.length &&
28
+ `Its pictures, on this Mac:\n${here.map((picture) => `- ${picture.name}: ${pictures}/${picture.file}`).join("\n")}`,
29
+ ]
30
+ .filter(Boolean)
31
+ .join("\n\n");
32
+ };
33
+
34
+ export const askingPrompt = (ids, question) => {
35
+ const decisions = ids.map((id) => inventory.find((each) => each.id === id));
36
+ const which =
37
+ decisions.length === 1
38
+ ? "one of their decisions"
39
+ : `${decisions.length} of their decisions`;
40
+ return [
41
+ `I am reviewing pull requests in PRoctolog and have a question on ${which} before I choose. Answer it plainly, with evidence. Change nothing while you do: no edits, no commits, no checkout, nothing on GitHub. Read a pull request with \`gh pr view\` and \`gh pr diff\`; if one has moved on since the head named here, say so.`,
42
+ ...decisions.map(about),
43
+ `My question:\n${question.trim()}`,
44
+ ].join("\n\n");
45
+ };
46
+
47
+ // Claude's own address for a new Code session in the checkout under review,
48
+ // with the prompt typed in. Nothing is sent until the owner sends it there.
49
+ export const sessionAddress = (prompt) =>
50
+ `claude://code/new?folder=${encodeURIComponent(checkout)}&q=${encodeURIComponent(prompt)}`;
@@ -0,0 +1,40 @@
1
+ // What Diffle holds of this pull request, on the outline: the comments nobody
2
+ // resolved and the files marked viewed. Diffle goes on in a tab of its own,
3
+ // and closing it tells this one nothing, so the page asks the desk again
4
+ // whenever the owner comes back to it. The desk reads Diffle's own file:
5
+ // asking costs nothing of GitHub.
6
+
7
+ const comments = document.querySelector("[data-outline] [data-code-tally]");
8
+ const viewing = document.querySelector("[data-outline] [data-files-viewed]");
9
+ const count = (line) => line.querySelector(".count");
10
+
11
+ const say = ({ open, viewed, files }) => {
12
+ count(comments).textContent = open || "";
13
+ count(comments).dataset.waits = open > 0;
14
+ viewing.hidden = files == null;
15
+ count(viewing).textContent = `${viewed}/${files}`;
16
+ };
17
+
18
+ // Coming back to a tab is both shown and focused: one question answers both.
19
+ let asking;
20
+ const ask = () => {
21
+ asking ??= fetch(comments.dataset.codeTally, {
22
+ headers: { Accept: "application/json" },
23
+ })
24
+ .then((response) => (response.ok ? response.json() : undefined))
25
+ .then((tally) => tally && say(tally))
26
+ .catch(() => {})
27
+ .finally(() => {
28
+ asking = undefined;
29
+ });
30
+ };
31
+
32
+ if (comments) {
33
+ document.addEventListener("visibilitychange", () => {
34
+ if (!document.hidden) ask();
35
+ });
36
+ addEventListener("focus", ask);
37
+ addEventListener("pageshow", (event) => {
38
+ if (event.persisted) ask();
39
+ });
40
+ }
data/public/cursor.js ADDED
@@ -0,0 +1,292 @@
1
+ // The cursor of a pull request's page. The reading rests on one block at a
2
+ // time, and notes.js and decisions.js move it; this script only shows where:
3
+ // a mark in the gutter, the keys of that block beside it and at the head of
4
+ // the docked list, and on the outline the section the block is in. Nothing
5
+ // else on the page carries keys, so the page stays quiet until it is asked.
6
+
7
+ import { thisBlock } from "./keys.js";
8
+ import { prNumber } from "./pr-number.js";
9
+ import { progress } from "./review-drafts.js";
10
+
11
+ const main = document.querySelector("body.shell > .main");
12
+ const sections = [...document.querySelectorAll("details.section")];
13
+ // The outline lists the page's sections in the page's own order.
14
+ const folds = [...document.querySelectorAll("[data-outline] [data-fold]")];
15
+
16
+ const made = (tag, className, text = "") =>
17
+ Object.assign(document.createElement(tag), {
18
+ className,
19
+ textContent: text,
20
+ });
21
+
22
+ // The keys of a block, as pairs like the bar's. A third member is what a
23
+ // press of the pointer does, where the key alone would not reach it.
24
+ const deciding = () => document.body.dataset.decisionMode === "true";
25
+ const folded = (summary) => !summary.parentElement.open;
26
+ const fold = (summary) => () => {
27
+ summary.parentElement.open = folded(summary);
28
+ };
29
+ const pictured = (decision) =>
30
+ decision.querySelectorAll('a[href*="#screenshot-"]').length;
31
+
32
+ // Where f begins deciding, by its place among the blocking decisions: the first
33
+ // still to review, which decisions.js marks, and the page marks in its turn
34
+ // while the reading rests on their heading.
35
+ const beginsAt = () => {
36
+ const all = [
37
+ ...document.querySelectorAll(
38
+ 'li[data-decision]:not([data-grade="non_blocking"])',
39
+ ),
40
+ ];
41
+ const first = all.findIndex((box) => box.hasAttribute("data-first"));
42
+ if (first >= 0) return `decide, from ${first + 1} of ${all.length}`;
43
+ return progress(pullRequest).left
44
+ ? "blocking reviewed: what waits"
45
+ : "all reviewed: what now";
46
+ };
47
+
48
+ const keysOf = (block) => {
49
+ // What can be done with the whole pull request stands beside its title itself.
50
+ if (block.matches("h1")) return [];
51
+ if (block.matches("li[data-decision]")) {
52
+ if (!deciding())
53
+ return [
54
+ ["Enter", "decide on this"],
55
+ ["n", "note on this"],
56
+ ];
57
+ const pictures = pictured(block);
58
+ return [
59
+ ...(pictures ? [["s", `its pictures · ${pictures}`]] : []),
60
+ ["u", "undo"],
61
+ [
62
+ "Esc",
63
+ document.body.dataset.returnsTo
64
+ ? "back to the list"
65
+ : "back to reading",
66
+ ],
67
+ ];
68
+ }
69
+ // A decision's line where they end unfolds to its words, and opens it.
70
+ if (block.matches("[data-line-of]"))
71
+ return [
72
+ block.getAttribute("aria-expanded") === "true"
73
+ ? ["←", "less"]
74
+ : ["→", "more"],
75
+ ["Enter", "decide on this"],
76
+ ["Esc", "the whole page"],
77
+ ];
78
+ // A picture an answer rests on is looked at, and takes no note.
79
+ if (block.matches("figure.evidence")) return [["Space", "look closely"]];
80
+ if (block.matches("figure.picture"))
81
+ return [
82
+ ["Space", "look closely"],
83
+ ["n", "note on this"],
84
+ ];
85
+ // The condition on its decisions goes where they are decided.
86
+ if (block.matches('.conditions > [data-about="decisions"]'))
87
+ return document.querySelector("li[data-decision]")
88
+ ? [
89
+ ["f", beginsAt()],
90
+ ["Enter", "the same"],
91
+ ]
92
+ : [];
93
+ if (block.matches("#decisions > summary"))
94
+ return [
95
+ ["f", beginsAt()],
96
+ ["Enter", folded(block) ? "open" : "close", fold(block)],
97
+ ];
98
+ // The non-blocking decisions' heading: one gesture leaves those that wait
99
+ // for nothing as they are; decisions.js says how many it would.
100
+ if (block.matches("details[data-non-blocking] > summary"))
101
+ return [
102
+ ...(Number(block.dataset.leaves)
103
+ ? [["A", `leave ${block.dataset.leaves} as is`]]
104
+ : []),
105
+ ["Enter", folded(block) ? "open" : "close", fold(block)],
106
+ ];
107
+ if (block.matches("details.section > summary"))
108
+ return [
109
+ ["Enter", folded(block) ? "open" : "close", fold(block)],
110
+ ["]", "next section"],
111
+ ];
112
+ if (block.matches("summary"))
113
+ return [["Enter", folded(block) ? "open" : "close", fold(block)]];
114
+ if (block.closest("[data-quotable]")) return [["n", "note on this"]];
115
+ return [];
116
+ };
117
+
118
+ // A press of the pointer on a block's key is the key itself, pressed where
119
+ // the reading rests.
120
+ const NAMED = {
121
+ Space: " ",
122
+ Esc: "Escape",
123
+ "→": "ArrowRight",
124
+ "←": "ArrowLeft",
125
+ };
126
+ const press = (key, on) =>
127
+ on.dispatchEvent(
128
+ new KeyboardEvent("keydown", {
129
+ key: NAMED[key] ?? key,
130
+ // keys.js reads a capital by Shift, as a keyboard sends it.
131
+ shiftKey: /^[A-Z]$/.test(key),
132
+ bubbles: true,
133
+ cancelable: true,
134
+ }),
135
+ );
136
+
137
+ const mark = made("span", "mark", ">");
138
+ mark.setAttribute("aria-hidden", "true");
139
+ // A rule down the gutter as far as the block goes, so that where the reading
140
+ // rests is seen whole, however many lines it takes.
141
+ const reach = made("span", "reach");
142
+ const beside = made("div", "beside");
143
+ const cursor = made("div", "cursor");
144
+ cursor.setAttribute("data-desk", "");
145
+ cursor.hidden = true;
146
+ cursor.append(mark, reach, beside);
147
+ main.append(cursor);
148
+
149
+ const hint = (block, [keys, does, pressed]) => {
150
+ const button = made("button", "");
151
+ button.type = "button";
152
+ button.append(
153
+ ...keys.split(" ").map((key) => made("kbd", "", key)),
154
+ ` ${does}`,
155
+ );
156
+ // The reading keeps its place while the pointer presses.
157
+ button.addEventListener("mousedown", (event) => event.preventDefault());
158
+ button.addEventListener("click", () => {
159
+ if (pressed) pressed();
160
+ else press(keys.split(" ")[0], block);
161
+ place();
162
+ });
163
+ return button;
164
+ };
165
+
166
+ // What can be done with the whole pull request shows while the title holds the
167
+ // focus, and a press on a link takes none: the title keeps it, or the panel
168
+ // would go before the press ends and the click land on the page behind.
169
+ document
170
+ .querySelector(".pull-request .whole")
171
+ ?.addEventListener("mousedown", (event) => event.preventDefault());
172
+
173
+ const onOutline = (block) => {
174
+ const section = block?.closest("details.section");
175
+ folds.forEach((line, index) => {
176
+ if (section && sections[index] === section)
177
+ line.setAttribute("aria-current", "true");
178
+ else line.removeAttribute("aria-current");
179
+ });
180
+ };
181
+
182
+ // The block the reading last rested on. A press of the pointer on bare page
183
+ // takes the focus from it; the reading goes back there, so that the cursor is
184
+ // never lost and j and k go on from where they were. Not while the press may
185
+ // be the start of a selection, nor while words are selected: taking the focus
186
+ // then would undo the selection under the owner's hand.
187
+ let rested;
188
+ let pointerDown = false;
189
+ const comeBack = () => {
190
+ if (document.activeElement !== document.body) return;
191
+ if (document.querySelector("dialog[open], :popover-open")) return;
192
+ if (pointerDown || !getSelection().isCollapsed) return;
193
+ if (rested?.isConnected && rested.getClientRects().length)
194
+ rested.focus({ preventScroll: true });
195
+ };
196
+
197
+ const place = () => {
198
+ const block = document.activeElement?.closest?.("[data-reading]");
199
+ const shown =
200
+ !!block && main.contains(block) && block.getClientRects().length > 0;
201
+ if (shown) rested = block;
202
+ cursor.hidden = !shown;
203
+ onOutline(shown ? block : null);
204
+ if (!shown) return thisBlock([]);
205
+
206
+ const keys = keysOf(block);
207
+ beside.replaceChildren(...keys.map((key) => hint(block, key)));
208
+ thisBlock(keys);
209
+
210
+ // The mark stands at the block's first line, whatever the block's own face.
211
+ const style = getComputedStyle(block);
212
+ const top =
213
+ block.getBoundingClientRect().top -
214
+ main.getBoundingClientRect().top +
215
+ Number.parseFloat(style.paddingTop) +
216
+ Number.parseFloat(style.borderTopWidth);
217
+ cursor.style.top = `${top}px`;
218
+ const line = Number.parseFloat(style.lineHeight);
219
+ cursor.style.setProperty(
220
+ "--line",
221
+ `${Number.isNaN(line) ? Number.parseFloat(style.fontSize) * 1.6 : line}px`,
222
+ );
223
+ cursor.style.setProperty(
224
+ "--height",
225
+ `${
226
+ block.getBoundingClientRect().height -
227
+ Number.parseFloat(style.paddingTop) -
228
+ Number.parseFloat(style.paddingBottom) -
229
+ Number.parseFloat(style.borderTopWidth) -
230
+ Number.parseFloat(style.borderBottomWidth)
231
+ }px`,
232
+ );
233
+ };
234
+
235
+ // The outline goes to a section at a press. From a decision that is being
236
+ // decided, the page comes back first.
237
+ folds.forEach((line, index) => {
238
+ line.addEventListener("click", () => {
239
+ const heading = sections[index]?.querySelector("summary");
240
+ if (!heading) return;
241
+ if (document.body.dataset.focus) press("Escape", document.activeElement);
242
+ heading.focus({ preventScroll: true });
243
+ heading.scrollIntoView({ block: "start" });
244
+ });
245
+ });
246
+
247
+ // How many decisions still wait, on the outline: a number that goes when none do.
248
+ const pullRequest = prNumber(
249
+ document.querySelector("[data-review-draft]")?.dataset.pr,
250
+ );
251
+ const count = () => {
252
+ const said = document.querySelector(
253
+ '[data-outline] [data-waits-for="decisions"]',
254
+ );
255
+ if (!said) return;
256
+ const { left } = progress(pullRequest);
257
+ said.textContent = left || "";
258
+ said.dataset.waits = left > 0;
259
+ };
260
+ count();
261
+
262
+ // The page moves under the cursor when a section folds, a picture arrives or
263
+ // the list of keys docks: the mark follows its block.
264
+ const soon = () => requestAnimationFrame(place);
265
+ document.addEventListener("focusin", soon);
266
+ document.addEventListener("focusout", () =>
267
+ setTimeout(() => {
268
+ comeBack();
269
+ place();
270
+ }),
271
+ );
272
+ document.addEventListener("pointerdown", () => {
273
+ pointerDown = true;
274
+ });
275
+ document.addEventListener("pointerup", () => {
276
+ pointerDown = false;
277
+ setTimeout(comeBack);
278
+ });
279
+ document.addEventListener("toggle", soon, true);
280
+ document.addEventListener("keys-docked", soon);
281
+ document.addEventListener("drafts-changed", () => {
282
+ count();
283
+ soon();
284
+ });
285
+ addEventListener("resize", soon);
286
+ addEventListener("load", soon);
287
+ new ResizeObserver(soon).observe(main);
288
+ new MutationObserver(soon).observe(document.body, {
289
+ attributes: true,
290
+ attributeFilter: ["data-focus", "data-decision-mode"],
291
+ });
292
+ soon();