principles 0.0.1

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 (278) hide show
  1. checksums.yaml +7 -0
  2. data/exe/principles +10 -0
  3. data/lib/principles/after_commit_hook_options.rb +44 -0
  4. data/lib/principles/cli.rb +149 -0
  5. data/lib/principles/command_line.rb +188 -0
  6. data/lib/principles/command_routes.rb +60 -0
  7. data/lib/principles/controllers/acknowledgement.rb +106 -0
  8. data/lib/principles/controllers/answer.rb +172 -0
  9. data/lib/principles/controllers/audit.rb +203 -0
  10. data/lib/principles/controllers/audit_completion.rb +113 -0
  11. data/lib/principles/controllers/command.rb +201 -0
  12. data/lib/principles/controllers/correction.rb +99 -0
  13. data/lib/principles/controllers/help.rb +107 -0
  14. data/lib/principles/controllers/inventory.rb +181 -0
  15. data/lib/principles/controllers/mapping.rb +56 -0
  16. data/lib/principles/controllers/member_entries.rb +149 -0
  17. data/lib/principles/controllers/members_a_commit_changed.rb +43 -0
  18. data/lib/principles/controllers/members_to_assess.rb +160 -0
  19. data/lib/principles/controllers/meta_principle_setup.rb +92 -0
  20. data/lib/principles/controllers/repoint.rb +68 -0
  21. data/lib/principles/controllers/running_tool.rb +41 -0
  22. data/lib/principles/data/audit_files.rb +96 -0
  23. data/lib/principles/data/bounded_file.rb +45 -0
  24. data/lib/principles/data/candidate_files_found.rb +105 -0
  25. data/lib/principles/data/command-steps.json +305 -0
  26. data/lib/principles/data/command_steps_file.rb +50 -0
  27. data/lib/principles/data/expected_type.rb +71 -0
  28. data/lib/principles/data/ignored_by_git.rb +54 -0
  29. data/lib/principles/data/json_text.rb +57 -0
  30. data/lib/principles/data/known_fields.rb +42 -0
  31. data/lib/principles/data/linked_path.rb +102 -0
  32. data/lib/principles/data/map_files.rb +104 -0
  33. data/lib/principles/data/principle_id.rb +32 -0
  34. data/lib/principles/data/principles_subfolder.rb +93 -0
  35. data/lib/principles/data/project_files.rb +109 -0
  36. data/lib/principles/data/repository.rb +165 -0
  37. data/lib/principles/data/running_version.rb +44 -0
  38. data/lib/principles/data/schema_check.rb +139 -0
  39. data/lib/principles/data/schemas/acknowledgement-answer.schema.json +25 -0
  40. data/lib/principles/data/schemas/answer-guide.schema.json +99 -0
  41. data/lib/principles/data/schemas/assessment-answer.schema.json +332 -0
  42. data/lib/principles/data/schemas/audit-record.schema.json +277 -0
  43. data/lib/principles/data/schemas/command-steps.schema.json +83 -0
  44. data/lib/principles/data/schemas/correction-answer-guide.schema.json +68 -0
  45. data/lib/principles/data/schemas/correction-answer.schema.json +306 -0
  46. data/lib/principles/data/schemas/member-map.schema.json +39 -0
  47. data/lib/principles/data/schemas/repointing-answer.schema.json +44 -0
  48. data/lib/principles/error.rb +102 -0
  49. data/lib/principles/invocation_options.rb +77 -0
  50. data/lib/principles/models/accepted_loading.rb +36 -0
  51. data/lib/principles/models/acknowledgement.rb +203 -0
  52. data/lib/principles/models/activation.rb +64 -0
  53. data/lib/principles/models/agent_loads.rb +64 -0
  54. data/lib/principles/models/answer_fields.rb +67 -0
  55. data/lib/principles/models/answer_place.rb +53 -0
  56. data/lib/principles/models/answer_text.rb +37 -0
  57. data/lib/principles/models/answered_time.rb +65 -0
  58. data/lib/principles/models/assessment_answer.rb +131 -0
  59. data/lib/principles/models/assessment_basis.rb +47 -0
  60. data/lib/principles/models/assessment_report.rb +62 -0
  61. data/lib/principles/models/audit.rb +200 -0
  62. data/lib/principles/models/audit_record.rb +249 -0
  63. data/lib/principles/models/audit_records.rb +103 -0
  64. data/lib/principles/models/audit_start.rb +96 -0
  65. data/lib/principles/models/audited_member_lines.rb +45 -0
  66. data/lib/principles/models/candidate_files.rb +66 -0
  67. data/lib/principles/models/candidate_inventory.rb +67 -0
  68. data/lib/principles/models/candidate_listing.rb +32 -0
  69. data/lib/principles/models/carried_assessments.rb +76 -0
  70. data/lib/principles/models/check_since_last_audit.rb +61 -0
  71. data/lib/principles/models/cited_files.rb +39 -0
  72. data/lib/principles/models/command_steps.rb +270 -0
  73. data/lib/principles/models/commits_brought_in.rb +27 -0
  74. data/lib/principles/models/commits_since_base.rb +32 -0
  75. data/lib/principles/models/correction_answer.rb +66 -0
  76. data/lib/principles/models/correction_basis.rb +70 -0
  77. data/lib/principles/models/dated_line.rb +29 -0
  78. data/lib/principles/models/decided_prior_findings.rb +106 -0
  79. data/lib/principles/models/declared_map_changes.rb +156 -0
  80. data/lib/principles/models/disposition.rb +233 -0
  81. data/lib/principles/models/entry_file.rb +65 -0
  82. data/lib/principles/models/entry_files.rb +46 -0
  83. data/lib/principles/models/evidence_item.rb +48 -0
  84. data/lib/principles/models/evidence_lines_since.rb +57 -0
  85. data/lib/principles/models/evidence_replacements.rb +99 -0
  86. data/lib/principles/models/finding_write_ups.rb +108 -0
  87. data/lib/principles/models/findings_left_open.rb +34 -0
  88. data/lib/principles/models/first_raised_claim.rb +128 -0
  89. data/lib/principles/models/frontmatter_fields.rb +128 -0
  90. data/lib/principles/models/hook_commands.rb +100 -0
  91. data/lib/principles/models/ignored_candidates.rb +46 -0
  92. data/lib/principles/models/json_digest.rb +35 -0
  93. data/lib/principles/models/json_path_query.rb +131 -0
  94. data/lib/principles/models/json_path_selectors.rb +64 -0
  95. data/lib/principles/models/json_path_string.rb +76 -0
  96. data/lib/principles/models/kept_prior_finding.rb +70 -0
  97. data/lib/principles/models/last_completed_revision.rb +70 -0
  98. data/lib/principles/models/line_span.rb +49 -0
  99. data/lib/principles/models/loaded_files.rb +60 -0
  100. data/lib/principles/models/loading_decision.rb +45 -0
  101. data/lib/principles/models/loading_facts.rb +45 -0
  102. data/lib/principles/models/loads_to_check.rb +93 -0
  103. data/lib/principles/models/locate_refusal.rb +23 -0
  104. data/lib/principles/models/located_text.rb +43 -0
  105. data/lib/principles/models/locator.rb +52 -0
  106. data/lib/principles/models/map_members.rb +171 -0
  107. data/lib/principles/models/markdown_file.rb +92 -0
  108. data/lib/principles/models/member.rb +171 -0
  109. data/lib/principles/models/member_assessments.rb +144 -0
  110. data/lib/principles/models/member_elsewhere.rb +74 -0
  111. data/lib/principles/models/member_file.rb +156 -0
  112. data/lib/principles/models/member_grounds.rb +32 -0
  113. data/lib/principles/models/member_history.rb +68 -0
  114. data/lib/principles/models/member_lines.rb +95 -0
  115. data/lib/principles/models/member_loads.rb +197 -0
  116. data/lib/principles/models/member_map.rb +219 -0
  117. data/lib/principles/models/member_map_history.rb +143 -0
  118. data/lib/principles/models/member_maps.rb +185 -0
  119. data/lib/principles/models/member_moves.rb +95 -0
  120. data/lib/principles/models/member_problems.rb +253 -0
  121. data/lib/principles/models/member_sighting.rb +176 -0
  122. data/lib/principles/models/member_snapshots.rb +36 -0
  123. data/lib/principles/models/member_state.rb +35 -0
  124. data/lib/principles/models/members_elsewhere.rb +46 -0
  125. data/lib/principles/models/members_sharing_lines.rb +116 -0
  126. data/lib/principles/models/meta_principle.rb +51 -0
  127. data/lib/principles/models/missing_files.rb +85 -0
  128. data/lib/principles/models/misspelt_member_id.rb +45 -0
  129. data/lib/principles/models/open_finding.rb +221 -0
  130. data/lib/principles/models/open_findings_elsewhere.rb +45 -0
  131. data/lib/principles/models/other_principles.rb +47 -0
  132. data/lib/principles/models/placement.rb +42 -0
  133. data/lib/principles/models/plain_text_file.rb +32 -0
  134. data/lib/principles/models/principles_folder_move.rb +59 -0
  135. data/lib/principles/models/principles_package.rb +53 -0
  136. data/lib/principles/models/prior_finding_decision.rb +162 -0
  137. data/lib/principles/models/prior_finding_outcomes.rb +60 -0
  138. data/lib/principles/models/prior_findings.rb +109 -0
  139. data/lib/principles/models/problem_report.rb +110 -0
  140. data/lib/principles/models/project.rb +214 -0
  141. data/lib/principles/models/project_history.rb +203 -0
  142. data/lib/principles/models/record_judged_against.rb +42 -0
  143. data/lib/principles/models/recorded_entry.rb +93 -0
  144. data/lib/principles/models/recorded_finding.rb +97 -0
  145. data/lib/principles/models/recorded_findings.rb +58 -0
  146. data/lib/principles/models/recorded_members.rb +149 -0
  147. data/lib/principles/models/repoint.rb +112 -0
  148. data/lib/principles/models/reported_check.rb +130 -0
  149. data/lib/principles/models/request_id.rb +70 -0
  150. data/lib/principles/models/runs_from.rb +36 -0
  151. data/lib/principles/models/skill_loads.rb +123 -0
  152. data/lib/principles/models/still_holds_restatement.rb +87 -0
  153. data/lib/principles/models/text_size.rb +26 -0
  154. data/lib/principles/models/uncommitted_outside_the_map.rb +30 -0
  155. data/lib/principles/models/waits_on.rb +58 -0
  156. data/lib/principles/models/write_up_opening.rb +93 -0
  157. data/lib/principles/models/write_up_pointer.rb +50 -0
  158. data/lib/principles/models/write_up_repoint.rb +39 -0
  159. data/lib/principles/prose_list.rb +23 -0
  160. data/lib/principles/supported_ruby.rb +93 -0
  161. data/lib/principles/version.rb +11 -0
  162. data/lib/principles/views/acknowledged_since_last_audit.rb +34 -0
  163. data/lib/principles/views/acknowledgement_result.rb +44 -0
  164. data/lib/principles/views/activation_in_hand.rb +47 -0
  165. data/lib/principles/views/assessment_request.rb +131 -0
  166. data/lib/principles/views/audit_changes.rb +79 -0
  167. data/lib/principles/views/audit_commit.rb +55 -0
  168. data/lib/principles/views/audit_result.rb +147 -0
  169. data/lib/principles/views/candidate_files_result.rb +95 -0
  170. data/lib/principles/views/check_result_template.rb +75 -0
  171. data/lib/principles/views/diff_since_last_audit.rb +29 -0
  172. data/lib/principles/views/findings_result.rb +45 -0
  173. data/lib/principles/views/help.rb +119 -0
  174. data/lib/principles/views/invocation.rb +66 -0
  175. data/lib/principles/views/landed_since_base.rb +27 -0
  176. data/lib/principles/views/lines_shared_with_member.rb +111 -0
  177. data/lib/principles/views/loads_to_check_result.rb +45 -0
  178. data/lib/principles/views/map_result.rb +169 -0
  179. data/lib/principles/views/member_change_since_baseline.rb +58 -0
  180. data/lib/principles/views/member_repairs.rb +82 -0
  181. data/lib/principles/views/members_a_commit_changed.rb +66 -0
  182. data/lib/principles/views/members_with_no_baseline.rb +56 -0
  183. data/lib/principles/views/meta_principle_passage.rb +23 -0
  184. data/lib/principles/views/meta_principle_result.rb +170 -0
  185. data/lib/principles/views/open_findings_elsewhere_result.rb +32 -0
  186. data/lib/principles/views/open_findings_line.rb +125 -0
  187. data/lib/principles/views/other_principles_unjudged_result.rb +32 -0
  188. data/lib/principles/views/outside_the_map_result.rb +31 -0
  189. data/lib/principles/views/package_since_last_audit_result.rb +34 -0
  190. data/lib/principles/views/principle_read_result.rb +86 -0
  191. data/lib/principles/views/principle_with_changed_members.rb +26 -0
  192. data/lib/principles/views/principles_with_no_completed_audit.rb +30 -0
  193. data/lib/principles/views/printed_principle.rb +45 -0
  194. data/lib/principles/views/prior_finding_decisions_result.rb +35 -0
  195. data/lib/principles/views/prior_findings_result.rb +80 -0
  196. data/lib/principles/views/problem_lines.rb +84 -0
  197. data/lib/principles/views/problem_result.rb +107 -0
  198. data/lib/principles/views/prompts/acknowledge-a-change/say-whether-the-change-keeps-the-meaning.md +12 -0
  199. data/lib/principles/views/prompts/answer-any-judgement-request/what-a-self-audit-cannot-show.md +6 -0
  200. data/lib/principles/views/prompts/assess-every-member/answer-guide.json +63 -0
  201. data/lib/principles/views/prompts/assess-every-member/assess-each-member-and-the-set.md +12 -0
  202. data/lib/principles/views/prompts/assess-every-member/check-roles-other-principles-give-these-lines.md +7 -0
  203. data/lib/principles/views/prompts/assess-every-member/decide-each-member-loading.md +17 -0
  204. data/lib/principles/views/prompts/assess-every-member/decide-each-prior-finding.md +38 -0
  205. data/lib/principles/views/prompts/assess-every-member/give-problem-evidence-at-still-holds.md +2 -0
  206. data/lib/principles/views/prompts/assess-every-member/give-the-evidence-a-prior-finding-lacks.md +7 -0
  207. data/lib/principles/views/prompts/assess-every-member/have-a-changed-check-reviewed-before-running-it.md +4 -0
  208. data/lib/principles/views/prompts/assess-every-member/have-a-new-check-reviewed-before-running-it.md +3 -0
  209. data/lib/principles/views/prompts/assess-every-member/have-hidden-characters-in-a-check-explained.md +4 -0
  210. data/lib/principles/views/prompts/assess-every-member/judge-acknowledged-edits-together.md +6 -0
  211. data/lib/principles/views/prompts/assess-every-member/judge-the-keeping-against-the-other-principles.md +7 -0
  212. data/lib/principles/views/prompts/assess-every-member/judge-the-keeping-under-the-release-since-the-last-audit.md +10 -0
  213. data/lib/principles/views/prompts/assess-every-member/judge-the-set-with-carried-assessments.md +10 -0
  214. data/lib/principles/views/prompts/assess-every-member/keep-the-last-review-basis-unless-you-give-a-new-one.md +1 -0
  215. data/lib/principles/views/prompts/assess-every-member/name-another-principles-open-finding-rather-than-raise-it.md +9 -0
  216. data/lib/principles/views/prompts/assess-every-member/read-whether-each-prior-finding-s-evidence-changed.md +12 -0
  217. data/lib/principles/views/prompts/assess-every-member/run-a-named-check-at-every-full-audit.md +2 -0
  218. data/lib/principles/views/prompts/assess-every-member/run-the-check-then-report-it.md +2 -0
  219. data/lib/principles/views/prompts/assess-every-member/run-the-reviewed-check-and-say-what-its-review-rested-on.md +6 -0
  220. data/lib/principles/views/prompts/assess-every-member/uncommitted-files-outside-the-map.md +4 -0
  221. data/lib/principles/views/prompts/assess-every-member/why-each-member-is-judged-again.md +24 -0
  222. data/lib/principles/views/prompts/dispose-of-each-finding/answer-guide.json +34 -0
  223. data/lib/principles/views/prompts/dispose-of-each-finding/fix-or-record-each-finding.md +24 -0
  224. data/lib/principles/views/prompts/find-a-principle-to-map/ask-the-owner-before-mapping.md +1 -0
  225. data/lib/principles/views/prompts/find-a-principle-to-map/choose-one-principle.md +5 -0
  226. data/lib/principles/views/prompts/find-a-principle-to-map/owner-ask-ignored-folders.md +2 -0
  227. data/lib/principles/views/prompts/find-a-principle-to-map/owner-ask-ignored.md +3 -0
  228. data/lib/principles/views/prompts/find-a-principle-to-map/owner-ask-possibly-test-data.md +2 -0
  229. data/lib/principles/views/prompts/find-a-principle-to-map/owner-ask-untracked.md +2 -0
  230. data/lib/principles/views/prompts/find-a-principle-to-map/what-each-command-writes.md +4 -0
  231. data/lib/principles/views/prompts/find-a-principle-to-map/where-the-inventory-looks.md +10 -0
  232. data/lib/principles/views/prompts/learn-what-a-command-does/add-member.md +45 -0
  233. data/lib/principles/views/prompts/learn-what-a-command-does/change-activation.md +21 -0
  234. data/lib/principles/views/prompts/learn-what-a-command-does/change-description.md +2 -0
  235. data/lib/principles/views/prompts/learn-what-a-command-does/change-member-check.md +17 -0
  236. data/lib/principles/views/prompts/learn-what-a-command-does/change-member-role.md +9 -0
  237. data/lib/principles/views/prompts/learn-what-a-command-does/create-principle.md +6 -0
  238. data/lib/principles/views/prompts/learn-what-a-command-does/every-command-and-how-they-chain.md +28 -0
  239. data/lib/principles/views/prompts/learn-what-a-command-does/list-candidate-files.md +17 -0
  240. data/lib/principles/views/prompts/learn-what-a-command-does/list-member-problems.md +73 -0
  241. data/lib/principles/views/prompts/learn-what-a-command-does/list-open-findings.md +71 -0
  242. data/lib/principles/views/prompts/learn-what-a-command-does/list-principles.md +7 -0
  243. data/lib/principles/views/prompts/learn-what-a-command-does/move-member.md +11 -0
  244. data/lib/principles/views/prompts/learn-what-a-command-does/remove-member-check.md +10 -0
  245. data/lib/principles/views/prompts/learn-what-a-command-does/remove-member.md +6 -0
  246. data/lib/principles/views/prompts/learn-what-a-command-does/show-answer-check.md +16 -0
  247. data/lib/principles/views/prompts/learn-what-a-command-does/show-audit.md +20 -0
  248. data/lib/principles/views/prompts/learn-what-a-command-does/show-meta-principle-passage.md +52 -0
  249. data/lib/principles/views/prompts/learn-what-a-command-does/show-principle.md +86 -0
  250. data/lib/principles/views/prompts/learn-what-a-command-does/start-audit.md +68 -0
  251. data/lib/principles/views/prompts/learn-what-a-command-does/submit-answer.md +56 -0
  252. data/lib/principles/views/prompts/repoint-write-ups/point-each-moved-write-up-at-its-new-place.md +10 -0
  253. data/lib/principles/views/prompts/report-after-a-commit/it-refused-after-the-commits-touched-the-maps.md +2 -0
  254. data/lib/principles/views/prompts/report-after-a-commit/others-are-still-listed.md +1 -0
  255. data/lib/principles/views/prompts/report-after-a-commit/the-commits-changed-these-members.md +2 -0
  256. data/lib/principles/views/prompts/report-after-a-commit/this-command-shows-each-one.md +1 -0
  257. data/lib/principles/views/prompts/report-the-completed-audit/commit-only-what-the-audit-changed.md +2 -0
  258. data/lib/principles/views/prompts/report-the-completed-audit/replace-the-default-commit-message.md +4 -0
  259. data/lib/principles/views/prompts/report-the-completed-audit/what-completion-means.md +2 -0
  260. data/lib/principles/views/prompts/set-principles-up/entry-file-passage.md +3 -0
  261. data/lib/principles/views/prompts/set-principles-up/name-the-step-each-member-lacks.md +15 -0
  262. data/lib/principles/views/prompts/set-principles-up/place-the-passage-with-the-user.md +31 -0
  263. data/lib/principles/views/prompts/set-principles-up/set-up-first.md +2 -0
  264. data/lib/principles/views/prompts/set-principles-up/what-the-passage-presumes.md +4 -0
  265. data/lib/principles/views/prompts/start-a-session/act-on-the-open-findings.md +5 -0
  266. data/lib/principles/views/prompts/start-a-session/it-returns-after-each-clear-or-compaction-too.md +1 -0
  267. data/lib/principles/views/prompts/start-a-session/it-returns-at-each-session-start.md +1 -0
  268. data/lib/principles/views/prompts/start-a-session/raise-them-at-the-next-pause.md +1 -0
  269. data/lib/principles/views/prompts/start-a-session/raise-them-before-the-first-task.md +1 -0
  270. data/lib/principles/views/prompts.rb +71 -0
  271. data/lib/principles/views/repoint_result.rb +80 -0
  272. data/lib/principles/views/request_part.rb +26 -0
  273. data/lib/principles/views/request_parts.rb +26 -0
  274. data/lib/principles/views/shell_word.rb +29 -0
  275. data/lib/principles/views/visible_text.rb +26 -0
  276. data/lib/principles/views/workflow_diagram.rb +77 -0
  277. data/lib/principles.rb +5 -0
  278. metadata +326 -0
@@ -0,0 +1,112 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../error"
5
+ require_relative "answer_fields"
6
+ require_relative "answer_place"
7
+ require_relative "audit_record"
8
+ require_relative "project_history"
9
+ require_relative "request_id"
10
+ require_relative "write_up_repoint"
11
+
12
+ module Principles
13
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
14
+ module Models
15
+ # A repoint of a principle's open findings' write-ups without a full audit (D59): the agent, having checked
16
+ # where a write-up moved to, gives each finding it names a new recorded_at, and the last completed record keeps
17
+ # it, with where it pointed before, when and why. Judging that the new place holds the write-up is the agent's.
18
+ class Repoint
19
+ PHASE = "repointing"
20
+
21
+ # record is the principle's audit record now; history says whether a new pointer's file is committed.
22
+ #: (principle_id: String, record: AuditRecord?, history: ProjectHistory) -> void
23
+ def initialize(principle_id:, record:, history:)
24
+ @principle_id = principle_id
25
+ @record = record
26
+ @history = history
27
+ freeze
28
+ end
29
+
30
+ # The request's id, digesting the last completed record's revision it was printed after, so an answer is
31
+ # matched to the record it read.
32
+ #: () -> RequestId
33
+ def request_id = RequestId.for(principle_id: @principle_id, phase: PHASE, parts: [revision])
34
+
35
+ # The completed record's revision, which the request prints and the answer hands back.
36
+ #: () -> String
37
+ def revision = completed.revision
38
+
39
+ # The record with each repoint the answer gives in it, once each names a different open finding and each new
40
+ # pointer is located by the block, in a committed file; a repoint whose recorded_at is null leaves its
41
+ # finding as it is.
42
+ #: (untyped, at: String) { (Locator) -> void } -> AuditRecord
43
+ def accepted(response, at:, &locate)
44
+ fields = AnswerFields.new(locate)
45
+ repoints = response.fetch("repoints")
46
+ each_open_once(repoints.map { |repoint| repoint.fetch("finding_id") })
47
+ given = repoints.each_with_index.select { |repoint, _| repoint["recorded_at"] }
48
+ completed.repointed(given.map { |repoint, index| located(repoint, index, fields) }, at: at)
49
+ end
50
+
51
+ # The ids of the completed record's open findings that repointed, the record accepted, points elsewhere (T16).
52
+ #: (AuditRecord) -> Array[String]
53
+ def moved_ids(repointed) = repointed.write_ups_moved_since(completed)
54
+
55
+ # The ids of the rest, which repointed leaves where they pointed, so a repeat or a skipped finding is seen as one.
56
+ #: (AuditRecord) -> Array[String]
57
+ def unchanged_ids(repointed) = completed.open_ids - moved_ids(repointed)
58
+
59
+ # Refuses unless the principle's record is a completed audit's: an open record outside git keeps the completed
60
+ # one, whose findings its correction will decide, and with none there is nothing to repoint (D59).
61
+ #: () -> AuditRecord
62
+ def completed
63
+ record = @record
64
+ return record if record&.completed?
65
+
66
+ start = "#{Error::EXECUTABLE} start-audit #{@principle_id}"
67
+ if record
68
+ raise Error.new("cannot_repoint",
69
+ "#{@principle_id} has an audit left open, whose correction decides its findings",
70
+ "Answer that correction, or start the audit again: #{start}")
71
+ end
72
+
73
+ raise Error.new("cannot_repoint", "#{@principle_id} has no completed audit, so no finding is open",
74
+ "Run a full audit first: #{start}")
75
+ end
76
+
77
+ private
78
+
79
+ # Refuses ids, the findings an answer's repoints name, when one is not open in the completed record or two
80
+ # name one finding, since either would otherwise read as a success (#760, T20).
81
+ #: (Array[String]) -> void
82
+ def each_open_once(ids)
83
+ open = completed.open_ids
84
+ unknown = ids - open
85
+ unless unknown.empty?
86
+ raise Error.new("unknown_finding", "#{@principle_id}'s last completed audit leaves no finding " \
87
+ "#{unknown.join(", ")} open",
88
+ "Repoint only its open findings: #{open.join(", ")}")
89
+ end
90
+ each_once(ids)
91
+ end
92
+
93
+ #: (Array[String]) -> void
94
+ def each_once(ids)
95
+ repeated = ids.tally.select { |_, count| count > 1 }.keys
96
+ return if repeated.empty?
97
+
98
+ raise Error.new("duplicate_finding", "Repoints name #{repeated.join(", ")} more than once",
99
+ "Give each finding one repoint.")
100
+ end
101
+
102
+ # The repoint at index, its recorded_at located and committed, and its reason given.
103
+ #: (Hash[String, untyped], Integer, AnswerFields) -> WriteUpRepoint
104
+ def located(repoint, index, fields)
105
+ place = AnswerPlace.new("/repoints/#{index}", "Finding #{repoint["finding_id"]}")
106
+ pointer = fields.write_up_pointer(repoint["recorded_at"], place.child("recorded_at"))
107
+ @history.refuse_uncommitted_write_up(pointer.path)
108
+ WriteUpRepoint.answered(repoint, pointer, fields, place)
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,130 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../error"
5
+ require_relative "answer_place"
6
+ require_relative "answer_text"
7
+ require_relative "dated_line"
8
+
9
+ module Principles
10
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
11
+ module Models
12
+ # One member's check and the result an agent reports for it (D30). This tool never runs the check: it
13
+ # refuses a report that cannot be about the member's own check, and cannot confirm the run happened.
14
+ class ReportedCheck
15
+ SHAPES = "Give check_result as answer_guide.check_result shows."
16
+ BASIS = "Say in review_basis, in one line, what the review before the run rested on, such as " \
17
+ "\"The owner reviewed the command in this session.\" or the document that confirms it. If you did " \
18
+ "the review yourself, such as confirming against the documentation a check you changed, say so: " \
19
+ "\"Self-reviewed by the agent: <what you checked it against>\". If no record of the review was " \
20
+ "kept, say so, such as \"Reviewed in an earlier session; basis not kept.\"" #: String
21
+
22
+ # assessment is the place of the member's assessment in the answer, whose check_result each refusal points
23
+ # into (#817). ran is the command the principle's stored record was given an exit status for, nil when it ran
24
+ # none; a check it did not run is reviewed before it runs, and the result says in review_basis what that review
25
+ # rested on (D63). kept is the line the stored record keeps for that run's review, with its
26
+ # review_basis_since, which an unchanged check's result carries until the answer gives another (D52).
27
+ #: (AnswerPlace, String?, ran: String?, kept: Hash[String, untyped]) -> void
28
+ def initialize(assessment, check, ran:, kept:)
29
+ @assessment = assessment
30
+ @check = check
31
+ @ran = ran
32
+ @kept = kept
33
+ freeze
34
+ end
35
+
36
+ # The reported result, once it is checked against the member's check; nil when it has none.
37
+ #: (untyped) -> Hash[String, untyped]?
38
+ def result(reported)
39
+ return no_check(reported) unless @check
40
+
41
+ missing_result unless reported
42
+ same_command(reported.fetch("command"))
43
+ status_or_reason(reported)
44
+ review_basis(reported)
45
+ carrying_review(reported)
46
+ end
47
+
48
+ private
49
+
50
+ # The place of the reported check_result, or of one of its fields.
51
+ #: (?String?) -> AnswerPlace
52
+ def reported_at(field = nil)
53
+ place = @assessment.child("check_result")
54
+ field ? place.child(field) : place
55
+ end
56
+
57
+ # Refuses an assessment of a checked member that reports no result for its check, pointing at the assessment
58
+ # it is missing from.
59
+ #: () -> bot
60
+ def missing_result
61
+ refuse(@assessment, "the member has a check, which context.members gives, so its assessment needs a " \
62
+ "check_result",
63
+ "Report the member's check once you have run it, after a review if " \
64
+ "answer_guide.check_result.limits asks for one. #{SHAPES}")
65
+ end
66
+
67
+ #: (untyped) -> nil
68
+ def no_check(reported)
69
+ return unless reported
70
+
71
+ refuse(reported_at, "the member has no check, so its assessment takes no check_result",
72
+ "Remove check_result from this member's assessment.")
73
+ end
74
+
75
+ #: (String) -> void
76
+ def same_command(command)
77
+ return if command == @check
78
+
79
+ refuse(reported_at("command"), "check_result.command is not the member's check as context.members gives it",
80
+ "Report the command exactly as context.members lists it, run after a review if " \
81
+ "answer_guide.check_result.limits asks for one.")
82
+ end
83
+
84
+ #: (Hash[String, untyped]) -> void
85
+ def status_or_reason(reported)
86
+ unless reported.key?("exit_status") ^ reported.key?("not_run_reason")
87
+ refuse(reported_at, "check_result needs exactly one of exit_status and not_run_reason",
88
+ "Give exit_status once the command has run, or not_run_reason if it could not, including when " \
89
+ "no review it needed was possible. #{SHAPES}")
90
+ end
91
+ return if !reported.key?("not_run_reason") || AnswerText.new(reported["not_run_reason"]).written?
92
+
93
+ refuse(reported_at("not_run_reason"), "check_result.not_run_reason must be a nonempty string",
94
+ "Say why the command could not be run here.")
95
+ end
96
+
97
+ # Refuses an exit status for a check the stored record did not run unless review_basis says what its review
98
+ # rested on, and any review_basis that is not one written line. What the line says is the agent's: this tool
99
+ # keeps it and cannot verify it.
100
+ #: (Hash[String, untyped]) -> void
101
+ def review_basis(reported)
102
+ if reported.key?("review_basis")
103
+ return if AnswerText.new(reported["review_basis"]).one_line?
104
+
105
+ refuse(reported_at("review_basis"), "check_result.review_basis is not one written line", BASIS)
106
+ end
107
+ return unless reported.key?("exit_status") && @ran != @check
108
+
109
+ refuse(reported_at("review_basis"),
110
+ "check_result needs review_basis beside exit_status, since no audit ran this command", BASIS)
111
+ end
112
+
113
+ # The result as the record keeps it: an unchanged check's exit status with the kept line and its date when the
114
+ # answer gives the same line or none, or the answer's new line alone; any other result as reported.
115
+ #: (Hash[String, untyped]) -> Hash[String, untyped]
116
+ def carrying_review(reported)
117
+ return reported unless reported.key?("exit_status") && @ran == @check
118
+
119
+ line = DatedLine.new(reported.fetch("review_basis", @kept["review_basis"]), field: "review_basis")
120
+ reported.except("review_basis").merge(line.kept_over(@kept))
121
+ end
122
+
123
+ # Refuses with what is wrong at place, which the refusal names with whose it is and points at (#817).
124
+ #: (AnswerPlace, String, String) -> bot
125
+ def refuse(place, message, repair)
126
+ raise place.refusal(Error.new("invalid_check_result", message, repair))
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,70 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+ require "json"
6
+ require_relative "../error"
7
+
8
+ module Principles
9
+ # The domain logic, which loads and saves itself through data/. models/CLAUDE.md owns the boundary.
10
+ module Models
11
+ # The id of a judgement request: the principle, the phase and a digest of everything the request
12
+ # depends on, as ID.PHASE.DIGEST. This tool stores no request, so an answer is matched by recomputing
13
+ # the digest from the project as it is now: a different digest means something the request depended
14
+ # on has changed (D25). The digest has no secret, so it catches mistakes, not forgery.
15
+ class RequestId
16
+ PHASES = %w[assessment correction acknowledgement repointing].freeze
17
+ FORMAT = /\A(?<principle>[a-z0-9]+(?:-[a-z0-9]+)*)\.(?<phase>[a-z]+)\.(?<digest>[0-9a-f]{12})\z/
18
+
19
+ #: String
20
+ attr_reader :principle_id
21
+ #: String
22
+ attr_reader :phase
23
+
24
+ # The id of a request in phase for principle_id, which depends on parts.
25
+ #: (principle_id: String, phase: String, parts: Array[untyped]) -> RequestId
26
+ def self.for(principle_id:, phase:, parts:)
27
+ new(principle_id, phase, Digest::SHA256.hexdigest(JSON.generate([principle_id, phase, *parts]))[0, 12].to_s)
28
+ end
29
+
30
+ # An id as an agent passed it, refused unless it has the shape this tool prints.
31
+ #: (String) -> RequestId
32
+ def self.parse(text)
33
+ match = FORMAT.match(text)
34
+ unless match && PHASES.include?(match[:phase])
35
+ raise Error.new("unknown_request", "#{text} does not have the form of a request id",
36
+ "Use the request_id of the latest start-audit or submit-answer result.")
37
+ end
38
+
39
+ new(match[:principle].to_s, match[:phase].to_s, match[:digest].to_s)
40
+ end
41
+
42
+ #: (String, String, String) -> void
43
+ def initialize(principle_id, phase, digest)
44
+ @principle_id = principle_id
45
+ @phase = phase
46
+ @digest = digest
47
+ freeze
48
+ end
49
+
50
+ # Refuses answer unless it is an object whose request_id is this id.
51
+ #: (untyped) -> void
52
+ def check_answer(answer)
53
+ return if answer.is_a?(Hash) && answer["request_id"] == to_s
54
+
55
+ raise Error.new("request_mismatch", "answer.request_id must equal #{self}",
56
+ "Start from the response_template of this request.")
57
+ end
58
+
59
+ #: () -> String
60
+ def to_s
61
+ "#{@principle_id}.#{@phase}.#{@digest}"
62
+ end
63
+
64
+ #: (untyped) -> bool
65
+ def ==(other)
66
+ other.is_a?(RequestId) ? other.to_s == to_s : false
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,36 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Principles
5
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
6
+ module Models
7
+ # Where this run of the tool comes from, by the executable its printed commands name. The Claude Code plugin's
8
+ # bin/principles is the one entry point that names itself bare, since its folder is on the Bash tool's PATH
9
+ # while the plugin is enabled (#528); a checkout or an installed gem names a Ruby and a path. Only under the
10
+ # plugin does its session-start hook report beside CLAUDE.md (D41).
11
+ class RunsFrom
12
+ # The executable the plugin's entry point names itself by.
13
+ PLUGIN_EXECUTABLE = "principles"
14
+
15
+ #: (String) -> void
16
+ def initialize(executable)
17
+ @executable = executable
18
+ freeze
19
+ end
20
+
21
+ # Where this run comes from, as a fact the agent reads.
22
+ #: () -> String
23
+ def to_s = plugin? ? "claude_code_plugin" : "command_line"
24
+
25
+ # Whether the plugin's session-start hook runs in the session this run is part of, which only a run from the
26
+ # plugin shows; it says nothing of any other person's sessions.
27
+ #: () -> bool
28
+ def session_start_hook_in_this_session? = plugin?
29
+
30
+ private
31
+
32
+ #: () -> bool
33
+ def plugin? = @executable == PLUGIN_EXECUTABLE
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,123 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../error"
5
+ require_relative "frontmatter_fields"
6
+ require_relative "json_path_query"
7
+
8
+ module Principles
9
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
10
+ module Models
11
+ # When Claude Code loads a skill: its body when the skill is invoked, and its description, which tells the
12
+ # model the skill exists, at session start, on the files its paths field names, or, for a skill in a folder
13
+ # below the project root, when a file there is first touched. A skill only a person invokes puts nothing in
14
+ # the model's context until they do. This follows Claude Code's skills documentation and the #354 experiment.
15
+ # This tool shows the paths field as written, and reads disable-model-invocation only as a plain true or false.
16
+ class SkillLoads
17
+ # A skill's own file: SKILL.md in a skill's folder in a .claude/skills folder, at the root or below it.
18
+ SKILL_FILE = %r{\A(?<folder>(?:.*/)?)\.claude/skills/[^/]+/SKILL\.md\z} #: Regexp
19
+
20
+ PERSON_ONLY = "disable-model-invocation" #: String
21
+
22
+ # The characters of a skill's description and when_to_use the listing keeps (code.claude.com/docs/en/skills,
23
+ # read 2026-11-09); a project can set another with skillListingMaxDescChars, which this tool does not read.
24
+ LISTING_LIMIT = 1536 #: Integer
25
+
26
+ # text is the skill's whole file, or nil when it cannot be read; query is the member's, when it is a
27
+ # frontmatter field of the skill.
28
+ #: (String, String?, ?query: String?) -> void
29
+ def initialize(path, text, query: nil)
30
+ @path = path
31
+ @text = text
32
+ @query = query
33
+ freeze
34
+ end
35
+
36
+ #: () -> Hash[String, untyped]
37
+ def to_h
38
+ lines = @text&.lines or return { "when" => "not_determined" }
39
+ frontmatter = FrontmatterFields.new(path: @path, lines: lines)
40
+ person_only = person_only(frontmatter)
41
+ return not_derived("#{PERSON_ONLY} is not a plain true or false") if person_only.nil?
42
+
43
+ description = description(frontmatter, person_only)
44
+ return description if description.key?("not_derived")
45
+
46
+ description_member? ? description.merge("skill_part" => "description") : body(person_only, description)
47
+ rescue Error => e
48
+ { "when" => "not_determined", "frontmatter_refused" => e.payload.fetch("code") }
49
+ end
50
+
51
+ # What the skill listing loads of the skill: its description and when_to_use lines as written, cut at the
52
+ # listing's limit; nil for a skill only a person invokes, which the listing leaves out, or one this tool cannot
53
+ # read.
54
+ #: () -> String?
55
+ def description_text
56
+ lines = @text&.lines or return nil
57
+ frontmatter = FrontmatterFields.new(path: @path, lines: lines)
58
+ return nil unless person_only(frontmatter) == false
59
+
60
+ %w[description when_to_use].filter_map { |field| frontmatter.field_lines("$.#{field}")&.join }
61
+ .join[0, LISTING_LIMIT]
62
+ rescue Error
63
+ nil
64
+ end
65
+
66
+ private
67
+
68
+ # When the skill's body loads, who may invoke it, and when its description loads.
69
+ #: (bool, Hash[String, untyped]) -> Hash[String, untyped]
70
+ def body(person_only, description)
71
+ { "when" => "on_invocation", "invoked_by" => person_only ? "person" : "model_or_person",
72
+ "description" => description }
73
+ end
74
+
75
+ # Whether the member is the skill's description field, however its query names it. The docs summary says
76
+ # when_to_use joins the description in the listing; until that is read verbatim, it keeps the body's answer.
77
+ #: () -> bool
78
+ def description_member?
79
+ query = @query or return false
80
+ JsonPathQuery.new(query).top_level_name == "description"
81
+ end
82
+
83
+ # Whether only a person invokes the skill: false without the field, nil when it is not a plain true or false.
84
+ #: (FrontmatterFields) -> bool?
85
+ def person_only(frontmatter)
86
+ field = frontmatter.field_lines("$[\"#{PERSON_ONLY}\"]") or return false
87
+ value = field.join.strip.delete_prefix("#{PERSON_ONLY}:").strip
88
+ value == "true" if %w[true false].include?(value)
89
+ end
90
+
91
+ # When the description loads.
92
+ #: (FrontmatterFields, bool) -> Hash[String, untyped]
93
+ def description(frontmatter, person_only)
94
+ return { "when" => "not_loaded" } if person_only
95
+
96
+ paths = frontmatter.field_lines("$.paths")
97
+ return on_paths(paths) if paths
98
+ return { "when" => "on_files", "files" => [folder] } unless folder.empty?
99
+
100
+ { "when" => "session_start" }
101
+ end
102
+
103
+ # The description's moment for a skill with a paths field, shown as written.
104
+ #: (Array[String]) -> Hash[String, untyped]
105
+ def on_paths(paths)
106
+ return not_derived("a skill below the project root with a paths field") unless folder.empty?
107
+
108
+ { "when" => "on_files", "paths_field" => paths.map(&:chomp) }
109
+ end
110
+
111
+ # The folder whose .claude/skills holds the skill: empty at the project root, else ending in a slash.
112
+ #: () -> String
113
+ def folder
114
+ SKILL_FILE.match(@path)&.[](:folder).to_s
115
+ end
116
+
117
+ #: (String) -> Hash[String, untyped]
118
+ def not_derived(why)
119
+ { "when" => "not_determined", "not_derived" => why }
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,87 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "answer_fields"
5
+ require_relative "answer_place"
6
+ require_relative "answer_text"
7
+ require_relative "evidence_replacements"
8
+ require_relative "open_finding"
9
+
10
+ module Principles
11
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
12
+ module Models
13
+ # What a decision on a prior finding restates of that finding, which only a still_holds decision may: the
14
+ # problem its record lacks, a new pointer to its write-up (D35), the evidence its record lacks (D64) and a new
15
+ # place for each kept evidence item no longer found (D70).
16
+ # PriorFindingDecision checks the rest of the decision, and refuses for this through refuse.
17
+ class StillHoldsRestatement
18
+ # finding is the prior finding as its record left it open; place is the decision's place in the answer; refuse
19
+ # raises the decision's refusal for a reason and a repair, or the decision's own repair for nil.
20
+ #: (Hash[String, untyped], OpenFinding, AnswerPlace, refuse: ^(String, String?) -> void) -> void
21
+ def initialize(decision, finding, place, refuse:)
22
+ @decision = decision
23
+ @finding = finding
24
+ @place = place
25
+ @refuse = refuse
26
+ @id = finding.id #: String
27
+ freeze
28
+ end
29
+
30
+ # Refuses a restatement on any decision but still_holds, a problem the finding already states or that is not
31
+ # one line, kept evidence it replaces but that is still found or named by no replaces, and a pointer locate
32
+ # cannot resolve.
33
+ #: (^(Locator) -> void) -> void
34
+ def check(locate)
35
+ evidence(locate) if @decision.key?("problem_evidence")
36
+ return if @decision.slice("problem", "recorded_at").empty?
37
+
38
+ unless @decision["decision"] == "still_holds"
39
+ @refuse.call("only still_holds may give a problem or recorded_at",
40
+ "Drop problem and recorded_at from #{@id}'s decision, or decide it still_holds.")
41
+ end
42
+ problem if @decision.key?("problem")
43
+ return unless @decision["recorded_at"]
44
+
45
+ AnswerFields.new(locate).pointer(@decision["recorded_at"], @place.child("recorded_at"))
46
+ end
47
+
48
+ private
49
+
50
+ # The evidence a still_holds decision may give: a finding whose record keeps none, its evidence (D64), and one
51
+ # whose record keeps it, the new place of each kept item no longer found, which EvidenceReplacements checks, so a
52
+ # decision never rewrites what an audit found (D70). This checks only that each pointer resolves.
53
+ #: (^(Locator) -> void) -> void
54
+ def evidence(locate)
55
+ unless @decision["decision"] == "still_holds"
56
+ @refuse.call("only still_holds may give problem_evidence",
57
+ "Drop problem_evidence from #{@id}'s decision, or decide it still_holds.")
58
+ end
59
+ place = @place.child("problem_evidence")
60
+ if @finding.evidence_kept?
61
+ return @finding.replacements(@decision["problem_evidence"]).check(locate, @id, place, refuse: @refuse)
62
+ end
63
+
64
+ lacking(locate, place)
65
+ end
66
+
67
+ # The evidence a decision gives a finding whose record keeps none, which has nothing to replace (D64).
68
+ #: (^(Locator) -> void, AnswerPlace) -> void
69
+ def lacking(locate, place)
70
+ given = @decision["problem_evidence"]
71
+ if given.any? { |entry| entry.key?(EvidenceReplacements::REPLACES) }
72
+ @refuse.call("its record keeps no evidence to replace", "Drop replaces from #{@id}'s problem_evidence.")
73
+ end
74
+ AnswerFields.new(locate).evidence(given, place)
75
+ end
76
+
77
+ #: () -> void
78
+ def problem
79
+ if @finding.states_problem?
80
+ @refuse.call("it already states its problem",
81
+ "Drop problem from #{@id}'s decision, or raise it again as a new finding to restate it.")
82
+ end
83
+ @refuse.call("problem must be one line", nil) unless AnswerText.new(@decision["problem"]).one_line?
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,26 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Principles
5
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
6
+ module Models
7
+ # How big a text that loads into context is: its bytes, and its tokens estimated as characters / 4, rounded up,
8
+ # an estimate rather than a tokenizer count. A text not measured has no size, so nothing is printed for it.
9
+ class TextSize
10
+ #: (String?) -> void
11
+ def initialize(text)
12
+ @text = text
13
+ freeze
14
+ end
15
+
16
+ # The size under fields' two names, its bytes then its tokens; empty when there is no text.
17
+ #: ([String, String]) -> Hash[String, Integer]
18
+ def under(fields)
19
+ text = @text or return {}
20
+
21
+ bytes, tokens = fields
22
+ { bytes => text.bytesize, tokens => (text.length / 4.0).ceil }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,30 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Principles
5
+ # The domain logic, which loads and saves itself through data/. models/CLAUDE.md owns the boundary.
6
+ module Models
7
+ # The project's files outside a principle's map that differ from the audit's base commit, as an assessment
8
+ # request names them (#409). A project with many untracked files would put every one into each request, so
9
+ # it names the first few and counts the rest (#426, D38).
10
+ class UncommittedOutsideTheMap
11
+ # The most paths a request names: enough to show a pattern, such as a generated folder, in a short list of
12
+ # its own; git status lists them all.
13
+ NAMED_AT_MOST = 10
14
+
15
+ #: (Array[String]) -> void
16
+ def initialize(paths)
17
+ @paths = paths
18
+ freeze
19
+ end
20
+
21
+ # The paths a request names, sorted, and how many beyond them it does not, when any.
22
+ #: () -> Hash[String, untyped]
23
+ def shown
24
+ named = { "paths" => @paths.sort.first(NAMED_AT_MOST) }
25
+ not_named = @paths.length - NAMED_AT_MOST
26
+ not_named.positive? ? named.merge("not_named" => not_named) : named
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,58 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../error"
5
+ require_relative "answer_text"
6
+ require_relative "dated_line"
7
+
8
+ module Principles
9
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
10
+ module Models
11
+ # The one line an answer may give on a deferred finding, saying whom it waits on and to decide what (D52).
12
+ # What the line says is the agent's judgement; this tool checks only that it is one line, on a finding left
13
+ # deferred. A null stands on any finding, so a template's null kept on another action is not refused.
14
+ class WaitsOn
15
+ # What to write instead of a line this tool cannot keep.
16
+ REPAIR = "Say in one line whom the deferral waits on and to decide what, or set waits_on to null." #: String
17
+
18
+ # line is what the answer gave, and action the finding's action once the answer is applied, or nil when the
19
+ # answer does not leave it open.
20
+ #: (untyped, action: String?) -> void
21
+ def initialize(line, action:)
22
+ @line = line
23
+ @action = action
24
+ freeze
25
+ end
26
+
27
+ # Refuses the line, with code and naming subject, unless it is null, or one line on a finding left deferred.
28
+ #: (code: String, subject: String) -> void
29
+ def check(code:, subject:)
30
+ return if @line.nil?
31
+
32
+ unless deferred?
33
+ raise Error.new(code, "#{subject} gives waits_on, but only a deferred finding waits on anyone",
34
+ "Set waits_on to null, or defer the finding.")
35
+ end
36
+ return if AnswerText.new(@line).one_line?
37
+
38
+ raise Error.new(code, "#{subject} gives a waits_on that is not one line", REPAIR)
39
+ end
40
+
41
+ # What a kept finding waits on once its decision is applied, this being the line the decision gave or, when it
42
+ # gave none, the one it carried: had, the waits_on and waits_on_since it carried, when the line is unchanged,
43
+ # so an ask keeps its date; this line alone, dated by the audit that records it, when new; nothing when the
44
+ # finding is no longer deferred or the decision gave null.
45
+ #: (Hash[String, untyped]) -> Hash[String, untyped]
46
+ def kept_over(had)
47
+ return {} unless deferred?
48
+
49
+ DatedLine.new(@line, field: "waits_on").kept_over(had)
50
+ end
51
+
52
+ private
53
+
54
+ #: () -> bool
55
+ def deferred? = @action == "deferred"
56
+ end
57
+ end
58
+ end