@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,500 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require_relative "worktree"
6
+ require_relative "scaffold_intent"
7
+ require_relative "session_ledger"
8
+
9
+ # SessionGit - the branch model and per-repo flow settings behind
10
+ # `scripts/session-commit` (intent 300), which is how a checklist item
11
+ # verified during a session becomes one git commit on the right branch of
12
+ # the repository that contains it. Implements the ruling recorded in intent
13
+ # 296's "Branch model" section: a session branch per repository per day cut
14
+ # from that repository's own base branch under `mode: direct`, or a small
15
+ # branch-and-PR per item under `mode: pull_request`.
16
+ #
17
+ # Pure and dependency-injected: every git call goes through an injected
18
+ # `runner:` (default `Worktree::ShellRunner.new`, the same seam Worktree
19
+ # itself uses), every `gh` call goes through an injected `gh_runner:`
20
+ # (default `SessionGit::GhRunner.new`, since `gh` is not `git` and needs its
21
+ # own seam), and this file reads no environment variable anywhere -- only
22
+ # `scripts/session-commit` reads the environment and passes what it read in.
23
+ module SessionGit
24
+ module_function
25
+
26
+ MODES = %w[direct pull_request].freeze
27
+ WORKSPACES = %w[checkout worktree].freeze
28
+ MAX_SUBJECT_LENGTH = 72
29
+
30
+ DEFAULT_BRANCH_TEMPLATE = "session/{{day}}"
31
+ WORKSPACE_WORKTREE_NOTE = "workspace: worktree is not implemented in this release; " \
32
+ "committed through the checkout"
33
+
34
+ # One outcome, always. `event` is "Item" (a commit landed on the happy
35
+ # path: the session branch was fast-forwarded into the base, or a PR
36
+ # opened) or "Note" (every degradation: no repo, nothing to commit, an
37
+ # agent-owned branch, a wrong branch, a refused push or checkout, a
38
+ # missing `gh`, a rejected commit-msg hook, an unknown or unimplemented
39
+ # flow value). `message` is both the CLI's stdout line and the ledger's
40
+ # savepoint summary (spec D1: "the same text as a Note or Item savepoint
41
+ # line").
42
+ Result = Struct.new(:message, :event, keyword_init: true)
43
+
44
+ # The default `gh` seam. `gh` is a different executable than `git`, so it
45
+ # cannot reuse Worktree::ShellRunner's git-only `run`. Every call takes an
46
+ # explicit `dir:` and runs there (review R1): without it, `gh pr create`
47
+ # resolves its target repository from the calling process's own working
48
+ # directory, not `--cwd`'s repo, which is the one place this library could
49
+ # otherwise act on a repository other than the one it was asked about.
50
+ # `available?` probes PATH the same way a shell would (no
51
+ # environment-variable read: `system` resolves PATH itself via the OS, the
52
+ # same as typing `gh` at a prompt).
53
+ class GhRunner
54
+ Result = Struct.new(:status, :stdout, :stderr) do
55
+ def success?
56
+ status.zero?
57
+ end
58
+ end
59
+
60
+ def available?(dir = nil)
61
+ opts = dir ? { chdir: dir } : {}
62
+ system("gh", "--version", out: File::NULL, err: File::NULL, **opts) == true
63
+ end
64
+
65
+ def run(*args, dir: nil)
66
+ require "open3"
67
+ opts = dir ? { chdir: dir } : {}
68
+ out, err, status = Open3.capture3("gh", *args.map(&:to_s), **opts)
69
+ Result.new(status.exitstatus.to_i, out, err)
70
+ end
71
+ end
72
+
73
+ def blank?(value)
74
+ value.nil? || value.to_s.strip.empty?
75
+ end
76
+
77
+ def note(message)
78
+ Result.new(message: message, event: "Note")
79
+ end
80
+
81
+ # `res.stderr`, falling back to `res.stdout` when stderr is empty (review
82
+ # N2): a hook or a git subcommand can write its explanation to either
83
+ # stream, and an empty diagnosis is worse than a stdout-sourced one.
84
+ def diagnose(res)
85
+ text = res.stderr.to_s.strip
86
+ return text unless text.empty?
87
+
88
+ res.stdout.to_s.strip
89
+ end
90
+
91
+ # --- repo resolution --------------------------------------------------------
92
+
93
+ # The repo root containing `cwd`, or nil when `cwd` is outside any git
94
+ # repository (spec D2).
95
+ def resolve_repo(cwd, runner:)
96
+ res = runner.run("-C", cwd.to_s, "rev-parse", "--show-toplevel")
97
+ return nil unless res.success?
98
+
99
+ top = res.stdout.to_s.strip
100
+ top.empty? ? nil : top
101
+ end
102
+
103
+ # --- flow resolution ---------------------------------------------------------
104
+
105
+ # [flow_hash, notes]. `flow_hash` always carries all five knobs (spec D2):
106
+ # mode, base, branch_template, ticket_source, workspace. `notes` is a list
107
+ # of human-readable strings for every unknown `mode` or `workspace` value
108
+ # found in the project's `flow:` block, plus `workspace: worktree` itself
109
+ # (review BLOCKER 3 ruling: the knob stays a valid config value, but its
110
+ # real implementation is a follow-up, so it is treated as `checkout` and
111
+ # always degrades the outcome to a Note). See SessionGit.commit!.
112
+ def load_flow(cwd:, repo:, plastic_home:, runner:)
113
+ notes = []
114
+ slug = SessionLedger.project_slug(cwd, plastic_home: plastic_home)
115
+ project_flow = read_project_flow(slug, plastic_home)
116
+
117
+ base_default = repo ? ScaffoldIntent.detect_base_branch(repo, runner: runner) : nil
118
+
119
+ flow = {
120
+ "mode" => "direct",
121
+ "base" => base_default,
122
+ "branch_template" => DEFAULT_BRANCH_TEMPLATE,
123
+ "ticket_source" => "intent_id",
124
+ "workspace" => "checkout",
125
+ }
126
+
127
+ if project_flow
128
+ flow["base"] = project_flow["base"].to_s unless blank?(project_flow["base"])
129
+ unless blank?(project_flow["branch_template"])
130
+ flow["branch_template"] = project_flow["branch_template"].to_s
131
+ end
132
+ unless blank?(project_flow["ticket_source"])
133
+ flow["ticket_source"] = project_flow["ticket_source"].to_s
134
+ end
135
+
136
+ apply_enum_knob!(flow, notes, project_flow, key: "mode", allowed: MODES, default: "direct")
137
+ apply_enum_knob!(flow, notes, project_flow, key: "workspace", allowed: WORKSPACES, default: "checkout")
138
+ end
139
+
140
+ if flow["workspace"] == "worktree"
141
+ flow["workspace"] = "checkout"
142
+ notes << WORKSPACE_WORKTREE_NOTE
143
+ end
144
+
145
+ [flow, notes]
146
+ end
147
+
148
+ def apply_enum_knob!(flow, notes, project_flow, key:, allowed:, default:)
149
+ value = project_flow[key]
150
+ return if blank?(value)
151
+
152
+ if allowed.include?(value.to_s)
153
+ flow[key] = value.to_s
154
+ else
155
+ notes << "unknown flow #{key} #{value.inspect}, using default: #{default}"
156
+ end
157
+ end
158
+
159
+ def read_project_flow(slug, plastic_home)
160
+ return nil if slug == "global"
161
+
162
+ project_yml = File.join(plastic_home, "projects", slug, "project.yml")
163
+ return nil unless File.exist?(project_yml)
164
+
165
+ data = begin
166
+ YAML.safe_load(File.read(project_yml))
167
+ rescue StandardError
168
+ nil
169
+ end
170
+ data.is_a?(Hash) && data["flow"].is_a?(Hash) ? data["flow"] : nil
171
+ end
172
+
173
+ # --- token rendering ----------------------------------------------------------
174
+
175
+ def render_branch(template, day:, ticket:, slug:)
176
+ template.to_s
177
+ .gsub("{{day}}", day.to_s)
178
+ .gsub("{{ticket}}", ticket.to_s)
179
+ .gsub("{{slug}}", slug.to_s)
180
+ end
181
+
182
+ # Kebab-case the first `max_words` words of `text`. Used to build the
183
+ # `{{slug}}` token for a pull-request branch name (spec D4).
184
+ def summary_slug(text, max_words: 5)
185
+ text.to_s.downcase.scan(/[a-z0-9]+/).first(max_words).join("-")
186
+ end
187
+
188
+ # The intent id named by the session's pointer file when `ticket_source`
189
+ # is `intent_id`, else the day id (spec D4). Per intent 298 spec D6, the
190
+ # pointer at `.tmp/<session>/current` holds exactly one line: either
191
+ # today's day id (the session records into the day ledger) or an intent
192
+ # id (an auto team owns the record). A day id in the pointer is therefore
193
+ # not an intent name, and falls back to the day id here too, which is the
194
+ # same value either way.
195
+ def resolve_ticket(day:, store:, session:, ticket_source:)
196
+ return day.to_s unless ticket_source.to_s == "intent_id"
197
+
198
+ pointer = SessionLedger.pointer_path(store, session)
199
+ return day.to_s unless File.exist?(pointer)
200
+
201
+ content = File.read(pointer).to_s.strip
202
+ return day.to_s if content.empty?
203
+ return day.to_s if SessionLedger.valid_day_id?(content)
204
+
205
+ content
206
+ end
207
+
208
+ # --- commit message ------------------------------------------------------------
209
+
210
+ # The first line of `summary`, cut at the last word boundary at or before
211
+ # MAX_SUBJECT_LENGTH characters, falling back to the hard slice when no
212
+ # boundary exists at or before the limit (spec D6, amends spec 300 D5's
213
+ # unconditional `first_line[0, MAX_SUBJECT_LENGTH]`, which cut mid-word).
214
+ # A subject at or under the limit is returned unchanged. Post-execution
215
+ # review item 7: when the character immediately after the 72-char prefix
216
+ # is ITSELF a space, the prefix already ends exactly on a word boundary and
217
+ # needs no trimming at all -- checked before consulting `rindex`, because
218
+ # an earlier internal space inside the 72-char prefix would otherwise make
219
+ # `rindex` walk back past a whole trailing word that fit perfectly.
220
+ def subject_for(summary)
221
+ first_line = summary.to_s.split(/\r?\n/, 2).first.to_s.strip
222
+ return first_line if first_line.length <= MAX_SUBJECT_LENGTH
223
+
224
+ cut = first_line[0, MAX_SUBJECT_LENGTH]
225
+ return cut if first_line[MAX_SUBJECT_LENGTH] == " "
226
+
227
+ boundary = cut.rindex(" ")
228
+ boundary ? cut[0, boundary] : cut
229
+ end
230
+
231
+ # The commit body for `summary`/`subject` (spec D6): the full summary when
232
+ # `subject` is a cut-down copy of it, nil when the subject already carries
233
+ # the summary whole (no redundant body on a short, single-line summary).
234
+ # Post-execution review item 4: compares the STRIPPED raw summary, not the
235
+ # raw summary verbatim -- a summary with only trailing/leading whitespace
236
+ # around an otherwise-identical subject must not repeat the same sentence
237
+ # twice as a redundant body.
238
+ def body_for(summary, subject)
239
+ raw = summary.to_s
240
+ raw.strip == subject ? nil : raw
241
+ end
242
+
243
+ # --- git primitives (all use -C, never cwd) -------------------------------------
244
+
245
+ # The branch HEAD points to, even on an unborn branch (review R3: a fresh
246
+ # `git init`, zero commits). `git rev-parse --abbrev-ref HEAD` FAILS on an
247
+ # unborn branch (there is no commit for it to resolve yet), which the old
248
+ # implementation misread as "cannot determine a branch" and folded into
249
+ # the detached-HEAD case. `git symbolic-ref --quiet --short HEAD` succeeds
250
+ # on both a normal AND an unborn branch (HEAD is a symbolic ref to
251
+ # `refs/heads/<name>` in both cases) and only fails when HEAD is
252
+ # genuinely detached, which is exactly the distinction this method needs.
253
+ # Returns the literal string "HEAD" for a detached HEAD (matched by
254
+ # callers below), and the real branch name otherwise.
255
+ def current_branch(repo, runner:)
256
+ sym = runner.run("-C", repo, "symbolic-ref", "--quiet", "--short", "HEAD")
257
+ return sym.stdout.to_s.strip if sym.success?
258
+
259
+ "HEAD"
260
+ end
261
+
262
+ # True iff `repo` has at least one commit. An unborn branch (current_branch
263
+ # returns a real name, but there is nothing to commit onto as a base yet)
264
+ # is otherwise indistinguishable from a normal repo until a git command
265
+ # that needs a resolvable HEAD is attempted and fails partway through.
266
+ def has_commits?(repo, runner:)
267
+ runner.run("-C", repo, "rev-parse", "--verify", "--quiet", "HEAD").success?
268
+ end
269
+
270
+ def dirty?(repo, runner:)
271
+ res = runner.run("-C", repo, "status", "--porcelain")
272
+ res.success? && !res.stdout.to_s.strip.empty?
273
+ end
274
+
275
+ def branch_exists?(repo, branch, runner:)
276
+ runner.run("-C", repo, "rev-parse", "--verify", "--quiet", branch).success?
277
+ end
278
+
279
+ # True iff `name` is a syntactically valid git branch name (review
280
+ # BLOCKER 2): a `branch_template` that renders an empty or malformed ref
281
+ # (a stray `{{ticket}}` left unpopulated in direct mode, for one) must
282
+ # never reach `git branch`/`git checkout`, whose failure this library
283
+ # already has to handle regardless, but which is cheaper and clearer to
284
+ # catch before ever attempting.
285
+ def valid_branch_name?(name, runner:)
286
+ return false if blank?(name)
287
+
288
+ runner.run("check-ref-format", "--branch", name.to_s).success?
289
+ end
290
+
291
+ # True iff `branch` is a branch an agent owns and this library must never
292
+ # touch (spec D3b): its name starts with `plastic/` (every Plastic intent
293
+ # worktree's branch, per Worktree.paths), or `repo` itself is a worktree
294
+ # checked out under `.claude/worktrees/`.
295
+ def agent_owned?(repo, branch)
296
+ return true if branch.to_s.start_with?("plastic/")
297
+
298
+ parts = File.expand_path(repo.to_s).split(File::SEPARATOR)
299
+ parts.each_cons(2).any? { |a, b| a == ".claude" && b == "worktrees" }
300
+ end
301
+
302
+ def stage_and_commit(dir, subject, runner:, body: nil)
303
+ runner.run("-C", dir, "add", "-A")
304
+ args = ["-C", dir, "commit", "-m", subject]
305
+ args += ["-m", body] if body
306
+ runner.run(*args)
307
+ end
308
+
309
+ def short_sha(dir, runner:)
310
+ res = runner.run("-C", dir, "rev-parse", "--short", "HEAD")
311
+ return nil unless res.success?
312
+
313
+ res.stdout.to_s.strip
314
+ end
315
+
316
+ # --- the branch model (spec D3, D4) ----------------------------------------------
317
+
318
+ # Resolve the repo and flow for `cwd`, apply the branch model, and return
319
+ # the one Result the caller writes to the ledger. Fail-open throughout:
320
+ # every branch of this method returns a Result, never raises, and the CLI
321
+ # always exits 0 (spec D1).
322
+ #
323
+ # The detached-HEAD, agent-lock, and unborn-repo guards run here, BEFORE
324
+ # the mode dispatch (review BLOCKER 1): they used to live only inside
325
+ # commit_direct, so `mode: pull_request` skipped them entirely and could
326
+ # switch a checkout holding another agent's uncommitted work, or commit on
327
+ # a detached HEAD. Both modes now share exactly one check of each.
328
+ def commit!(cwd:, summary:, day:, session:, plastic_home:, store: nil,
329
+ runner: Worktree::ShellRunner.new, gh_runner: GhRunner.new)
330
+ effective_store = store || File.join(plastic_home, "store")
331
+ repo = resolve_repo(cwd, runner: runner)
332
+ return note("no repo") if repo.nil?
333
+
334
+ branch_now = current_branch(repo, runner: runner)
335
+ return note("detached HEAD: no commit") if branch_now == "HEAD"
336
+ return note("left branch #{branch_now} untouched: agent lock") if agent_owned?(repo, branch_now)
337
+ return note("repository has no commits yet: no commit") unless has_commits?(repo, runner: runner)
338
+
339
+ flow, flow_notes = load_flow(cwd: cwd, repo: repo, plastic_home: plastic_home, runner: runner)
340
+ subject = subject_for(summary)
341
+ body = body_for(summary, subject)
342
+
343
+ result =
344
+ if flow["mode"] == "pull_request"
345
+ commit_pull_request(repo: repo, subject: subject, body: body, day: day, session: session,
346
+ store: effective_store, flow: flow, branch_now: branch_now,
347
+ runner: runner, gh_runner: gh_runner)
348
+ else
349
+ commit_direct(repo: repo, subject: subject, body: body, day: day, flow: flow, branch_now: branch_now,
350
+ runner: runner)
351
+ end
352
+
353
+ return result if flow_notes.empty?
354
+
355
+ # An unknown mode/workspace value, or workspace: worktree's degradation
356
+ # to checkout, is itself a degradation (spec D2, BLOCKER 3 ruling), so
357
+ # it always turns the outcome into a Note, folding both facts into the
358
+ # single savepoint line spec D7 allows.
359
+ Result.new(message: "#{flow_notes.join('; ')}; #{result.message}", event: "Note")
360
+ end
361
+
362
+ # --- direct mode (spec D3) --------------------------------------------------------
363
+
364
+ def commit_direct(repo:, subject:, day:, flow:, branch_now:, runner:, body: nil)
365
+ return note("nothing to commit") unless dirty?(repo, runner: runner)
366
+ return note("summary is empty after truncation: no commit") if blank?(subject)
367
+
368
+ base = flow["base"]
369
+ return note("no base branch detected") if blank?(base)
370
+ return note("configured base #{base} does not exist") unless branch_exists?(repo, base, runner: runner)
371
+
372
+ template = flow["branch_template"]
373
+ if template.to_s.include?("{{ticket}}") || template.to_s.include?("{{slug}}")
374
+ return note("branch_template #{template} needs {{ticket}} or {{slug}}, which direct mode " \
375
+ "leaves empty; configure a {{day}}-only template for direct mode")
376
+ end
377
+
378
+ session_branch = render_branch(template, day: day, ticket: "", slug: "")
379
+ unless valid_branch_name?(session_branch, runner: runner)
380
+ return note("rendered branch name #{session_branch.inspect} is not a valid git ref; check branch_template")
381
+ end
382
+
383
+ if branch_now == base || branch_now == session_branch
384
+ commit_on_session_branch(repo: repo, subject: subject, base: base, body: body,
385
+ session_branch: session_branch, branch_now: branch_now, runner: runner)
386
+ else
387
+ commit_on_other_branch(repo: repo, subject: subject, body: body, branch_now: branch_now, runner: runner)
388
+ end
389
+ end
390
+
391
+ # Every state-changing git call is checked (review BLOCKER 2): a
392
+ # `git branch` or `git checkout` failure (a conflicting dirty file, the
393
+ # session branch already checked out in a sibling worktree, and so on)
394
+ # used to be ignored, so staging and committing ran unconditionally in
395
+ # `repo` regardless of which branch was actually checked out afterward,
396
+ # landing the item straight on the base branch while the Note claimed the
397
+ # session branch. `current_branch` is re-read after the switch and used
398
+ # for the commit message instead of trusting the branch this method
399
+ # intended to reach.
400
+ def commit_on_session_branch(repo:, subject:, base:, session_branch:, branch_now:, runner:, body: nil)
401
+ unless branch_exists?(repo, session_branch, runner: runner)
402
+ create = runner.run("-C", repo, "branch", session_branch, base.to_s)
403
+ return note("could not create session branch #{session_branch}: #{diagnose(create)}") unless create.success?
404
+ end
405
+
406
+ if branch_now != session_branch
407
+ switch = runner.run("-C", repo, "checkout", session_branch)
408
+ return note("could not check out session branch #{session_branch}: #{diagnose(switch)}") unless switch.success?
409
+ end
410
+
411
+ actual_branch = current_branch(repo, runner: runner)
412
+ unless actual_branch == session_branch
413
+ return note("expected to be on #{session_branch} but the checkout is on #{actual_branch.inspect}")
414
+ end
415
+
416
+ commit_and_push(dir: repo, push_dir: repo, subject: subject, body: body, from: actual_branch, base: base,
417
+ runner: runner)
418
+ end
419
+
420
+ def commit_on_other_branch(repo:, subject:, branch_now:, runner:, body: nil)
421
+ res = stage_and_commit(repo, subject, runner: runner, body: body)
422
+ return note("commit rejected by commit-msg hook: #{diagnose(res)}") unless res.success?
423
+
424
+ sha = short_sha(repo, runner: runner)
425
+ note("committed #{subject} (#{sha}) on #{branch_now}, not the session branch")
426
+ end
427
+
428
+ # Stage, commit, and (attempt to) fast-forward `base` from `from` in one
429
+ # shared tail. A non-fast-forward push (spec D3, "base moved ahead
430
+ # independently") stays a Note: the commit itself already landed on the
431
+ # session branch.
432
+ def commit_and_push(dir:, push_dir:, subject:, from:, base:, runner:, body: nil)
433
+ res = stage_and_commit(dir, subject, runner: runner, body: body)
434
+ return note("commit rejected by commit-msg hook: #{diagnose(res)}") unless res.success?
435
+
436
+ sha = short_sha(dir, runner: runner)
437
+ push = runner.run("-C", push_dir, "push", ".", "#{from}:#{base}")
438
+ if push.success?
439
+ runner.run("-C", dir, "merge", "--ff-only", base.to_s)
440
+ Result.new(message: "#{subject} (#{sha})", event: "Item")
441
+ else
442
+ note("committed #{subject} (#{sha}) on #{from}, push to #{base} refused: not a fast-forward")
443
+ end
444
+ end
445
+
446
+ # --- pull request mode (spec D4) ------------------------------------------------
447
+
448
+ def commit_pull_request(repo:, subject:, day:, session:, store:, flow:, branch_now:, runner:, gh_runner:, body: nil)
449
+ return note("nothing to commit") unless dirty?(repo, runner: runner)
450
+ return note("summary is empty after truncation: no commit") if blank?(subject)
451
+
452
+ base = flow["base"]
453
+ return note("no base branch detected") if blank?(base)
454
+ return note("configured base #{base} does not exist") unless branch_exists?(repo, base, runner: runner)
455
+
456
+ ticket = resolve_ticket(day: day, store: store, session: session, ticket_source: flow["ticket_source"])
457
+ slug = summary_slug(subject)
458
+ branch = render_branch(flow["branch_template"], day: day, ticket: ticket, slug: slug)
459
+ unless valid_branch_name?(branch, runner: runner)
460
+ return note("rendered branch name #{branch.inspect} is not a valid git ref; check branch_template")
461
+ end
462
+
463
+ unless branch_exists?(repo, branch, runner: runner)
464
+ create = runner.run("-C", repo, "branch", branch, base.to_s)
465
+ return note("could not create branch #{branch}: #{diagnose(create)}") unless create.success?
466
+ end
467
+
468
+ if branch_now != branch
469
+ switch = runner.run("-C", repo, "checkout", branch)
470
+ return note("could not check out branch #{branch}: #{diagnose(switch)}") unless switch.success?
471
+ end
472
+
473
+ res = stage_and_commit(repo, subject, runner: runner, body: body)
474
+ outcome =
475
+ if res.success?
476
+ pull_request_outcome(repo: repo, subject: subject, branch: branch, base: base, gh_runner: gh_runner, runner: runner)
477
+ else
478
+ note("commit rejected by commit-msg hook: #{diagnose(res)}")
479
+ end
480
+
481
+ if branch_now != branch
482
+ runner.run("-C", repo, "checkout", branch_now)
483
+ end
484
+
485
+ outcome
486
+ end
487
+
488
+ def pull_request_outcome(repo:, subject:, branch:, base:, gh_runner:, runner:)
489
+ sha = short_sha(repo, runner: runner)
490
+ return note("gh missing: commit #{subject} (#{sha}) is on #{branch}") unless gh_runner.available?(repo)
491
+
492
+ pr = gh_runner.run("pr", "create", "--base", base.to_s, "--head", branch, "--fill", dir: repo)
493
+ if pr.success?
494
+ url = pr.stdout.to_s.strip.lines.last.to_s.strip
495
+ Result.new(message: "pr #{url}", event: "Item")
496
+ else
497
+ note("committed #{subject} (#{sha}) on #{branch}: gh pr create failed: #{diagnose(pr)}")
498
+ end
499
+ end
500
+ end