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,113 @@
1
+ return unless Proctolog::Desk.grouse?
2
+
3
+ require "open3"
4
+
5
+ # Grouse: the overlay the owner writes notes on the desk with, read in its inbox at /grouse
6
+ # and by coding agents through its export API (AGENTS.md, "Review notes"). Every setting
7
+ # left commented out is at its default.
8
+ #
9
+ # Grouse calls these lambdas from its own controllers, which answer before any callback of
10
+ # the desk's runs, so each finds what it needs from the request itself.
11
+
12
+ key = ->(name) do
13
+ next unless Rails.configuration.x.reads_keys
14
+
15
+ reference = YAML.load_file(Rails.root.join("config/keys.yml")).fetch(name)
16
+ read, done = Open3.capture2("op", "read", "--no-newline", reference)
17
+ read.presence if done.success?
18
+ rescue Errno::ENOENT
19
+ Rails.logger.warn("[proctolog] 1Password's op is not installed, so Grouse has no #{name} key.")
20
+ nil
21
+ end
22
+
23
+ Grouse.configure do |c|
24
+ # The desk serves one person, on this Mac, and listens on localhost alone: a request from
25
+ # this machine is the owner. Its tests open Grouse only where they test it.
26
+ c.enabled_for = ->(request) { request.local? } unless Rails.env.test?
27
+
28
+ # The desk has no sign-in. Every note is the owner's, and nobody else is signed in.
29
+ c.current_author = ->(request) { Proctolog::Owner.current }
30
+ # c.author_label = ->(person) { person.name } # default: name, else username, else "User 12"
31
+ # c.app_user_label = nil # nil: author_label labels both
32
+
33
+ # Grouse's controllers inherit this. Keep ActionController::Base unless every callback of
34
+ # the class you name is safe to run on Grouse's endpoints.
35
+ # c.parent_controller = "ActionController::Base"
36
+
37
+ # Where things live, and for how long.
38
+ # c.base_url = nil # e.g. "https://app.example.com": inbox links in PostHog events and hooks, which run with no request
39
+ # c.queue_name = :default
40
+ # c.rate_limits.notes = {to: 30, within: 1.minute} # notes per reviewer
41
+ # c.rate_limits.takes = {to: 120, within: 1.minute} # per reviewer: the updates while a voice note records
42
+ # c.rate_limits.screenshots = {to: 30, within: 1.minute} # screenshot uploads per reviewer, one a note
43
+ # c.rate_limits.store = nil # nil: Grouse's own memory store
44
+ # c.inbox.poll_interval = 20.seconds # how often an open inbox asks whether it changed; nil: never
45
+ # c.storage_service = nil # where recordings and screenshots are kept, a service in config/storage.yml; nil: config.active_storage.service
46
+ # c.retention.media = 90.days # reserved for pruning: nothing prunes yet
47
+ # c.retention.resolved_notes = 90.days # reserved for pruning: nothing prunes yet
48
+
49
+ # What a page says about itself.
50
+ # c.release = nil # e.g. -> { ENV["GIT_REVISION"] }, or a String
51
+ # c.context_providers = [] # each ->(request) { {"native_shell" => true} }, 4 KB in all
52
+ # c.privacy.mask_selectors = [] # on top of .ph-mask and [data-grouse-mask]
53
+ # c.privacy.block_selectors = [] # on top of .ph-no-capture and [data-grouse-block]
54
+ # c.privacy.redact = %i[tokens emails numbers] # drop a name to keep that kind of text in the outline
55
+ # c.capture_network_errors = false # true wraps fetch and XHR to keep same-origin requests answered >= 400 or not at all:
56
+ # # method, path, status; never a body, a header, a query, another origin's or Grouse's own
57
+ c.screenshots = :opt_in # :opt_in offers a camera per note, :always starts it on, :off takes none and refuses uploads;
58
+ # # screenshots are kept by Active Storage, as recordings are
59
+
60
+ # The pill.
61
+ c.widget.position = "bottom-right" # clear of the route pane at the desk's left
62
+ # c.widget.z_index = 58
63
+ # c.widget.shortcut = "Alt+Shift+KeyN" # a KeyboardEvent.code form; nil turns it off
64
+ # c.widget.gesture = "toggle" # or "hold": the record button is held while speaking
65
+
66
+ # Voice notes need Active Storage (bin/rails active_storage:install). Without it they are
67
+ # simply not offered, and notes are typed-only.
68
+ # c.voice.enabled = true # false: typed-only, even with Active Storage
69
+ # c.voice.salvage_after = 3.minutes # a voice draft no page has touched for this long is delivered as it stands
70
+
71
+ # PostHog. Grouse reads your posthog-js and never boots its own. With posthog-rails
72
+ # installed, its PostHog.client is used for server events.
73
+ # c.posthog.client = nil # e.g. PostHog::Client.new(api_key: ENV["POSTHOG_KEY"], host: "https://us.i.posthog.com")
74
+ # c.posthog.ready_event = nil # e.g. "posthog:loaded", if your snippet's `loaded` callback dispatches it
75
+ # c.posthog.consent_meta = nil # e.g. "posthog-consent": a <meta name> whose content says whether analytics were accepted
76
+ # c.posthog.client_events = false # true sends grouse_note_started into your PostHog project when a reviewer starts a note
77
+ # c.posthog.replay_hosts = %w[us.posthog.com eu.posthog.com] # the PostHog UI hosts a replay link may point at
78
+ # c.posthog.distinct_id_for = nil # e.g. ->(author) { author.id.to_s }
79
+ # c.posthog.include_text = false # true sends the note's words (first 2,000 characters) with grouse_note_created
80
+ # c.posthog.llm_analytics = false # true sends one $ai_generation per structuring request (PostHog LLM analytics, billed on its own); the prompt and the answer only with include_text
81
+
82
+ # Transcription, structuring and hand-off. Each is optional and ends in a visible state.
83
+ # c.on_delivered = [] # each ->(note) { }, run in a job after the request; keep them short
84
+ # c.on_ready = {} # name => sink, each run in a job once a note's words and structure are in; none: nothing is handed on
85
+ # c.on_ready[:linear] = ReviewNotes::LinearExport # a callable: gets a Grouse::ReadyNote, answers {id:, url:}, or raises Grouse::Sinks::Unavailable, "linear_503" to be asked again
86
+ # The owner's keys, read from 1Password by config/keys.yml's references where the desk
87
+ # reads keys at all (config.x.reads_keys). Without one, a voice note arrives as audio only
88
+ # and a note is not written up; `op` says on the terminal why it could not read it. Tests
89
+ # read none, and send nothing to a vendor.
90
+ c.transcriber = Grouse::Transcribers::ElevenLabs.new(api_key: key.call("elevenlabs"))
91
+ c.structurer = Grouse::Structurers::Anthropic.new(api_key: key.call("anthropic"))
92
+ # c.transcriber = nil # turns a recording into words; with none, voice notes arrive as audio only
93
+ # Grouse::Transcribers::ElevenLabs.new(api_key: ENV["ELEVENLABS_API_KEY"], language: nil, keyterms: %w[YourProduct])
94
+ # Grouse::Transcribers::OpenAI.new(api_key: ENV["OPENAI_API_KEY"], model: "gpt-transcribe", language: "he")
95
+ # ->(io, filename:, content_type:) { "the words" } # or your own: a String, or a Grouse::Transcript
96
+ # c.structurer = nil # writes each note up as an issue (title, kind, steps); with none, notes stay as written
97
+ # Grouse::Structurers::Anthropic.new(api_key: Rails.application.credentials.dig(:anthropic, :api_key))
98
+ # Grouse::Structurers::OpenAI.new(api_key: ENV["OPENAI_API_KEY"], model: "gpt-5-mini") # base_url: any server that speaks its chat API
99
+ # ->(note, prompt) { YourGateway.json(system: prompt.system, user: prompt.user, schema: prompt.schema) } # a Hash in that schema's shape
100
+
101
+ # Coding agents read notes with a token each: {label => token}, tokens of 32+ characters.
102
+ # No token means no API.
103
+ # The agents' token, made the first time the desk boots and kept beside its secret, out
104
+ # of Git (AGENTS.md says where). Tests have no API.
105
+ c.api_tokens = -> { {"agent" => Proctolog::Desk.kept("grouse_token") { SecureRandom.hex(32) }} } unless Rails.env.test?
106
+
107
+ # Review sessions (docs/plan/06): a review an outside tool prepares, from a runner with
108
+ # Grouse::Sessions.open or with an API token over POST /api/sessions, shows the widget to whoever
109
+ # carries its cookie and collects their notes. They open only in these environments:
110
+ # c.sessions.environments = %w[development]
111
+ # The hosts a session's notes may be pushed back to, at its return_url; [] turns pushing off.
112
+ # c.sessions.return_hosts = %w[localhost 127.0.0.1 ::1]
113
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The desk makes its database, and brings it up to the latest migration,
4
+ # whenever it boots: nobody runs db:migrate on a tool they only start, and a
5
+ # pull that brings a migration then needs no step of the owner's.
6
+ Rails.application.config.after_initialize do
7
+ ActiveRecord::Migration.verbose = false
8
+ ActiveRecord::Base.connection_pool.migration_context.migrate
9
+ end
data/config/keys.yml ADDED
@@ -0,0 +1,6 @@
1
+ # The owner's vendor keys for Grouse, as 1Password references, never the keys
2
+ # themselves: the desk reads each with `op read` when it serves
3
+ # (config/initializers/grouse.rb). A key 1Password cannot give leaves voice
4
+ # notes audio only, or notes unwritten up.
5
+ elevenlabs: "op://Private/eqn4mdg3vkzv5gkgrxuuqchiqa/credential"
6
+ anthropic: "op://Private/Anthropic API Key/credential"
data/config/routes.rb ADDED
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ Proctolog::Desk.routes.draw do
4
+ # The owner's notes on the desk itself: the overlay's endpoints, its inbox and the export API.
5
+ mount Grouse::Engine, at: "/grouse" if Proctolog::Desk.grouse?
6
+
7
+ scope module: :proctolog do
8
+ root "bursts#index"
9
+
10
+ # The repository's open pull requests, to pick a burst from.
11
+ resources :open_pull_requests, only: :index, path: "pull_requests" do
12
+ post :alone, on: :member
13
+ post :chain, on: :member
14
+ end
15
+ post "stacks/:id", to: "open_pull_requests#stack", as: :review_stack, constraints: {id: /\d+/}
16
+
17
+ # The pull requests, of every state, that a # typed in any box may mean.
18
+ resources :mentions, only: :index
19
+
20
+ resources :bursts, only: %i[show create] do
21
+ resource :put_away, only: %i[create destroy], controller: :put_away
22
+ resources :pull_requests, only: :show, param: :number do
23
+ resources :notes, only: %i[create update]
24
+ resource :good_to_go, only: %i[create destroy], controller: :good_to_go
25
+ resource :waiting, only: :update, controller: :waiting
26
+ resource :code_review, only: %i[show create destroy]
27
+ resources :decisions, only: :update, param: :position
28
+ resource :acceptance, only: %i[new create]
29
+ end
30
+
31
+ resources :decisions, only: :index
32
+ resource :prompt, only: %i[show create] do
33
+ post :preview
34
+ end
35
+ resource :review_data, only: :show, controller: :review_data
36
+ resource :rendered_pieces, only: :create
37
+ resource :evidence, only: :show, controller: :evidence do
38
+ get :compare
39
+ end
40
+ resource :choices, only: %i[show update]
41
+ resources :rules, only: %i[update destroy], id: /[^\/]+/
42
+ resource :freshness, only: :show, controller: :freshness
43
+ resource :membership, only: :update do
44
+ post :handed_over
45
+ end
46
+ resources :scenarios, only: :index do
47
+ post :open, on: :member
48
+ post :saw, on: :member
49
+ post :pull, on: :member
50
+ resources :notes, only: %i[create update], controller: :scenario_notes
51
+ end
52
+ resource :sitting_server, only: %i[show create destroy]
53
+ resources :recordings, only: :index
54
+ get "recordings/*path", to: "recordings#show", as: :recording, format: false
55
+ get "stills/:index/*path", to: "recordings#still", as: :recording_still, format: false, constraints: {index: /\d+/}
56
+ resources :moments, only: %i[create update]
57
+ end
58
+
59
+ # Bursts were called reviews: a tab opened then still reads and saves.
60
+ # 307 keeps a PUT a PUT, with its body, and is not remembered by the browser.
61
+ match "reviews(/*rest)", via: :all, format: false, to: redirect(status: 307) { |_, request| request.fullpath.sub(%r{\A/reviews}, "/bursts") }
62
+
63
+ # Rack::Files answers Range requests, which a browser needs to seek in a video.
64
+ mount Rack::Files.new(Proctolog::Recording.directory.to_s, {"cache-control" => "no-cache"}), at: "films"
65
+
66
+ resources :entries, only: :show, param: :token
67
+ get "pictures/:asset", to: "pictures#show", as: :picture, constraints: {asset: Proctolog::Picture::ASSET}
68
+ get "pictures/:before/against/:after", to: "pictures#compare", as: :difference, format: false,
69
+ constraints: {before: Proctolog::Picture::FILE, after: Proctolog::Picture::FILE}
70
+ end
71
+ end
@@ -0,0 +1,4 @@
1
+ # Voice notes and screenshots, beside the database that holds their notes.
2
+ local:
3
+ service: Disk
4
+ root: <%= Proctolog::Desk.grouse_notes.join("files") %>
@@ -0,0 +1,128 @@
1
+ # This migration comes from grouse (originally 20261014000000)
2
+ # Portions adapted from rails-markup (https://github.com/nauman/rails-markup) at ad756b8, Copyright (c) 2026 InventList, MIT licence. See NOTICE.
3
+
4
+ # Grouse's two tables in one migration, so a host never needs a second one to turn on
5
+ # voice (M2) or review sessions (M6a) (D11, D16). It is copied into the host's db/migrate,
6
+ # so it decides two things when it runs there, not when the gem was built: jsonb on
7
+ # PostgreSQL (json on SQLite), and the host's primary-key type, the way Active Storage's
8
+ # own first migration does. Written against 8.0 because the gemspec admits 8.0 hosts.
9
+ class CreateGrouseTables < ActiveRecord::Migration[8.0]
10
+ def change
11
+ refuse_unsupported_adapter!
12
+ primary_key_type, foreign_key_type = primary_and_foreign_key_types
13
+
14
+ create_table :grouse_sessions, id: primary_key_type do |t|
15
+ t.string :public_id, null: false
16
+ t.string :source, null: false # who opened it, e.g. "proctolog"
17
+ t.string :label, null: false # what the overlay shows
18
+ t.string :reviewer_label, null: false # the author of the session's notes
19
+ t.string :external_ref # the opener's own key for it
20
+ t.column :metadata, json_type, null: false, default: {}
21
+ t.text :return_url # allow-listed again before any push (M6a)
22
+ t.string :entry_token_digest # the one-time enter link (M6a), stored as a digest only
23
+ t.datetime :entered_at # set when that link is spent
24
+ t.datetime :expires_at, null: false
25
+ t.datetime :closed_at
26
+ t.timestamps
27
+
28
+ t.index :public_id, unique: true, name: "index_grouse_sessions_on_public_id"
29
+ t.index :entry_token_digest, unique: true, name: "index_grouse_sessions_on_entry_token"
30
+ t.index %i[source external_ref], name: "index_grouse_sessions_on_source_ref"
31
+ end
32
+
33
+ create_table :grouse_notes, id: primary_key_type do |t|
34
+ t.string :public_id, null: false
35
+ t.string :client_uuid, null: false
36
+
37
+ # Who. No foreign key to the host's users: they are not ours (03 §7).
38
+ t.references :author, polymorphic: true, type: foreign_key_type, index: false
39
+ t.string :author_label
40
+ t.string :app_user_gid
41
+ t.string :app_user_label
42
+ # Named: a host may have its own `sessions` table (sartoria does).
43
+ t.references :session, type: foreign_key_type, index: {name: "index_grouse_notes_on_session_id"},
44
+ foreign_key: {to_table: :grouse_sessions, on_delete: :nullify, name: "fk_grouse_notes_session"}
45
+
46
+ # Capture: a draft until exactly one ending claims it (invariant 2).
47
+ t.string :capture, null: false, default: "draft"
48
+ t.string :delivered_via # sent | closed | salvaged (D29)
49
+ t.datetime :delivered_at
50
+ t.string :channel, null: false, default: "typed"
51
+ t.datetime :started_at # when the reviewer began; the context froze then
52
+ t.integer :take_seq, null: false, default: 0 # M2: a PATCH carrying an older take changes nothing
53
+
54
+ # Words, and the reviewer's own labels for them (D22).
55
+ t.text :body
56
+ t.string :intent
57
+ t.string :severity
58
+
59
+ # Annotations (M2, M4). Each ends in a state the inbox shows (invariant 1).
60
+ t.text :transcript
61
+ t.string :transcript_provider
62
+ t.string :transcript_language
63
+ t.string :transcription_state, null: false, default: "none"
64
+ t.string :transcription_error
65
+ t.integer :audio_ms
66
+ t.column :structured, json_type, null: false, default: {}
67
+ t.string :structure_state, null: false, default: "none"
68
+ t.string :structure_error
69
+
70
+ # Where: copies lifted from the frozen context by Contextual, for filtering and lists.
71
+ t.text :page_url
72
+ t.string :route # the URI pattern, e.g. /orders/:id(.:format)
73
+ t.string :controller_action # orders#show (CC-14)
74
+ t.string :request_id
75
+ t.string :release
76
+ t.string :environment
77
+ t.column :context, json_type, null: false, default: {}
78
+ t.column :anchor, json_type, null: false, default: {} # M3; {} means unanchored
79
+
80
+ # PostHog: why a replay link exists, or why it does not (D29).
81
+ t.string :posthog_distinct_id
82
+ t.string :posthog_session_id
83
+ t.string :replay_status, null: false, default: "error"
84
+ t.string :replay_detail
85
+ t.text :replay_url
86
+
87
+ # Triage (D14).
88
+ t.string :status, null: false, default: "pending"
89
+ t.integer :revision, null: false, default: 0
90
+ t.column :thread, json_type, null: false, default: []
91
+ t.datetime :resolved_at
92
+ t.string :resolved_by_label
93
+ t.string :resolution_commit
94
+
95
+ # What happened after delivery: the PostHog event and the hooks (M1), sinks (M4).
96
+ t.column :external_refs, json_type, null: false, default: {}
97
+
98
+ t.timestamps
99
+
100
+ t.index :public_id, unique: true, name: "index_grouse_notes_on_public_id"
101
+ t.index :client_uuid, unique: true, name: "index_grouse_notes_on_client_uuid"
102
+ t.index %i[status created_at], name: "index_grouse_notes_for_inbox"
103
+ t.index %i[capture updated_at], name: "index_grouse_notes_for_sweep"
104
+ t.index :posthog_session_id, name: "index_grouse_notes_on_posthog_session"
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ # Active Storage's own idiom (activestorage-8.1.4 create_active_storage_tables.rb:49-55). A
111
+ # UUID-keyed host gets UUID keys here too, which M2 needs: active_storage_attachments.record_id
112
+ # follows the same setting.
113
+ def primary_and_foreign_key_types
114
+ config = Rails.configuration.generators
115
+ setting = config.options[config.orm][:primary_key_type]
116
+ [setting || :primary_key, setting || :bigint]
117
+ end
118
+
119
+ def json_type = postgresql? ? :jsonb : :json
120
+ def postgresql? = connection.adapter_name.match?(/postg/i)
121
+
122
+ # MySQL refuses literal defaults on JSON columns, and nothing here is tested on it. A plain
123
+ # refusal beats a cryptic failure halfway through a host's deploy.
124
+ def refuse_unsupported_adapter!
125
+ return if postgresql? || connection.adapter_name.match?(/sqlite/i)
126
+ raise NotImplementedError, "Grouse's tables need PostgreSQL or SQLite: their JSON columns carry defaults"
127
+ end
128
+ end
@@ -0,0 +1,58 @@
1
+ # This migration comes from active_storage (originally 20170806125915)
2
+ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
3
+ def change
4
+ # Use Active Record's configured type for primary and foreign keys
5
+ primary_key_type, foreign_key_type = primary_and_foreign_key_types
6
+
7
+ create_table :active_storage_blobs, id: primary_key_type do |t|
8
+ t.string :key, null: false
9
+ t.string :filename, null: false
10
+ t.string :content_type
11
+ t.text :metadata
12
+ t.string :service_name, null: false
13
+ t.bigint :byte_size, null: false
14
+ t.string :checksum
15
+
16
+ if connection.supports_datetime_with_precision?
17
+ t.datetime :created_at, precision: 6, null: false
18
+ else
19
+ t.datetime :created_at, null: false
20
+ end
21
+
22
+ t.index [:key], unique: true
23
+ end
24
+
25
+ create_table :active_storage_attachments, id: primary_key_type do |t|
26
+ t.string :name, null: false
27
+ t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
28
+ t.references :blob, null: false, type: foreign_key_type
29
+
30
+ if connection.supports_datetime_with_precision?
31
+ t.datetime :created_at, precision: 6, null: false
32
+ else
33
+ t.datetime :created_at, null: false
34
+ end
35
+
36
+ t.index [:record_type, :record_id, :name, :blob_id], name: :index_active_storage_attachments_uniqueness, unique: true
37
+ t.foreign_key :active_storage_blobs, column: :blob_id
38
+ end
39
+
40
+ create_table :active_storage_variant_records, id: primary_key_type do |t|
41
+ t.belongs_to :blob, null: false, index: false, type: foreign_key_type
42
+ t.string :variation_digest, null: false
43
+
44
+ t.index [:blob_id, :variation_digest], name: :index_active_storage_variant_records_uniqueness, unique: true
45
+ t.foreign_key :active_storage_blobs, column: :blob_id
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def primary_and_foreign_key_types
52
+ config = Rails.configuration.generators
53
+ setting = config.options[config.orm][:primary_key_type]
54
+ primary_key_type = setting || :primary_key
55
+ foreign_key_type = setting || :bigint
56
+ [primary_key_type, foreign_key_type]
57
+ end
58
+ end
@@ -0,0 +1,157 @@
1
+ # How the desk runs your app
2
+
3
+ A scenario button starts the app, puts it in a state, signs the right member in
4
+ and opens a browser on the right page. The desk knows nothing about your app to
5
+ do that. It reads `.proctolog/app.yml` in the checkout under review, a handful
6
+ of commands and paths in your app's own words. With no `app.yml`, scenarios
7
+ still list their steps, and you mark what you saw by hand.
8
+
9
+ A pull request's scenarios come from its description: each bold heading of its
10
+ "How to test locally" with numbered steps under it is one, opening the home
11
+ page as whoever is signed in. A burst's `scenarios/<burst>.yml` names the state
12
+ and the page a pull request's scenarios start from, as `recipe:` and `opens:`;
13
+ a pull request it names takes its scenarios from the file alone.
14
+
15
+ ```yaml
16
+ # Makes the app ready to serve, run before `serve` on every Start: so a
17
+ # database that is not there yet is made, and one that is costs a moment.
18
+ prepare: bin/scratch_dev %{slug} bin/rails db:prepare
19
+
20
+ # Runs until stopped, in the sitting's checkout. The desk calls the app up once
21
+ # every port in `listens` answers, counted up from %{port}.
22
+ serve: bin/scratch_dev %{slug} env PORT=%{port} bin/dev
23
+ listens: [0, 36]
24
+
25
+ # Makes a named state. %{args} becomes one key=value word per argument.
26
+ state: bin/scratch_dev %{slug} bin/dev-state %{name} %{args}
27
+
28
+ # Paths the browser goes through to be signed in or out, then on to %{to}.
29
+ sign_in: /dev/sessions/as?email=%{email}&to=%{to}
30
+ sign_out: /dev/sessions/out?to=%{to}
31
+
32
+ # Cookies a clean browser starts with, as name=value.
33
+ cookies: ["cookie_consent=1:declined"]
34
+
35
+ # Grouse's overlay, labelled with the scenario (below): `overlay` opens a review
36
+ # session from %{brief}, one JSON word, and `notes` prints one's notes.
37
+ overlay: env GROUSE_SESSION=%{brief} bin/rails grouse:sessions:open
38
+ notes: env GROUSE_SESSION_ID=%{session} bin/rails grouse:sessions:notes
39
+
40
+ # Window sizes beyond the desk's own desktop (1440×900) and phone (390×844).
41
+ sizes:
42
+ app: {width: 390, height: 844, user_agent: "… Hotwire Native iOS; Turbo Native iOS; …"}
43
+ ```
44
+
45
+ Every key is optional; a scenario that needs a missing one says which.
46
+
47
+ ## Where it runs
48
+
49
+ With nothing more said, a burst's scenarios run the checkout you review, as
50
+ it is, on port 3100 and a database named after the burst. A burst file can say
51
+ otherwise with a `sitting:`, for a checkout kept for reviewing:
52
+
53
+ ```yaml
54
+ sitting:
55
+ checkout: ~/code/review-checkout # moved onto the branch when it has no changes of its own
56
+ branch: review/everything-together
57
+ port: 3300
58
+ slug: sitting
59
+ ```
60
+
61
+ The desk starts the app and makes its states only in a checkout that keeps a
62
+ `.proctolog/app.yml` of its own. A checkout older than that file is older than
63
+ the commands in it, which may not do there what they say: a `prepare` that
64
+ counts on the app reading `%{slug}` would make and fill the app's own
65
+ development database instead. Try it says so beside the pull request, and
66
+ Start refuses.
67
+
68
+ ## What the desk expects back
69
+
70
+ `state`'s last line of output is one JSON object. `email`, when present, is the
71
+ member the desk signs in through `sign_in`. Every other key whose value is a
72
+ path is a place a scenario can open by name (`opens: lands_on`). Anything
73
+ earlier in the output is ignored, and a command that fails shows its last lines.
74
+
75
+ ```json
76
+ {"email": "clara.vogel@example.com", "lands_on": "/onboarding/pact"}
77
+ ```
78
+
79
+ `sign_in` and `sign_out` are paths on the app, opened by GET in a browser with
80
+ nothing else to go on. They answer by redirecting to `to`, a path. They only
81
+ need to exist where the app runs in development.
82
+
83
+ ## Grouse's overlay
84
+
85
+ An app with [Grouse](https://github.com/LemuelCushing/grouse-rails) mounted
86
+ can name `overlay` and `notes`. Then a scenario's button opens Grouse's overlay
87
+ in the app too, labelled with the scenario. The panel opens by itself on the
88
+ first page, with a strip at its top: `proctolog · ` and the label, until when
89
+ the session lasts, what to expect, the steps, End session, and Back to the desk,
90
+ which opens the scenario's page on the desk in a new tab. Whatever you say or
91
+ type there is a note on the scenario once you press **Pull notes** beside it.
92
+
93
+ `overlay` runs after the state is made, and is given `%{brief}`, one JSON word:
94
+
95
+ | | |
96
+ |---|---|
97
+ | `source` | `proctolog` |
98
+ | `label` | `grouse-rails #7 · Send a note`, or `sartoria · Step 2 · Sign in` for a step of the sitting |
99
+ | `reviewer_label` | who narrates, and each note's author: `Gill (PRoctolog)` |
100
+ | `external_ref` | `proctolog:<burst>/<scenario>@<head>` |
101
+ | `metadata` | `expect`, `steps` (one line each), `desk_url`, and the burst, pull request, scenario, recipe and head |
102
+
103
+ The member's email is never in the label, what to expect or the steps. Its
104
+ last line of output is `{"public_id": …, "cookie": "grouse_session=…; path=/; …"}`.
105
+ The cookie goes to the browser as it was printed, after the app's own, whichever
106
+ browser the scenario opens in: cookies on `localhost` are shared by every
107
+ port. A refusal, `{"problem": …}`, or a command that fails, never stops the
108
+ entering: the member is signed in all the same, and the desk says why the
109
+ overlay is missing.
110
+
111
+ Each press opens a session of its own, kept beside its scenario in the marks
112
+ file (`overlays:`, by scenario), with the notes pinned from it. **Pull notes**
113
+ runs `notes` for each with `%{session}`, its `public_id`, and expects
114
+ `{"session": {…}, "notes": […]}` last. Every note it has not pinned yet becomes
115
+ a note on the scenario, once: a fix or a change as a change, a question as a
116
+ question, anything else as a remark, in the owner's words (typed, then spoken),
117
+ then a line with Grouse's link to it, its route and the first templates that
118
+ drew it. A voice note still being written out waits for the next pull. Pinned,
119
+ they go with the next handover like any other note. `notes` boots the app, so
120
+ it runs only when you press.
121
+
122
+ An app without `overlay` opens as it always did.
123
+
124
+ ## Placeholders
125
+
126
+ | | |
127
+ |---|---|
128
+ | `%{slug}`, `%{port}` | the sitting's (above) |
129
+ | `%{name}`, `%{args}` | the scenario's `recipe:` and its arguments |
130
+ | `%{email}`, `%{to}` | filled in URL-encoded |
131
+ | `%{brief}`, `%{session}` | the overlay's brief, as one JSON word, and a session's `public_id` |
132
+
133
+ Commands are split into words before they are filled in, so a value with a
134
+ space stays one word. They run in the sitting's checkout, outside the desk's
135
+ bundle.
136
+
137
+ ## Settings
138
+
139
+ Beside `app.yml`, `.proctolog/project.yml` says what is true of the repository
140
+ in every checkout of it, so it is kept in Git too:
141
+
142
+ ```yaml
143
+ # Folders of decision records, from the root. A handover asks for a rule you
144
+ # made to be written in CLAUDE.md or a record in one of them.
145
+ decision_records: [docs/adr, docs/solutions/architecture-decisions]
146
+
147
+ # The themes a decision may name, each with what it means, in the order the
148
+ # desk groups them. Without them, the desk's own stand: proctolog themes.
149
+ themes:
150
+ copy: What the words on a screen say, and how
151
+ money: What anything costs
152
+ ```
153
+
154
+ `.proctolog/local.yml`, kept out of Git, says what is true of one machine:
155
+ `capture_kit`, the folder of the capture kit films come from. A key it names
156
+ wins over the same key in `project.yml`, whole: its `themes` replace the
157
+ project's rather than add to them.