@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,469 @@
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`, truncated to MAX_SUBJECT_LENGTH characters,
211
+ # with no trailer (spec D5).
212
+ def subject_for(summary)
213
+ first_line = summary.to_s.split(/\r?\n/, 2).first.to_s.strip
214
+ first_line[0, MAX_SUBJECT_LENGTH]
215
+ end
216
+
217
+ # --- git primitives (all use -C, never cwd) -------------------------------------
218
+
219
+ # The branch HEAD points to, even on an unborn branch (review R3: a fresh
220
+ # `git init`, zero commits). `git rev-parse --abbrev-ref HEAD` FAILS on an
221
+ # unborn branch (there is no commit for it to resolve yet), which the old
222
+ # implementation misread as "cannot determine a branch" and folded into
223
+ # the detached-HEAD case. `git symbolic-ref --quiet --short HEAD` succeeds
224
+ # on both a normal AND an unborn branch (HEAD is a symbolic ref to
225
+ # `refs/heads/<name>` in both cases) and only fails when HEAD is
226
+ # genuinely detached, which is exactly the distinction this method needs.
227
+ # Returns the literal string "HEAD" for a detached HEAD (matched by
228
+ # callers below), and the real branch name otherwise.
229
+ def current_branch(repo, runner:)
230
+ sym = runner.run("-C", repo, "symbolic-ref", "--quiet", "--short", "HEAD")
231
+ return sym.stdout.to_s.strip if sym.success?
232
+
233
+ "HEAD"
234
+ end
235
+
236
+ # True iff `repo` has at least one commit. An unborn branch (current_branch
237
+ # returns a real name, but there is nothing to commit onto as a base yet)
238
+ # is otherwise indistinguishable from a normal repo until a git command
239
+ # that needs a resolvable HEAD is attempted and fails partway through.
240
+ def has_commits?(repo, runner:)
241
+ runner.run("-C", repo, "rev-parse", "--verify", "--quiet", "HEAD").success?
242
+ end
243
+
244
+ def dirty?(repo, runner:)
245
+ res = runner.run("-C", repo, "status", "--porcelain")
246
+ res.success? && !res.stdout.to_s.strip.empty?
247
+ end
248
+
249
+ def branch_exists?(repo, branch, runner:)
250
+ runner.run("-C", repo, "rev-parse", "--verify", "--quiet", branch).success?
251
+ end
252
+
253
+ # True iff `name` is a syntactically valid git branch name (review
254
+ # BLOCKER 2): a `branch_template` that renders an empty or malformed ref
255
+ # (a stray `{{ticket}}` left unpopulated in direct mode, for one) must
256
+ # never reach `git branch`/`git checkout`, whose failure this library
257
+ # already has to handle regardless, but which is cheaper and clearer to
258
+ # catch before ever attempting.
259
+ def valid_branch_name?(name, runner:)
260
+ return false if blank?(name)
261
+
262
+ runner.run("check-ref-format", "--branch", name.to_s).success?
263
+ end
264
+
265
+ # True iff `branch` is a branch an agent owns and this library must never
266
+ # touch (spec D3b): its name starts with `plastic/` (every Plastic intent
267
+ # worktree's branch, per Worktree.paths), or `repo` itself is a worktree
268
+ # checked out under `.claude/worktrees/`.
269
+ def agent_owned?(repo, branch)
270
+ return true if branch.to_s.start_with?("plastic/")
271
+
272
+ parts = File.expand_path(repo.to_s).split(File::SEPARATOR)
273
+ parts.each_cons(2).any? { |a, b| a == ".claude" && b == "worktrees" }
274
+ end
275
+
276
+ def stage_and_commit(dir, subject, runner:)
277
+ runner.run("-C", dir, "add", "-A")
278
+ runner.run("-C", dir, "commit", "-m", subject)
279
+ end
280
+
281
+ def short_sha(dir, runner:)
282
+ res = runner.run("-C", dir, "rev-parse", "--short", "HEAD")
283
+ return nil unless res.success?
284
+
285
+ res.stdout.to_s.strip
286
+ end
287
+
288
+ # --- the branch model (spec D3, D4) ----------------------------------------------
289
+
290
+ # Resolve the repo and flow for `cwd`, apply the branch model, and return
291
+ # the one Result the caller writes to the ledger. Fail-open throughout:
292
+ # every branch of this method returns a Result, never raises, and the CLI
293
+ # always exits 0 (spec D1).
294
+ #
295
+ # The detached-HEAD, agent-lock, and unborn-repo guards run here, BEFORE
296
+ # the mode dispatch (review BLOCKER 1): they used to live only inside
297
+ # commit_direct, so `mode: pull_request` skipped them entirely and could
298
+ # switch a checkout holding another agent's uncommitted work, or commit on
299
+ # a detached HEAD. Both modes now share exactly one check of each.
300
+ def commit!(cwd:, summary:, day:, session:, plastic_home:, store: nil,
301
+ runner: Worktree::ShellRunner.new, gh_runner: GhRunner.new)
302
+ effective_store = store || File.join(plastic_home, "store")
303
+ repo = resolve_repo(cwd, runner: runner)
304
+ return note("no repo") if repo.nil?
305
+
306
+ branch_now = current_branch(repo, runner: runner)
307
+ return note("detached HEAD: no commit") if branch_now == "HEAD"
308
+ return note("left branch #{branch_now} untouched: agent lock") if agent_owned?(repo, branch_now)
309
+ return note("repository has no commits yet: no commit") unless has_commits?(repo, runner: runner)
310
+
311
+ flow, flow_notes = load_flow(cwd: cwd, repo: repo, plastic_home: plastic_home, runner: runner)
312
+ subject = subject_for(summary)
313
+
314
+ result =
315
+ if flow["mode"] == "pull_request"
316
+ commit_pull_request(repo: repo, subject: subject, day: day, session: session,
317
+ store: effective_store, flow: flow, branch_now: branch_now,
318
+ runner: runner, gh_runner: gh_runner)
319
+ else
320
+ commit_direct(repo: repo, subject: subject, day: day, flow: flow, branch_now: branch_now, runner: runner)
321
+ end
322
+
323
+ return result if flow_notes.empty?
324
+
325
+ # An unknown mode/workspace value, or workspace: worktree's degradation
326
+ # to checkout, is itself a degradation (spec D2, BLOCKER 3 ruling), so
327
+ # it always turns the outcome into a Note, folding both facts into the
328
+ # single savepoint line spec D7 allows.
329
+ Result.new(message: "#{flow_notes.join('; ')}; #{result.message}", event: "Note")
330
+ end
331
+
332
+ # --- direct mode (spec D3) --------------------------------------------------------
333
+
334
+ def commit_direct(repo:, subject:, day:, flow:, branch_now:, runner:)
335
+ return note("nothing to commit") unless dirty?(repo, runner: runner)
336
+ return note("summary is empty after truncation: no commit") if blank?(subject)
337
+
338
+ base = flow["base"]
339
+ return note("no base branch detected") if blank?(base)
340
+ return note("configured base #{base} does not exist") unless branch_exists?(repo, base, runner: runner)
341
+
342
+ template = flow["branch_template"]
343
+ if template.to_s.include?("{{ticket}}") || template.to_s.include?("{{slug}}")
344
+ return note("branch_template #{template} needs {{ticket}} or {{slug}}, which direct mode " \
345
+ "leaves empty; configure a {{day}}-only template for direct mode")
346
+ end
347
+
348
+ session_branch = render_branch(template, day: day, ticket: "", slug: "")
349
+ unless valid_branch_name?(session_branch, runner: runner)
350
+ return note("rendered branch name #{session_branch.inspect} is not a valid git ref; check branch_template")
351
+ end
352
+
353
+ if branch_now == base || branch_now == session_branch
354
+ commit_on_session_branch(repo: repo, subject: subject, base: base,
355
+ session_branch: session_branch, branch_now: branch_now, runner: runner)
356
+ else
357
+ commit_on_other_branch(repo: repo, subject: subject, branch_now: branch_now, runner: runner)
358
+ end
359
+ end
360
+
361
+ # Every state-changing git call is checked (review BLOCKER 2): a
362
+ # `git branch` or `git checkout` failure (a conflicting dirty file, the
363
+ # session branch already checked out in a sibling worktree, and so on)
364
+ # used to be ignored, so staging and committing ran unconditionally in
365
+ # `repo` regardless of which branch was actually checked out afterward,
366
+ # landing the item straight on the base branch while the Note claimed the
367
+ # session branch. `current_branch` is re-read after the switch and used
368
+ # for the commit message instead of trusting the branch this method
369
+ # intended to reach.
370
+ def commit_on_session_branch(repo:, subject:, base:, session_branch:, branch_now:, runner:)
371
+ unless branch_exists?(repo, session_branch, runner: runner)
372
+ create = runner.run("-C", repo, "branch", session_branch, base.to_s)
373
+ return note("could not create session branch #{session_branch}: #{diagnose(create)}") unless create.success?
374
+ end
375
+
376
+ if branch_now != session_branch
377
+ switch = runner.run("-C", repo, "checkout", session_branch)
378
+ return note("could not check out session branch #{session_branch}: #{diagnose(switch)}") unless switch.success?
379
+ end
380
+
381
+ actual_branch = current_branch(repo, runner: runner)
382
+ unless actual_branch == session_branch
383
+ return note("expected to be on #{session_branch} but the checkout is on #{actual_branch.inspect}")
384
+ end
385
+
386
+ commit_and_push(dir: repo, push_dir: repo, subject: subject, from: actual_branch, base: base, runner: runner)
387
+ end
388
+
389
+ def commit_on_other_branch(repo:, subject:, branch_now:, runner:)
390
+ res = stage_and_commit(repo, subject, runner: runner)
391
+ return note("commit rejected by commit-msg hook: #{diagnose(res)}") unless res.success?
392
+
393
+ sha = short_sha(repo, runner: runner)
394
+ note("committed #{subject} (#{sha}) on #{branch_now}, not the session branch")
395
+ end
396
+
397
+ # Stage, commit, and (attempt to) fast-forward `base` from `from` in one
398
+ # shared tail. A non-fast-forward push (spec D3, "base moved ahead
399
+ # independently") stays a Note: the commit itself already landed on the
400
+ # session branch.
401
+ def commit_and_push(dir:, push_dir:, subject:, from:, base:, runner:)
402
+ res = stage_and_commit(dir, subject, runner: runner)
403
+ return note("commit rejected by commit-msg hook: #{diagnose(res)}") unless res.success?
404
+
405
+ sha = short_sha(dir, runner: runner)
406
+ push = runner.run("-C", push_dir, "push", ".", "#{from}:#{base}")
407
+ if push.success?
408
+ runner.run("-C", dir, "merge", "--ff-only", base.to_s)
409
+ Result.new(message: "#{subject} (#{sha})", event: "Item")
410
+ else
411
+ note("committed #{subject} (#{sha}) on #{from}, push to #{base} refused: not a fast-forward")
412
+ end
413
+ end
414
+
415
+ # --- pull request mode (spec D4) ------------------------------------------------
416
+
417
+ def commit_pull_request(repo:, subject:, day:, session:, store:, flow:, branch_now:, runner:, gh_runner:)
418
+ return note("nothing to commit") unless dirty?(repo, runner: runner)
419
+ return note("summary is empty after truncation: no commit") if blank?(subject)
420
+
421
+ base = flow["base"]
422
+ return note("no base branch detected") if blank?(base)
423
+ return note("configured base #{base} does not exist") unless branch_exists?(repo, base, runner: runner)
424
+
425
+ ticket = resolve_ticket(day: day, store: store, session: session, ticket_source: flow["ticket_source"])
426
+ slug = summary_slug(subject)
427
+ branch = render_branch(flow["branch_template"], day: day, ticket: ticket, slug: slug)
428
+ unless valid_branch_name?(branch, runner: runner)
429
+ return note("rendered branch name #{branch.inspect} is not a valid git ref; check branch_template")
430
+ end
431
+
432
+ unless branch_exists?(repo, branch, runner: runner)
433
+ create = runner.run("-C", repo, "branch", branch, base.to_s)
434
+ return note("could not create branch #{branch}: #{diagnose(create)}") unless create.success?
435
+ end
436
+
437
+ if branch_now != branch
438
+ switch = runner.run("-C", repo, "checkout", branch)
439
+ return note("could not check out branch #{branch}: #{diagnose(switch)}") unless switch.success?
440
+ end
441
+
442
+ res = stage_and_commit(repo, subject, runner: runner)
443
+ outcome =
444
+ if res.success?
445
+ pull_request_outcome(repo: repo, subject: subject, branch: branch, base: base, gh_runner: gh_runner, runner: runner)
446
+ else
447
+ note("commit rejected by commit-msg hook: #{diagnose(res)}")
448
+ end
449
+
450
+ if branch_now != branch
451
+ runner.run("-C", repo, "checkout", branch_now)
452
+ end
453
+
454
+ outcome
455
+ end
456
+
457
+ def pull_request_outcome(repo:, subject:, branch:, base:, gh_runner:, runner:)
458
+ sha = short_sha(repo, runner: runner)
459
+ return note("gh missing: commit #{subject} (#{sha}) is on #{branch}") unless gh_runner.available?(repo)
460
+
461
+ pr = gh_runner.run("pr", "create", "--base", base.to_s, "--head", branch, "--fill", dir: repo)
462
+ if pr.success?
463
+ url = pr.stdout.to_s.strip.lines.last.to_s.strip
464
+ Result.new(message: "pr #{url}", event: "Item")
465
+ else
466
+ note("committed #{subject} (#{sha}) on #{branch}: gh pr create failed: #{diagnose(pr)}")
467
+ end
468
+ end
469
+ end