@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.
- package/PLASTIC.md +101 -294
- package/README.md +5 -6
- package/agents/plastic-advisor.md +18 -17
- package/agents/plastic-enforcer.md +57 -69
- package/agents/plastic-executor.md +34 -20
- package/agents/plastic-faux-advisor.md +18 -18
- package/config_asks.yml +38 -0
- package/hooks/capture +4 -0
- package/hooks/check-update +12 -3
- package/hooks/close +5 -0
- package/hooks/hooks.json +9 -49
- package/hooks/record +4 -0
- package/package.json +3 -2
- package/scripts/agent-report +8 -9
- package/scripts/append-ledger +195 -0
- package/scripts/codex-hook +101 -95
- package/scripts/dashboard.rb +4 -4
- package/scripts/doctor.rb +106 -43
- package/scripts/end-intent +77 -76
- package/scripts/exec-worktree +11 -20
- package/scripts/file-session-intent +61 -0
- package/scripts/hook-capture +242 -0
- package/scripts/hook-close +34 -0
- package/scripts/hook-record +149 -0
- package/scripts/hook-session-start +95 -12
- package/scripts/install.rb +8 -6
- package/scripts/lib/agent_models.rb +1 -7
- package/scripts/lib/arm.rb +283 -0
- package/scripts/lib/backfill_intent.rb +316 -0
- package/scripts/lib/bridge.rb +24 -1597
- package/scripts/lib/doctor_core.rb +17 -88
- package/scripts/lib/doctor_session_ledger.rb +106 -0
- package/scripts/lib/exec_worktree.rb +14 -60
- package/scripts/lib/harness_text.rb +5 -4
- package/scripts/lib/hook_registry.rb +44 -107
- package/scripts/lib/insights.rb +3 -4
- package/scripts/lib/installer_core.rb +19 -30
- package/scripts/lib/lock.rb +6 -26
- package/scripts/lib/outcome_guard.rb +7 -5
- package/scripts/lib/project_validator.rb +30 -1
- package/scripts/lib/roadmap_queue.rb +3 -3
- package/scripts/lib/roadmap_savepoint.rb +1 -1
- package/scripts/lib/rule_catalog.rb +6 -3
- package/scripts/lib/savepoint.rb +320 -0
- package/scripts/lib/scaffold_intent.rb +13 -179
- package/scripts/lib/session_backfill.rb +184 -0
- package/scripts/lib/session_close.rb +84 -0
- package/scripts/lib/session_git.rb +469 -0
- package/scripts/lib/session_ledger.rb +497 -0
- package/scripts/lib/store_discovery.rb +1 -1
- package/scripts/lib/store_provisioning.rb +1 -1
- package/scripts/maintenance-run +7 -7
- package/scripts/new-intent +63 -4
- package/scripts/plastic-lock +94 -41
- package/scripts/promote-session-item +150 -0
- package/scripts/rebuild-graph +1 -1
- package/scripts/roadmap-next +2 -2
- package/scripts/rollback.rb +42 -3
- package/scripts/scaffold-intent +37 -63
- package/scripts/session-commit +116 -0
- package/scripts/spawn-preamble +5 -5
- package/scripts/update.rb +7 -3
- package/skills/agent-advisor/SKILL.md +3 -3
- package/skills/agent-advisor/references/advisor-protocol.md +20 -20
- package/skills/auto/SKILL.md +218 -317
- package/skills/auto/evals/evals.json +10 -10
- package/skills/auto/references/agent-architecture.md +102 -90
- package/skills/auto/references/agent-report-contract.md +4 -21
- package/skills/auto/references/end-tail.md +47 -53
- package/skills/auto/references/human-report-contract.md +8 -8
- package/skills/conventions/SKILL.md +1 -3
- package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
- package/skills/conventions/references/locks-and-worktrees.md +47 -55
- package/skills/conventions/references/maintenance-and-revisions.md +12 -12
- package/skills/dashboard/SKILL.md +1 -1
- package/skills/direct/SKILL.md +66 -0
- package/skills/direct/references/request-signals.md +59 -0
- package/skills/doctor/SKILL.md +58 -11
- package/skills/doctor/report.md +3 -1
- package/skills/install/SKILL.md +11 -9
- package/skills/intent-continuing/SKILL.md +131 -89
- package/skills/intent-continuing/references/boarding-matrix.md +34 -0
- package/skills/intent-continuing/references/context-management.md +2 -2
- package/skills/intent-creating/SKILL.md +2 -2
- package/skills/intent-creating/references/lifecycle.md +1 -1
- package/skills/intent-ending/SKILL.md +28 -37
- package/skills/intent-ending/evals/evals.json +4 -4
- package/skills/intent-executing/SKILL.md +12 -49
- package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
- package/skills/intent-speccing/SKILL.md +108 -51
- package/skills/intent-speccing/evals/evals.json +5 -5
- package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
- package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
- package/skills/project-creating/SKILL.md +1 -1
- package/skills/releasing/SKILL.md +7 -8
- package/skills/tutorial/SKILL.md +2 -2
- package/skills/tutorial/references/track-1-guided.md +16 -18
- package/skills/tutorial/references/track-2-auto.md +12 -13
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
- package/templates/agents.md +4 -7
- package/templates/project.yml +7 -0
- package/templates/roadmap.md +1 -1
- package/templates/savepoint.md +2 -2
- package/templates/session-intent.md +29 -0
- package/agents/plastic-brainstorming.md +0 -48
- package/agents/plastic-future-intent-researcher.md +0 -35
- package/agents/plastic-intent-curator.md +0 -47
- package/agents/plastic-intent-discovery.md +0 -43
- package/agents/plastic-planner.md +0 -56
- package/agents/plastic-spec-specialist.md +0 -53
- package/hooks/auto-arm +0 -5
- package/hooks/bash-gate +0 -3
- package/hooks/continue +0 -31
- package/hooks/edit-gates +0 -3
- package/hooks/future-intent-check +0 -25
- package/hooks/gate-check +0 -12
- package/hooks/power-tools +0 -8
- package/scripts/hook-auto-arm +0 -51
- package/scripts/hook-bash-gate +0 -78
- package/scripts/hook-code-gate +0 -38
- package/scripts/hook-continue +0 -48
- package/scripts/hook-create-gate +0 -51
- package/scripts/hook-edit-gates +0 -58
- package/scripts/hook-future-intent-check +0 -90
- package/scripts/hook-gate-check +0 -169
- package/scripts/hook-links-gate +0 -54
- package/scripts/hook-lock-gate +0 -47
- package/scripts/hook-power-tools +0 -38
- package/scripts/hook-savepoint-pre +0 -32
- package/scripts/lib/codex_edit_gates.rb +0 -138
- package/scripts/lib/edit_gates.rb +0 -398
- package/scripts/lib/links_gate.rb +0 -140
- package/scripts/lib/qmd_hook.rb +0 -41
- package/scripts/lib/spec_header.rb +0 -83
- package/scripts/lib/start_intent.rb +0 -296
- package/scripts/start-intent +0 -89
- package/skills/_active-intent-gate.md +0 -26
- package/skills/auto/references/tiers.md +0 -100
- package/skills/continuing/SKILL.md +0 -34
- package/skills/continuing/evals/evals.json +0 -91
- package/skills/conventions/references/gates-and-enforcement.md +0 -53
- package/skills/conventions/references/tiers-and-dispatch.md +0 -135
- package/skills/doctor/references/gates-stuck-detection.md +0 -43
- package/skills/intent-brainstorming/SKILL.md +0 -118
- package/skills/intent-brainstorming/evals/evals.json +0 -67
- package/skills/intent-continuing/evals/evals.json +0 -145
- package/skills/intent-discovering/SKILL.md +0 -56
- package/skills/intent-grilling/SKILL.md +0 -108
- package/skills/intent-linking/SKILL.md +0 -128
- package/skills/intent-linking/evals/evals.json +0 -22
- package/skills/intent-linking/references/zettelkasten.md +0 -45
- package/skills/intent-locking/SKILL.md +0 -64
- package/skills/intent-planning/SKILL.md +0 -145
- package/skills/intent-planning/evals/evals.json +0 -81
- package/skills/intent-planning/references/plan-format.md +0 -130
- package/skills/intent-researching/SKILL.md +0 -123
- package/skills/intent-researching/evals/evals.json +0 -22
- package/skills/intent-savepoint/SKILL.md +0 -83
- package/skills/intent-savepoint/references/context-management.md +0 -32
- package/skills/intent-starting/SKILL.md +0 -151
- package/skills/intent-starting/evals/evals.json +0 -117
- package/skills/intent-starting/references/boarding-matrix.md +0 -35
- package/skills/project-continuing/SKILL.md +0 -119
- package/skills/project-continuing/evals/evals.json +0 -100
- package/skills/roadmap-continuing/SKILL.md +0 -89
- package/skills/roadmap-continuing/evals/evals.json +0 -82
- package/skills/skill-creating/SKILL.md +0 -75
- package/skills/skill-creating/evals/evals.json +0 -108
- package/skills/skill-creating/references/agents.md +0 -168
- package/skills/skill-creating/references/defaults-first.md +0 -23
- package/skills/skill-creating/references/evals.md +0 -41
- package/skills/skill-creating/references/hooks.md +0 -251
- package/skills/skill-creating/references/progressive-disclosure.md +0 -176
- package/skills/skill-creating/references/scripts.md +0 -166
- package/skills/skill-creating/references/skills.md +0 -169
- package/skills/skill-creating/scripts/scaffold.rb +0 -313
- package/skills/skill-evaluating/SKILL.md +0 -141
- package/skills/skill-evaluating/assets/eval-template.json +0 -12
- package/skills/skill-evaluating/evals/evals.json +0 -75
- package/skills/skill-evaluating/references/convention-checks.md +0 -76
- package/skills/skill-evaluating/references/eval-methodology.md +0 -154
- package/skills/store-curating/SKILL.md +0 -64
- package/skills/store-curating/evals/evals.json +0 -38
- package/skills/store-indexing/SKILL.md +0 -93
- package/skills/store-indexing/evals/evals.json +0 -22
- package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
- package/skills/store-provisioning/SKILL.md +0 -55
- /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
- /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
- /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
|