carson 4.1.1 → 4.2.0

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: d709f0737b7411687d882b100598ae5d86bef2cf05cc39b493e567c03e687aa4
4
- data.tar.gz: 968d6124cc0406f21f44ce6430e4635954f8aecb77bca53ff7d1d111cce2dbe3
3
+ metadata.gz: 57e828abcdfc77caa38be92fa08a41d8d13abd37a09e7a4bf726d07253a48749
4
+ data.tar.gz: 5a6141be7046db72d03971fc9c4f45f1b7c81a47d685b13c97810711ebe9db39
5
5
  SHA512:
6
- metadata.gz: fae3226c3f11a5fb7a7269d086a8c1f1dfc2bbd5e4ca71b2823663891189fb1bc8a8da3eb67dd93a6462f20857f3ead10c4cd456efd618eb830b650b75b92f44
7
- data.tar.gz: 67bf606c7221408e585145f2e37452e260ed417de8016c7f0f776b70218db821cd4c9ba7ef564cf2d689336fe989d7a2bcceb22b0be492d3f6a4a649b85a8906
6
+ metadata.gz: 409ab608b1bed1423f16aa257efcc53f608643112db23020ac1786bd77fcc94a1776df32991be7ff0c00eb5d9b31f6db454e1ea4ad6020b65aaeb4dd186e39aa
7
+ data.tar.gz: a482b66aa5220c17d9d1b4c2852cc10b9cfeaa0d8af803e0b7974c20994754b1eb2affbb4d3a5659804764a398f25f7c822ffe3feaaa61b4c6b7fea46cf4b88a
data/API.md CHANGED
@@ -173,7 +173,7 @@ Environment overrides:
173
173
  - `agent.codex` / `agent.claude`: provider-specific options (reserved).
174
174
  - `check_wait`: seconds to wait for CI checks before classifying (default: `30`).
175
175
  - `merge.method`: `"squash"` only in governed mode.
176
- - `state_path`: JSON ledger path for active deliveries and revisions. Legacy SQLite ledgers are imported automatically on first read; explicit legacy `.sqlite3` paths keep working after import.
176
+ - `state_path`: JSON ledger path for active deliveries and revisions.
177
177
 
178
178
  `template` schema:
179
179
 
data/RELEASE.md CHANGED
@@ -7,6 +7,31 @@ Release-note scope rule:
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 4.2.0
11
+
12
+ ### New
13
+
14
+ - **`carson checkin <name>`** — the agent's verb for getting a fresh workbench. Receives the latest production standard before building. Sweeps delivered workbenches automatically — the Warehouse cleans behind the agent, so explicit checkout is rarely needed.
15
+ - **`carson checkout <name>`** — the agent's verb for releasing a workbench when done. Checks the seal (parcel in flight blocks checkout), CWD, process holds, dirty state, and unpushed work. Use at end of session; the common case is handled by the next checkin.
16
+
17
+ ### Changed
18
+
19
+ - **CLI moved outside the story world** — `lib/carson/cli.rb` → `lib/cli.rb`. `lib/carson/` is domain objects only. checkin and checkout wire CLI directly to Warehouse with no Runtime. This is the template for Runtime dissolution.
20
+ - **`tear_down_workbench!` renamed to `remove_workbench!`** — simpler verb, matches the domain.
21
+ - **Remote branch deletion removed from workbench removal** — GitHub handles remote branch cleanup on PR merge. The Warehouse now only removes the directory and local branch.
22
+
23
+ ### UX
24
+
25
+ - `carson checkin` output: `⧓ Workbench ready: <name>` with path and branch.
26
+ - `carson checkout` output: `⧓ Workbench released: <name>`.
27
+ - Agent lifecycle simplified: `checkin → work → deliver → checkin` (repeat). No explicit checkout between tasks.
28
+
29
+ ## 4.1.2
30
+
31
+ ### Fixed
32
+
33
+ - **Hook templates misplaced under `.github/`** — `config/.github/hooks/` moved to `config/hooks/`. Git hooks are local mechanisms installed to `~/.carson/hooks/`, not GitHub configuration. The previous path falsely implied a relationship with GitHub's `.github/` convention.
34
+
10
35
  ## 4.1.1
11
36
 
12
37
  ### Fixed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 4.1.1
1
+ 4.2.0
data/carson.gemspec CHANGED
@@ -28,7 +28,6 @@ Gem::Specification.new do |spec|
28
28
  spec.bindir = "exe"
29
29
  spec.executables = [ "carson" ]
30
30
  spec.require_paths = [ "lib" ]
31
- spec.add_dependency "sqlite3", ">= 1.3", "< 3"
32
31
  spec.files = Dir.glob( "{lib,exe,templates,config}/**/*", File::FNM_DOTMATCH ).select { |path| File.file?( path ) } + [
33
32
  ".github/workflows/carson_policy.yml",
34
33
  "README.md",
data/lib/carson/ledger.rb CHANGED
@@ -7,12 +7,10 @@ module Carson
7
7
  class Ledger
8
8
  UNSET = Object.new
9
9
  ACTIVE_DELIVERY_STATES = Delivery::ACTIVE_STATES
10
- SQLITE_HEADER = "SQLite format 3\0".b.freeze
11
10
 
12
11
  def initialize( path: )
13
12
  @path = File.expand_path( path )
14
13
  FileUtils.mkdir_p( File.dirname( @path ) )
15
- migrate_legacy_state_if_needed!
16
14
  end
17
15
 
18
16
  attr_reader :path
@@ -273,22 +271,6 @@ module Carson
273
271
  )
274
272
  end
275
273
 
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
-
282
- with_state_lock do |lock_file|
283
- lock_file.flock( File::LOCK_EX )
284
- source_path = legacy_sqlite_source_path
285
- next unless source_path
286
-
287
- state = load_legacy_sqlite_state( path: source_path )
288
- save_state!( state )
289
- end
290
- end
291
-
292
274
  def with_state_lock
293
275
  lock_path = "#{path}.lock"
294
276
  FileUtils.mkdir_p( File.dirname( lock_path ) )
@@ -299,110 +281,6 @@ module Carson
299
281
  end
300
282
  end
301
283
 
302
- def legacy_sqlite_source_path
303
- return nil unless state_path_requires_migration?
304
- return path if sqlite_database_file?( path: path )
305
-
306
- legacy_path = legacy_state_path
307
- return nil unless legacy_path
308
- return legacy_path if sqlite_database_file?( path: legacy_path )
309
-
310
- nil
311
- end
312
-
313
- def state_path_requires_migration?
314
- return true if sqlite_database_file?( path: path )
315
- return false if File.exist?( path )
316
- !legacy_state_path.nil?
317
- end
318
-
319
- def legacy_state_path
320
- return nil unless path.end_with?( ".json" )
321
- path.sub( /\.json\z/, ".sqlite3" )
322
- end
323
-
324
- def sqlite_database_file?( path: )
325
- return false unless File.file?( path )
326
- File.binread( path, SQLITE_HEADER.bytesize ) == SQLITE_HEADER
327
- rescue StandardError
328
- false
329
- end
330
-
331
- def load_legacy_sqlite_state( path: )
332
- begin
333
- require "sqlite3"
334
- rescue LoadError => exception
335
- raise "legacy SQLite ledger found at #{path}, but sqlite3 support is unavailable: #{exception.message}"
336
- end
337
-
338
- database = open_legacy_sqlite_database( path: path )
339
- deliveries = database.execute( "SELECT * FROM deliveries ORDER BY id ASC" )
340
- revisions_by_delivery = database.execute(
341
- "SELECT * FROM revisions ORDER BY delivery_id ASC, number ASC, id ASC"
342
- ).group_by { |row| row.fetch( "delivery_id" ) }
343
-
344
- state = {
345
- "deliveries" => {},
346
- "recovery_events" => [],
347
- "next_sequence" => 1
348
- }
349
- deliveries.each do |row|
350
- key = delivery_key(
351
- repo_path: row.fetch( "repo_path" ),
352
- branch_name: row.fetch( "branch_name" ),
353
- head: row.fetch( "head" )
354
- )
355
- state[ "deliveries" ][ key ] = {
356
- "sequence" => row.fetch( "id" ).to_i,
357
- "repo_path" => row.fetch( "repo_path" ),
358
- "branch_name" => row.fetch( "branch_name" ),
359
- "head" => row.fetch( "head" ),
360
- "worktree_path" => row.fetch( "worktree_path" ),
361
- "status" => row.fetch( "status" ),
362
- "pr_number" => row.fetch( "pr_number" ),
363
- "pr_url" => row.fetch( "pr_url" ),
364
- "pull_request_state" => nil,
365
- "pull_request_draft" => nil,
366
- "pull_request_merged_at" => nil,
367
- "merge_proof" => nil,
368
- "cause" => row.fetch( "cause" ),
369
- "summary" => row.fetch( "summary" ),
370
- "created_at" => row.fetch( "created_at" ),
371
- "updated_at" => row.fetch( "updated_at" ),
372
- "integrated_at" => row.fetch( "integrated_at" ),
373
- "superseded_at" => row.fetch( "superseded_at" ),
374
- "revisions" => Array( revisions_by_delivery[ row.fetch( "id" ) ] ).map do |revision|
375
- {
376
- "number" => revision.fetch( "number" ).to_i,
377
- "cause" => revision.fetch( "cause" ),
378
- "provider" => revision.fetch( "provider" ),
379
- "status" => revision.fetch( "status" ),
380
- "started_at" => revision.fetch( "started_at" ),
381
- "finished_at" => revision.fetch( "finished_at" ),
382
- "summary" => revision.fetch( "summary" )
383
- }
384
- end
385
- }
386
- end
387
- normalise_state!( state: state )
388
- state
389
- ensure
390
- database&.close
391
- end
392
-
393
- def open_legacy_sqlite_database( path: )
394
- database = SQLite3::Database.new( "file:#{path}?immutable=1", readonly: true, uri: true )
395
- database.results_as_hash = true
396
- database.busy_timeout = 5_000
397
- database
398
- rescue SQLite3::CantOpenException
399
- database&.close
400
- database = SQLite3::Database.new( path, readonly: true )
401
- database.results_as_hash = true
402
- database.busy_timeout = 5_000
403
- database
404
- end
405
-
406
284
  def normalise_state!( state: )
407
285
  deliveries = state[ "deliveries" ]
408
286
  raise "state file must contain a JSON object at #{path}" unless deliveries.is_a?( Hash )
@@ -140,17 +140,18 @@ module Carson
140
140
  end
141
141
 
142
142
  if worktree
143
- check = Worktree.remove_check( path: worktree.path, runtime: self, force: false, skip_unpushed: true )
143
+ check = worktree_warehouse.assess_removal( worktree, force: false, skip_unpushed: true )
144
144
  return nil if check.fetch( :status ) == :ok
145
145
 
146
- recovery = check.fetch( :recovery )
147
- if check.fetch( :error ) == "worktree has uncommitted changes"
146
+ recovery = check[ :recovery ]
147
+ if check[ :error ] == "worktree has uncommitted changes"
148
148
  recovery = "commit or discard the changes, then retry carson abandon #{branch}"
149
149
  end
150
150
 
151
+ exit_code = check[ :status ] == :block ? EXIT_BLOCK : EXIT_ERROR
151
152
  return {
152
- exit_code: check.fetch( :exit_code ),
153
- error: check.fetch( :error ),
153
+ exit_code: exit_code,
154
+ error: check[ :error ],
154
155
  recovery: recovery
155
156
  }
156
157
  end
@@ -64,7 +64,7 @@ module Carson
64
64
 
65
65
  # Canonical hook template location inside Carson repository.
66
66
  def hook_template_path( hook_name: )
67
- File.join( tool_root, "config", ".github", "hooks", hook_name )
67
+ File.join( tool_root, "config", "hooks", hook_name )
68
68
  end
69
69
 
70
70
  # Reports full hook health and can enforce stricter action messaging in `check`.
@@ -1,31 +1,46 @@
1
1
  # Thin worktree delegate layer on Runtime.
2
- # Lifecycle operations live on Carson::Worktree; this module delegates
3
- # and keeps only methods that genuinely belong on Runtime (path resolution,
4
- # CWD branch detection).
2
+ # Lifecycle operations delegate to Warehouse::Workbench.
3
+ # Keeps only methods that genuinely belong on Runtime (path resolution,
4
+ # CWD branch detection, output rendering).
5
5
  module Carson
6
6
  class Runtime
7
7
  module Local
8
8
 
9
- # --- Delegates to Carson::Worktree ---
9
+ # --- Delegates to Warehouse::Workbench ---
10
10
 
11
11
  # Creates a new worktree under .claude/worktrees/<name>.
12
12
  def worktree_create!( name:, json_output: false )
13
- Worktree.create!( name: name, runtime: self, json_output: json_output )
13
+ result = worktree_warehouse.build_workbench!( name: name )
14
+ finish_worktree( result: result, json_output: json_output )
14
15
  end
15
16
 
16
17
  # Removes a worktree: directory, git registration, and branch.
17
18
  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
+ wh = worktree_warehouse
20
+ workbench = wh.workbench_named( worktree_path )
21
+ unless workbench
22
+ return finish_worktree(
23
+ result: { command: "worktree remove", status: "error",
24
+ name: File.basename( worktree_path ),
25
+ error: "#{worktree_path} is not a registered worktree",
26
+ recovery: "git worktree list" },
27
+ json_output: json_output )
28
+ end
29
+ result = wh.remove_workbench!( workbench, force: force, skip_unpushed: skip_unpushed )
30
+ finish_worktree( result: result, json_output: json_output )
19
31
  end
20
32
 
21
33
  # Removes agent-owned worktrees whose branch content is already on main.
34
+ # Still uses Worktree.sweep_stale! which needs classify_worktree_cleanup
35
+ # on Runtime. Migrates to warehouse.sweep_workbenches! when housekeep
36
+ # is absorbed (Phase 4 item 40).
22
37
  def sweep_stale_worktrees!
23
38
  Worktree.sweep_stale!( runtime: self )
24
39
  end
25
40
 
26
41
  # Returns all registered worktrees as Carson::Worktree instances.
27
42
  def worktree_list
28
- Worktree.list( runtime: self )
43
+ worktree_warehouse.workbenches
29
44
  end
30
45
 
31
46
  # Human and JSON status surface for all registered worktrees.
@@ -109,6 +124,65 @@ module Carson
109
124
 
110
125
  private
111
126
 
127
+ # Build a Warehouse for workbench operations.
128
+ # Always rooted at the main worktree so workbench management
129
+ # works correctly even when called from inside a worktree.
130
+ def worktree_warehouse
131
+ Warehouse.new(
132
+ path: main_worktree_root,
133
+ main_label: config.main_branch,
134
+ bureau_address: config.git_remote
135
+ )
136
+ end
137
+
138
+ # Render a workbench operation result as JSON or human text.
139
+ # Returns the exit code for CLI dispatch.
140
+ def finish_worktree( result:, json_output: false )
141
+ exit_code = result.fetch( :exit_code, nil )
142
+ status = result[ :status ]
143
+
144
+ # Derive exit code from status if not explicitly set.
145
+ exit_code ||= case status
146
+ when "ok" then EXIT_OK
147
+ when "block" then EXIT_BLOCK
148
+ else EXIT_ERROR
149
+ end
150
+
151
+ result[ :exit_code ] = exit_code
152
+
153
+ if json_output
154
+ output.puts JSON.pretty_generate( result )
155
+ else
156
+ print_worktree_result( result: result )
157
+ end
158
+
159
+ exit_code
160
+ end
161
+
162
+ # Human-readable output for worktree operation results.
163
+ def print_worktree_result( result: )
164
+ command = result[ :command ]
165
+ status = result[ :status ]
166
+
167
+ case status
168
+ when "ok"
169
+ case command
170
+ when "worktree create"
171
+ puts_line "Worktree created: #{result[ :name ]}"
172
+ puts_line " Path: #{result[ :path ]}"
173
+ puts_line " Branch: #{result[ :branch ]}"
174
+ when "worktree remove"
175
+ puts_line "Worktree removed: #{result[ :name ]}" unless verbose?
176
+ end
177
+ when "error"
178
+ puts_line result[ :error ]
179
+ puts_line " → #{result[ :recovery ]}" if result[ :recovery ]
180
+ when "block"
181
+ puts_line "#{result[ :error ]&.capitalize || 'Held'}: #{result[ :name ]}"
182
+ puts_line " → #{result[ :recovery ]}" if result[ :recovery ]
183
+ end
184
+ end
185
+
112
186
  def worktree_inventory
113
187
  worktree_list.map { |worktree| worktree_inventory_entry( worktree: worktree ) }
114
188
  end
@@ -2,7 +2,7 @@
2
2
  module Carson
3
3
  class Runtime
4
4
  module Recover
5
- GOVERNANCE_SURFACE_PREFIXES = %w[ .github/ config/.github/ ].freeze
5
+ GOVERNANCE_SURFACE_PREFIXES = %w[ .github/ config/hooks/ ].freeze
6
6
 
7
7
  def recover!( check_name:, json_output: false )
8
8
  result = {
@@ -71,7 +71,7 @@ module Carson
71
71
 
72
72
  unless relation.fetch( :related )
73
73
  result[ :error ] = "branch does not touch the governance surface for #{check_name}"
74
- result[ :recovery ] = "update the branch to repair .github/ or config/.github/, then rerun carson recover --check #{check_name.inspect}"
74
+ result[ :recovery ] = "update the branch to repair .github/ or config/hooks/, then rerun carson recover --check #{check_name.inspect}"
75
75
  return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
76
76
  end
77
77
 
@@ -0,0 +1,117 @@
1
+ # The warehouse's bureau-facing concern.
2
+ # The warehouse owns the connection to the bureau (GitHub).
3
+ # It queries, files, and registers on behalf of the courier.
4
+ require "json"
5
+
6
+ module Carson
7
+ class Warehouse
8
+ module Bureau
9
+
10
+ # Check the parcel's status at the bureau using the waybill.
11
+ # Calls gh pr view + gh pr checks. Records findings onto the waybill.
12
+ def check_parcel_at_bureau_with( waybill )
13
+ state = fetch_pr_state_for( waybill.tracking_number )
14
+ ci, ci_diagnostic = fetch_ci_state_for( waybill.tracking_number )
15
+ waybill.record( state: state, ci: ci, ci_diagnostic: ci_diagnostic )
16
+ end
17
+
18
+ # File a waybill at the bureau for this parcel.
19
+ # Calls gh pr create. Returns a Waybill with tracking number, or nil on failure.
20
+ def file_waybill_for!( parcel, title: nil, body_file: nil )
21
+ filing_title = title || Waybill.default_title_for( parcel.label )
22
+ arguments = [ "pr", "create", "--title", filing_title, "--head", parcel.label ]
23
+
24
+ if body_file && File.exist?( body_file )
25
+ arguments.push( "--body-file", body_file )
26
+ else
27
+ arguments.push( "--body", "" )
28
+ end
29
+
30
+ stdout, _, status = gh( *arguments )
31
+ tracking_number = nil
32
+ url = nil
33
+
34
+ if status.success?
35
+ url = stdout.to_s.strip
36
+ tracking_number = url.split( "/" ).last.to_i
37
+ tracking_number = nil if tracking_number == 0
38
+ end
39
+
40
+ # If create failed or returned no number, try to find an existing PR.
41
+ unless tracking_number
42
+ tracking_number, url = find_existing_waybill_for( parcel.label )
43
+ end
44
+
45
+ return nil unless tracking_number
46
+
47
+ Waybill.new( label: parcel.label, tracking_number: tracking_number, url: url )
48
+ end
49
+
50
+ # Register the parcel at the bureau using the waybill.
51
+ # Calls gh pr merge. Stamps the waybill on success.
52
+ def register_parcel_at_bureau_with!( waybill, method: )
53
+ _, _, status = gh( "pr", "merge", waybill.tracking_number.to_s, "--#{method}" )
54
+ if status.success?
55
+ waybill.stamp( :accepted )
56
+ else
57
+ # Re-check the state — the merge may have revealed a new blocker.
58
+ check_parcel_at_bureau_with( waybill )
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ # Fetch PR state from the bureau for a tracking number.
65
+ # Returns the parsed state hash, or nil on failure.
66
+ def fetch_pr_state_for( tracking_number )
67
+ stdout, _, status = gh(
68
+ "pr", "view", tracking_number.to_s,
69
+ "--json", "number,state,isDraft,url,mergeStateStatus,mergeable,mergedAt"
70
+ )
71
+ return nil unless status.success?
72
+
73
+ JSON.parse( stdout )
74
+ rescue JSON::ParserError
75
+ nil
76
+ end
77
+
78
+ # Fetch CI state from the bureau for a tracking number.
79
+ # Returns [ci_symbol, diagnostic_or_nil].
80
+ # Captures the first line of stderr as diagnostic when the command fails.
81
+ def fetch_ci_state_for( tracking_number )
82
+ stdout, stderr, status = gh(
83
+ "pr", "checks", tracking_number.to_s,
84
+ "--json", "name,bucket"
85
+ )
86
+ unless status.success?
87
+ return [ :error, stderr.to_s.strip.lines.first&.strip ]
88
+ end
89
+
90
+ checks = JSON.parse( stdout ) rescue []
91
+ return [ :none, nil ] if checks.empty?
92
+
93
+ buckets = checks.map { it[ "bucket" ].to_s.downcase }
94
+ return [ :fail, nil ] if buckets.include?( "fail" )
95
+ return [ :pending, nil ] if buckets.include?( "pending" )
96
+
97
+ [ :pass, nil ]
98
+ end
99
+
100
+ # Try to find an existing PR for this label at the bureau.
101
+ # Returns [tracking_number, url] or [nil, nil].
102
+ def find_existing_waybill_for( label )
103
+ stdout, _, status = gh(
104
+ "pr", "view", label,
105
+ "--json", "number,url,state"
106
+ )
107
+ if status.success?
108
+ data = JSON.parse( stdout ) rescue nil
109
+ if data && data[ "number" ] && data[ "state" ] == "OPEN"
110
+ return [ data[ "number" ], data[ "url" ].to_s ]
111
+ end
112
+ end
113
+ [ nil, nil ]
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,55 @@
1
+ # The warehouse's workbench seal concern.
2
+ # Once a parcel ships and the waybill is filed, the warehouse seals the
3
+ # workbench. No more packing until the delivery outcome is confirmed.
4
+ # The seal marker lives outside the worktree (~/.carson/seals/) so it
5
+ # does not pollute git status.
6
+ require "digest"
7
+ require "fileutils"
8
+
9
+ module Carson
10
+ class Warehouse
11
+ module Seal
12
+
13
+ # Seal the workbench — no more packing until delivery outcome is confirmed.
14
+ # The courier seals the workbench after shipping and filing the waybill.
15
+ def seal_workbench!( tracking_number: )
16
+ marker = delivering_marker_path
17
+ FileUtils.mkdir_p( File.dirname( marker ) )
18
+ File.write( marker, "#{tracking_number}\n#{@path}" )
19
+ end
20
+
21
+ # Unseal the workbench — the courier brought back the parcel.
22
+ # Called when the delivery outcome is held or rejected.
23
+ def unseal_workbench!
24
+ File.delete( delivering_marker_path ) if File.exist?( delivering_marker_path )
25
+ end
26
+
27
+ # Is this workbench sealed for a delivery in flight?
28
+ def sealed?
29
+ File.exist?( delivering_marker_path )
30
+ end
31
+
32
+ # The tracking number of the in-flight delivery (nil if not sealed).
33
+ def sealed_tracking_number
34
+ return nil unless sealed?
35
+ File.read( delivering_marker_path ).lines.first.strip
36
+ end
37
+
38
+ # --- Transitional aliases ---
39
+ # Keep old names working until all callers are updated.
40
+ alias seal_shelf! seal_workbench!
41
+ alias unseal_shelf! unseal_workbench!
42
+
43
+ private
44
+
45
+ # Path to the delivery marker file.
46
+ # Lives outside the worktree at ~/.carson/seals/<sha256-of-path>
47
+ # so it does not pollute git status.
48
+ def delivering_marker_path
49
+ seals_dir = File.join( Dir.home, ".carson", "seals" )
50
+ key = Digest::SHA256.hexdigest( @path )
51
+ File.join( seals_dir, key )
52
+ end
53
+ end
54
+ end
55
+ end