carson 3.28.0 → 3.29.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a58a0b99b9cd8c7e241c6278f9b72fd4a6188cf45a51f22a5cb88bb6a83a703a
4
- data.tar.gz: 0203fbacb58d17d33f58e90f4b868ad7065c086d0fb0963100eb0cac7f4d5f51
3
+ metadata.gz: bdb70b55b0dddf63dd2e96b68d060472e3abfd10ae6c55010df2d43ad766c7b8
4
+ data.tar.gz: 122ef3005263475ce82fce1177037ddd4f017662a5a74bbee16ada7a464e0fc6
5
5
  SHA512:
6
- metadata.gz: 356111e22c7bf14aac077ebc02b80554713c1228dc7ab36688abdd8fb199e40e9c9965f1da0398e2736545b1bf9ffb38dee11f36081f8d781acc7c23e11d506e
7
- data.tar.gz: 2476eae596465a0f3b7256646a7ed8364c709ed6279a3183da6ea4636dc05129cb7168d9e6dc2eee701ff4f05d56ec13e94a0c86719b0ec0dc82ce967548ab1c
6
+ metadata.gz: f526c974bf821f31a025e5a7c2a09644d3ef814c04f62a5d7021c0c6d102a9a883320c16ab20ab559159a95eee0198a26dfdec37c5684fd09936a13cbd7d081e
7
+ data.tar.gz: 6e7b04be1bf3a96d607599af821f38b4fb9cbaadea9c54f69407bf5d5d90bd4f10b46eec1e9254b4bbc6f1fa0c3e3bd673e9be8343a3b6e4006fe3a3291b8913
@@ -27,13 +27,13 @@ jobs:
27
27
 
28
28
  steps:
29
29
  - name: Checkout host repository
30
- uses: actions/checkout@v4
30
+ uses: actions/checkout@v6
31
31
  with:
32
32
  path: host
33
33
  fetch-depth: 0
34
34
 
35
35
  - name: Checkout Carson runtime
36
- uses: actions/checkout@v4
36
+ uses: actions/checkout@v6
37
37
  with:
38
38
  repository: wanghailei/carson
39
39
  ref: ${{ inputs.carson_ref }}
data/API.md CHANGED
@@ -52,7 +52,7 @@ All batch commands operate across every governed repository registered in `gover
52
52
  | `carson template check --all` | Read-only template drift detection across all governed repos. |
53
53
  | `carson housekeep --all [--loop SECONDS]` | Attempt sync, then reap worktrees with strong abandonment evidence, reconcile integrated delivery worktree records from the ledger, and prune across all governed repos. Safe cleanup still runs when sync is blocked. |
54
54
 
55
- `--loop SECONDS` runs the housekeep cycle continuously, sleeping SECONDS between cycles. It requires `--all`, accepts only positive integers, and exits cleanly on `Ctrl-C` with a cycle count summary.
55
+ `--loop SECONDS` runs the housekeep cycle continuously, sleeping SECONDS between cycles. It requires `--all`, accepts only positive integers, and exits cleanly on `Ctrl-C` or `SIGTERM` with a cycle count summary.
56
56
 
57
57
  ### Govern commands
58
58
 
@@ -60,7 +60,7 @@ All batch commands operate across every governed repository registered in `gover
60
60
  |---|---|
61
61
  | `carson govern [--dry-run] [--json] [--loop SECONDS]` | Portfolio-level delivery oversight: assess active deliveries, integrate ready branches, dispatch revisions, and escalate blocked work. Live integrated rows include merge proof. |
62
62
 
63
- `--loop SECONDS` runs the govern cycle continuously, sleeping SECONDS between cycles. The loop isolates errors per cycle — a single failing cycle does not stop the daemon. `Ctrl-C` cleanly exits with a cycle count summary. SECONDS must be a positive integer.
63
+ `--loop SECONDS` runs the govern cycle continuously, sleeping SECONDS between cycles. The loop isolates errors per cycle — a single failing cycle does not stop the daemon. `Ctrl-C` or `SIGTERM` cleanly exits with a cycle count summary. SECONDS must be a positive integer.
64
64
 
65
65
  Governed integration is fixed to `squash`. Non-squash `govern.merge.method` values are rejected by config validation.
66
66
 
data/MANUAL.md CHANGED
@@ -66,12 +66,12 @@ on:
66
66
 
67
67
  jobs:
68
68
  governance:
69
- uses: wanghailei/carson/.github/workflows/carson_policy.yml@v3.28.0
69
+ uses: wanghailei/carson/.github/workflows/carson_policy.yml@v3.29.0
70
70
  secrets:
71
71
  CARSON_READ_TOKEN: ${{ secrets.CARSON_READ_TOKEN }}
72
72
  with:
73
- carson_ref: "v3.28.0"
74
- carson_version: "3.28.0"
73
+ carson_ref: "v3.29.0"
74
+ carson_version: "3.29.0"
75
75
  rubocop_version: "1.81.0"
76
76
  ```
77
77
 
@@ -209,11 +209,13 @@ carson abandon feature/stale-work
209
209
 
210
210
  `abandon` closes the PR when it is still open, removes the matching worktree when safe, deletes the local and remote branch refs when allowed, and marks the delivery as failed in Carson's ledger.
211
211
 
212
- **Safety guards** `worktree remove` blocks when:
212
+ `abandon` is an intentional discard: committed-but-unpushed branch work does not block abandonment. Typing `carson abandon` is explicit consent to discard committed work on that branch.
213
+
214
+ **Safety guards** — `abandon` blocks when:
213
215
  - Shell CWD is inside the worktree (prevents session crash).
214
- - Branch has unpushed commits with content that differs from main (prevents data loss).
216
+ - Worktree has uncommitted changes (prevents accidental loss of unsaved work).
215
217
 
216
- After squash or rebase merge, the content matches main removal proceeds without `--force`.
218
+ Committed-but-unpushed work is treated as intentional discard`abandon` proceeds. The `worktree remove` command retains its own unpushed-commit guard and `--force` override for manual cleanup outside the abandon flow.
217
219
 
218
220
  **Stale worktree recovery** — if a worktree directory is destroyed externally (for example by a raw GitHub merge/delete flow), `worktree remove`, `worktree list`, `housekeep`, and `prune` handle the stale entry gracefully: they clean up the git registration and delete the branch without error when Carson has enough evidence. Use Carson's delivery and cleanup commands instead of raw `gh pr merge --delete-branch` so the worktree directory stays intact for orderly cleanup.
219
221
 
@@ -299,7 +301,7 @@ carson housekeep --all --loop 300 # housekeep every 5 minutes
299
301
 
300
302
  `refresh --all` checks each repo for safety before operating: repos with active worktrees or uncommitted changes are skipped with clear reasons. Other batch commands attempt each repo and report failures without stopping.
301
303
 
302
- `housekeep --all --loop SECONDS` runs the full housekeep cycle continuously, sleeping SECONDS between passes. It requires `--all`, accepts only positive integers, and exits cleanly on `Ctrl-C` with a cycle count summary.
304
+ `housekeep --all --loop SECONDS` runs the full housekeep cycle continuously, sleeping SECONDS between passes. It requires `--all`, accepts only positive integers, and exits cleanly on `Ctrl-C` or `SIGTERM` with a cycle count summary.
303
305
 
304
306
  **Periodic maintenance:**
305
307
 
@@ -319,7 +321,7 @@ carson govern --loop 300 --dry-run # observe mode, no integration or revision
319
321
 
320
322
  The loop is built-in and cross-platform — no cron, launchd, or Task Scheduler required. Run it in a terminal, tmux, screen, or as a system service.
321
323
 
322
- Each cycle runs independently: if one cycle fails (network error, GitHub API timeout), the error is logged and the next cycle proceeds normally. Press `Ctrl-C` to stop — Carson exits cleanly with a cycle count summary.
324
+ Each cycle runs independently: if one cycle fails (network error, GitHub API timeout), the error is logged and the next cycle proceeds normally. Press `Ctrl-C` or send `SIGTERM` to stop — Carson exits cleanly with a cycle count summary.
323
325
 
324
326
  ### Govern and Coding Agents
325
327
 
data/RELEASE.md CHANGED
@@ -5,6 +5,29 @@ Release-note scope rule:
5
5
  - `RELEASE.md` records only version deltas, breaking changes, and migration actions.
6
6
  - Operational usage guides live in `MANUAL.md` and `API.md`.
7
7
 
8
+ ## 3.29.1
9
+
10
+ ### What changed
11
+
12
+ - **Delivery output now shows the remote target explicitly** — `Delivery: branch → github/main` instead of the ambiguous `→ main`, making it clear the target is the remote branch, not local.
13
+
14
+ ### No migration required
15
+
16
+ - Existing workflows continue to work unchanged.
17
+
18
+ ## 3.29.0
19
+
20
+ ### What changed
21
+
22
+ - **Govern and housekeep loops are clearer and safer to run unattended** — `carson govern --loop` now prints per-delivery progress hints and a sleep announcement with the next-cycle timestamp, and both `govern --loop` and `housekeep --all --loop` now stop cleanly on `SIGTERM` as well as `Ctrl-C`.
23
+ - **Govern and worktree creation no longer mutate the user's main worktree behind their back** — govern now fetches instead of syncing/pruning after merges, worktree creation branches from the remote tracking ref instead of pulling on main, and revision dispatch now defers when the target worktree is busy or dirty.
24
+ - **Abandon guidance now matches the actual safety contract** — `carson abandon` no longer suggests an unsupported `--force`, and committed-but-unpushed branch work no longer blocks abandonment; only dirty worktree changes do.
25
+ - **CI and release workflows are hardened for the Node 24 transition** — GitHub Actions checkouts now use `actions/checkout@v6`, Carson includes a dedicated forced-Node-24 probe for the RubyGems credentials action before changing the release path, and the standalone review smoke script now seeds its temporary `main` branch without tripping the shared main-branch commit guard.
26
+
27
+ ### No migration required
28
+
29
+ - Existing workflows continue to work unchanged.
30
+
8
31
  ## 3.28.0
9
32
 
10
33
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.28.0
1
+ 3.29.1
data/carson.gemspec CHANGED
@@ -37,7 +37,6 @@ Gem::Specification.new do |spec|
37
37
  "RELEASE.md",
38
38
  "VERSION",
39
39
  "LICENSE",
40
- "SKILL.md",
41
40
  "icon.svg",
42
41
  "carson.gemspec"
43
42
  ]
data/lib/carson/ledger.rb CHANGED
@@ -274,6 +274,11 @@ module Carson
274
274
  end
275
275
 
276
276
  def migrate_legacy_state_if_needed!
277
+ # Skip lock acquisition entirely when no legacy SQLite file exists.
278
+ # Read-only file checks are safe without the lock; the migration
279
+ # itself is idempotent so a narrow race is harmless.
280
+ return unless state_path_requires_migration?
281
+
277
282
  with_state_lock do |lock_file|
278
283
  lock_file.flock( File::LOCK_EX )
279
284
  source_path = legacy_sqlite_source_path
@@ -44,7 +44,7 @@ module Carson
44
44
 
45
45
  if worktree
46
46
  remove_exit = with_captured_output do
47
- worktree_remove!( worktree_path: worktree.path, json_output: false )
47
+ worktree_remove!( worktree_path: worktree.path, skip_unpushed: true, json_output: false )
48
48
  end
49
49
  unless remove_exit == EXIT_OK
50
50
  result[ :error ] = "worktree cleanup failed for #{worktree.path}"
@@ -131,29 +131,32 @@ module Carson
131
131
  nil
132
132
  end
133
133
 
134
+ # Abandon is an intentional discard: committed-but-unpushed work
135
+ # does not block abandonment. Only uncommitted (dirty) worktree
136
+ # changes block, because those may be accidental.
134
137
  def abandon_preflight_issue( branch:, worktree: )
135
138
  if config.protected_branches.include?( branch )
136
139
  return { exit_code: EXIT_BLOCK, error: "cannot abandon protected branch #{branch}", recovery: "choose a feature branch instead" }
137
140
  end
138
141
 
139
142
  if worktree
140
- check = Worktree.remove_check( path: worktree.path, runtime: self, force: false )
143
+ check = Worktree.remove_check( path: worktree.path, runtime: self, force: false, skip_unpushed: true )
141
144
  return nil if check.fetch( :status ) == :ok
142
145
 
146
+ recovery = check.fetch( :recovery )
147
+ if check.fetch( :error ) == "worktree has uncommitted changes"
148
+ recovery = "commit or discard the changes, then retry carson abandon #{branch}"
149
+ end
150
+
143
151
  return {
144
152
  exit_code: check.fetch( :exit_code ),
145
153
  error: check.fetch( :error ),
146
- recovery: check.fetch( :recovery )
154
+ recovery: recovery
147
155
  }
148
156
  end
149
157
 
150
158
  return { exit_code: EXIT_BLOCK, error: "current branch is #{branch}", recovery: "switch to main or a different branch, then retry" } if current_branch == branch
151
- return nil unless local_branch_exists?( branch: branch )
152
-
153
- unpushed = Worktree.branch_unpushed_issue( branch: branch, worktree_path: repo_root, runtime: self )
154
- return nil if unpushed.nil?
155
-
156
- { exit_code: EXIT_BLOCK, error: unpushed.fetch( :error ), recovery: unpushed.fetch( :recovery ) }
159
+ nil
157
160
  end
158
161
 
159
162
  def close_pull_request!( number:, result: )
@@ -16,6 +16,7 @@ module Carson
16
16
  result = {
17
17
  command: "deliver",
18
18
  branch: branch_name,
19
+ git_remote: remote_name,
19
20
  watch_window_seconds: config.govern_check_wait.to_i,
20
21
  waited_seconds: 0,
21
22
  merge_attempted: false
@@ -948,8 +949,10 @@ module Carson
948
949
 
949
950
  if result[ :delivery ]
950
951
  branch = result[ :branch ]
952
+ remote = result[ :git_remote ] || "github"
951
953
  main = result[ :main_branch ] || "main"
952
- puts_line "Delivery: #{branch} → #{main}"
954
+ remote_main = "#{remote}/#{main}"
955
+ puts_line "Delivery: #{branch} → #{remote_main}"
953
956
  end
954
957
  if result[ :commit ]
955
958
  puts_line "Committed: #{result.dig( :commit, :summary )}"
@@ -961,9 +964,9 @@ module Carson
961
964
  summary = result[ :summary ]
962
965
  if outcome == "integrated" || status == "integrated"
963
966
  if result[ :merge_method ]
964
- puts_line "Merged into #{main} with #{result[ :merge_method ]}."
967
+ puts_line "Merged into #{remote_main} with #{result[ :merge_method ]}."
965
968
  else
966
- puts_line "Merged into #{main}."
969
+ puts_line "Merged into #{remote_main}."
967
970
  end
968
971
  if result[ :synced ] == false
969
972
  puts_line "Local #{main} sync failed — #{result[ :sync_error ]}."
@@ -39,17 +39,17 @@ module Carson
39
39
  end
40
40
 
41
41
  def govern_loop!( dry_run:, json_output:, loop_seconds: )
42
- cycle_count = 0
43
- loop do
44
- cycle_count += 1
45
- puts_line ""
46
- puts_line "cycle #{cycle_count} at #{Time.now.utc.strftime( '%Y-%m-%d %H:%M:%S UTC' )}"
42
+ run_signal_aware_loop!(
43
+ loop_name: "govern",
44
+ loop_seconds: loop_seconds,
45
+ cycle_line: ->( cycle_count ) { "cycle #{cycle_count} at #{Time.now.utc.strftime( '%Y-%m-%d %H:%M:%S UTC' )}" },
46
+ sleep_line: ->( seconds ) do
47
+ next_at = Time.now + seconds
48
+ "sleeping #{seconds}s — next cycle at #{next_at.strftime( '%Y-%m-%d %H:%M:%S %z' )}"
49
+ end
50
+ ) do
47
51
  govern_cycle!( dry_run: dry_run, json_output: json_output )
48
- sleep loop_seconds
49
52
  end
50
- rescue Interrupt
51
- puts_line "govern loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}"
52
- EXIT_OK
53
53
  end
54
54
 
55
55
  private
@@ -85,6 +85,8 @@ module Carson
85
85
  next_to_integrate = reconciled.find( &:ready? )&.key
86
86
 
87
87
  reconciled.each do |delivery|
88
+ hint = delivery_action_hint( delivery: delivery, next_to_integrate: next_to_integrate, dry_run: dry_run )
89
+ puts_line " #{delivery.branch} — #{hint}" if hint && !silent
88
90
  delivery_report = scoped_runtime.send(
89
91
  :decide_delivery_action,
90
92
  delivery: delivery,
@@ -246,15 +248,10 @@ module Carson
246
248
  pull_request_draft: false,
247
249
  pull_request_merged_at: Time.now.utc.iso8601
248
250
  )
249
- housekeep_result = housekeep_repo!( repo_path: repo_path )
250
- proof = if housekeep_result.is_a?( Hash ) && housekeep_result[ :sync_status ] != "ok"
251
- merge_proof_unavailable(
252
- main_ref: config.main_branch,
253
- summary: "proof unavailable — local #{config.main_branch} sync did not complete."
254
- )
255
- else
256
- merge_proof_for_branch( branch: integrated.branch, main_ref: config.main_branch )
257
- end
251
+ # Fetch-only: update the remote tracking ref without mutating the
252
+ # main worktree. Reap and prune are deferred to explicit housekeep.
253
+ fetch_for_merge_proof!( repo_path: repo_path )
254
+ proof = merge_proof_for_remote_ref( branch: integrated.branch )
258
255
  ledger.update_delivery(
259
256
  delivery: integrated,
260
257
  merge_proof: proof
@@ -274,6 +271,27 @@ module Carson
274
271
  return escalate_delivery!( delivery: delivery, reason: "no agent provider available" ) if provider.nil?
275
272
  return escalate_delivery!( delivery: delivery, reason: "worktree missing for revision" ) unless File.directory?( delivery.worktree_path.to_s )
276
273
 
274
+ # Defer if the target worktree is occupied — temporary hold, not failure.
275
+ worktree = Carson::Worktree.find( path: delivery.worktree_path.to_s, runtime: self )
276
+ if worktree
277
+ if worktree.held_by_other_process?
278
+ return ledger.update_delivery(
279
+ delivery: delivery,
280
+ status: "gated",
281
+ cause: "busy",
282
+ summary: "worktree held by another process — deferring revision"
283
+ )
284
+ end
285
+ if worktree.dirty?
286
+ return ledger.update_delivery(
287
+ delivery: delivery,
288
+ status: "gated",
289
+ cause: "busy",
290
+ summary: "worktree has uncommitted changes — deferring revision"
291
+ )
292
+ end
293
+ end
294
+
277
295
  objective = revision_objective( cause: delivery.cause )
278
296
  context = evidence( delivery: delivery, repo_path: repo_path, objective: objective )
279
297
  work_order = Adapters::Agent::WorkOrder.new(
@@ -340,7 +358,16 @@ module Carson
340
358
  end
341
359
 
342
360
  def held_delivery?( delivery: )
343
- [ "merge", "freshness" ].include?( delivery.cause )
361
+ [ "merge", "freshness", "busy" ].include?( delivery.cause )
362
+ end
363
+
364
+ def delivery_action_hint( delivery:, next_to_integrate:, dry_run: )
365
+ return nil if dry_run
366
+ return nil if delivery.superseded? || delivery.integrated? || delivery.failed?
367
+ return "integrating…" if delivery.ready? && delivery.key == next_to_integrate
368
+ return nil unless delivery.blocked?
369
+ return nil if held_delivery?( delivery: delivery )
370
+ delivery.revision_count >= 3 ? "escalating…" : "revising…"
344
371
  end
345
372
 
346
373
  def housekeep_repo!( repo_path: )
@@ -348,6 +375,15 @@ module Carson
348
375
  scoped_runtime.send( :housekeep_one_entry, repo_path: repo_path, silent: true )
349
376
  end
350
377
 
378
+ # Fetch-only helper for post-merge proof generation.
379
+ # Updates the remote tracking ref without mutating the main worktree.
380
+ def fetch_for_merge_proof!( repo_path: )
381
+ scoped = repo_runtime_for( repo_path: repo_path )
382
+ scoped.send( :git_run, "fetch", scoped.config.git_remote, "--prune" )
383
+ rescue StandardError
384
+ # Best-effort — merge proof falls back to unavailable if fetch fails.
385
+ end
386
+
351
387
  def select_agent_provider
352
388
  provider = config.govern_agent_provider
353
389
  case provider
@@ -81,17 +81,13 @@ module Carson
81
81
  end
82
82
 
83
83
  def housekeep_loop!( json_output:, dry_run:, loop_seconds: )
84
- cycle_count = 0
85
- loop do
86
- cycle_count += 1
87
- puts_line ""
88
- puts_line "housekeep cycle #{cycle_count} at #{Time.now.utc.strftime( '%Y-%m-%d %H:%M:%S UTC' )}"
84
+ run_signal_aware_loop!(
85
+ loop_name: "housekeep",
86
+ loop_seconds: loop_seconds,
87
+ cycle_line: ->( cycle_count ) { "housekeep cycle #{cycle_count} at #{Time.now.utc.strftime( '%Y-%m-%d %H:%M:%S UTC' )}" }
88
+ ) do
89
89
  housekeep_all!( json_output: json_output, dry_run: dry_run )
90
- sleep loop_seconds
91
90
  end
92
- rescue Interrupt
93
- puts_line "housekeep loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}"
94
- EXIT_OK
95
91
  end
96
92
 
97
93
  # Prints a dry-run plan for this repo without making any changes.
@@ -2,6 +2,24 @@
2
2
  module Carson
3
3
  class Runtime
4
4
  module Local
5
+ # Generates merge proof against the remote tracking ref directly.
6
+ # Skips the local-main trust check — the caller is responsible for
7
+ # fetching before calling. Used by govern's post-merge path to avoid
8
+ # mutating the main worktree.
9
+ def merge_proof_for_remote_ref( branch:, remote: config.git_remote, main_ref: config.main_branch )
10
+ remote_ref = "#{remote}/#{main_ref}"
11
+ return merge_proof_not_applicable( main_ref: main_ref ) if branch.to_s == main_ref.to_s
12
+
13
+ candidate = merge_proof_candidate( branch: branch, main_ref: remote_ref )
14
+ return candidate if candidate.fetch( :basis ) == "unavailable"
15
+
16
+ # Normalise display: show the local branch name, not the remote tracking ref.
17
+ candidate.merge(
18
+ main_branch: main_ref,
19
+ summary: candidate.fetch( :summary ).gsub( remote_ref, main_ref )
20
+ )
21
+ end
22
+
5
23
  def merge_proof_for_branch( branch:, main_ref: config.main_branch )
6
24
  return merge_proof_not_applicable( main_ref: main_ref ) if branch.to_s == main_ref.to_s
7
25
 
@@ -14,8 +14,8 @@ module Carson
14
14
  end
15
15
 
16
16
  # Removes a worktree: directory, git registration, and branch.
17
- def worktree_remove!( worktree_path:, force: false, json_output: false )
18
- Worktree.remove!( path: worktree_path, runtime: self, force: force, json_output: json_output )
17
+ def worktree_remove!( worktree_path:, force: false, skip_unpushed: false, json_output: false )
18
+ Worktree.remove!( path: worktree_path, runtime: self, force: force, skip_unpushed: skip_unpushed, json_output: json_output )
19
19
  end
20
20
 
21
21
  # Removes agent-owned worktrees whose branch content is already on main.
@@ -0,0 +1,90 @@
1
+ # Shared loop runner for commands that poll on a schedule.
2
+ module Carson
3
+ class Runtime
4
+ module LoopRunner
5
+ LOOP_STOP_SIGNALS = %w[INT TERM].freeze
6
+ LOOP_SLEEP_SLICE_SECONDS = 0.25
7
+
8
+ private
9
+
10
+ def run_signal_aware_loop!( loop_name:, loop_seconds:, cycle_line:, sleep_line: nil )
11
+ cycle_count = 0
12
+ stop_requested = false
13
+ previous_handlers = install_loop_stop_handlers! do
14
+ stop_requested = true
15
+ end
16
+
17
+ loop do
18
+ break if stop_requested
19
+
20
+ cycle_count += 1
21
+ puts_line ""
22
+ puts_line cycle_line.call( cycle_count )
23
+ yield cycle_count
24
+ break if stop_requested
25
+
26
+ puts_line sleep_line.call( loop_seconds ) if sleep_line
27
+ loop_runner_wait( seconds: loop_seconds ) { stop_requested }
28
+ end
29
+
30
+ puts_line "#{loop_name} loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}"
31
+ EXIT_OK
32
+ rescue Interrupt
33
+ puts_line "#{loop_name} loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}"
34
+ EXIT_OK
35
+ rescue SignalException => exception
36
+ raise unless graceful_loop_signal?( exception )
37
+
38
+ puts_line "#{loop_name} loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}"
39
+ EXIT_OK
40
+ ensure
41
+ restore_loop_stop_handlers!( previous_handlers ) if previous_handlers
42
+ end
43
+
44
+ def install_loop_stop_handlers!
45
+ LOOP_STOP_SIGNALS.each_with_object( {} ) do |signal_name, handlers|
46
+ handlers[ signal_name ] = loop_runner_trap( signal_name ) { yield signal_name }
47
+ end
48
+ end
49
+
50
+ def restore_loop_stop_handlers!( previous_handlers )
51
+ previous_handlers.each do |signal_name, previous_handler|
52
+ loop_runner_trap( signal_name, previous_handler )
53
+ end
54
+ end
55
+
56
+ def graceful_loop_signal?( exception )
57
+ signo = exception.respond_to?( :signo ) ? exception.signo : nil
58
+ LOOP_STOP_SIGNALS.any? do |signal_name|
59
+ Signal.list.fetch( signal_name, nil ) == signo
60
+ end
61
+ end
62
+
63
+ def loop_runner_wait( seconds: )
64
+ deadline = loop_runner_monotonic_now + seconds.to_f
65
+ while ( remaining = deadline - loop_runner_monotonic_now ) > 0
66
+ break if block_given? && yield
67
+ loop_runner_sleep( [ remaining, LOOP_SLEEP_SLICE_SECONDS ].min )
68
+ end
69
+ end
70
+
71
+ def loop_runner_trap( signal_name, handler = nil, &block )
72
+ if handler
73
+ Signal.trap( signal_name, handler )
74
+ else
75
+ Signal.trap( signal_name, &block )
76
+ end
77
+ end
78
+
79
+ def loop_runner_monotonic_now
80
+ Process.clock_gettime( Process::CLOCK_MONOTONIC )
81
+ end
82
+
83
+ def loop_runner_sleep( seconds )
84
+ sleep seconds
85
+ end
86
+ end
87
+
88
+ include LoopRunner
89
+ end
90
+ end
@@ -33,11 +33,17 @@ module Carson
33
33
  @config = Config.load( repo_root: repo_root )
34
34
  @git_adapter = Adapters::Git.new( repo_root: repo_root )
35
35
  @github_adapter = Adapters::GitHub.new( repo_root: repo_root )
36
- @ledger = Ledger.new( path: @config.govern_state_path )
37
36
  @template_sync_result = nil
38
37
  end
39
38
 
40
- attr_reader :template_sync_result, :ledger
39
+ attr_reader :template_sync_result
40
+
41
+ # Lazy ledger: only constructed when a command actually needs delivery state.
42
+ # Read-only commands (worktree list, audit, prune, sync) never touch the
43
+ # govern state lock file.
44
+ def ledger
45
+ @ledger ||= Ledger.new( path: @config.govern_state_path )
46
+ end
41
47
 
42
48
  private
43
49
 
@@ -364,6 +370,7 @@ end
364
370
 
365
371
  require_relative "runtime/local"
366
372
  require_relative "runtime/audit"
373
+ require_relative "runtime/loop_runner"
367
374
  require_relative "runtime/housekeep"
368
375
  require_relative "runtime/repos"
369
376
  require_relative "runtime/review"
@@ -96,12 +96,23 @@ module Carson
96
96
  # Determine the base branch (main branch from config).
97
97
  base = runtime.config.main_branch
98
98
 
99
- # Sync main from remote before branching so the worktree starts
100
- # from the latest code. Prevents stale-base merge conflicts later.
101
- # Best-effort — if pull fails (non-ff, offline), continue anyway.
99
+ # Fetch to update the remote tracking ref without mutating the main worktree.
100
+ # Best-effort if fetch fails (no remote, offline), branch from local main.
102
101
  main_root = runtime.main_worktree_root
103
- _, _, pull_ok, = Open3.capture3( "git", "-C", main_root, "pull", "--ff-only", runtime.config.git_remote, base )
104
- runtime.puts_verbose( pull_ok.success? ? "synced #{base} before branching" : "sync skipped — continuing from local #{base}" ) unless json_output
102
+ remote = runtime.config.git_remote
103
+ _, _, fetch_ok, = Open3.capture3( "git", "-C", main_root, "fetch", remote, base )
104
+ if fetch_ok.success?
105
+ remote_ref = "#{remote}/#{base}"
106
+ _, _, ref_ok, = Open3.capture3( "git", "-C", main_root, "rev-parse", "--verify", remote_ref )
107
+ if ref_ok.success?
108
+ base = remote_ref
109
+ runtime.puts_verbose( "branching from #{remote_ref}" ) unless json_output
110
+ else
111
+ runtime.puts_verbose( "fetch succeeded but #{remote_ref} not found — branching from local #{runtime.config.main_branch}" ) unless json_output
112
+ end
113
+ else
114
+ runtime.puts_verbose( "fetch skipped — branching from local #{runtime.config.main_branch}" ) unless json_output
115
+ end
105
116
 
106
117
  # Ensure .claude/ is excluded from git status in the host repository.
107
118
  # Uses .git/info/exclude (local-only, never committed) to respect the outsider boundary.
@@ -144,7 +155,7 @@ module Carson
144
155
  # Removes a worktree: directory, git registration, and branch.
145
156
  # Never forces removal — if the worktree has uncommitted changes, refuses unless
146
157
  # the caller explicitly passes force: true via CLI --force flag.
147
- def self.remove!( path:, runtime:, force: false, json_output: false )
158
+ def self.remove!( path:, runtime:, force: false, skip_unpushed: false, json_output: false )
148
159
  fingerprint_status = runtime.block_if_outsider_fingerprints!
149
160
  unless fingerprint_status.nil?
150
161
  if json_output
@@ -158,7 +169,7 @@ module Carson
158
169
  return fingerprint_status
159
170
  end
160
171
 
161
- check = remove_check( path: path, runtime: runtime, force: force )
172
+ check = remove_check( path: path, runtime: runtime, force: force, skip_unpushed: skip_unpushed )
162
173
  unless check.fetch( :status ) == :ok
163
174
  return finish(
164
175
  result: { command: "worktree remove", status: check.fetch( :result_status ), name: File.basename( check.fetch( :resolved_path ) ),
@@ -236,7 +247,7 @@ module Carson
236
247
  # Preflight guard for worktree removal. Shared by `worktree remove` and
237
248
  # other runtime flows that need to know whether cleanup is safe before
238
249
  # mutating GitHub or branch state.
239
- def self.remove_check( path:, runtime:, force: false )
250
+ def self.remove_check( path:, runtime:, force: false, skip_unpushed: false )
240
251
  resolved_path = resolve_path( path: path, runtime: runtime )
241
252
 
242
253
  if !Dir.exist?( resolved_path ) && registered?( path: resolved_path, runtime: runtime )
@@ -296,7 +307,7 @@ module Carson
296
307
  }
297
308
  end
298
309
 
299
- unless force
310
+ unless force || skip_unpushed
300
311
  unpushed = branch_unpushed_issue( branch: branch, worktree_path: resolved_path, runtime: runtime )
301
312
  if unpushed
302
313
  return {
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carson
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.28.0
4
+ version: 3.29.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang
@@ -51,7 +51,6 @@ files:
51
51
  - MANUAL.md
52
52
  - README.md
53
53
  - RELEASE.md
54
- - SKILL.md
55
54
  - VERSION
56
55
  - carson.gemspec
57
56
  - exe/carson
@@ -89,6 +88,7 @@ files:
89
88
  - lib/carson/runtime/local/sync.rb
90
89
  - lib/carson/runtime/local/template.rb
91
90
  - lib/carson/runtime/local/worktree.rb
91
+ - lib/carson/runtime/loop_runner.rb
92
92
  - lib/carson/runtime/recover.rb
93
93
  - lib/carson/runtime/repos.rb
94
94
  - lib/carson/runtime/review.rb
data/SKILL.md DELETED
@@ -1,99 +0,0 @@
1
- # Carson Skill
2
-
3
- You are working in a repository governed by Carson — an autonomous git strategist and repositories governor. Carson handles git hooks, PR triage, agent dispatch, merge, and cleanup. You provide the intelligence; Carson provides the infrastructure.
4
-
5
- ## When to use Carson commands
6
-
7
- | User intent | Command | What happens |
8
- |---|---|---|
9
- | "Check if my code is ready" | `carson audit` | Scope, boundary checks. Exit 0 = clean. Exit 2 = policy block. |
10
- | "Is my PR mergeable?" | `carson review gate` | Polls for unresolved review threads and actionable comments. Blocks until resolved. |
11
- | "What's happening across my repos?" | `carson govern --dry-run` | Classifies every open PR without taking action. Read the summary. |
12
- | "Run governance continuously" | `carson govern --loop 300` | Triage-dispatch-merge cycle every 300 seconds. Ctrl-C to stop. |
13
- | "Merge ready PRs and dispatch fixes" | `carson govern` | Full autonomous cycle: merge, dispatch agents, escalate. |
14
- | "Set up Carson for a repo" | `carson onboard /path/to/repo` | Installs hooks, syncs templates, runs first audit. |
15
- | "Refresh after upgrading Carson" | `carson refresh` | Re-applies hooks and templates for the current version. |
16
- | "Update my local main" | `carson sync` | Fast-forward local main from remote. Blocks if tree is dirty. |
17
- | "Clean up stale branches" | `carson prune` | Removes local branches whose upstream is gone. |
18
- | "Check template drift" | `carson template check` then `carson template apply` | Detect and fix .github/* drift. |
19
- | "Remove Carson from a repo" | `carson offboard /path/to/repo` | Removes hooks and managed files. |
20
- | "What version?" | `carson version` | Prints installed version with ⧓ badge. |
21
-
22
- ## Exit codes
23
-
24
- - `0` — success, all clear.
25
- - `1` — runtime or configuration error. Read the error message.
26
- - `2` — policy block. Something must be fixed before proceeding (unresolved review, boundary breach).
27
-
28
- When you see exit 2, do NOT bypass it. Read the output, fix the root cause, and re-run.
29
-
30
- ## Interpreting audit output
31
-
32
- Carson audit output is structured as labelled key-value lines prefixed with ⧓. Key sections:
33
-
34
- - **Working Tree** — staged/unstaged status.
35
- - **Main Sync Status** — whether local main matches remote. If ahead, reset drift before committing.
36
- - **Scope Integrity Guard** — checks that commits stay within a single business intent and scope group.
37
- - **Audit Result** — final verdict: `status: ok` (clean), `status: attention` (advisory, not blocking), `status: block` (must fix).
38
-
39
- ## Interpreting govern output
40
-
41
- `carson govern --dry-run` classifies each PR:
42
-
43
- - **ready** → would merge. All gates pass.
44
- - **ci_failing** → would dispatch agent to fix CI.
45
- - **review_blocked** → would dispatch agent to address review comments.
46
- - **pending** → skip. Checks still running (within check_wait window).
47
- - **needs_attention** → escalate. Needs human judgement.
48
-
49
- The summary line: `govern_summary: repos=N prs=N ready=N blocked=N`
50
-
51
- ## Configuration
52
-
53
- Single config file: `~/.carson/config.json`. Key settings:
54
-
55
- ```json
56
- {
57
- "govern": {
58
- "repos": ["~/Dev/repo-a", "~/Dev/repo-b"],
59
- "merge": { "method": "rebase" },
60
- "agent": { "provider": "auto" }
61
- },
62
- "review": {
63
- "bot_usernames": ["gemini-code-assist"]
64
- }
65
- }
66
- ```
67
-
68
- - `govern.merge.method` — must match GitHub branch protection. Use `rebase` if linear history is required.
69
- - `govern.repos` — list of repo paths for portfolio-level governance. Empty = current repo only.
70
- - `govern.agent.provider` — `auto` (tries codex then claude), `codex`, or `claude`.
71
- - `review.bot_usernames` — bot logins to ignore in review gate. Use GraphQL login format (no `[bot]` suffix).
72
-
73
- Environment overrides take precedence over config file. Common ones:
74
- - `CARSON_GOVERN_MERGE_METHOD`
75
- - `CARSON_REVIEW_BOT_USERNAMES`
76
- - `CARSON_GOVERN_CHECK_WAIT`
77
-
78
- ## Common scenarios
79
-
80
- **Commit blocked by audit:**
81
- Run `carson audit`, read the block reason, fix it, then `git add` and `git commit` again. Do not skip the hook.
82
-
83
- **Review gate blocked:**
84
- Run `carson review gate` to see which comments need disposition. Respond to each with the required prefix (default: `Disposition:`), then re-run.
85
-
86
- **Local main drifted ahead of remote:**
87
- This means a commit was made to main that couldn't be pushed (branch protection). Reset: `git checkout main && git reset --hard github/main`.
88
-
89
- **Hooks out of date after upgrade:**
90
- Run `carson refresh` to re-apply hooks and templates for the current version.
91
-
92
- **Govern merge fails:**
93
- Check that `govern.merge.method` in config matches what GitHub allows. If the repo enforces linear history, only `rebase` works.
94
-
95
- ## Boundaries
96
-
97
- - Carson never lives inside governed repositories. No `.carson.yml`, no `bin/carson`, no `.tools/carson/`.
98
- - Carson-managed files in repos are limited to `.github/*` templates.
99
- - Carson's hooks live at `~/.carson/hooks/<version>/`, never in `.git/hooks/`.