@zalom/plastic 1.14.0 → 2.0.0-alpha.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 (190) 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/config_asks.yml +38 -0
  8. package/hooks/capture +4 -0
  9. package/hooks/check-update +12 -3
  10. package/hooks/close +5 -0
  11. package/hooks/hooks.json +9 -49
  12. package/hooks/record +4 -0
  13. package/package.json +3 -2
  14. package/scripts/agent-report +8 -9
  15. package/scripts/append-ledger +195 -0
  16. package/scripts/codex-hook +101 -95
  17. package/scripts/dashboard.rb +4 -4
  18. package/scripts/doctor.rb +106 -43
  19. package/scripts/end-intent +77 -76
  20. package/scripts/exec-worktree +11 -20
  21. package/scripts/file-session-intent +61 -0
  22. package/scripts/hook-capture +242 -0
  23. package/scripts/hook-close +34 -0
  24. package/scripts/hook-record +149 -0
  25. package/scripts/hook-session-start +95 -12
  26. package/scripts/install.rb +8 -6
  27. package/scripts/lib/agent_models.rb +1 -7
  28. package/scripts/lib/arm.rb +283 -0
  29. package/scripts/lib/backfill_intent.rb +316 -0
  30. package/scripts/lib/bridge.rb +24 -1597
  31. package/scripts/lib/doctor_core.rb +17 -88
  32. package/scripts/lib/doctor_session_ledger.rb +106 -0
  33. package/scripts/lib/exec_worktree.rb +14 -60
  34. package/scripts/lib/harness_text.rb +5 -4
  35. package/scripts/lib/hook_registry.rb +44 -107
  36. package/scripts/lib/insights.rb +3 -4
  37. package/scripts/lib/installer_core.rb +19 -30
  38. package/scripts/lib/lock.rb +6 -26
  39. package/scripts/lib/outcome_guard.rb +7 -5
  40. package/scripts/lib/project_validator.rb +30 -1
  41. package/scripts/lib/roadmap_queue.rb +3 -3
  42. package/scripts/lib/roadmap_savepoint.rb +1 -1
  43. package/scripts/lib/rule_catalog.rb +6 -3
  44. package/scripts/lib/savepoint.rb +320 -0
  45. package/scripts/lib/scaffold_intent.rb +13 -179
  46. package/scripts/lib/session_backfill.rb +184 -0
  47. package/scripts/lib/session_close.rb +84 -0
  48. package/scripts/lib/session_git.rb +469 -0
  49. package/scripts/lib/session_ledger.rb +497 -0
  50. package/scripts/lib/store_discovery.rb +1 -1
  51. package/scripts/lib/store_provisioning.rb +1 -1
  52. package/scripts/maintenance-run +7 -7
  53. package/scripts/new-intent +63 -4
  54. package/scripts/plastic-lock +94 -41
  55. package/scripts/promote-session-item +150 -0
  56. package/scripts/rebuild-graph +1 -1
  57. package/scripts/roadmap-next +2 -2
  58. package/scripts/rollback.rb +42 -3
  59. package/scripts/scaffold-intent +37 -63
  60. package/scripts/session-commit +116 -0
  61. package/scripts/spawn-preamble +5 -5
  62. package/scripts/update.rb +7 -3
  63. package/skills/agent-advisor/SKILL.md +3 -3
  64. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  65. package/skills/auto/SKILL.md +218 -317
  66. package/skills/auto/evals/evals.json +10 -10
  67. package/skills/auto/references/agent-architecture.md +102 -90
  68. package/skills/auto/references/agent-report-contract.md +4 -21
  69. package/skills/auto/references/end-tail.md +47 -53
  70. package/skills/auto/references/human-report-contract.md +8 -8
  71. package/skills/conventions/SKILL.md +1 -3
  72. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  73. package/skills/conventions/references/locks-and-worktrees.md +47 -55
  74. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  75. package/skills/dashboard/SKILL.md +1 -1
  76. package/skills/direct/SKILL.md +66 -0
  77. package/skills/direct/references/request-signals.md +59 -0
  78. package/skills/doctor/SKILL.md +58 -11
  79. package/skills/doctor/report.md +3 -1
  80. package/skills/install/SKILL.md +11 -9
  81. package/skills/intent-continuing/SKILL.md +131 -89
  82. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  83. package/skills/intent-continuing/references/context-management.md +2 -2
  84. package/skills/intent-creating/SKILL.md +2 -2
  85. package/skills/intent-creating/references/lifecycle.md +1 -1
  86. package/skills/intent-ending/SKILL.md +28 -37
  87. package/skills/intent-ending/evals/evals.json +4 -4
  88. package/skills/intent-executing/SKILL.md +12 -49
  89. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  90. package/skills/intent-speccing/SKILL.md +108 -51
  91. package/skills/intent-speccing/evals/evals.json +5 -5
  92. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  93. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  94. package/skills/project-creating/SKILL.md +1 -1
  95. package/skills/releasing/SKILL.md +7 -8
  96. package/skills/tutorial/SKILL.md +2 -2
  97. package/skills/tutorial/references/track-1-guided.md +16 -18
  98. package/skills/tutorial/references/track-2-auto.md +12 -13
  99. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  100. package/templates/agents.md +4 -7
  101. package/templates/project.yml +7 -0
  102. package/templates/roadmap.md +1 -1
  103. package/templates/savepoint.md +2 -2
  104. package/templates/session-intent.md +29 -0
  105. package/agents/plastic-brainstorming.md +0 -48
  106. package/agents/plastic-future-intent-researcher.md +0 -35
  107. package/agents/plastic-intent-curator.md +0 -47
  108. package/agents/plastic-intent-discovery.md +0 -43
  109. package/agents/plastic-planner.md +0 -56
  110. package/agents/plastic-spec-specialist.md +0 -53
  111. package/hooks/auto-arm +0 -5
  112. package/hooks/bash-gate +0 -3
  113. package/hooks/continue +0 -31
  114. package/hooks/edit-gates +0 -3
  115. package/hooks/future-intent-check +0 -25
  116. package/hooks/gate-check +0 -12
  117. package/hooks/power-tools +0 -8
  118. package/scripts/hook-auto-arm +0 -51
  119. package/scripts/hook-bash-gate +0 -78
  120. package/scripts/hook-code-gate +0 -38
  121. package/scripts/hook-continue +0 -48
  122. package/scripts/hook-create-gate +0 -51
  123. package/scripts/hook-edit-gates +0 -58
  124. package/scripts/hook-future-intent-check +0 -90
  125. package/scripts/hook-gate-check +0 -169
  126. package/scripts/hook-links-gate +0 -54
  127. package/scripts/hook-lock-gate +0 -47
  128. package/scripts/hook-power-tools +0 -38
  129. package/scripts/hook-savepoint-pre +0 -32
  130. package/scripts/lib/codex_edit_gates.rb +0 -138
  131. package/scripts/lib/edit_gates.rb +0 -398
  132. package/scripts/lib/links_gate.rb +0 -140
  133. package/scripts/lib/qmd_hook.rb +0 -41
  134. package/scripts/lib/spec_header.rb +0 -83
  135. package/scripts/lib/start_intent.rb +0 -296
  136. package/scripts/start-intent +0 -89
  137. package/skills/_active-intent-gate.md +0 -26
  138. package/skills/auto/references/tiers.md +0 -100
  139. package/skills/continuing/SKILL.md +0 -34
  140. package/skills/continuing/evals/evals.json +0 -91
  141. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  142. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  143. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  144. package/skills/intent-brainstorming/SKILL.md +0 -118
  145. package/skills/intent-brainstorming/evals/evals.json +0 -67
  146. package/skills/intent-continuing/evals/evals.json +0 -145
  147. package/skills/intent-discovering/SKILL.md +0 -56
  148. package/skills/intent-grilling/SKILL.md +0 -108
  149. package/skills/intent-linking/SKILL.md +0 -128
  150. package/skills/intent-linking/evals/evals.json +0 -22
  151. package/skills/intent-linking/references/zettelkasten.md +0 -45
  152. package/skills/intent-locking/SKILL.md +0 -64
  153. package/skills/intent-planning/SKILL.md +0 -145
  154. package/skills/intent-planning/evals/evals.json +0 -81
  155. package/skills/intent-planning/references/plan-format.md +0 -130
  156. package/skills/intent-researching/SKILL.md +0 -123
  157. package/skills/intent-researching/evals/evals.json +0 -22
  158. package/skills/intent-savepoint/SKILL.md +0 -83
  159. package/skills/intent-savepoint/references/context-management.md +0 -32
  160. package/skills/intent-starting/SKILL.md +0 -151
  161. package/skills/intent-starting/evals/evals.json +0 -117
  162. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  163. package/skills/project-continuing/SKILL.md +0 -119
  164. package/skills/project-continuing/evals/evals.json +0 -100
  165. package/skills/roadmap-continuing/SKILL.md +0 -89
  166. package/skills/roadmap-continuing/evals/evals.json +0 -82
  167. package/skills/skill-creating/SKILL.md +0 -75
  168. package/skills/skill-creating/evals/evals.json +0 -108
  169. package/skills/skill-creating/references/agents.md +0 -168
  170. package/skills/skill-creating/references/defaults-first.md +0 -23
  171. package/skills/skill-creating/references/evals.md +0 -41
  172. package/skills/skill-creating/references/hooks.md +0 -251
  173. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  174. package/skills/skill-creating/references/scripts.md +0 -166
  175. package/skills/skill-creating/references/skills.md +0 -169
  176. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  177. package/skills/skill-evaluating/SKILL.md +0 -141
  178. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  179. package/skills/skill-evaluating/evals/evals.json +0 -75
  180. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  181. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  182. package/skills/store-curating/SKILL.md +0 -64
  183. package/skills/store-curating/evals/evals.json +0 -38
  184. package/skills/store-indexing/SKILL.md +0 -93
  185. package/skills/store-indexing/evals/evals.json +0 -22
  186. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  187. package/skills/store-provisioning/SKILL.md +0 -55
  188. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  189. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  190. /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -0
@@ -0,0 +1,497 @@
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
+ # One LF-terminated checklist line, byte exact per spec D5. The state
190
+ # marker is fixed width across all three states, which is what lets a later
191
+ # promote or tick be a one-byte write at a known offset.
192
+ def checklist_line(state, session, project, summary)
193
+ marker = STATES.fetch(state) { raise ArgumentError, "unknown checklist state: #{state.inspect}" }
194
+ "- [#{marker}] [#{session}] [#{project}] #{summary}\n"
195
+ end
196
+
197
+ # One LF-terminated savepoint line, byte exact per spec D5. Separators are
198
+ # two spaces, so `split(/\s{2,}/)` yields exactly three parts. The
199
+ # timestamp is UTC ISO 8601, matching every other savepoint line in the
200
+ # store; the day id stays local wall clock, an intentional asymmetry.
201
+ def savepoint_line(event, session, project, summary, now:)
202
+ raise ArgumentError, "unknown savepoint event: #{event.inspect}" unless EVENTS.include?(event)
203
+
204
+ timestamp = now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
205
+ "#{timestamp} #{event} [#{session}] [#{project}] #{summary}\n"
206
+ end
207
+
208
+ # The inverse of #checklist_line: parse a checklist line positionally (the
209
+ # state marker, the first bracket group, the second bracket group, then the
210
+ # rest as the summary), so a bracket typed inside a summary is never read
211
+ # as a tag. Returns nil when the line does not match.
212
+ CHECKLIST_LINE_RE = /\A- \[(.)\] \[([^\]]*)\] \[([^\]]*)\] (.*)\z/m.freeze
213
+ private_constant :CHECKLIST_LINE_RE
214
+
215
+ def parse_checklist_line(line)
216
+ # #scrub replaces any invalid byte with U+FFFD so a stray non-UTF-8 byte
217
+ # anywhere in checklist.md never raises ArgumentError out of the regex
218
+ # match; it only ever affects that one line's parsed summary. Callers
219
+ # that need byte-exact offsets (namely #set_state) must measure against
220
+ # the UNSCRUBBED line, since #scrub can change a line's bytesize.
221
+ match = CHECKLIST_LINE_RE.match(line.to_s.chomp.scrub)
222
+ return nil unless match
223
+
224
+ marker, session, project, summary = match.captures
225
+ state = STATES.key(marker)
226
+ return nil unless state
227
+
228
+ { state: state, session: session, project: project, summary: summary }
229
+ end
230
+
231
+ # --- Token rendering (internal) --------------------------------------------
232
+
233
+ # Block-form gsub, mirroring scripts/new-intent#render_tokens: a String
234
+ # replacement argument reinterprets backslash sequences, so the block form
235
+ # substitutes each value literally.
236
+ def render_tokens(text, tokens)
237
+ tokens.reduce(text) { |acc, (key, value)| acc.gsub("{{#{key}}}") { value.to_s } }
238
+ end
239
+
240
+ # --- Locked writes and reads (spec D8) --------------------------------------
241
+
242
+ # Append `line` to `path`, taking an exclusive lock on the target file
243
+ # itself (never a sibling lock file, never via a rename). Opens
244
+ # WRONLY | APPEND | CREAT so a concurrent append always lands at the
245
+ # current end of file. Once the lock is held, writes `header` first only
246
+ # when the file is still size zero (the size check happens under the lock
247
+ # on purpose: O_CREAT without O_EXCL hands every racer the same inode, so
248
+ # the lock serializes them and exactly one racer sees size zero). Then
249
+ # writes the full line in one call. Rescues SystemCallError from flock
250
+ # only: on a filesystem without flock support, the append proceeds
251
+ # unlocked, since a single O_APPEND write still lands whole there. Always
252
+ # returns true.
253
+ def append_line(path, line, header: nil, flock: DEFAULT_FLOCK)
254
+ handle = File.open(path, File::WRONLY | File::APPEND | File::CREAT, 0o644)
255
+ begin
256
+ begin
257
+ flock.call(handle, File::LOCK_EX)
258
+ rescue SystemCallError
259
+ nil
260
+ end
261
+
262
+ handle.write(header) if header && handle.size.zero?
263
+ handle.write(line)
264
+ # Flush explicitly before unlocking. MRI happens to flush a writable
265
+ # handle's buffer as a side effect inside rb_file_flock, but that is
266
+ # undocumented behavior, so flush on purpose rather than depend on it.
267
+ handle.flush
268
+
269
+ begin
270
+ handle.flock(File::LOCK_UN)
271
+ rescue SystemCallError
272
+ nil
273
+ end
274
+ ensure
275
+ handle.close
276
+ end
277
+ true
278
+ end
279
+
280
+ # Flip the newest line (last in file order) whose session tag equals
281
+ # `session` and whose state equals `from` to `to`, narrowed to lines whose
282
+ # summary contains `match` when given (spec D7). The lock is taken on the
283
+ # target file itself. Once held, the file is scanned by byte offset, and
284
+ # exactly one byte (the state marker, at the matched line's start offset
285
+ # plus 3, past the "- [" prefix) is overwritten with `IO#pwrite`, so every
286
+ # other byte in the file stays identical: this is exactly why the pending
287
+ # marker is `[~]`, a fixed width shared with `[ ]` and `[x]`, rather than a
288
+ # bare tilde.
289
+ #
290
+ # Identifying the target line and flipping it happen inside the SAME
291
+ # LOCK_EX hold, on purpose: an earlier version identified the target under
292
+ # a released LOCK_SH, in a separate call, then re-identified and flipped it
293
+ # under a fresh LOCK_EX. Two concurrent promoters could both read the same
294
+ # "newest pending" line before either flipped anything, each then flip a
295
+ # DIFFERENT line under their own (correctly serialized) LOCK_EX, and the
296
+ # caller's earlier lookup would go stale, naming the wrong line in a
297
+ # --savepoint entry. Returning the flipped line's own summary from inside
298
+ # this lock is what makes that identify-and-flip atomic, so a caller never
299
+ # needs a second, separately-locked read to learn what it just changed.
300
+ #
301
+ # Returns the flipped line's summary (a String) when a byte changed, or nil
302
+ # (writing nothing) when the file does not exist or nothing matches. Raises
303
+ # LockUnavailableError, refusing to write at all, when the flock cannot be
304
+ # taken (filesystem without flock support): unlike an append, an in-place
305
+ # edit cannot fall back to unlocked, since a racing writer could tear the
306
+ # read-modify-write. `flock:` is a test seam (see DEFAULT_FLOCK); production
307
+ # callers never pass it.
308
+ def set_state(path, from:, to:, session:, match: nil, flock: DEFAULT_FLOCK)
309
+ return nil unless File.exist?(path)
310
+
311
+ STATES.fetch(from)
312
+ to_marker = STATES.fetch(to)
313
+
314
+ handle = File.open(path, File::RDWR)
315
+ locked = true
316
+ begin
317
+ flock.call(handle, File::LOCK_EX)
318
+ rescue SystemCallError
319
+ locked = false
320
+ end
321
+
322
+ unless locked
323
+ handle.close
324
+ raise LockUnavailableError, "cannot take an exclusive lock on #{path}"
325
+ end
326
+
327
+ begin
328
+ content = handle.read
329
+ target_offset = nil
330
+ target_summary = nil
331
+ offset = 0
332
+
333
+ content.each_line do |raw_line|
334
+ parsed = parse_checklist_line(raw_line)
335
+ if parsed && (session.nil? || parsed[:session] == session) && parsed[:state] == from &&
336
+ (match.nil? || parsed[:summary].include?(match))
337
+ target_offset = offset + 3 # past the "- [" prefix
338
+ target_summary = parsed[:summary]
339
+ end
340
+ # Accumulate over the UNSCRUBBED raw_line, never the copy
341
+ # #parse_checklist_line scrubs internally for matching: #scrub can
342
+ # change a line's bytesize, and pwrite below must land at the true
343
+ # on-disk byte offset.
344
+ offset += raw_line.bytesize
345
+ end
346
+
347
+ if target_offset.nil?
348
+ nil
349
+ else
350
+ handle.pwrite(to_marker, target_offset)
351
+ handle.flush
352
+ target_summary
353
+ end
354
+ ensure
355
+ begin
356
+ handle.flock(File::LOCK_UN)
357
+ rescue SystemCallError
358
+ nil
359
+ end
360
+ handle.close
361
+ end
362
+ end
363
+
364
+ # Flip every checklist line in state `from` to state `to` for one session
365
+ # (or any session when `session` is nil), narrowed to summaries containing
366
+ # `match` when given (intent 301). Same lock discipline as #set_state: one
367
+ # LOCK_EX for the whole read-scan-write, one pwrite of one byte per
368
+ # flipped line at its measured offset, never a whole-file rewrite, and a
369
+ # LockUnavailableError rather than any unlocked write. Returns the count of
370
+ # lines flipped, 0 when the file is absent or nothing matches.
371
+ def flip_all(path, from:, to:, session: nil, match: nil, flock: DEFAULT_FLOCK)
372
+ return 0 unless File.exist?(path)
373
+
374
+ STATES.fetch(from)
375
+ to_marker = STATES.fetch(to)
376
+ handle = File.open(path, File::RDWR)
377
+ locked = true
378
+ begin
379
+ flock.call(handle, File::LOCK_EX)
380
+ rescue SystemCallError
381
+ locked = false
382
+ end
383
+ unless locked
384
+ handle.close
385
+ raise LockUnavailableError, "cannot take an exclusive lock on #{path}"
386
+ end
387
+
388
+ begin
389
+ content = handle.read
390
+ offsets = []
391
+ offset = 0
392
+ content.each_line do |raw_line|
393
+ parsed = parse_checklist_line(raw_line)
394
+ if parsed && (session.nil? || parsed[:session] == session) && parsed[:state] == from &&
395
+ (match.nil? || parsed[:summary].include?(match))
396
+ offsets << offset + 3
397
+ end
398
+ offset += raw_line.bytesize
399
+ end
400
+ offsets.each { |o| handle.pwrite(to_marker, o) }
401
+ handle.flush unless offsets.empty?
402
+ offsets.size
403
+ ensure
404
+ begin
405
+ handle.flock(File::LOCK_UN)
406
+ rescue SystemCallError
407
+ nil
408
+ end
409
+ handle.close
410
+ end
411
+ end
412
+
413
+ # Read `path` under a shared lock, returning its content. Returns an empty
414
+ # string when the file does not exist. Rescues SystemCallError from flock
415
+ # and reads anyway.
416
+ def read_locked(path)
417
+ return "" unless File.exist?(path)
418
+
419
+ handle = File.open(path, File::RDONLY)
420
+ begin
421
+ begin
422
+ handle.flock(File::LOCK_SH)
423
+ rescue SystemCallError
424
+ nil
425
+ end
426
+ handle.read
427
+ ensure
428
+ begin
429
+ handle.flock(File::LOCK_UN)
430
+ rescue SystemCallError
431
+ nil
432
+ end
433
+ handle.close
434
+ end
435
+ end
436
+
437
+ # --- The day scaffold (spec D12) --------------------------------------------
438
+
439
+ # The one and only scaffold implementation. `new-intent --tmp` is its CLI,
440
+ # and `append-ledger` calls it directly on EVERY invocation (cheap and
441
+ # idempotent, so there is no cheaper-but-wrong guard to key on instead), so
442
+ # a capture that crosses midnight never fails and never needs a second
443
+ # process. Creates exactly `<day>/<day>.md`: no checklist.md, no
444
+ # savepoint.md, no actions/, no resources/. `checklist.md` and
445
+ # `savepoint.md` come into existence on first append, written by
446
+ # append-ledger under the lock, which is why this method never touches
447
+ # them.
448
+ #
449
+ # Create versus join is decided by opening the day file with
450
+ # File::CREAT | File::EXCL: the winner renders the template and returns
451
+ # created: true; every loser, including a repair of a crashed
452
+ # mid-scaffold with no md file yet, returns created: false without
453
+ # changing a byte. DATE (the day's own calendar date, used in the `intent:`
454
+ # line) is derived from `day`, not from `now`, so an explicit --day renders
455
+ # its own date rather than today's; `now:` instead sources CREATED, the
456
+ # `created:` frontmatter field, since that field records when the scaffold
457
+ # FILE was actually written, which can differ from the day it is for (a
458
+ # repair or a midnight-crossing capture can scaffold a past day's file
459
+ # today). Defaults to Time.now; tests inject a fixed `now:` to make the
460
+ # scaffold's `created:` value deterministic.
461
+ #
462
+ # If rendering fails partway (a missing or relocated templates dir, a bad
463
+ # day), the file this call just created is unlinked before the error
464
+ # re-raises, so no zero-byte or partial <day>.md is left behind to wedge
465
+ # every later #open_day call onto the Errno::EEXIST "already exists"
466
+ # branch with no file to repair.
467
+ def open_day(store:, day:, templates:, author:, now: Time.now)
468
+ dir = day_dir(store, day)
469
+ FileUtils.mkdir_p(dir)
470
+
471
+ file = day_file(store, day)
472
+ handle = begin
473
+ File.open(file, File::WRONLY | File::CREAT | File::EXCL, 0o644)
474
+ rescue Errno::EEXIST
475
+ nil
476
+ end
477
+
478
+ return { dir: dir, created: false } unless handle
479
+
480
+ begin
481
+ begin
482
+ date = Date.strptime(day, "%Y%m%d").iso8601
483
+ created = now.strftime("%Y-%m-%d")
484
+ template = File.read(File.join(templates, "session-intent.md"))
485
+ rendered = render_tokens(template, "DAY" => day, "DATE" => date, "CREATED" => created, "AUTHOR" => author)
486
+ handle.write(rendered)
487
+ rescue StandardError
488
+ File.delete(file) if File.exist?(file)
489
+ raise
490
+ end
491
+ ensure
492
+ handle.close unless handle.closed?
493
+ end
494
+
495
+ { dir: dir, created: true }
496
+ end
497
+ 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
@@ -21,22 +21,37 @@
21
21
  # [--tags tag,tag] [--templates <dir>]
22
22
  #
23
23
  # Exit codes: 0 (born complete), 1 (scaffold failed self-validation or bad input).
24
+ #
25
+ # A second, unrelated mode (intent 297):
26
+ # new-intent --tmp [--store <dir>] [--day YYYYMMDD] [--author <name>] \
27
+ # [--templates <dir>]
28
+ #
29
+ # `--tmp` scaffolds the shared session intent day ledger under the global
30
+ # store's `.sessions/<YYYYMMDD>/` instead of allocating a real intent id. It
31
+ # is a thin CLI over SessionLedger.open_day (the single scaffold
32
+ # implementation, spec D12) and never touches the id-allocation path above:
33
+ # no folgezettel-id call, no actions/ or resources/ dirs, no sentinel
34
+ # placeholders, no born savepoint stamp, no Links projection. Stdout is
35
+ # exactly one line, `created <dir>` or `joined <dir>`; exit 0 either way,
36
+ # exit 1 on bad input or failed self-validation.
24
37
 
25
38
  require "fileutils"
26
39
  require "date"
27
- require_relative "lib/bridge"
40
+ require_relative "lib/savepoint"
28
41
  require_relative "lib/intent_validator"
29
42
  require_relative "lib/graph_rebuild"
30
43
  require_relative "lib/links_projection"
31
44
  require_relative "lib/links_section"
32
45
  require_relative "lib/store_discovery"
46
+ require_relative "lib/session_ledger"
33
47
 
34
48
  # --- Explicit flag parsing (no eval, no global injection) ------------------
35
49
 
36
50
  def parse_args(argv)
37
51
  opts = {
38
52
  store: nil, intent: nil, slug: nil, parent: nil,
39
- author: "claude-code", sources: [], tags: [], templates: nil
53
+ author: "claude-code", sources: [], tags: [], templates: nil,
54
+ tmp: false, day: nil
40
55
  }
41
56
  i = 0
42
57
  while i < argv.length
@@ -50,6 +65,8 @@ def parse_args(argv)
50
65
  when "--sources" then opts[:sources] = split_list(argv[i += 1])
51
66
  when "--tags" then opts[:tags] = split_list(argv[i += 1])
52
67
  when "--templates" then opts[:templates] = argv[i += 1]
68
+ when "--tmp" then opts[:tmp] = true
69
+ when "--day" then opts[:day] = argv[i += 1]
53
70
  else
54
71
  abort "new-intent: unknown argument #{arg.inspect}"
55
72
  end
@@ -253,8 +270,50 @@ def project_links_for(file_path, referer_store_key, maps)
253
270
  true
254
271
  end
255
272
 
273
+ # The --tmp branch (intent 297, spec D1): scaffold the shared session intent
274
+ # day ledger instead of a real intent. A thin CLI over
275
+ # SessionLedger.open_day, the single scaffold implementation (spec D12); this
276
+ # method never allocates a folgezettel id, never creates actions/ or
277
+ # resources/, never writes a sentinel placeholder, never stamps a savepoint
278
+ # line, and never runs the Links projection build.
279
+ def run_tmp(opts)
280
+ { intent: "--intent", slug: "--slug", parent: "--parent" }.each do |key, flag|
281
+ abort "new-intent: #{flag} is not valid with --tmp" if opts[key] && !opts[key].to_s.empty?
282
+ end
283
+ abort "new-intent: --sources is not valid with --tmp" unless opts[:sources].empty?
284
+ abort "new-intent: --tags is not valid with --tmp" unless opts[:tags].empty?
285
+
286
+ store = opts[:store] ? expand(opts[:store]) : expand(File.join(ENV.fetch("PLASTIC_HOME", "~/.plastic"), "store"))
287
+ templates = opts[:templates] ? expand(opts[:templates]) : default_templates_dir
288
+ abort "new-intent: templates dir not found: #{templates}" unless Dir.exist?(templates)
289
+
290
+ day = opts[:day] || SessionLedger.day_id
291
+ unless SessionLedger.valid_day_id?(day)
292
+ abort "new-intent: --day must be eight digits parsing as a real date, got #{opts[:day].inspect}"
293
+ end
294
+
295
+ result = SessionLedger.open_day(store: store, day: day, templates: templates, author: opts[:author], now: Time.now)
296
+
297
+ plastic_home, = store_context(store)
298
+ known_stores = StoreDiscovery.known_slugs(plastic_home)
299
+ validation = IntentValidator.validate(result[:dir], known_stores: known_stores)
300
+ unless validation[:ok]
301
+ warn "new-intent: scaffolded day ledger is NOT born complete:"
302
+ validation[:missing].each { |f| warn " missing field: #{f}" }
303
+ validation[:errors].each { |e| warn " #{e}" }
304
+ exit 1
305
+ end
306
+
307
+ puts "#{result[:created] ? "created" : "joined"} #{result[:dir]}"
308
+ exit 0
309
+ end
310
+
256
311
  def main(argv)
257
312
  opts = parse_args(argv)
313
+ return run_tmp(opts) if opts[:tmp]
314
+
315
+ abort "new-intent: --day is only valid with --tmp" if opts[:day]
316
+
258
317
  abort "new-intent: --store is required" if opts[:store].nil? || opts[:store].empty?
259
318
  abort "new-intent: --intent is required" if opts[:intent].nil? || opts[:intent].empty?
260
319
  abort "new-intent: --slug is required" if opts[:slug].nil? || opts[:slug].empty?
@@ -338,7 +397,7 @@ def main(argv)
338
397
  template_path = File.join(templates, name)
339
398
  body = File.exist?(template_path) ? File.read(template_path) : ""
340
399
  body = render_tokens(body, { "INTENT_NAME" => opts[:intent] })
341
- File.write(File.join(intent_dir, name), "#{Bridge::PLACEHOLDER_SENTINEL}\n#{body}")
400
+ File.write(File.join(intent_dir, name), "#{Savepoint::PLACEHOLDER_SENTINEL}\n#{body}")
342
401
  end
343
402
 
344
403
  # 6. Stamp the born savepoint line (intent 81). The first ledger line is the
@@ -347,7 +406,7 @@ def main(argv)
347
406
  # sessions / harnesses). The intent file is never a sentinel placeholder, so
348
407
  # append_savepoint records `What {id}--{slug}.md`. Idempotent: a later gate
349
408
  # fire adds nothing.
350
- Bridge.append_savepoint(intent_dir, intent_file)
409
+ Savepoint.append_savepoint(intent_dir, intent_file)
351
410
 
352
411
  # 7. Self-validate (frontmatter + sanctioned sections), including that every
353
412
  # sources/chain cross-store token names a real store (intent 189 D3).