@zalom/plastic 1.14.1 → 2.0.0-alpha.10

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 (213) hide show
  1. package/PLASTIC.md +101 -294
  2. package/README.md +5 -6
  3. package/agents/plastic-advisor.md +18 -17
  4. package/agents/plastic-enforcer.md +57 -69
  5. package/agents/plastic-executor.md +34 -20
  6. package/agents/plastic-faux-advisor.md +18 -18
  7. package/bin/lib/context_budget.rb +453 -0
  8. package/bin/plastic-bench +78 -0
  9. package/config_asks.yml +38 -0
  10. package/hooks/capture +4 -0
  11. package/hooks/close +5 -0
  12. package/hooks/hooks.json +14 -42
  13. package/hooks/message-display +81 -0
  14. package/hooks/record +4 -0
  15. package/hooks/savepoint +5 -5
  16. package/package.json +3 -2
  17. package/scripts/agent-report +15 -10
  18. package/scripts/append-ledger +208 -0
  19. package/scripts/codex-hook +78 -90
  20. package/scripts/dashboard.rb +43 -14
  21. package/scripts/day-summary +53 -0
  22. package/scripts/doctor.rb +269 -43
  23. package/scripts/end-intent +170 -76
  24. package/scripts/exec-worktree +11 -20
  25. package/scripts/file-session-intent +61 -0
  26. package/scripts/hook-capture +255 -0
  27. package/scripts/hook-close +36 -0
  28. package/scripts/hook-message-display +74 -0
  29. package/scripts/hook-record +157 -0
  30. package/scripts/hook-savepoint +45 -0
  31. package/scripts/hook-session-start +128 -12
  32. package/scripts/install.rb +8 -6
  33. package/scripts/intent-screen +77 -0
  34. package/scripts/lib/agent_models.rb +1 -7
  35. package/scripts/lib/arm.rb +308 -0
  36. package/scripts/lib/backfill_intent.rb +316 -0
  37. package/scripts/lib/bridge.rb +24 -1597
  38. package/scripts/lib/compact_instructions.rb +56 -0
  39. package/scripts/lib/day_summary.rb +211 -0
  40. package/scripts/lib/doctor_core.rb +66 -88
  41. package/scripts/lib/doctor_session_ledger.rb +158 -0
  42. package/scripts/lib/exec_worktree.rb +14 -60
  43. package/scripts/lib/handoff.rb +184 -0
  44. package/scripts/lib/harness_text.rb +5 -4
  45. package/scripts/lib/hook_registry.rb +57 -106
  46. package/scripts/lib/insights.rb +3 -4
  47. package/scripts/lib/installer_core.rb +136 -41
  48. package/scripts/lib/intent_screen.rb +309 -0
  49. package/scripts/lib/intent_screen_ansi.rb +262 -0
  50. package/scripts/lib/lock.rb +6 -26
  51. package/scripts/lib/message_display.rb +290 -0
  52. package/scripts/lib/outcome_guard.rb +7 -5
  53. package/scripts/lib/project_validator.rb +30 -1
  54. package/scripts/lib/report_screen.rb +648 -0
  55. package/scripts/lib/roadmap_queue.rb +3 -3
  56. package/scripts/lib/roadmap_savepoint.rb +1 -1
  57. package/scripts/lib/rule_catalog.rb +6 -3
  58. package/scripts/lib/savepoint.rb +334 -0
  59. package/scripts/lib/scaffold_intent.rb +13 -179
  60. package/scripts/lib/screen_paint.rb +276 -0
  61. package/scripts/lib/session_backfill.rb +184 -0
  62. package/scripts/lib/session_close.rb +104 -0
  63. package/scripts/lib/session_git.rb +500 -0
  64. package/scripts/lib/session_ledger.rb +621 -0
  65. package/scripts/lib/store_discovery.rb +1 -1
  66. package/scripts/lib/store_provisioning.rb +1 -1
  67. package/scripts/maintenance-run +7 -7
  68. package/scripts/new-intent +63 -4
  69. package/scripts/plastic-lock +101 -41
  70. package/scripts/promote-session-item +150 -0
  71. package/scripts/read-config +3 -0
  72. package/scripts/rebuild-graph +1 -1
  73. package/scripts/report-screen +120 -0
  74. package/scripts/roadmap-next +2 -2
  75. package/scripts/rollback.rb +48 -3
  76. package/scripts/savepoint-note +67 -0
  77. package/scripts/scaffold-intent +37 -63
  78. package/scripts/session-commit +116 -0
  79. package/scripts/spawn-preamble +14 -7
  80. package/scripts/update.rb +7 -3
  81. package/scripts/write-handoff +60 -0
  82. package/skills/agent-advisor/SKILL.md +3 -3
  83. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  84. package/skills/auto/SKILL.md +223 -317
  85. package/skills/auto/evals/evals.json +10 -10
  86. package/skills/auto/references/agent-architecture.md +102 -90
  87. package/skills/auto/references/agent-report-contract.md +4 -21
  88. package/skills/auto/references/end-tail.md +47 -53
  89. package/skills/auto/references/human-report-contract.md +62 -56
  90. package/skills/conventions/SKILL.md +1 -3
  91. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  92. package/skills/conventions/references/locks-and-worktrees.md +62 -58
  93. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  94. package/skills/dashboard/SKILL.md +1 -1
  95. package/skills/direct/SKILL.md +66 -0
  96. package/skills/direct/references/request-signals.md +59 -0
  97. package/skills/doctor/SKILL.md +58 -11
  98. package/skills/doctor/report.md +3 -1
  99. package/skills/install/SKILL.md +11 -9
  100. package/skills/intent-continuing/SKILL.md +142 -91
  101. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  102. package/skills/intent-continuing/references/context-management.md +3 -3
  103. package/skills/intent-creating/SKILL.md +2 -2
  104. package/skills/intent-creating/references/lifecycle.md +1 -1
  105. package/skills/intent-ending/SKILL.md +34 -37
  106. package/skills/intent-ending/evals/evals.json +4 -4
  107. package/skills/intent-executing/SKILL.md +15 -46
  108. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  109. package/skills/intent-speccing/SKILL.md +108 -51
  110. package/skills/intent-speccing/evals/evals.json +5 -5
  111. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  112. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  113. package/skills/project-creating/SKILL.md +1 -1
  114. package/skills/releasing/SKILL.md +7 -8
  115. package/skills/tutorial/SKILL.md +2 -2
  116. package/skills/tutorial/references/track-1-guided.md +16 -18
  117. package/skills/tutorial/references/track-2-auto.md +12 -13
  118. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  119. package/templates/agents.md +4 -7
  120. package/templates/config.yml +5 -0
  121. package/templates/intent-screen.md +17 -0
  122. package/templates/outcome.md +14 -1
  123. package/templates/project.yml +7 -0
  124. package/templates/report-state.md +11 -0
  125. package/templates/roadmap.md +1 -1
  126. package/templates/savepoint.md +2 -2
  127. package/templates/session-intent.md +29 -0
  128. package/agents/plastic-brainstorming.md +0 -48
  129. package/agents/plastic-future-intent-researcher.md +0 -35
  130. package/agents/plastic-intent-curator.md +0 -47
  131. package/agents/plastic-intent-discovery.md +0 -43
  132. package/agents/plastic-planner.md +0 -56
  133. package/agents/plastic-spec-specialist.md +0 -53
  134. package/hooks/auto-arm +0 -5
  135. package/hooks/bash-gate +0 -3
  136. package/hooks/continue +0 -31
  137. package/hooks/edit-gates +0 -3
  138. package/hooks/future-intent-check +0 -25
  139. package/hooks/gate-check +0 -12
  140. package/hooks/power-tools +0 -8
  141. package/scripts/hook-auto-arm +0 -51
  142. package/scripts/hook-bash-gate +0 -78
  143. package/scripts/hook-code-gate +0 -38
  144. package/scripts/hook-continue +0 -48
  145. package/scripts/hook-create-gate +0 -51
  146. package/scripts/hook-edit-gates +0 -58
  147. package/scripts/hook-future-intent-check +0 -90
  148. package/scripts/hook-gate-check +0 -169
  149. package/scripts/hook-links-gate +0 -54
  150. package/scripts/hook-lock-gate +0 -47
  151. package/scripts/hook-power-tools +0 -38
  152. package/scripts/hook-savepoint-pre +0 -32
  153. package/scripts/lib/codex_edit_gates.rb +0 -138
  154. package/scripts/lib/edit_gates.rb +0 -398
  155. package/scripts/lib/links_gate.rb +0 -140
  156. package/scripts/lib/qmd_hook.rb +0 -41
  157. package/scripts/lib/spec_header.rb +0 -83
  158. package/scripts/lib/start_intent.rb +0 -296
  159. package/scripts/start-intent +0 -89
  160. package/skills/_active-intent-gate.md +0 -26
  161. package/skills/auto/references/tiers.md +0 -100
  162. package/skills/continuing/SKILL.md +0 -34
  163. package/skills/continuing/evals/evals.json +0 -91
  164. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  165. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  166. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  167. package/skills/intent-brainstorming/SKILL.md +0 -118
  168. package/skills/intent-brainstorming/evals/evals.json +0 -67
  169. package/skills/intent-continuing/evals/evals.json +0 -145
  170. package/skills/intent-discovering/SKILL.md +0 -56
  171. package/skills/intent-grilling/SKILL.md +0 -108
  172. package/skills/intent-linking/SKILL.md +0 -128
  173. package/skills/intent-linking/evals/evals.json +0 -22
  174. package/skills/intent-linking/references/zettelkasten.md +0 -45
  175. package/skills/intent-locking/SKILL.md +0 -64
  176. package/skills/intent-planning/SKILL.md +0 -145
  177. package/skills/intent-planning/evals/evals.json +0 -81
  178. package/skills/intent-planning/references/plan-format.md +0 -130
  179. package/skills/intent-researching/SKILL.md +0 -123
  180. package/skills/intent-researching/evals/evals.json +0 -22
  181. package/skills/intent-savepoint/SKILL.md +0 -83
  182. package/skills/intent-savepoint/references/context-management.md +0 -32
  183. package/skills/intent-starting/SKILL.md +0 -151
  184. package/skills/intent-starting/evals/evals.json +0 -117
  185. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  186. package/skills/project-continuing/SKILL.md +0 -119
  187. package/skills/project-continuing/evals/evals.json +0 -100
  188. package/skills/roadmap-continuing/SKILL.md +0 -89
  189. package/skills/roadmap-continuing/evals/evals.json +0 -82
  190. package/skills/skill-creating/SKILL.md +0 -75
  191. package/skills/skill-creating/evals/evals.json +0 -108
  192. package/skills/skill-creating/references/agents.md +0 -168
  193. package/skills/skill-creating/references/defaults-first.md +0 -23
  194. package/skills/skill-creating/references/evals.md +0 -41
  195. package/skills/skill-creating/references/hooks.md +0 -251
  196. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  197. package/skills/skill-creating/references/scripts.md +0 -166
  198. package/skills/skill-creating/references/skills.md +0 -169
  199. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  200. package/skills/skill-evaluating/SKILL.md +0 -141
  201. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  202. package/skills/skill-evaluating/evals/evals.json +0 -75
  203. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  204. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  205. package/skills/store-curating/SKILL.md +0 -64
  206. package/skills/store-curating/evals/evals.json +0 -38
  207. package/skills/store-indexing/SKILL.md +0 -93
  208. package/skills/store-indexing/evals/evals.json +0 -22
  209. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  210. package/skills/store-provisioning/SKILL.md +0 -55
  211. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  212. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  213. /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -0
@@ -0,0 +1,621 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "date"
5
+ require "fileutils"
6
+ require_relative "store_provisioning"
7
+
8
+ # SessionLedger - the pure library behind the session intent day ledger (intent
9
+ # 297). It owns identity derivation (day id, short session id, project slug),
10
+ # the day and session path layout under a store's `.sessions/` and `.tmp/`
11
+ # directories, the byte-exact checklist and savepoint line formats, and the
12
+ # locked reads and writes that let many sessions share one day's files safely.
13
+ #
14
+ # Every method takes every path as an argument. This file reads no environment
15
+ # variable and runs no dynamically constructed code: only its CLIs
16
+ # (`scripts/new-intent --tmp`, `scripts/append-ledger`) read the environment
17
+ # and pass what they read in.
18
+ module SessionLedger
19
+ # Raised by #set_state when an in-place edit cannot take an exclusive lock
20
+ # (a filesystem without flock support). An append stays safe unlocked, since
21
+ # a single O_APPEND write lands whole; an in-place byte edit does not, so it
22
+ # refuses rather than risk a torn read-modify-write. The CLI maps this to
23
+ # exit 3.
24
+ class LockUnavailableError < StandardError; end
25
+
26
+ module_function
27
+
28
+ # --- Constants (spec D13), byte exact ------------------------------------
29
+
30
+ SESSIONS_DIR = ".sessions"
31
+ TMP_DIR = ".tmp"
32
+ DAY_ID = /\A\d{8}\z/
33
+ EVENTS = %w[Item Done Note]
34
+ STATES = { pending: "~", open: " ", done: "x", moved: ">", dropped: "-", promoted: "^" }.freeze
35
+
36
+ # Project slugs and session tags are restricted to this character class
37
+ # (spec D4). Used both to filter a slug read out of projects.yml
38
+ # (#project_slug) and, by append-ledger, to validate an explicit --project.
39
+ SLUG_RE = /\A[a-z0-9-]+\z/.freeze
40
+
41
+ # Default lock strategy for #append_line and #set_state: a real flock call
42
+ # on the handle. Tests inject a replacement lambda here (never a global or
43
+ # an environment-variable flag, per the library's no-environment-read rule)
44
+ # to simulate a SystemCallError from a filesystem without flock support,
45
+ # proving the unlocked-append fallback and the LockUnavailableError -> exit
46
+ # 3 mapping hermetically, with no need for a real flock-less filesystem.
47
+ DEFAULT_FLOCK = ->(handle, mode) { handle.flock(mode) }
48
+ private_constant :DEFAULT_FLOCK
49
+
50
+ # The first append to checklist.md writes this header, then a blank line,
51
+ # before any checklist content (spec D9). savepoint.md never gets a header.
52
+ def checklist_header(day)
53
+ "# Checklist: session ledger #{day}\n\n"
54
+ end
55
+
56
+ # --- Identity -------------------------------------------------------------
57
+
58
+ # The day id is the caller's local wall-clock date. Never call `.utc` here:
59
+ # the owner's day is his wall clock, and a UTC id would already read
60
+ # tomorrow in the evening locally. Instants inside ledger lines stay UTC,
61
+ # which is a concern of #savepoint_line, not this method.
62
+ def day_id(now = Time.now)
63
+ now.strftime("%Y%m%d")
64
+ end
65
+
66
+ # True iff `id` is eight digits that also parse as a real calendar date, so
67
+ # a shape match like "20261340" (month 13) is still rejected.
68
+ def valid_day_id?(id)
69
+ s = id.to_s
70
+ return false unless s.match?(DAY_ID)
71
+
72
+ begin
73
+ Date.strptime(s, "%Y%m%d")
74
+ true
75
+ rescue Date::Error, ArgumentError
76
+ false
77
+ end
78
+ end
79
+
80
+ # The first eight `[a-z0-9]` characters of the caller's session id, after
81
+ # downcasing. Source order: `explicit`, then `env_id`, then the literal
82
+ # `"local"`. Both candidates are arguments; only a CLI reads the environment.
83
+ def short_session_id(explicit = nil, env_id = nil)
84
+ candidate = [explicit, env_id].find { |c| c.is_a?(String) && !c.strip.empty? }
85
+ candidate ||= "local"
86
+
87
+ cleaned = candidate.downcase.gsub(/[^a-z0-9]/, "")
88
+ cleaned = "local" if cleaned.empty?
89
+ cleaned[0, 8]
90
+ end
91
+
92
+ # The slug of the registered `projects.yml` path that is the longest match
93
+ # for `cwd` (equal to, or nested under, that path). Falls back to the
94
+ # literal `"global"` when nothing matches: an invented slug (from a
95
+ # directory basename) would name no real store. A candidate slug that does
96
+ # not match SLUG_RE (spec D4: `[a-z0-9-]` only) is skipped rather than
97
+ # returned, since it would corrupt a ledger line that embeds it verbatim;
98
+ # the "global" fallback is always safe by construction.
99
+ def project_slug(cwd, plastic_home:)
100
+ expanded_cwd = File.expand_path(cwd)
101
+ projects = StoreProvisioning.load_projects(plastic_home)
102
+ return "global" unless projects.is_a?(Hash)
103
+
104
+ matches = projects.filter_map do |slug, info|
105
+ next unless info.is_a?(Hash)
106
+ next unless slug.is_a?(String) && SLUG_RE.match?(slug)
107
+
108
+ path = info["path"]
109
+ next unless path
110
+
111
+ root = File.expand_path(path)
112
+ next unless expanded_cwd == root || expanded_cwd.start_with?(root + File::SEPARATOR)
113
+
114
+ [root.length, slug]
115
+ end
116
+
117
+ best = matches.max_by { |(length, _slug)| length }
118
+ best ? best[1] : "global"
119
+ end
120
+
121
+ # --- Day paths (no I/O) ----------------------------------------------------
122
+
123
+ def sessions_root(store)
124
+ File.join(store, SESSIONS_DIR)
125
+ end
126
+
127
+ def day_dir(store, day)
128
+ File.join(sessions_root(store), day)
129
+ end
130
+
131
+ def day_file(store, day)
132
+ File.join(day_dir(store, day), "#{day}.md")
133
+ end
134
+
135
+ def checklist_path(store, day)
136
+ File.join(day_dir(store, day), "checklist.md")
137
+ end
138
+
139
+ def savepoint_path(store, day)
140
+ File.join(day_dir(store, day), "savepoint.md")
141
+ end
142
+
143
+ # --- Session paths (no I/O except #ensure_tmp_root) ------------------------
144
+
145
+ def tmp_root(store)
146
+ File.join(store, TMP_DIR)
147
+ end
148
+
149
+ def session_tmp_dir(store, session_id)
150
+ File.join(tmp_root(store), session_id)
151
+ end
152
+
153
+ def pointer_path(store, session_id)
154
+ File.join(session_tmp_dir(store, session_id), "current")
155
+ end
156
+
157
+ def heartbeat_path(store, session_id)
158
+ File.join(session_tmp_dir(store, session_id), "heartbeat")
159
+ end
160
+
161
+ # Create `.tmp/` plus a `.gitignore` holding exactly `*`, if that file does
162
+ # not already exist. The store is a local git repo that auto-commits
163
+ # `add -A`, so without this ignore file every heartbeat write would enter
164
+ # git history. Intent 298 calls this before writing a pointer or heartbeat;
165
+ # this intent only defines it. Returns the tmp root path.
166
+ def ensure_tmp_root(store)
167
+ root = tmp_root(store)
168
+ FileUtils.mkdir_p(root)
169
+
170
+ gitignore = File.join(root, ".gitignore")
171
+ File.write(gitignore, "*\n") unless File.exist?(gitignore)
172
+
173
+ root
174
+ end
175
+
176
+ # --- Lines ------------------------------------------------------------------
177
+
178
+ # Collapse every run of whitespace (newlines included) to one space, strip,
179
+ # then cap the result at 200 characters total, ending in "..." when the
180
+ # input was longer. Returns an empty string when nothing survives; the
181
+ # caller decides whether that is a usage error.
182
+ def sanitize_summary(text)
183
+ collapsed = text.to_s.gsub(/\s+/, " ").strip
184
+ return collapsed if collapsed.length <= 200
185
+
186
+ "#{collapsed[0, 197]}..."
187
+ end
188
+
189
+ # --- capture_worthy? (spec D2, D7; supersedes 298 D2(c)) -------------------
190
+
191
+ # One complete top-level harness envelope tag block: "<name ...>...</name>".
192
+ # Non-greedy (.*?) so sibling blocks are each matched on their own rather
193
+ # than one match spanning from the first block's opening tag all the way to
194
+ # the LAST block's closing tag (post-execution review item 3: an envelope
195
+ # on both sides of real work, "<system-reminder>...</system-reminder>\nfix
196
+ # the parser\n<task-notification>...</task-notification>", must not be
197
+ # read as one giant envelope swallowing the work in the middle).
198
+ ENVELOPE_BLOCK_RE = /<([A-Za-z][\w-]*)(?:\s[^>]*)?>.*?<\/\1>/m
199
+ private_constant :ENVELOPE_BLOCK_RE
200
+
201
+ # The whole prompt, case- and whitespace-insensitively, and nothing else
202
+ # (rule 3, D2): a trigger word inside a longer real instruction ("continue
203
+ # the dashboard fix and then release") must not match this.
204
+ BARE_TRIGGERS = %w[continue auto].freeze
205
+ private_constant :BARE_TRIGGERS
206
+
207
+ # Words whose presence marks a prompt as actionable work (rule 4's escape
208
+ # hatch, D2's accept bias). Deliberately excludes common nouns that also
209
+ # read as everyday verbs in casual remarks (e.g. "release", "ship", "plan"):
210
+ # including them would make ordinary conversation about a past release or
211
+ # plan look like a work request. Matched with an optional inflection suffix
212
+ # (post-execution review BLOCKER): the bare stems alone missed "fixed",
213
+ # "updated", "added", "reviewed", "implemented" -- exactly the past-tense
214
+ # and -ing forms real work summaries use.
215
+ WORK_MARKER_WORDS = %w[
216
+ fix add remove delete update upgrade implement write build create refactor
217
+ debug investigate review test deploy commit merge revert rename configure
218
+ install migrate document generate draft resolve help need want make change
219
+ setup
220
+ ].freeze
221
+ private_constant :WORK_MARKER_WORDS
222
+
223
+ WORK_MARKER_PHRASES = [
224
+ "can you", "could you", "would you", "let's", "let us", "set up", "look into", "figure out",
225
+ ].freeze
226
+ private_constant :WORK_MARKER_PHRASES
227
+
228
+ WORK_MARKER_RE = /\b(?:#{WORK_MARKER_WORDS.join("|")})(?:s|d|ed|ing)?\b/i
229
+ private_constant :WORK_MARKER_RE
230
+
231
+ # A first word that reads as an interrogative opener, checked case-
232
+ # insensitively against the prompt's first whitespace-separated token.
233
+ # Post-execution review BLOCKER: trimmed from the original, wider list
234
+ # (which also carried "how", "when", "where", "was", "were", "do", "did",
235
+ # "will", "shall", "should") down to words that open a genuine QUESTION at
236
+ # least as often as an ordinary command or request. Measured against 43
237
+ # invented and 27 real day-ledger prompts: the dropped words open ordinary
238
+ # work requests ("do the release now...", "when you are done, tag the
239
+ # release...", "will you push that branch...", "should I bump the version
240
+ # files...") far more often than they open a bare question worth rejecting.
241
+ QUESTION_STARTERS = %w[
242
+ what why who whom whose which is are am does can could would
243
+ ].freeze
244
+ private_constant :QUESTION_STARTERS
245
+
246
+ # A narrow set of retrospective-remark shapes ("that release went smoother
247
+ # than the last one"): comparative or evaluative observations about how
248
+ # something already went. Deliberately narrow (D2's accept bias): a broad
249
+ # "any declarative sentence with no recognized verb" rule would also catch
250
+ # ordinary work summaries like "harness text wins the pending line", which
251
+ # must stay accepted.
252
+ REMARK_PATTERNS = [
253
+ /\bwent\s+\w+\s+than\b/i,
254
+ /\bwent\s+(?:well|badly|smoothly|great|poorly|terribly)\b/i,
255
+ ].freeze
256
+ private_constant :REMARK_PATTERNS
257
+
258
+ # True iff nothing but harness envelope tag block(s) -- and whitespace --
259
+ # remain once every complete top-level block is stripped out. A prompt
260
+ # that is one envelope alone, or several envelopes with no other content,
261
+ # matches; a prompt carrying real work anywhere outside an envelope (before,
262
+ # after, or between several of them) does not.
263
+ def whole_prompt_envelope?(stripped)
264
+ stripped.gsub(ENVELOPE_BLOCK_RE, "").strip.empty?
265
+ end
266
+
267
+ def bare_trigger?(stripped)
268
+ BARE_TRIGGERS.include?(stripped.downcase)
269
+ end
270
+
271
+ def work_marker?(text)
272
+ return true if WORK_MARKER_RE.match?(text)
273
+
274
+ downcased = text.downcase
275
+ WORK_MARKER_PHRASES.any? { |p| downcased.include?(p) }
276
+ end
277
+
278
+ def interrogative?(stripped)
279
+ return true if stripped.end_with?("?")
280
+
281
+ first_word = stripped.split(/\s+/).first.to_s.downcase.gsub(/[^a-z]/, "")
282
+ QUESTION_STARTERS.include?(first_word)
283
+ end
284
+
285
+ def bare_remark?(stripped)
286
+ REMARK_PATTERNS.any? { |re| re.match?(stripped) }
287
+ end
288
+
289
+ # Internal helpers only: #capture_worthy? is the sole public contract
290
+ # (post-execution review item 9).
291
+ private_class_method :whole_prompt_envelope?, :bare_trigger?, :work_marker?, :interrogative?, :bare_remark?
292
+
293
+ # Whether `prompt` earns a pending checklist line (spec D2). Bias is
294
+ # ACCEPT: this rejects only on four named rules -- the 10-char floor (on
295
+ # its own collapsed copy), a whole-prompt harness envelope, a bare
296
+ # continue/auto trigger, and an interrogative or bare-remark prompt
297
+ # carrying no work marker -- and accepts everything else, including a
298
+ # work-shaped question and an envelope followed by real work. Takes the
299
+ # RAW prompt (not the sanitized/truncated line text) so rule 2 sees the
300
+ # prompt's true first character and multi-line shape.
301
+ def capture_worthy?(prompt)
302
+ raw = prompt.to_s
303
+ return false if sanitize_summary(raw).length < 10
304
+
305
+ stripped = raw.strip
306
+ return false if whole_prompt_envelope?(stripped)
307
+ return false if bare_trigger?(stripped)
308
+ return false if !work_marker?(stripped) && (interrogative?(stripped) || bare_remark?(stripped))
309
+
310
+ true
311
+ end
312
+
313
+ # One LF-terminated checklist line, byte exact per spec D5. The state
314
+ # marker is fixed width across all three states, which is what lets a later
315
+ # promote or tick be a one-byte write at a known offset.
316
+ def checklist_line(state, session, project, summary)
317
+ marker = STATES.fetch(state) { raise ArgumentError, "unknown checklist state: #{state.inspect}" }
318
+ "- [#{marker}] [#{session}] [#{project}] #{summary}\n"
319
+ end
320
+
321
+ # One LF-terminated savepoint line, byte exact per spec D5. Separators are
322
+ # two spaces, so `split(/\s{2,}/)` yields exactly three parts. The
323
+ # timestamp is UTC ISO 8601, matching every other savepoint line in the
324
+ # store; the day id stays local wall clock, an intentional asymmetry.
325
+ def savepoint_line(event, session, project, summary, now:)
326
+ raise ArgumentError, "unknown savepoint event: #{event.inspect}" unless EVENTS.include?(event)
327
+
328
+ timestamp = now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
329
+ "#{timestamp} #{event} [#{session}] [#{project}] #{summary}\n"
330
+ end
331
+
332
+ # The inverse of #checklist_line: parse a checklist line positionally (the
333
+ # state marker, the first bracket group, the second bracket group, then the
334
+ # rest as the summary), so a bracket typed inside a summary is never read
335
+ # as a tag. Returns nil when the line does not match.
336
+ CHECKLIST_LINE_RE = /\A- \[(.)\] \[([^\]]*)\] \[([^\]]*)\] (.*)\z/m.freeze
337
+ private_constant :CHECKLIST_LINE_RE
338
+
339
+ def parse_checklist_line(line)
340
+ # #scrub replaces any invalid byte with U+FFFD so a stray non-UTF-8 byte
341
+ # anywhere in checklist.md never raises ArgumentError out of the regex
342
+ # match; it only ever affects that one line's parsed summary. Callers
343
+ # that need byte-exact offsets (namely #set_state) must measure against
344
+ # the UNSCRUBBED line, since #scrub can change a line's bytesize.
345
+ match = CHECKLIST_LINE_RE.match(line.to_s.chomp.scrub)
346
+ return nil unless match
347
+
348
+ marker, session, project, summary = match.captures
349
+ state = STATES.key(marker)
350
+ return nil unless state
351
+
352
+ { state: state, session: session, project: project, summary: summary }
353
+ end
354
+
355
+ # --- Token rendering (internal) --------------------------------------------
356
+
357
+ # Block-form gsub, mirroring scripts/new-intent#render_tokens: a String
358
+ # replacement argument reinterprets backslash sequences, so the block form
359
+ # substitutes each value literally.
360
+ def render_tokens(text, tokens)
361
+ tokens.reduce(text) { |acc, (key, value)| acc.gsub("{{#{key}}}") { value.to_s } }
362
+ end
363
+
364
+ # --- Locked writes and reads (spec D8) --------------------------------------
365
+
366
+ # Append `line` to `path`, taking an exclusive lock on the target file
367
+ # itself (never a sibling lock file, never via a rename). Opens
368
+ # WRONLY | APPEND | CREAT so a concurrent append always lands at the
369
+ # current end of file. Once the lock is held, writes `header` first only
370
+ # when the file is still size zero (the size check happens under the lock
371
+ # on purpose: O_CREAT without O_EXCL hands every racer the same inode, so
372
+ # the lock serializes them and exactly one racer sees size zero). Then
373
+ # writes the full line in one call. Rescues SystemCallError from flock
374
+ # only: on a filesystem without flock support, the append proceeds
375
+ # unlocked, since a single O_APPEND write still lands whole there. Always
376
+ # returns true.
377
+ def append_line(path, line, header: nil, flock: DEFAULT_FLOCK)
378
+ handle = File.open(path, File::WRONLY | File::APPEND | File::CREAT, 0o644)
379
+ begin
380
+ begin
381
+ flock.call(handle, File::LOCK_EX)
382
+ rescue SystemCallError
383
+ nil
384
+ end
385
+
386
+ handle.write(header) if header && handle.size.zero?
387
+ handle.write(line)
388
+ # Flush explicitly before unlocking. MRI happens to flush a writable
389
+ # handle's buffer as a side effect inside rb_file_flock, but that is
390
+ # undocumented behavior, so flush on purpose rather than depend on it.
391
+ handle.flush
392
+
393
+ begin
394
+ handle.flock(File::LOCK_UN)
395
+ rescue SystemCallError
396
+ nil
397
+ end
398
+ ensure
399
+ handle.close
400
+ end
401
+ true
402
+ end
403
+
404
+ # Flip the newest line (last in file order) whose session tag equals
405
+ # `session` and whose state equals `from` to `to`, narrowed to lines whose
406
+ # summary contains `match` when given (spec D7). The lock is taken on the
407
+ # target file itself. Once held, the file is scanned by byte offset, and
408
+ # exactly one byte (the state marker, at the matched line's start offset
409
+ # plus 3, past the "- [" prefix) is overwritten with `IO#pwrite`, so every
410
+ # other byte in the file stays identical: this is exactly why the pending
411
+ # marker is `[~]`, a fixed width shared with `[ ]` and `[x]`, rather than a
412
+ # bare tilde.
413
+ #
414
+ # Identifying the target line and flipping it happen inside the SAME
415
+ # LOCK_EX hold, on purpose: an earlier version identified the target under
416
+ # a released LOCK_SH, in a separate call, then re-identified and flipped it
417
+ # under a fresh LOCK_EX. Two concurrent promoters could both read the same
418
+ # "newest pending" line before either flipped anything, each then flip a
419
+ # DIFFERENT line under their own (correctly serialized) LOCK_EX, and the
420
+ # caller's earlier lookup would go stale, naming the wrong line in a
421
+ # --savepoint entry. Returning the flipped line's own summary from inside
422
+ # this lock is what makes that identify-and-flip atomic, so a caller never
423
+ # needs a second, separately-locked read to learn what it just changed.
424
+ #
425
+ # Returns the flipped line's summary (a String) when a byte changed, or nil
426
+ # (writing nothing) when the file does not exist or nothing matches. Raises
427
+ # LockUnavailableError, refusing to write at all, when the flock cannot be
428
+ # taken (filesystem without flock support): unlike an append, an in-place
429
+ # edit cannot fall back to unlocked, since a racing writer could tear the
430
+ # read-modify-write. `flock:` is a test seam (see DEFAULT_FLOCK); production
431
+ # callers never pass it.
432
+ def set_state(path, from:, to:, session:, match: nil, flock: DEFAULT_FLOCK)
433
+ return nil unless File.exist?(path)
434
+
435
+ STATES.fetch(from)
436
+ to_marker = STATES.fetch(to)
437
+
438
+ handle = File.open(path, File::RDWR)
439
+ locked = true
440
+ begin
441
+ flock.call(handle, File::LOCK_EX)
442
+ rescue SystemCallError
443
+ locked = false
444
+ end
445
+
446
+ unless locked
447
+ handle.close
448
+ raise LockUnavailableError, "cannot take an exclusive lock on #{path}"
449
+ end
450
+
451
+ begin
452
+ content = handle.read
453
+ target_offset = nil
454
+ target_summary = nil
455
+ offset = 0
456
+
457
+ content.each_line do |raw_line|
458
+ parsed = parse_checklist_line(raw_line)
459
+ if parsed && (session.nil? || parsed[:session] == session) && parsed[:state] == from &&
460
+ (match.nil? || parsed[:summary].include?(match))
461
+ target_offset = offset + 3 # past the "- [" prefix
462
+ target_summary = parsed[:summary]
463
+ end
464
+ # Accumulate over the UNSCRUBBED raw_line, never the copy
465
+ # #parse_checklist_line scrubs internally for matching: #scrub can
466
+ # change a line's bytesize, and pwrite below must land at the true
467
+ # on-disk byte offset.
468
+ offset += raw_line.bytesize
469
+ end
470
+
471
+ if target_offset.nil?
472
+ nil
473
+ else
474
+ handle.pwrite(to_marker, target_offset)
475
+ handle.flush
476
+ target_summary
477
+ end
478
+ ensure
479
+ begin
480
+ handle.flock(File::LOCK_UN)
481
+ rescue SystemCallError
482
+ nil
483
+ end
484
+ handle.close
485
+ end
486
+ end
487
+
488
+ # Flip every checklist line in state `from` to state `to` for one session
489
+ # (or any session when `session` is nil), narrowed to summaries containing
490
+ # `match` when given (intent 301). Same lock discipline as #set_state: one
491
+ # LOCK_EX for the whole read-scan-write, one pwrite of one byte per
492
+ # flipped line at its measured offset, never a whole-file rewrite, and a
493
+ # LockUnavailableError rather than any unlocked write. Returns the count of
494
+ # lines flipped, 0 when the file is absent or nothing matches.
495
+ def flip_all(path, from:, to:, session: nil, match: nil, flock: DEFAULT_FLOCK)
496
+ return 0 unless File.exist?(path)
497
+
498
+ STATES.fetch(from)
499
+ to_marker = STATES.fetch(to)
500
+ handle = File.open(path, File::RDWR)
501
+ locked = true
502
+ begin
503
+ flock.call(handle, File::LOCK_EX)
504
+ rescue SystemCallError
505
+ locked = false
506
+ end
507
+ unless locked
508
+ handle.close
509
+ raise LockUnavailableError, "cannot take an exclusive lock on #{path}"
510
+ end
511
+
512
+ begin
513
+ content = handle.read
514
+ offsets = []
515
+ offset = 0
516
+ content.each_line do |raw_line|
517
+ parsed = parse_checklist_line(raw_line)
518
+ if parsed && (session.nil? || parsed[:session] == session) && parsed[:state] == from &&
519
+ (match.nil? || parsed[:summary].include?(match))
520
+ offsets << offset + 3
521
+ end
522
+ offset += raw_line.bytesize
523
+ end
524
+ offsets.each { |o| handle.pwrite(to_marker, o) }
525
+ handle.flush unless offsets.empty?
526
+ offsets.size
527
+ ensure
528
+ begin
529
+ handle.flock(File::LOCK_UN)
530
+ rescue SystemCallError
531
+ nil
532
+ end
533
+ handle.close
534
+ end
535
+ end
536
+
537
+ # Read `path` under a shared lock, returning its content. Returns an empty
538
+ # string when the file does not exist. Rescues SystemCallError from flock
539
+ # and reads anyway.
540
+ def read_locked(path)
541
+ return "" unless File.exist?(path)
542
+
543
+ handle = File.open(path, File::RDONLY)
544
+ begin
545
+ begin
546
+ handle.flock(File::LOCK_SH)
547
+ rescue SystemCallError
548
+ nil
549
+ end
550
+ handle.read
551
+ ensure
552
+ begin
553
+ handle.flock(File::LOCK_UN)
554
+ rescue SystemCallError
555
+ nil
556
+ end
557
+ handle.close
558
+ end
559
+ end
560
+
561
+ # --- The day scaffold (spec D12) --------------------------------------------
562
+
563
+ # The one and only scaffold implementation. `new-intent --tmp` is its CLI,
564
+ # and `append-ledger` calls it directly on EVERY invocation (cheap and
565
+ # idempotent, so there is no cheaper-but-wrong guard to key on instead), so
566
+ # a capture that crosses midnight never fails and never needs a second
567
+ # process. Creates exactly `<day>/<day>.md`: no checklist.md, no
568
+ # savepoint.md, no actions/, no resources/. `checklist.md` and
569
+ # `savepoint.md` come into existence on first append, written by
570
+ # append-ledger under the lock, which is why this method never touches
571
+ # them.
572
+ #
573
+ # Create versus join is decided by opening the day file with
574
+ # File::CREAT | File::EXCL: the winner renders the template and returns
575
+ # created: true; every loser, including a repair of a crashed
576
+ # mid-scaffold with no md file yet, returns created: false without
577
+ # changing a byte. DATE (the day's own calendar date, used in the `intent:`
578
+ # line) is derived from `day`, not from `now`, so an explicit --day renders
579
+ # its own date rather than today's; `now:` instead sources CREATED, the
580
+ # `created:` frontmatter field, since that field records when the scaffold
581
+ # FILE was actually written, which can differ from the day it is for (a
582
+ # repair or a midnight-crossing capture can scaffold a past day's file
583
+ # today). Defaults to Time.now; tests inject a fixed `now:` to make the
584
+ # scaffold's `created:` value deterministic.
585
+ #
586
+ # If rendering fails partway (a missing or relocated templates dir, a bad
587
+ # day), the file this call just created is unlinked before the error
588
+ # re-raises, so no zero-byte or partial <day>.md is left behind to wedge
589
+ # every later #open_day call onto the Errno::EEXIST "already exists"
590
+ # branch with no file to repair.
591
+ def open_day(store:, day:, templates:, author:, now: Time.now)
592
+ dir = day_dir(store, day)
593
+ FileUtils.mkdir_p(dir)
594
+
595
+ file = day_file(store, day)
596
+ handle = begin
597
+ File.open(file, File::WRONLY | File::CREAT | File::EXCL, 0o644)
598
+ rescue Errno::EEXIST
599
+ nil
600
+ end
601
+
602
+ return { dir: dir, created: false } unless handle
603
+
604
+ begin
605
+ begin
606
+ date = Date.strptime(day, "%Y%m%d").iso8601
607
+ created = now.strftime("%Y-%m-%d")
608
+ template = File.read(File.join(templates, "session-intent.md"))
609
+ rendered = render_tokens(template, "DAY" => day, "DATE" => date, "CREATED" => created, "AUTHOR" => author)
610
+ handle.write(rendered)
611
+ rescue StandardError
612
+ File.delete(file) if File.exist?(file)
613
+ raise
614
+ end
615
+ ensure
616
+ handle.close unless handle.closed?
617
+ end
618
+
619
+ { dir: dir, created: true }
620
+ end
621
+ end
@@ -33,7 +33,7 @@ module StoreDiscovery
33
33
  # INDEX.md path. Sorted by slug (global first) for deterministic output.
34
34
  #
35
35
  # `missing` lists every projects.yml slug with no `store/` directory on disk: legal
36
- # (plastic-store-provisioning exists for exactly this state), reported so callers never
36
+ # (the plastic-doctor provisioning section exists for exactly this state), reported so callers never
37
37
  # mistake it for a store with zero intents.
38
38
  def discover(plastic_home)
39
39
  stores = []
@@ -14,7 +14,7 @@ require "yaml"
14
14
  # `project.yml` (from templates/project.yml). Re-running never clobbers existing
15
15
  # files. The logic was migrated here from the orphaned
16
16
  # `InstallerCore#bootstrap_project_store` so there is one definition that the
17
- # `provision-project-store` CLI, the `plastic-store-provisioning` skill, the
17
+ # `provision-project-store` CLI, the `plastic-doctor` skill's provisioning section, the
18
18
  # doctor fix hint, and the project skills all consult.
19
19
  #
20
20
  # Pure filesystem and dependency-injected: `provision` accepts an injectable
@@ -37,7 +37,7 @@ require "time"
37
37
  require_relative "lib/store_discovery"
38
38
  require_relative "lib/lock"
39
39
  require_relative "lib/maintenance_git"
40
- require_relative "lib/bridge"
40
+ require_relative "lib/savepoint"
41
41
  require_relative "lib/revisions_writer"
42
42
  require_relative "doctor" # safe: doctor.rb's CLI is behind $PROGRAM_NAME == __FILE__
43
43
 
@@ -233,7 +233,7 @@ def run_rebuild_savepoint(home, intent, store, apply)
233
233
  check_not_fresh!(dir, intent)
234
234
 
235
235
  outcome_path = File.join(dir, "outcome.md")
236
- unless Bridge.stage_file_present?(outcome_path)
236
+ unless Savepoint.stage_file_present?(outcome_path)
237
237
  abort_loud("intent #{intent}'s outcome.md is missing or a placeholder; the 124a recipe " \
238
238
  "requires a real disposition to echo, and 219 D6 forbids ever inventing one", 1)
239
239
  end
@@ -244,7 +244,7 @@ def run_rebuild_savepoint(home, intent, store, apply)
244
244
 
245
245
  unless apply
246
246
  puts "maintenance-run: DRY RUN, would reconstruct savepoint.md for #{intent} " \
247
- "(Bridge.rebuild_savepoint + Done #{disposition} echo)"
247
+ "(Savepoint.rebuild_savepoint + Done #{disposition} echo)"
248
248
  exit 0
249
249
  end
250
250
 
@@ -253,15 +253,15 @@ def run_rebuild_savepoint(home, intent, store, apply)
253
253
  repo_dir: home, branch_name: "maintenance/rebuild-savepoint-#{intent}-#{stamp}",
254
254
  commit_message: "chore: maintenance - rebuild-savepoint #{intent}"
255
255
  ) do
256
- Bridge.rebuild_savepoint(dir)
257
- Bridge.append_terminal_savepoint(dir, disposition)
256
+ Savepoint.rebuild_savepoint(dir)
257
+ Savepoint.append_terminal_savepoint(dir, disposition)
258
258
  RevisionsWriter.append!(
259
259
  dir,
260
260
  why: "reconstruct missing operational savepoint.md (generic operational-gap predicate)",
261
261
  rule: "savepoint-operational-reconstruction",
262
262
  prior_location: "#{intent}/savepoint.md",
263
- change: "savepoint.md rebuilt from disk (Bridge.rebuild_savepoint) plus Done " \
264
- "#{disposition} echo appended (Bridge.append_terminal_savepoint); disposition " \
263
+ change: "savepoint.md rebuilt from disk (Savepoint.rebuild_savepoint) plus Done " \
264
+ "#{disposition} echo appended (Savepoint.append_terminal_savepoint); disposition " \
265
265
  "read from outcome.md's own frontmatter, never invented"
266
266
  )
267
267
  end