@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,320 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # Savepoint: the intent-directory savepoint ledger and the stage derivation it
6
+ # rests on (intent 303, split out of bridge.rb). Everything here is derived from
7
+ # the files on disk inside one intent directory: which lifecycle files are real
8
+ # (not the placeholder sentinel), which stage that makes, and the append-only
9
+ # savepoint.md ledger that records each milestone. No session, no bridge, no
10
+ # lock: a caller that only wants to append a ledger line or ask "which stage is
11
+ # this intent at" loads this file and nothing else. bridge.rb (the session
12
+ # pointer, arm and disarm, lock repair) requires this file; this file never
13
+ # requires the bridge.
14
+ require "fileutils"
15
+ require "time" # Time#iso8601 for the ledger timestamps
16
+
17
+ module Savepoint
18
+ # Placeholder sentinel (intent 60b). A scaffolded lifecycle file
19
+ # (spec.md/plan.md/checklist.md/outcome.md) carries this exact string as its
20
+ # first line until an agent fills the file and deletes the sentinel. The
21
+ # sentinel is the "stage not reached yet" marker, so stage detection treats a
22
+ # sentinel-marked file as absent (see stage_file_present?). The intent file
23
+ # (<id>--<slug>.md) is never sentineled; it is born complete.
24
+ PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
25
+
26
+ def self.intent_file(intent_dir)
27
+ dir_name = File.basename(intent_dir)
28
+ "#{intent_dir}/#{dir_name}.md"
29
+ end
30
+
31
+ # Walk up from file_path; return the first ancestor that looks like an intent
32
+ # directory (`.../store/<id>--<slug>`), else nil. Used to derive the savepoint
33
+ # target without needing a bridge. The input is always a file inside the intent
34
+ # dir (never the dir itself), so the walk-up starts at its parent.
35
+ def self.intent_dir_for(file_path)
36
+ dir = File.expand_path(file_path)
37
+ loop do
38
+ parent = File.dirname(dir)
39
+ break if parent == dir # reached filesystem root
40
+ dir = parent
41
+ return dir if dir.match?(%r{/store/[^/]+--[^/]+\z})
42
+ end
43
+ nil
44
+ end
45
+
46
+ # True iff a lifecycle file is PRESENT AND REAL: it exists and its first line is
47
+ # not the placeholder sentinel. Reads only the file head (never the whole file)
48
+ # so the dashboard stays fast across many intents. Exact first-line match only,
49
+ # so a real file that merely contains an HTML comment later is unaffected, and a
50
+ # partially-edited sentinel reads as real rather than sticking as a placeholder.
51
+ def self.stage_file_present?(path)
52
+ return false unless File.exist?(path)
53
+ first = File.open(path, &:gets)
54
+ return true if first.nil? # empty file: present, not a sentinel
55
+ first.chomp != PLACEHOLDER_SENTINEL
56
+ rescue StandardError
57
+ File.exist?(path)
58
+ end
59
+
60
+ # True iff actions/ holds AT LEAST ONE real action file: a non-empty *.md whose
61
+ # first line is not the placeholder sentinel. A `.gitkeep` (no .md extension)
62
+ # never counts, an empty *.md never counts, and a sentinel-only *.md never
63
+ # counts. Pure and side-effect-free so the gate stays unit-testable. Fail-open:
64
+ # a missing actions/ dir globs to nothing and returns false (the gate then
65
+ # reports it needs a real action file); it never raises.
66
+ def self.has_real_action?(intent_dir)
67
+ Dir.glob("#{intent_dir}/actions/*.md").any? do |f|
68
+ File.file?(f) && File.size(f) > 0 && stage_file_present?(f)
69
+ end
70
+ rescue StandardError
71
+ false
72
+ end
73
+
74
+ def self.derive_stage(intent_dir)
75
+ return "done" if stage_file_present?("#{intent_dir}/outcome.md")
76
+ if stage_file_present?("#{intent_dir}/plan.md") &&
77
+ has_real_action?(intent_dir) &&
78
+ stage_file_present?("#{intent_dir}/checklist.md")
79
+ return "exec"
80
+ end
81
+ return "how" if stage_file_present?("#{intent_dir}/spec.md")
82
+ return "why" if File.exist?(intent_file(intent_dir))
83
+ "what"
84
+ end
85
+
86
+ def self.has_files(intent_dir)
87
+ files = []
88
+ ifile = File.basename(intent_file(intent_dir))
89
+ files << ifile if File.exist?("#{intent_dir}/#{ifile}")
90
+ ["spec.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
91
+ files << f if stage_file_present?("#{intent_dir}/#{f}")
92
+ end
93
+ files << "actions/" if has_real_action?(intent_dir)
94
+ files
95
+ end
96
+
97
+ def self.missing_for_stage(stage, intent_dir = nil)
98
+ ifile = intent_dir ? File.basename(intent_file(intent_dir)) : "intent.md"
99
+ case stage
100
+ when "what" then [ifile]
101
+ when "why" then ["spec.md"]
102
+ when "how" then ["plan.md", "actions/", "checklist.md"]
103
+ when "exec" then ["outcome.md"]
104
+ else []
105
+ end
106
+ end
107
+
108
+ # --- Cycle-step savepoint ledger (intent 34) ------------------------------
109
+ #
110
+ # savepoint.md is a deterministic, append-only, one-line-per-milestone ledger
111
+ # (newest at the bottom). It is sugar on top of the conventions: derived from
112
+ # files-on-disk, rebuildable, never a source of truth. Milestones are
113
+ # file-event boundaries only; action/resource files record nothing.
114
+
115
+ SAVEPOINT_FILE = "savepoint.md"
116
+
117
+ # Map a written filename to [stage_label, milestone_text], or nil if the file
118
+ # is not a lifecycle milestone.
119
+ def self.savepoint_milestone(intent_dir, basename)
120
+ return ["What", basename] if basename == File.basename(intent_file(intent_dir))
121
+
122
+ case basename
123
+ when "spec.md" then ["Why", "spec.md created"]
124
+ when "plan.md" then ["How", "plan.md created"]
125
+ when "checklist.md" then ["How", "checklist.md created"]
126
+ when "outcome.md" then ["Exec", "outcome.md created"]
127
+ end
128
+ end
129
+
130
+ # Milestones already recorded in the ledger (field 3 of each line).
131
+ def self.savepoint_recorded_milestones(intent_dir)
132
+ f = File.join(intent_dir, SAVEPOINT_FILE)
133
+ return [] unless File.exist?(f)
134
+ File.read(f).each_line.map do |line|
135
+ parts = line.strip.split(/\s{2,}/)
136
+ parts.length >= 3 ? parts[2] : nil
137
+ end.compact
138
+ end
139
+
140
+ # (stage, milestone) pairs already recorded in the ledger. The pair (not the
141
+ # milestone text alone) is the dedup key, because state-from-ledger lines like
142
+ # `Why started` and `How started` share the milestone text "started" while
143
+ # being distinct events (intent 81).
144
+ def self.savepoint_recorded_pairs(intent_dir)
145
+ f = File.join(intent_dir, SAVEPOINT_FILE)
146
+ return [] unless File.exist?(f)
147
+ File.read(f).each_line.filter_map do |line|
148
+ parts = line.strip.split(/\s{2,}/)
149
+ parts.length >= 3 ? [parts[1], parts[2]] : nil
150
+ end
151
+ end
152
+
153
+ # Append one ledger line for (stage, milestone) unless that pair is already
154
+ # recorded. The single append primitive shared by every line class. Returns
155
+ # true when a line was written, false when it was a no-op.
156
+ def self.append_savepoint_line(intent_dir, stage, milestone, now)
157
+ return false if savepoint_recorded_pairs(intent_dir).include?([stage, milestone])
158
+ line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
159
+ File.open(File.join(intent_dir, SAVEPOINT_FILE), "a") { |io| io.write(line) }
160
+ true
161
+ end
162
+
163
+ # Append the artifact-landing milestone for file_path if (and only if) it is a
164
+ # milestone not already recorded. Returns true when a line was written.
165
+ def self.append_savepoint(intent_dir, file_path, now: Time.now)
166
+ basename = File.basename(file_path)
167
+ stage, milestone = savepoint_milestone(intent_dir, basename)
168
+ return false unless milestone
169
+ # A sentinel-marked lifecycle file logs NO milestone (the stage is not real
170
+ # yet). The intent file is never sentineled, so it still logs its What line.
171
+ return false unless stage_file_present?(File.join(intent_dir, basename))
172
+
173
+ append_savepoint_line(intent_dir, stage, milestone, now)
174
+ end
175
+
176
+ # --- State-from-ledger: pre-stage, exec-start, and terminal lines (81) ------
177
+ #
178
+ # On top of intent 34's artifact-landing milestones, the ledger gains:
179
+ # - `started` lines, one per cycle stage entry (pre-stage, written by the
180
+ # PreToolUse savepoint hook the moment a stage's artifact is first written);
181
+ # - an `Exec started` companion emitted when checklist.md lands;
182
+ # - a terminal `Done delivered|abandoned` line written by the completion path.
183
+ # None of these are derivable from files on disk, so they are deliberately NOT
184
+ # part of savepoint_milestone and are never regenerated by rebuild_savepoint:
185
+ # a rebuilt ledger is the file-landing skeleton, the live ledger is richer.
186
+
187
+ # Map a written filename to the [stage, "started"] pre-stage milestone, or nil.
188
+ # spec.md => entering Why, plan.md => entering How. checklist.md/outcome.md do
189
+ # not open a stage (checklist's Exec-start is the append_exec_started companion).
190
+ def self.savepoint_started_milestone(basename)
191
+ case basename
192
+ when "spec.md" then ["Why", "started"]
193
+ when "plan.md" then ["How", "started"]
194
+ end
195
+ end
196
+
197
+ # Append the pre-stage `started` line for file_path, iff: the basename opens a
198
+ # stage, the stage is genuinely starting (its artifact is not yet a REAL file,
199
+ # so a sentinel placeholder still counts as "starting"), and the pair is not
200
+ # already recorded. Returns true when a line was written.
201
+ def self.append_started_savepoint(intent_dir, file_path, now: Time.now)
202
+ basename = File.basename(file_path)
203
+ stage, milestone = savepoint_started_milestone(basename)
204
+ return false unless milestone
205
+ return false if stage_file_present?(File.join(intent_dir, basename))
206
+
207
+ append_savepoint_line(intent_dir, stage, milestone, now)
208
+ end
209
+
210
+ # Append the `Exec started` companion (emitted when checklist.md lands, in the
211
+ # same PostToolUse event as the `How checklist.md created` line). Idempotent.
212
+ def self.append_exec_started(intent_dir, now: Time.now)
213
+ append_savepoint_line(intent_dir, "Exec", "started", now)
214
+ end
215
+
216
+ TERMINAL_DISPOSITIONS = %w[delivered abandoned].freeze
217
+
218
+ # Append the terminal bookend `Done delivered|abandoned`, written by the
219
+ # completion path when an intent transfers to INDEX's Completed/Abandoned
220
+ # section. Idempotent per disposition. Raises on an unknown disposition.
221
+ def self.append_terminal_savepoint(intent_dir, disposition, now: Time.now)
222
+ unless TERMINAL_DISPOSITIONS.include?(disposition)
223
+ raise ArgumentError,
224
+ "disposition must be one of #{TERMINAL_DISPOSITIONS.join(', ')}, got #{disposition.inspect}"
225
+ end
226
+
227
+ append_savepoint_line(intent_dir, "Done", disposition, now)
228
+ end
229
+
230
+ # Reconstruct the ledger from files on disk (timestamps from mtimes), in
231
+ # stage order, overwriting savepoint.md. Returns the number of lines written.
232
+ # A Plastic 1.x ledger may carry a `Tier <value>` line after the spec.md
233
+ # milestone (removed in 2.0, intent 304); a rebuild drops it, and the phantom
234
+ # detector ignores it, so a 1.x store reads clean.
235
+ def self.rebuild_savepoint(intent_dir)
236
+ ordered = [
237
+ File.basename(intent_file(intent_dir)),
238
+ "spec.md", "plan.md", "checklist.md", "outcome.md",
239
+ ]
240
+ lines = ordered.flat_map do |basename|
241
+ path = File.join(intent_dir, basename)
242
+ next [] unless stage_file_present?(path)
243
+ stage, milestone = savepoint_milestone(intent_dir, basename)
244
+ next [] unless milestone
245
+ stamp = File.mtime(path).utc.iso8601
246
+ ["#{stamp} #{stage} #{milestone}\n"]
247
+ end
248
+ File.write(File.join(intent_dir, SAVEPOINT_FILE), lines.join)
249
+ lines.length
250
+ end
251
+
252
+ # --- Phantom-line detection (intent 134) ------------------------------------
253
+ #
254
+ # A companion to the ledger, not a new writer: pure, disk-only, hermetic (no bridge or
255
+ # session resolution, no writes), matching intent 52's savepoint-decoupling precedent. Under
256
+ # a gate-routing misfire (bug 131) or an out-of-band merge (124a's precedent), a ledger line
257
+ # can go stale or duplicate without the file evidence agreeing. This detects, never repairs;
258
+ # repair is `rebuild_savepoint` (live intents) or the 124a manual Done-bookend recipe
259
+ # (terminal intents, human-granted only).
260
+
261
+ # (stage, milestone) -> basename, for every file-landing milestone this intent_dir could have
262
+ # produced (the intent file plus the four lifecycle artifacts). Reuses savepoint_milestone so
263
+ # the mapping never drifts from the one the writer itself uses.
264
+ def self.savepoint_file_landing_pairs(intent_dir)
265
+ basenames = [File.basename(intent_file(intent_dir)), "spec.md", "plan.md", "checklist.md", "outcome.md"]
266
+ basenames.each_with_object({}) do |basename, map|
267
+ pair = savepoint_milestone(intent_dir, basename)
268
+ map[pair] = basename if pair
269
+ end
270
+ end
271
+
272
+ # A `started` state line's real prerequisite is the PRECEDING stage's artifact, not its own
273
+ # (a `started` line legitimately fires before its own stage's file is real by design). `Exec
274
+ # started` additionally requires plan.md, since Exec cannot start before How produced it too.
275
+ SAVEPOINT_STATE_PREREQUISITES = {
276
+ ["How", "started"] => ["spec.md"],
277
+ ["Exec", "started"] => ["plan.md", "checklist.md"],
278
+ }.freeze
279
+
280
+ # Raw (stripped) ledger lines whose disk evidence contradicts them, each paired with a short
281
+ # reason: [line, reason]. Three phantom classes (D5):
282
+ # - a file-landing milestone whose file is absent or still a sentinel placeholder;
283
+ # - a duplicate (stage, milestone) pair (the later occurrence is the phantom);
284
+ # - a state line (`How started` / `Exec started`) whose stage prerequisites are absent.
285
+ # A clean ledger, or an absent one, returns [].
286
+ def self.savepoint_phantom_lines(intent_dir)
287
+ path = File.join(intent_dir, SAVEPOINT_FILE)
288
+ return [] unless File.exist?(path)
289
+
290
+ landing = savepoint_file_landing_pairs(intent_dir)
291
+ seen = []
292
+ phantoms = []
293
+
294
+ File.read(path).each_line do |raw|
295
+ line = raw.strip
296
+ next if line.empty?
297
+ parts = line.split(/\s{2,}/)
298
+ next if parts.length < 3
299
+ pair = [parts[1], parts[2]]
300
+
301
+ if seen.include?(pair)
302
+ phantoms << [line, "duplicate (stage, milestone) pair"]
303
+ next
304
+ end
305
+ seen << pair
306
+
307
+ if (basename = landing[pair]) && !stage_file_present?(File.join(intent_dir, basename))
308
+ phantoms << [line, "milestone file absent or still a sentinel placeholder"]
309
+ next
310
+ end
311
+
312
+ prereqs = SAVEPOINT_STATE_PREREQUISITES[pair]
313
+ if prereqs && prereqs.any? { |b| !stage_file_present?(File.join(intent_dir, b)) }
314
+ phantoms << [line, "state line prerequisite absent on disk"]
315
+ end
316
+ end
317
+
318
+ phantoms
319
+ end
320
+ end
@@ -4,20 +4,21 @@
4
4
  require "fileutils"
5
5
  require_relative "worktree"
6
6
  require_relative "bridge"
7
- require_relative "spec_header"
7
+ require_relative "savepoint"
8
8
  require_relative "intent_validator"
9
9
 
10
- # ScaffoldIntent - all logic for `scripts/scaffold-intent` (intent 213). Three
11
- # subcommands (spec|checklist|outcome), each writes one lifecycle artifact by copying or
12
- # mechanically deriving it from an already-committed source: the intent file's
13
- # `### Decisions` list, spec.md's `## Acceptance Criteria` list, or a `git diff --stat`
14
- # plus an optional supplied test-summary file. No subcommand interprets or invents prose;
15
- # a field this module cannot derive mechanically is left as the template's own stub text
16
- # instead of being guessed at.
10
+ # ScaffoldIntent - the shared, pure helpers behind `scripts/scaffold-intent` (intent 213)
11
+ # and its callers: path and template resolution, section splitting, the intent file's
12
+ # `### Decisions` extraction, and the git-derived diffstat (`build_verification_body`).
13
+ # BackfillIntent (scripts/lib/backfill_intent.rb, intent 308) composes these into the
14
+ # one writer that fills an intent's judgment documents from its record; verify_intent,
15
+ # exec_worktree, and session_git use the repo and base-branch helpers directly.
17
16
  #
18
- # `scaffold-intent` does not scaffold `actions/` in any form (intent 133a, D11): actions
19
- # require judgment and stay entirely with the planner. No method here creates the
20
- # directory, writes a `.gitkeep`, or writes a sentinel-only `ACTION_*.md`.
17
+ # The three per-file subcommands this module once carried were removed in 2.0 (intent
18
+ # 308): `scaffold_spec` and `scaffold_checklist` (removed in 2.0), `scaffold_outcome` with
19
+ # `--force` and `--test-summary` (removed in 2.0). The backfill writer covers their
20
+ # derivable targets, and nothing else called them. Nothing here invents prose: every derived field is a verbatim
21
+ # copy or a mechanical rendering of a committed artifact.
21
22
  #
22
23
  # Pure and dependency-injected: never calls `exit` or `abort`, never reads `ARGV` or
23
24
  # `ENV` directly (only via the ambient `Dir.home` default, matching Bridge/Worktree
@@ -28,15 +29,6 @@ require_relative "intent_validator"
28
29
  module ScaffoldIntent
29
30
  module_function
30
31
 
31
- SPEC_SECTIONS = [
32
- "## Problem", "## Goals", "## Non-Goals", "## Approach",
33
- "## Alternatives Considered", "## Decisions",
34
- "## Acceptance Criteria", "## Open Questions",
35
- ].freeze
36
-
37
- SETTLED_PLACEHOLDER_COMMENT =
38
- "<!-- Settled: yes (<reason>) optional, add this line only when the design is settled -->\n"
39
-
40
32
  # --- path resolution (pure) --------------------------------------------------
41
33
 
42
34
  def expand(path)
@@ -84,21 +76,10 @@ module ScaffoldIntent
84
76
  { status: :ok, code: 0, message: nil, path: path }
85
77
  end
86
78
 
87
- def refuse_result(path)
88
- { status: :refused, code: 2, path: path,
89
- message: "#{path} already has real content; pass --force to overwrite it deliberately" }
90
- end
91
-
92
79
  def error_result(message)
93
80
  { status: :error, code: 3, message: message, path: nil }
94
81
  end
95
82
 
96
- # True iff `target` exists, carries real (non-sentinel) content, and `force` was not
97
- # passed: the caller must refuse to write and leave the file untouched.
98
- def refuse_without_force?(target, force)
99
- File.exist?(target) && Bridge.stage_file_present?(target) && !force
100
- end
101
-
102
83
  # --- generic section helpers (pure) --------------------------------------------
103
84
 
104
85
  # Split `text` into { "## Heading" => body_text } by top-level `## ` headings (never
@@ -156,125 +137,11 @@ module ScaffoldIntent
156
137
  [body_lines.join, nil]
157
138
  end
158
139
 
159
- # --- `## Acceptance Criteria` extraction from spec.md (pure) --------------------
160
-
161
- # Byte-for-byte body of `## Acceptance Criteria` in `spec_content`. Returns
162
- # [body, nil] on success, or [nil, message] when the heading is absent or its body
163
- # holds no `- [ ]` line.
164
- def extract_acceptance_criteria(spec_content)
165
- lines = spec_content.lines
166
- idx = lines.index { |l| l.rstrip == "## Acceptance Criteria" }
167
- return [nil, "spec.md has no ## Acceptance Criteria section to copy"] if idx.nil?
168
-
169
- stop = idx + 1
170
- stop += 1 while stop < lines.length && !lines[stop].start_with?("## ")
171
- body_lines = strip_blank_edges(lines[(idx + 1)...stop])
172
-
173
- unless body_lines.any? { |l| l =~ /^\s*- \[ \]/ }
174
- return [nil, "spec.md's ## Acceptance Criteria has no checklist items (no line matching '- [ ]')"]
175
- end
176
-
177
- [body_lines.join, nil]
178
- end
179
-
180
140
  def strip_blank_edges(lines)
181
141
  lines = lines.drop_while { |l| l.strip.empty? }
182
142
  lines.reverse.drop_while { |l| l.strip.empty? }.reverse
183
143
  end
184
144
 
185
- # --- spec subcommand ------------------------------------------------------------
186
-
187
- def scaffold_spec(intent_dir:, force:, templates_dir: nil, home: Dir.home)
188
- target = File.join(intent_dir, "spec.md")
189
- return refuse_result(target) if refuse_without_force?(target, force)
190
-
191
- intent_file = Bridge.intent_file(intent_dir)
192
- return error_result("the intent file is missing at #{intent_file}") unless File.exist?(intent_file)
193
-
194
- intent_content = File.read(intent_file)
195
- fm = IntentValidator.parse_frontmatter_text(intent_content)
196
- intent_name = fm.is_a?(Hash) ? fm["intent"] : nil
197
- if Bridge.blank?(intent_name)
198
- return error_result("the intent file at #{intent_file} has no frontmatter intent name")
199
- end
200
-
201
- decisions_body, decisions_err = extract_decisions(intent_content)
202
- return error_result(decisions_err) if decisions_body.nil?
203
-
204
- tdir = templates_dir || resolve_templates_dir(home: home)
205
- return error_result(templates_missing_message(home: home)) if tdir.nil?
206
-
207
- spec_template_path = File.join(tdir, "spec.md")
208
- return error_result("spec.md template not found at #{spec_template_path}") unless File.exist?(spec_template_path)
209
-
210
- written = build_spec_content(intent_name: intent_name, decisions_body: decisions_body,
211
- template_text: File.read(spec_template_path))
212
-
213
- FileUtils.mkdir_p(intent_dir)
214
- File.write(target, written)
215
- ok_result(target)
216
- end
217
-
218
- def build_spec_content(intent_name:, decisions_body:, template_text:)
219
- template_sections = sections_from(template_text)
220
-
221
- out = []
222
- out << SpecHeader.render(tier: nil, settled_reason: nil)
223
- out << SETTLED_PLACEHOLDER_COMMENT
224
- out << "\n"
225
- out << "# Spec: #{intent_name}\n"
226
- out << "\n"
227
-
228
- SPEC_SECTIONS.each do |heading|
229
- out << "#{heading}\n"
230
- if heading == "## Decisions"
231
- out << decisions_body
232
- out << "\n"
233
- else
234
- out << (template_sections[heading] || "")
235
- end
236
- end
237
-
238
- out.join
239
- end
240
-
241
- # --- checklist subcommand --------------------------------------------------------
242
-
243
- def scaffold_checklist(intent_dir:, force:, templates_dir: nil, home: Dir.home)
244
- target = File.join(intent_dir, "checklist.md")
245
- return refuse_result(target) if refuse_without_force?(target, force)
246
-
247
- spec_path = File.join(intent_dir, "spec.md")
248
- unless File.exist?(spec_path) && Bridge.stage_file_present?(spec_path)
249
- return error_result("spec.md is missing or still the scaffold placeholder at #{spec_path}")
250
- end
251
-
252
- ac_body, ac_err = extract_acceptance_criteria(File.read(spec_path))
253
- return error_result(ac_err) if ac_body.nil?
254
-
255
- intent_file = Bridge.intent_file(intent_dir)
256
- fm = IntentValidator.parse_frontmatter(intent_file)
257
- intent_name = fm.is_a?(Hash) ? fm["intent"] : nil
258
- if Bridge.blank?(intent_name)
259
- return error_result("the intent file at #{intent_file} has no frontmatter intent name")
260
- end
261
-
262
- tdir = templates_dir || resolve_templates_dir(home: home)
263
- return error_result(templates_missing_message(home: home)) if tdir.nil?
264
-
265
- checklist_template_path = File.join(tdir, "checklist.md")
266
- unless File.exist?(checklist_template_path)
267
- return error_result("checklist.md template not found at #{checklist_template_path}")
268
- end
269
-
270
- template_text = File.read(checklist_template_path).sub("{{INTENT_NAME}}", intent_name)
271
- written = replace_section_body(template_text, "## In Progress", [ac_body, "\n"])
272
-
273
- FileUtils.mkdir_p(intent_dir)
274
- File.write(target, written)
275
- ok_result(target)
276
- end
277
-
278
145
  # --- repo / base-branch resolution (shared with ACTION_3) -----------------------
279
146
 
280
147
  # The provisioned code worktree for this intent when it exists on disk, else the git
@@ -318,40 +185,7 @@ module ScaffoldIntent
318
185
  [res.stdout.to_s, nil]
319
186
  end
320
187
 
321
- # --- outcome subcommand -----------------------------------------------------------
322
-
323
- def scaffold_outcome(intent_dir:, force:, store:, id:, test_summary: nil,
324
- home: Dir.home, runner: Worktree::ShellRunner.new, templates_dir: nil)
325
- target = File.join(intent_dir, "outcome.md")
326
- return refuse_result(target) if refuse_without_force?(target, force)
327
-
328
- intent_file = Bridge.intent_file(intent_dir)
329
- return error_result("the intent file is missing at #{intent_file}") unless File.exist?(intent_file)
330
-
331
- fm = IntentValidator.parse_frontmatter(intent_file)
332
- intent_name = fm.is_a?(Hash) ? fm["intent"] : nil
333
- if Bridge.blank?(intent_name)
334
- return error_result("the intent file at #{intent_file} has no frontmatter intent name")
335
- end
336
-
337
- tdir = templates_dir || resolve_templates_dir(home: home)
338
- return error_result(templates_missing_message(home: home)) if tdir.nil?
339
-
340
- outcome_template_path = File.join(tdir, "outcome.md")
341
- unless File.exist?(outcome_template_path)
342
- return error_result("outcome.md template not found at #{outcome_template_path}")
343
- end
344
-
345
- verification_body = build_verification_body(store: store, id: id, intent_dir: intent_dir,
346
- home: home, runner: runner, test_summary: test_summary)
347
-
348
- content = File.read(outcome_template_path).sub("# Outcome: <intent name>", "# Outcome: #{intent_name}")
349
- written = replace_section_body(content, "## Verification", [verification_body, "\n"])
350
-
351
- FileUtils.mkdir_p(intent_dir)
352
- File.write(target, written)
353
- ok_result(target)
354
- end
188
+ # --- verification body (diffstat plus an optional test summary) ----------------------
355
189
 
356
190
  def build_verification_body(store:, id:, intent_dir:, home:, runner:, test_summary:)
357
191
  repo = resolve_repo_dir(store: store, id: id, intent_dir: intent_dir, home: home, runner: runner)