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,76 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "audit_record"
5
+ require_relative "member"
6
+ require_relative "member_grounds"
7
+
8
+ module Principles
9
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
10
+ module Models
11
+ # Which members a full audit carries forward the last full audit's assessment of, and why it asks the agent to
12
+ # judge each other one (D48, D49). A member is carried only when the last completed record kept its assessment
13
+ # and judged it against the same description and question, and its text, id, map entry and loads are as that
14
+ # audit judged them. Each test compares stored values, so it is this tool's to make; whether the set still
15
+ # holds, and whether to re-judge a carried member, is the agent's.
16
+ class CarriedAssessments
17
+ # record is the principle's audit record now, if any; members are the map's members now, with their hashes in
18
+ # now and their grounds; judged_against digests the description and question this audit asks.
19
+ #: (AuditRecord?, members: MapMembers, judged_against: String, now: Hash[String, String],
20
+ #| grounds: MemberGrounds) -> void
21
+ def initialize(record, members:, judged_against:, now:, grounds:)
22
+ @last = record&.last_completed #: AuditRecord?
23
+ @members = members
24
+ @judged_against = judged_against
25
+ @now = now
26
+ @grounds = grounds
27
+ freeze
28
+ end
29
+
30
+ # Why the member id must be judged again, or nil when its last assessment is carried forward. A record from
31
+ # before assessments were kept assessed each member it recorded, but kept none to carry (#701).
32
+ #: (String) -> String?
33
+ def judge_because(id)
34
+ last = @last or return "not_assessed_before"
35
+ entry = recorded(id)
36
+ return reason_in_record_before_assessments_were_kept(entry) unless last.kept_assessments?
37
+ return "description_or_question_changed" unless last.judged_against?(@judged_against)
38
+
39
+ entry ? entry.judge_because(id: id, sha256: @now[id], grounds: @grounds.of(id)) : "not_assessed_before"
40
+ end
41
+
42
+ # Whether the member id's last assessment is carried forward.
43
+ #: (String) -> bool
44
+ def carried?(id) = judge_because(id).nil?
45
+
46
+ # What the request shows beside the member id: the assessment carried forward, with whether any evidence it
47
+ # cited changed since, which the block says from each evidence item's hash then; or why it is judged again, and
48
+ # where it was if it moved.
49
+ #: (String) { (Locator, String) -> bool } -> Hash[String, untyped]
50
+ def shown(id, &)
51
+ reason = judge_because(id)
52
+ return { "judge_because" => reason, "moved_from" => recorded(id)&.moved_from(id) }.compact if reason
53
+
54
+ entry = recorded(id) or return {}
55
+ { "carried_forward" => entry.carried_forward(&) }
56
+ end
57
+
58
+ # When the last full audit's sighting of the member id was, if a fresh observation repeats that audit's word for
59
+ # word; nil when it does not, or there was none. Whether the sighting is the same is the agent's to say (#680).
60
+ #: (String, String) -> String?
61
+ def repeated_sighting(id, observation) = recorded(id)&.repeated_sighting(observation)
62
+
63
+ private
64
+
65
+ # Why a member of a record from before assessments were kept is judged again: that audit assessed it, if the
66
+ # record holds its entry, but kept no assessment to carry.
67
+ #: (RecordedEntry?) -> String
68
+ def reason_in_record_before_assessments_were_kept(entry)
69
+ entry ? "assessed_before_assessments_were_kept" : "not_assessed_before"
70
+ end
71
+
72
+ #: (String) -> RecordedEntry?
73
+ def recorded(id) = @last&.members&.recorded(@members.found(id).key)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,61 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../error"
5
+ require_relative "audit_record"
6
+ require_relative "member"
7
+ require_relative "member_map_history"
8
+ require_relative "member_maps"
9
+ require_relative "project_history"
10
+
11
+ module Principles
12
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
13
+ module Models
14
+ # Whether a member's check is the one the principle's last audit was given an exit status for, and, when
15
+ # it is not, the commit that made it what it is now. A check is a command any collaborator can change in the
16
+ # committed map, and the audit asks the agent to run it, so a check no audit has seen needs a review
17
+ # first (#227). This tool states the fact; the review is the agent's step.
18
+ class CheckSinceLastAudit
19
+ #: (id: String, history: ProjectHistory, member_maps: MemberMaps, record: AuditRecord?) -> void
20
+ def initialize(id:, history:, member_maps:, record:)
21
+ @id = id
22
+ @history = history
23
+ @member_maps = member_maps
24
+ @record = record
25
+ freeze
26
+ end
27
+
28
+ # "unchanged" when the last audit was given an exit status for this same check; otherwise "new" or
29
+ # "changed", with changed_in the oldest commit of the unbroken run, back from HEAD, whose map holds it,
30
+ # and, when changed, last_run, the command the last audit ran. Beside an unchanged check, the line saying what its
31
+ # last review rested on and since when; beside a changed one, last_run_review_basis, that line for the command
32
+ # the last audit ran, not this one (D63).
33
+ # changed_in is nil outside git, or when no commit holds it. The walk stops at a map it cannot read, so
34
+ # changed_in may then name a later commit than the one that set the check: the review is still asked for.
35
+ #: (Member, String) -> Hash[String, untyped]
36
+ def of(member, check)
37
+ ran = @record&.check_run_for(member.key)
38
+ review = review_of(member.key)
39
+ return { "status" => "unchanged", **review } if ran == check
40
+
41
+ changed_in = commit_that_set(member.key, check)
42
+ return { "status" => "new", "changed_in" => changed_in } unless ran
43
+
44
+ last_run_review = review.slice("review_basis").transform_keys { "last_run_review_basis" }
45
+ { "status" => "changed", "last_run" => ran, **last_run_review, "changed_in" => changed_in }
46
+ end
47
+
48
+ private
49
+
50
+ # The line the last audit's record keeps for what the review of its run rested on, and since when (D63).
51
+ #: (String) -> Hash[String, untyped]
52
+ def review_of(key) = @record&.check_review_for(key) || {}
53
+
54
+ #: (String, String) -> String?
55
+ def commit_that_set(key, check)
56
+ run = MemberMapHistory.new(id: @id, history: @history, member_maps: @member_maps).runs([key]).fetch(key)
57
+ run.take_while { |_sha, fields| Member.new(fields).check == check }.last&.first
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,39 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "assessment_report"
5
+ require_relative "project_history"
6
+
7
+ module Principles
8
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
9
+ module Models
10
+ # The files an audit record cites, through its assessments' evidence and its findings' evidence and write-ups, as
11
+ # the project holds them now: which go with the record when its completed audit is committed, since a clone needs
12
+ # every file the record points at (#724), and which no longer exist, though the record still points at them (#861).
13
+ class CitedFiles
14
+ # paths are the files the record cites; in_git says whether its audit had a base commit to compare with.
15
+ #: (Array[String], ProjectHistory, in_git: bool) -> void
16
+ def initialize(paths, history, in_git:)
17
+ @paths = paths
18
+ @history = history
19
+ @in_git = in_git
20
+ freeze
21
+ end
22
+
23
+ # The files to commit with the record, sorted once each: each file the audit changed; in git, with each cited
24
+ # file, only those that still differ from HEAD, since a fix the agent already committed needs nothing. A cited
25
+ # file deleted since is left out, since adding it would stage a deletion the audit never made (#842); a file its
26
+ # fixes deleted stays. What reported cites that the record does not keep, as the answer's recent decision's
27
+ # paths, is cited too (#860).
28
+ #: (Array[String], ?AssessmentReport) -> Array[String]
29
+ def committed_with(changed, reported = AssessmentReport::NONE)
30
+ changed = @history.uncommitted(changed + @history.existing(@paths + reported.recent_decision_paths)) if @in_git
31
+ changed.uniq.sort
32
+ end
33
+
34
+ # Each cited file that no longer exists, in git or outside it (#861).
35
+ #: () -> Array[String]
36
+ def missing = @paths - @history.existing(@paths)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,270 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../error"
5
+ require_relative "../data/command_steps_file"
6
+
7
+ module Principles
8
+ # Values built from validated data, and the rules a schema cannot express. models/CLAUDE.md owns the
9
+ # boundary.
10
+ module Models
11
+ # The steps each command runs: which are this tool's and which are the agent's, the refusals each of
12
+ # this tool's steps can raise, and where each step leads. Each command's --help and the workflow diagram
13
+ # are drawn from it (ticket 001 D15). It is built from data Data::CommandStepsFile has already checked
14
+ # against its schema, and checks what a schema cannot: every step a branch names exists, every refusal
15
+ # group exists, and every command a step hands over to is real. The prompts it names are checked by
16
+ # spec/unit/principles/views/prompts_spec.rb, because the domain reads no files.
17
+ class CommandSteps
18
+ # Where a step leads when it branches.
19
+ class Branch
20
+ #: String
21
+ attr_reader :to
22
+ #: String
23
+ attr_reader :condition
24
+
25
+ # A step's branches, from its "next" list.
26
+ #: (Array[Hash[String, String]]) -> Array[Branch]
27
+ def self.all(edges)
28
+ edges.map { |edge| new(edge.fetch("to"), edge.fetch("when")) }.freeze
29
+ end
30
+
31
+ #: (String, String) -> void
32
+ def initialize(to, condition)
33
+ @to = to
34
+ @condition = condition
35
+ freeze
36
+ end
37
+ end
38
+
39
+ # What an agent's step hands back: what this tool then checks, what it cannot, and the command the agent
40
+ # answers with.
41
+ class Handover
42
+ #: String?
43
+ attr_reader :checks
44
+ #: String?
45
+ attr_reader :cannot_check
46
+ #: String?
47
+ attr_reader :answer_with
48
+ # When the agent runs answer_with, where that is the agent's to time, as a first full audit is (#627).
49
+ #: String?
50
+ attr_reader :answer_when
51
+ # The form of answer_with the step hands to, where its whole usage would mislead, as a first full audit
52
+ # that --member would make a refused one-member audit (#635).
53
+ #: String?
54
+ attr_reader :answer_usage
55
+
56
+ #: (Hash[String, untyped]) -> void
57
+ def initialize(data)
58
+ @checks = data["checks"] #: String?
59
+ @cannot_check = data["cannot_check"] #: String?
60
+ @answer_with = data["answer_with"] #: String?
61
+ @answer_when = data["answer_when"] #: String?
62
+ @answer_usage = data["answer_usage"] #: String?
63
+ freeze
64
+ end
65
+
66
+ # Whether answer_usage, if given, is a form of the command answer_with names, so it cannot drift to another.
67
+ #: () -> bool
68
+ def usage_names_its_command?
69
+ usage = @answer_usage
70
+ usage.nil? || usage == @answer_with || usage.start_with?("#{@answer_with} ")
71
+ end
72
+ end
73
+
74
+ # One step: this tool's ("cli") or the agent's ("agent").
75
+ class Step
76
+ #: String
77
+ attr_reader :id
78
+ #: String
79
+ attr_reader :does
80
+ #: Array[String]
81
+ attr_reader :refusals
82
+ #: String?
83
+ attr_reader :prompt
84
+ #: Handover
85
+ attr_reader :handover
86
+ #: Array[Branch]
87
+ attr_reader :branches
88
+
89
+ #: (Hash[String, untyped], Array[String]) -> void
90
+ def initialize(data, refusals)
91
+ @id = data.fetch("id") #: String
92
+ @agent = data.fetch("actor") == "agent" #: bool
93
+ @does = data.fetch("does") #: String
94
+ @refusals = refusals.freeze #: Array[String]
95
+ @prompt = data["prompt"] #: String?
96
+ @handover = Handover.new(data) #: Handover
97
+ @branches = Branch.all(data.fetch("next", [])) #: Array[Branch]
98
+ freeze
99
+ end
100
+
101
+ #: () -> bool
102
+ def agent?
103
+ @agent
104
+ end
105
+ end
106
+
107
+ # A heading the overview lists commands under, and what every command under it writes.
108
+ class Group
109
+ #: String
110
+ attr_reader :id
111
+ #: String
112
+ attr_reader :heading
113
+ #: String
114
+ attr_reader :writes
115
+
116
+ #: (Hash[String, untyped]) -> void
117
+ def initialize(data)
118
+ @id = data.fetch("id") #: String
119
+ @heading = data.fetch("heading") #: String
120
+ @writes = data.fetch("writes") #: String
121
+ freeze
122
+ end
123
+ end
124
+
125
+ # One command, with its steps in the order they run.
126
+ class Command
127
+ #: String
128
+ attr_reader :name
129
+ #: String
130
+ attr_reader :usage
131
+ #: String
132
+ attr_reader :group
133
+ # The named options it takes, without their dashes, besides --project and --help.
134
+ #: Array[String]
135
+ attr_reader :options
136
+ #: String
137
+ attr_reader :summary
138
+ #: String
139
+ attr_reader :help
140
+ #: Array[Step]
141
+ attr_reader :steps
142
+
143
+ #: (Hash[String, untyped], Array[Step]) -> void
144
+ def initialize(data, steps)
145
+ @name = data.fetch("name") #: String
146
+ @usage = data.fetch("usage") #: String
147
+ @group = data.fetch("group") #: String
148
+ @options = data.fetch("options").freeze #: Array[String]
149
+ @summary = data.fetch("summary") #: String
150
+ @help = data.fetch("help") #: String
151
+ @steps = steps.freeze #: Array[Step]
152
+ freeze
153
+ end
154
+
155
+ # The steps a step leads to: its branches, or else the next step unless it hands over to a command.
156
+ #: (Step) -> Array[Step]
157
+ def successors(step)
158
+ return step.branches.map { |branch| find(branch.to) } if step.branches.any?
159
+ return [] if step.handover.answer_with
160
+
161
+ following = @steps[@steps.index(step).to_i + 1]
162
+ following ? [following] : []
163
+ end
164
+
165
+ #: (String) -> Step
166
+ def find(id)
167
+ @steps.find { |step| step.id == id } || raise(KeyError, "#{@name} has no step #{id}")
168
+ end
169
+ end
170
+
171
+ # The overview's headings, in the order it lists them.
172
+ #: Array[Group]
173
+ attr_reader :groups
174
+ #: Array[String]
175
+ attr_reader :every_command_refuses
176
+ #: Array[Command]
177
+ attr_reader :commands
178
+
179
+ # Every command's steps, read from the command-steps.json the gem ships.
180
+ #: () -> CommandSteps
181
+ def self.load
182
+ new(Data::CommandStepsFile.new.read, Data::CommandStepsFile::PATH)
183
+ end
184
+
185
+ # data is the validated content of command-steps.json; source names it in a refusal.
186
+ #: (untyped, String) -> void
187
+ def initialize(data, source)
188
+ @source = source
189
+ @groups = data.fetch("groups").map { |group| Group.new(group) }.freeze #: Array[Group]
190
+ @refusal_groups = data.fetch("refusal_groups") #: Hash[String, Array[String]]
191
+ @every_command_refuses = @refusal_groups.fetch("any-command") #: Array[String]
192
+ @commands = data.fetch("commands").map { |command| build(command) }.freeze #: Array[Command]
193
+ check_references
194
+ freeze
195
+ end
196
+
197
+ #: (String) -> Command?
198
+ def command(name)
199
+ @commands.find { |command| command.name == name }
200
+ end
201
+
202
+ # What a command writes, which every command in its group shares.
203
+ #: (Command) -> String
204
+ def writes(command)
205
+ @groups.find { |group| group.id == command.group }&.writes || refuse("#{command.name} is in no group")
206
+ end
207
+
208
+ # Every refusal code some step, or every command, can raise.
209
+ #: () -> Array[String]
210
+ def refusal_codes
211
+ (@every_command_refuses + @commands.flat_map { |command| command.steps.flat_map(&:refusals) }).uniq.sort
212
+ end
213
+
214
+ # Every prompt this file names: each command's help and each step's prompt.
215
+ #: () -> Array[String]
216
+ def prompt_names
217
+ @commands.flat_map { |command| [command.help, *command.steps.filter_map(&:prompt)] }.uniq.sort
218
+ end
219
+
220
+ private
221
+
222
+ #: (Hash[String, untyped]) -> Command
223
+ def build(data)
224
+ steps = data.fetch("steps").map do |step|
225
+ Step.new(step, (step.fetch("refuses", []) + step.fetch("refuses_like", []).flat_map do |group|
226
+ refusal_group(group)
227
+ end).uniq)
228
+ end
229
+ Command.new(data, steps)
230
+ end
231
+
232
+ #: (String) -> Array[String]
233
+ def refusal_group(name)
234
+ @refusal_groups.fetch(name) { refuse("No refusal group #{name}") }
235
+ end
236
+
237
+ #: () -> void
238
+ def check_references
239
+ names = @commands.map(&:name)
240
+ refuse("Command names repeat") unless names.uniq == names
241
+ @commands.each { |command| check_command(command, names) }
242
+ end
243
+
244
+ #: (Command, Array[String]) -> void
245
+ def check_command(command, names)
246
+ writes(command)
247
+ ids = command.steps.map(&:id)
248
+ refuse("#{command.name} repeats a step id") unless ids.uniq == ids
249
+ command.steps.each { |step| check_step("#{command.name}/#{step.id}", step, names, ids) }
250
+ end
251
+
252
+ #: (String, Step, Array[String], Array[String]) -> void
253
+ def check_step(where, step, names, ids)
254
+ target = step.handover.answer_with
255
+ refuse("#{where} answers with unknown command #{target}") if target && !names.include?(target)
256
+ unless step.handover.usage_names_its_command?
257
+ refuse("#{where}'s handover form #{step.handover.answer_usage} does not name #{target}")
258
+ end
259
+ unknown = step.branches.map(&:to) - ids
260
+ refuse("#{where} branches to unknown step #{unknown.join(", ")}") if unknown.any?
261
+ end
262
+
263
+ #: (String) -> bot
264
+ def refuse(message)
265
+ raise Error.new("invalid_command_steps", message,
266
+ "Repair the file so every step it names exists.", @source)
267
+ end
268
+ end
269
+ end
270
+ end
@@ -0,0 +1,27 @@
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
+ # The commits one move of HEAD brought in, by abbreviated id, as the after-commit hook asks about them (D73): one
8
+ # for a commit or an amend, a lane for a fast-forward, the rewritten commits for a rebase. git chooses how long an
9
+ # abbreviated id is, so two name the same commit when one starts with the other (review R4).
10
+ class CommitsBroughtIn
11
+ #: (Array[String]) -> void
12
+ def initialize(commits)
13
+ @commits = commits
14
+ freeze
15
+ end
16
+
17
+ # The members, as list-member-problems lists them, whose newest changing commit the move brought in, in order.
18
+ #: (Array[Hash[String, untyped]]) -> Array[Hash[String, untyped]]
19
+ def changed_by(members)
20
+ members.select do |member|
21
+ newest = member.dig("history", "commits", 0, "commit")
22
+ newest && @commits.any? { |commit| commit.start_with?(newest) || newest.start_with?(commit) }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,32 @@
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
+ # In git, what landed after a completed audit's base commit: for each member the audit changed, the commits
8
+ # since the base that touched it, and HEAD now. When HEAD differs from the base and none touched one, an empty
9
+ # list alone left the agent unsure why an answer on an older base was accepted, so it says so (#740).
10
+ class CommitsSinceBase
11
+ #: (base: String, head: String?, by_member: Hash[String, Array[Hash[String, String]]]) -> void
12
+ def initialize(base:, head:, by_member:)
13
+ @base = base
14
+ @head = head
15
+ @by_member = by_member
16
+ freeze
17
+ end
18
+
19
+ # The commits since the base that touched each member the audit changed, by member id.
20
+ #: Hash[String, Array[Hash[String, String]]]
21
+ attr_reader :by_member
22
+
23
+ # The base and HEAD, when HEAD differs from the base and no commit since the base touched a member the audit
24
+ # changed; nil otherwise.
25
+ #: () -> [String, String]?
26
+ def head_past_base
27
+ head = @head
28
+ [@base, head] unless head.nil? || head == @base || @by_member.values.any?(&:any?)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,66 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../error"
5
+ require_relative "../prose_list"
6
+ require_relative "answer_fields"
7
+ require_relative "disposition"
8
+
9
+ module Principles
10
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
11
+ module Models
12
+ # An agent's answer to a correction request: one disposition for each finding the assessment raised, each
13
+ # checked as a Disposition, and no member changed that no fix claims. The block locates each piece of
14
+ # evidence, and where a finding is recorded, in the project. samples is the answer guide's disposition for
15
+ # each action, whose text no field may give back unchanged (#789).
16
+ class CorrectionAnswer
17
+ #: (untyped, Array[untyped], samples: Hash[String, untyped]) { (Locator) -> void } -> void
18
+ def initialize(response, findings, samples:, &locate)
19
+ @response = response
20
+ @samples = samples
21
+ @finding_ids = findings.map { |finding| finding.fetch("id") } #: Array[untyped]
22
+ @fields = AnswerFields.new(locate) #: AnswerFields
23
+ end
24
+
25
+ # The dispositions, once each is checked against changed, the members that changed since the audit began, as
26
+ # AuditStart#changed names them, those added included (T69).
27
+ #: (changed: Array[String]) -> Array[Disposition]
28
+ def dispositions(changed:)
29
+ dispositions = one_per_finding
30
+ unclaimed = changed - dispositions.flat_map { |disposition| disposition.claimed_members(changed) }
31
+ return dispositions if unclaimed.empty?
32
+
33
+ raise Error.new("unassessed_changes", "Other members changed: #{unclaimed.join(", ")}",
34
+ "Start a fresh audit, or include their reassessment in a fixed disposition.")
35
+ end
36
+
37
+ private
38
+
39
+ #: () -> Array[Disposition]
40
+ def one_per_finding
41
+ given = @fields.array(@response["dispositions"], "dispositions")
42
+ dispositions = given.each_with_index.map do |disposition, index|
43
+ Disposition.new(disposition, @fields, at: "/dispositions/#{index}", samples: @samples)
44
+ end
45
+ unless dispositions.map(&:finding_id).sort == @finding_ids.sort
46
+ raise Error.new("incomplete_dispositions",
47
+ "Dispose of each finding exactly once: #{@finding_ids.join(", ")}", "Complete dispositions.")
48
+ end
49
+
50
+ refuse_unfinished(dispositions)
51
+ dispositions
52
+ end
53
+
54
+ # Refuses every field the dispositions leave empty, as the template's placeholder or as the answer guide's
55
+ # sample in one refusal, so a copied template takes one repair, not one submit per field (#789, #791).
56
+ #: (Array[Disposition]) -> void
57
+ def refuse_unfinished(dispositions)
58
+ unfinished = dispositions.flat_map(&:unfinished)
59
+ return if unfinished.empty?
60
+
61
+ raise Error.new("invalid_disposition", "Still to write: #{ProseList.new(unfinished.map(&:to_s))}",
62
+ unfinished.map(&:repair).uniq.join(" "), fields: unfinished.map(&:field))
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,70 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "request_id"
5
+ require_relative "decided_prior_findings"
6
+ require_relative "audit_start"
7
+ require_relative "last_completed_revision"
8
+ require_relative "recorded_finding"
9
+
10
+ module Principles
11
+ # The domain logic, which loads and saves itself through data/. models/CLAUDE.md owns the boundary.
12
+ module Models
13
+ # What a correction request depends on, which it prints as its basis and the agent hands back with the answer:
14
+ # the base commit, the map's version, each finding's members and problem, what the assessment reported beyond its
15
+ # findings, as AssessmentReport carries it for the completed record to keep, and the decisions on prior findings
16
+ # (D35), which travel to completion as those do. It also carries the last completed record's revision, so a
17
+ # correction is refused once that record changed (#646).
18
+ class CorrectionBasis
19
+ # The basis printed for a correction of findings, assessed from start: its base, its map's version and the
20
+ # assessment's report, as AssessmentReport carries it; revision is the last completed record's.
21
+ #: (AuditStart, findings: Array[untyped], revision: String?, ?prior: DecidedPriorFindings) -> CorrectionBasis
22
+ def self.printed(start, findings:, revision:, prior: DecidedPriorFindings::NONE)
23
+ basis = { "base_commit" => start.base, "map_version" => start.map_version,
24
+ "last_completed_revision" => revision,
25
+ "findings" => findings.map { |finding| finding.slice(*RecordedFinding::RAISED_FIELDS) } }
26
+ basis = basis.merge(start.reported.basis_fields)
27
+ new(prior.none? ? basis : basis.merge("prior_findings" => prior.to_h))
28
+ end
29
+
30
+ #: (untyped) -> void
31
+ def initialize(fields)
32
+ @fields = fields #: untyped
33
+ freeze
34
+ end
35
+
36
+ #: () -> untyped
37
+ def to_h = @fields
38
+
39
+ # The commit the audit it corrects began from, or nil outside git.
40
+ #: () -> String?
41
+ def base_commit = @fields.fetch("base_commit")
42
+
43
+ # The version of the map the audit it corrects began from.
44
+ #: () -> String
45
+ def map_version = @fields.fetch("map_version")
46
+
47
+ # The findings it asks the correction to dispose of, each with the fields RecordedFinding::RAISED_FIELDS names.
48
+ #: () -> Array[Hash[String, untyped]]
49
+ def findings = @fields.fetch("findings")
50
+
51
+ # The decisions on prior findings it carries to completion, or none (D35).
52
+ #: () -> DecidedPriorFindings
53
+ def prior_decided
54
+ decided = @fields["prior_findings"]
55
+ decided ? DecidedPriorFindings.new(decided) : DecidedPriorFindings::NONE
56
+ end
57
+
58
+ # The id of the correction request of principle_id for this basis, with each member's hash when the audit began.
59
+ #: (String, Hash[String, String]) -> RequestId
60
+ def request_id(principle_id, before)
61
+ RequestId.for(principle_id: principle_id, phase: "correction", parts: [@fields, before])
62
+ end
63
+
64
+ # The last completed record's revision when the request was printed, which refuses a correction once that
65
+ # record changed (#646).
66
+ #: () -> LastCompletedRevision
67
+ def printed_after = LastCompletedRevision.new(@fields["last_completed_revision"])
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,29 @@
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
+ # A line an answer gives that a record keeps under field, with field_since, the date it was first given, beside
8
+ # it: the same line keeps its date, a new line is dated afresh by the audit that records it, and null drops it
9
+ # (D52).
10
+ class DatedLine
11
+ # line is what the answer gave, nil for none; field is the name the record keeps it under.
12
+ #: (untyped, field: String) -> void
13
+ def initialize(line, field:)
14
+ @line = line
15
+ @field = field
16
+ freeze
17
+ end
18
+
19
+ # The line as the record keeps it, given had, the line and date it carried: had when the line is unchanged, so
20
+ # it keeps its date; this line alone, which the record's audited_at dates, when new; nothing when it is null.
21
+ #: (Hash[String, untyped]) -> Hash[String, untyped]
22
+ def kept_over(had)
23
+ return had if @line == had[@field]
24
+
25
+ @line.nil? ? {} : { @field => @line }
26
+ end
27
+ end
28
+ end
29
+ end