samagotchi 0.157.0 → 0.159.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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -1
  3. data/README.md +1 -1
  4. data/bin/chi +5 -1
  5. data/docs/architecture.md +14 -12
  6. data/docs/bundle-authoring.md +5 -5
  7. data/docs/bundles.md +2 -1
  8. data/docs/cli.md +76 -17
  9. data/docs/configuration.md +16 -5
  10. data/docs/context.md +12 -4
  11. data/docs/guardrails.md +11 -8
  12. data/docs/hooks.md +47 -80
  13. data/docs/internals/bridge.md +2 -1
  14. data/docs/internals/bundles.md +51 -36
  15. data/docs/internals/engine.md +7 -5
  16. data/docs/memory.md +43 -9
  17. data/docs/plugins.md +13 -9
  18. data/docs/releasing.md +6 -5
  19. data/docs/scheduling.md +228 -0
  20. data/docs/sessions.md +30 -6
  21. data/docs/testing.md +4 -4
  22. data/lib/samagotchi/bridge/card_places.rb +5 -13
  23. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  24. data/lib/samagotchi/bridge/turn_drawings.rb +7 -1
  25. data/lib/samagotchi/bridge.rb +26 -2
  26. data/lib/samagotchi/bundle_command/check.rb +23 -10
  27. data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
  28. data/lib/samagotchi/bundle_command/try/run.rb +4 -1
  29. data/lib/samagotchi/bundle_command.rb +53 -44
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  31. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  32. data/lib/samagotchi/bundles/system/memory_guide.md +6 -6
  33. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  34. data/lib/samagotchi/client_id.rb +13 -3
  35. data/lib/samagotchi/config.rb +12 -0
  36. data/lib/samagotchi/context_command.rb +7 -5
  37. data/lib/samagotchi/context_move.rb +74 -0
  38. data/lib/samagotchi/context_sources.rb +4 -2
  39. data/lib/samagotchi/engine.rb +1 -6
  40. data/lib/samagotchi/extension_load.rb +20 -41
  41. data/lib/samagotchi/guardrails/load_failures.rb +3 -2
  42. data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
  43. data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
  44. data/lib/samagotchi/guardrails/review_paths.rb +1 -5
  45. data/lib/samagotchi/hook_routes.rb +2 -2
  46. data/lib/samagotchi/hooks.rb +2 -3
  47. data/lib/samagotchi/memory_bundle/asset_installer.rb +112 -211
  48. data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
  49. data/lib/samagotchi/memory_bundle/builder.rb +10 -34
  50. data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
  51. data/lib/samagotchi/memory_bundle/file_actions.rb +51 -0
  52. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  53. data/lib/samagotchi/memory_bundle/installed_copy.rb +11 -18
  54. data/lib/samagotchi/memory_bundle/installed_manifest.rb +2 -16
  55. data/lib/samagotchi/memory_bundle/installer.rb +32 -14
  56. data/lib/samagotchi/memory_bundle/manifest.rb +37 -54
  57. data/lib/samagotchi/memory_bundle/provenance.rb +11 -51
  58. data/lib/samagotchi/memory_bundle/remerge.rb +7 -3
  59. data/lib/samagotchi/memory_bundle/shas.rb +5 -8
  60. data/lib/samagotchi/memory_bundle/shipped_update.rb +41 -41
  61. data/lib/samagotchi/memory_bundle/status.rb +30 -9
  62. data/lib/samagotchi/memory_bundle/system_bundle.rb +16 -5
  63. data/lib/samagotchi/memory_bundle/trash.rb +7 -12
  64. data/lib/samagotchi/memory_bundle/uninstaller.rb +10 -4
  65. data/lib/samagotchi/memory_orphans.rb +4 -4
  66. data/lib/samagotchi/memory_paths.rb +102 -13
  67. data/lib/samagotchi/plugin/api.rb +1 -4
  68. data/lib/samagotchi/plugin/loader.rb +42 -5
  69. data/lib/samagotchi/project_command.rb +167 -0
  70. data/lib/samagotchi/project_init.rb +69 -0
  71. data/lib/samagotchi/project_scope.rb +6 -4
  72. data/lib/samagotchi/prompt_files.rb +10 -7
  73. data/lib/samagotchi/recap_place.rb +3 -4
  74. data/lib/samagotchi/run_options.rb +2 -0
  75. data/lib/samagotchi/schedule_command.rb +252 -0
  76. data/lib/samagotchi/schedules.rb +948 -0
  77. data/lib/samagotchi/session.rb +44 -18
  78. data/lib/samagotchi/session_access/contract.rb +1 -1
  79. data/lib/samagotchi/session_access/local.rb +20 -0
  80. data/lib/samagotchi/session_field.rb +5 -4
  81. data/lib/samagotchi/session_manager/spawn.rb +25 -11
  82. data/lib/samagotchi/session_manager/worker_command.rb +13 -13
  83. data/lib/samagotchi/steer.rb +10 -0
  84. data/lib/samagotchi/system_prompt.rb +20 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
  86. data/lib/samagotchi/terminal_ui/join_replay.rb +6 -1
  87. data/lib/samagotchi/testing/bundle.rb +5 -12
  88. data/lib/samagotchi/testing/install.rb +5 -5
  89. data/lib/samagotchi/testing/recorder.rb +1 -1
  90. data/lib/samagotchi/testing.rb +1 -1
  91. data/lib/samagotchi/tool_declarations.rb +44 -1
  92. data/lib/samagotchi/toolkit_binding.rb +1 -4
  93. data/lib/samagotchi/toolkit_command.rb +1 -4
  94. data/lib/samagotchi/tools/builtin_calls.rb +2 -0
  95. data/lib/samagotchi/tools/builtins.rb +17 -0
  96. data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
  97. data/lib/samagotchi/tools/delegate.rb +12 -8
  98. data/lib/samagotchi/tools/list_schedules.rb +43 -0
  99. data/lib/samagotchi/tools/schedule_task.rb +39 -0
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/web/app.rb +35 -0
  102. data/lib/samagotchi/web/project_list.rb +3 -2
  103. data/lib/samagotchi/web/public/chains.js +32 -8
  104. data/lib/samagotchi/web/public/index.html +10 -2
  105. data/lib/samagotchi/web/public/info_bar.js +4 -3
  106. data/lib/samagotchi/web/public/project_picker.js +86 -4
  107. data/lib/samagotchi/web/public/turn_events.js +4 -0
  108. data/lib/samagotchi/web/session_hub.rb +20 -0
  109. data/lib/samagotchi/worker.rb +24 -6
  110. data/lib/samagotchi/worker_boot.rb +3 -4
  111. data/lib/samagotchi/worker_inbound.rb +23 -0
  112. data/lib/samagotchi/worker_link.rb +5 -0
  113. data/lib/samagotchi/worker_wakes.rb +9 -1
  114. metadata +10 -2
  115. data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
@@ -214,7 +214,11 @@ module Samagotchi
214
214
  missing = MemoryFiles.select(GEM_BUNDLE_DIR, gem_manifest).reject { |key| File.exist?(File.join(target_dir, key)) }
215
215
  missing = restored_rows(missing, target_dir) unless missing.empty?
216
216
  return result(:up_to_date, from: version, to: version) if missing.empty?
217
- return result(:restored, from: version, to: version) if dry_run
217
+
218
+ if dry_run
219
+ warnings = missing.filter_map { |key| restore_mismatch(key, gem_manifest) }
220
+ return result(:restored, from: version, to: version, warnings: warnings)
221
+ end
218
222
 
219
223
  warnings = missing.flat_map { |key| restore_file(key, gem_manifest) }
220
224
  result(:restored, from: version, to: version, warnings: warnings)
@@ -238,7 +242,7 @@ module Samagotchi
238
242
  description = gem_manifest.file_descriptions[key]
239
243
  FileUtils.cp(source, target)
240
244
  Provenance.new(name: BUNDLE_NAME).record_restored(key, source, description: description)
241
- warnings = [FileDigest.mismatch(key, target, gem_manifest.checksum_for(key))].compact
245
+ warnings = [restore_mismatch(key, gem_manifest)].compact
242
246
  begin
243
247
  IndexUpdater.update_index(SCOPE, key.delete_suffix(".md"), File.size(target), description, source: BUNDLE_NAME)
244
248
  rescue StandardError => e
@@ -247,6 +251,10 @@ module Samagotchi
247
251
  warnings
248
252
  end
249
253
 
254
+ def restore_mismatch(key, gem_manifest)
255
+ FileDigest.mismatch(key, File.join(GEM_BUNDLE_DIR, key), gem_manifest.checksum_for(key))
256
+ end
257
+
250
258
  # An up-to-date bundle's marked files, retried. No marks: nothing is read.
251
259
  def remerge(bundle, verified, dry_run)
252
260
  done = Remerge.new(name: BUNDLE_NAME, scope: SCOPE, target_dir: MemoryPaths.system_dir, source_dir: GEM_BUNDLE_DIR, record: bundle)
@@ -264,7 +272,10 @@ module Samagotchi
264
272
  owned = provenance.record&.files&.keys || []
265
273
  forecast = Merger::Upgrade.new(provenance: provenance, target_dir: MemoryPaths.system_dir, source_dir: GEM_BUNDLE_DIR)
266
274
  .forecast(gem_manifest.files.keys.map(&:to_s).sort & owned, version: to)
267
- result(:updated, from: from, to: to, merged: forecast.merged, behind: forecast.behind)
275
+ installer = upgrade_installer(dry_run: true)
276
+ installer.run
277
+ warnings = installer.warnings.grep(/\AChecksum mismatch/)
278
+ result(:updated, from: from, to: to, merged: forecast.merged, behind: forecast.behind, warnings: warnings)
268
279
  end
269
280
 
270
281
  # Provenance#adopt_manifest from the shipped dir, for a record without a
@@ -276,9 +287,9 @@ module Samagotchi
276
287
  Provenance.new(name: BUNDLE_NAME).adopt_manifest(GEM_BUNDLE_DIR)
277
288
  end
278
289
 
279
- def upgrade_installer
290
+ def upgrade_installer(dry_run: false)
280
291
  Installer.new(source: GEM_BUNDLE_DIR, name: BUNDLE_NAME, scope: SCOPE, force: false, strict: true, upgrade: true,
281
- shipped_dir: File.dirname(GEM_BUNDLE_DIR))
292
+ dry_run: dry_run, shipped_dir: File.dirname(GEM_BUNDLE_DIR))
282
293
  end
283
294
  private_class_method :with_lock, :note_newer_installed, :install_fresh, :keep_manifest, :upgrade_existing, :verify_files_present, :restore_file, :remerge,
284
295
  :upgrade_installer, :result, :planned_update
@@ -99,24 +99,19 @@ module Samagotchi
99
99
  dest
100
100
  end
101
101
 
102
- # Copies the file at +path+ as +as+ before it is replaced or removed, when
103
- # that would lose something: its bytes are neither what the record says
104
- # was installed (no sha recorded: unknown, so it counts as edited) nor
105
- # +incoming+'s. A link (a dev install's) points at its source, which
106
- # nothing here touches.
107
- def copy_edit(path, as:, recorded: nil, incoming: nil)
108
- copy(path, as: as) if self.class.edit?(path, recorded: recorded, incoming: incoming)
109
- end
110
-
111
- # copy_edit for every file under +dir+, which is about to go, at any depth
112
- # (kept as <label>/<its path in dir>). A linked dir isn't followed.
102
+ # Copies every file under +dir+, which is about to be replaced or
103
+ # removed, when that would lose something: its bytes are neither what
104
+ # the record says was installed (no sha recorded: unknown, so it counts
105
+ # as edited) nor the incoming source's, at any depth (kept as
106
+ # <label>/<its path in dir>). A link (a dev install's) points at its
107
+ # source, which nothing here touches; a linked dir isn't followed.
113
108
  # @param recorded [Hash, nil] path in dir => its recorded sha256
114
109
  # @param incoming [Hash] path in dir => the source replacing it
115
110
  def copy_edits(dir, label, recorded = nil, incoming = {})
116
111
  self.class.edited_in(dir, recorded, incoming).each { |file| copy(File.join(dir, file), as: "#{label}/#{file}") }
117
112
  end
118
113
 
119
- # Whether #copy_edit would copy the file at +path+ (read-only): the rule
114
+ # Whether #copy_edits would copy the file at +path+ (read-only): the rule
120
115
  # ShippedUpdate's plan names its replaced files by.
121
116
  def self.edit?(path, recorded: nil, incoming: nil)
122
117
  return false if File.symlink?(path) || !File.file?(path)
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "digest"
5
5
  require_relative "../memory_paths"
6
6
  require_relative "provenance"
7
+ require_relative "asset_state"
7
8
  require_relative "file_actions"
8
9
  require_relative "index_updater"
9
10
  require_relative "trash"
@@ -144,12 +145,17 @@ module Samagotchi
144
145
  private
145
146
 
146
147
  # Copies each edited file of the bundle's own folder to the trash: the
147
- # rm_rf below takes them, --force or not.
148
+ # rm_rf below takes them, --force or not. Rules, the plugin and
149
+ # scripts by their source-less AssetState and ASSET_UNINSTALL's
150
+ # save_first; an older chi's hooks/ by Trash#copy_edits.
148
151
  def save_edits(provenance, bundle)
149
152
  @trash.copy_edits(provenance.hooks_dir, "hooks", bundle.hooks.transform_values(&:sha256))
150
- @trash.copy_edits(provenance.guardrails_dir, "guardrails", bundle.guardrails)
151
- @trash.copy_edits(provenance.plugin_dir, "plugin", bundle.plugin_shas)
152
- @trash.copy_edits(provenance.scripts_dir, "scripts", bundle.scripts)
153
+ { rule: [provenance.guardrails_dir, bundle.guardrails], plugin: [provenance.plugin_dir, bundle.plugin_shas],
154
+ script: [provenance.scripts_dir, bundle.scripts] }.each do |kind_of_file, (dir, recorded)|
155
+ AssetState.in(dir, kind_of_file: kind_of_file, recorded: recorded).each do |asset|
156
+ @trash.copy(asset.path, as: asset.label) if FileActions.for_asset(:uninstall, asset)&.save_first
157
+ end
158
+ end
153
159
  end
154
160
 
155
161
  # A kept shared file's index line names the bundle that still has it
@@ -19,14 +19,14 @@ module Samagotchi
19
19
 
20
20
  module_function
21
21
 
22
- # The orphan candidates for +cwd+'s project, or nil: only inside a git
23
- # repository (outside one the root is cwd, and every src/ or tmp/ would
24
- # match), and only while the computed folder holds no *.md (a
22
+ # The orphan candidates for +cwd+'s project, or nil: only inside a
23
+ # project, a git repository or a marked folder (outside both the root is
24
+ # cwd, and every src/ or tmp/ would match), and only while the computed folder holds no *.md (a
25
25
  # memory_write's mkdir_p or a project-scope bundle install alone must
26
26
  # not silence it).
27
27
  # @return [Found, nil]
28
28
  def find(env: ENV, cwd: Dir.pwd)
29
- return nil unless MemoryPaths.in_repo?(cwd)
29
+ return nil unless MemoryPaths.in_project?(cwd)
30
30
 
31
31
  projects = MemoryPaths.projects_dir(env: env)
32
32
  computed = MemoryPaths.project_dir(env: env, cwd: cwd)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "digest"
4
4
  require_relative "config"
5
+ require_relative "guardrails/path_resolve"
5
6
 
6
7
  module Samagotchi
7
8
  # Where memories live: <config dir>/memories, i.e. $XDG_CONFIG_HOME/samagotchi/memories
@@ -9,11 +10,61 @@ module Samagotchi
9
10
  # ConfigFile.global_path, so an XDG_CONFIG_HOME set after load (specs, smoke runs,
10
11
  # a fixture chi) moves memories together with config.yml.
11
12
  #
12
- # Project memories are keyed by the project root (see project_root), so every
13
- # worktree and subdirectory of one git repository shares one folder.
13
+ # Project memories are keyed by the project root (see #lookup): one folder
14
+ # per project root, a git repository (every worktree and subdirectory of it
15
+ # shares one folder) or a folder marked by `chi project init` (and its
16
+ # subfolders).
14
17
  module MemoryPaths
18
+ # The file that makes a folder in no git repository a chi project
19
+ # (`chi project init`). The file, not the bare .chi/ dir, is the marker.
20
+ MARKER = File.join(".chi", "project.yml").freeze
21
+
22
+ # How a folder's project was found (#lookup): +kind+ is :git, :marker or
23
+ # :none; +root+ is what memories are keyed on (#project_root: git's
24
+ # common-dir checkout, the marked folder's realpath, or the folder
25
+ # itself); +top+ is the folder a session reads its AGENTS.md chain from
26
+ # (the work tree's top, the marked root; nil for :none).
27
+ ProjectLookup = Data.define(:kind, :root, :top) do
28
+ def project? = kind != :none
29
+ def git? = kind == :git
30
+ def marker? = kind == :marker
31
+ end
32
+
15
33
  module_function
16
34
 
35
+ # The project +cwd+ belongs to, looked up once: a .git above it wins
36
+ # (git is the identity wherever it exists); only when there is none, the
37
+ # nearest MARKER above it, ignoring one in $HOME or any folder above it
38
+ # (and /), so a marker in ~ never makes every folder under home one
39
+ # project. The marker walk starts from +cwd+'s real path and the marked
40
+ # root is a realpath, as git's root is, so every spelling of a folder
41
+ # (/tmp, /var on macOS, a symlink into or out of a marked tree) gets the
42
+ # answer the worker's physical Dir.pwd gets.
43
+ # @param home [String, nil] $HOME by default; nil or blank ignores only /
44
+ # @return [ProjectLookup]
45
+ def lookup(cwd = Dir.pwd, home: default_home)
46
+ dot_git = find_dot_git(cwd)
47
+ return ProjectLookup.new(kind: :git, root: git_root(dot_git), top: File.dirname(dot_git)) if dot_git
48
+
49
+ marked = nearest_marker(cwd, home: home)
50
+ return ProjectLookup.new(kind: :marker, root: marked, top: marked) if marked
51
+
52
+ ProjectLookup.new(kind: :none, root: cwd, top: nil)
53
+ end
54
+
55
+ # Whether a marker at +dir+ is ignored: +dir+ is / or holds $HOME (both
56
+ # resolved; Guardrails::PathResolve.home_root?).
57
+ def above_home?(dir, home: default_home)
58
+ Guardrails::PathResolve.real(dir) == "/" || Guardrails::PathResolve.home_root?(dir, home)
59
+ end
60
+
61
+ # $HOME, or nil when it can't be found (no HOME, no passwd entry).
62
+ def default_home
63
+ Dir.home
64
+ rescue ArgumentError
65
+ nil
66
+ end
67
+
17
68
  def system_dir(env: ENV)
18
69
  File.join(ConfigFile.config_dir(env: env), "memories")
19
70
  end
@@ -28,16 +79,31 @@ module Samagotchi
28
79
  # #project_key for a root already resolved (a session's project_root).
29
80
  def key_for_root(root) = "#{File.basename(root)}_#{Digest::MD5.hexdigest(root)[0..7]}"
30
81
 
31
- # The repository's common git directory identifies the project; every linked
32
- # worktree shares it. Returns the checkout holding it when it is named .git,
33
- # otherwise the git dir itself (a bare repo, a proj/.bare layout,
34
- # --separate-git-dir, a submodule's .git/modules/<name>), so for those layouts
35
- # the "project root" is a git dir, not a checkout. Outside any repository the
36
- # root is cwd. No git subprocess: at most two small files are read.
37
- def project_root(cwd = Dir.pwd)
38
- dot_git = find_dot_git(cwd)
39
- return cwd unless dot_git
82
+ # The project root (#lookup): in a repository, its common git directory
83
+ # identifies the project; every linked worktree shares it. Returns the
84
+ # checkout holding it when it is named .git, otherwise the git dir itself
85
+ # (a bare repo, a proj/.bare layout, --separate-git-dir, a submodule's
86
+ # .git/modules/<name>), so for those layouts the "project root" is a git
87
+ # dir, not a checkout. Outside any repository, the marked folder holding
88
+ # +cwd+, else cwd. No git subprocess: at most two small files are read.
89
+ def project_root(cwd = Dir.pwd) = lookup(cwd).root
90
+
91
+ # Is +cwd+ in a project: a git repository or a marked folder?
92
+ def in_project?(cwd = Dir.pwd) = lookup(cwd).project?
93
+
94
+ # The marked folder (realpath) +cwd+'s project is, or nil (in a git
95
+ # repository, or in no project).
96
+ def marked_root(cwd = Dir.pwd)
97
+ found = lookup(cwd)
98
+ found.marker? ? found.root : nil
99
+ end
100
+
101
+ # The top of +cwd+'s project: the git work tree's top (#work_tree_root) or
102
+ # the marked root; nil in no project.
103
+ def project_top(cwd = Dir.pwd) = lookup(cwd).top
40
104
 
105
+ # The project root of the checkout whose .git is +dot_git+ (#project_root).
106
+ def git_root(dot_git)
41
107
  holder = File.dirname(dot_git)
42
108
  gitdir = File.directory?(dot_git) ? dot_git : gitdir_from_file(dot_git)
43
109
  return holder unless gitdir
@@ -78,7 +144,8 @@ module Samagotchi
78
144
  File.directory?(dot_git) ? dot_git : gitdir_from_file(dot_git)
79
145
  end
80
146
 
81
- # Is +cwd+ inside a git repository (a .git somewhere above it)?
147
+ # Is +cwd+ inside a git repository (a .git somewhere above it)? Git only:
148
+ # a marked folder is not a repository (#in_project?).
82
149
  def in_repo?(cwd = Dir.pwd)
83
150
  !find_dot_git(cwd).nil?
84
151
  end
@@ -96,6 +163,28 @@ module Samagotchi
96
163
  end
97
164
  end
98
165
 
166
+ # The realpath of the nearest folder at or above +start+ holding MARKER,
167
+ # git or not (#lookup asks only outside a repository; `chi project` tells
168
+ # of one a repository makes ignored), or nil. A marker at or above +home+
169
+ # (#above_home?) ends the walk with nil: every folder above it is above
170
+ # home too.
171
+ def nearest_marker(start, home: default_home)
172
+ dir = Guardrails::PathResolve.real(start)
173
+ loop do
174
+ if File.file?(File.join(dir, MARKER))
175
+ return nil if above_home?(dir, home: home)
176
+
177
+ return File.realpath(dir)
178
+ end
179
+ parent = File.dirname(dir)
180
+ return nil if parent == dir
181
+
182
+ dir = parent
183
+ end
184
+ rescue SystemCallError
185
+ nil
186
+ end
187
+
99
188
  # A ".git" file holds "gitdir: <path>", relative to the file's directory.
100
189
  def gitdir_from_file(path)
101
190
  line = File.read(path, 4096).to_s[/\Agitdir:\s*(.+)$/, 1]&.strip
@@ -115,7 +204,7 @@ module Samagotchi
115
204
  rescue SystemCallError
116
205
  gitdir
117
206
  end
118
- private_class_method :find_dot_git, :gitdir_from_file, :common_dir
207
+ private_class_method :find_dot_git, :git_root, :gitdir_from_file, :common_dir
119
208
 
120
209
  def project_dir(env: ENV, cwd: Dir.pwd)
121
210
  File.join(projects_dir(env: env), project_key(cwd))
@@ -32,10 +32,7 @@ module Samagotchi
32
32
  # plugin writes it (ApiCalls#keywords: `ctx.sessions.send(…,
33
33
  # delivery:)` is seen, a `sessions = ctx.sessions` alias isn't).
34
34
  # Events aren't listed: chi.on takes any symbol and an older chi never
35
- # fires one it doesn't know (harmless; chi bundle check warns on it),
36
- # and a manifest hook's events: list (BundleHook) needs the bundle's
37
- # requires_chi set by hand (docs/hooks.md, Bundle Hooks): no source
38
- # scan covers manifest.yml.
35
+ # fires one it doesn't know (harmless; chi bundle check warns on it).
39
36
  SINCE = {
40
37
  "ctx.steer" => "0.3.0", "ctx.stop_turn" => "0.3.0", "ctx.stop_generation" => "0.6.0",
41
38
  "Samagotchi::AtomicFile" => "0.8.0", "ctx.model" => "0.17.0", "ctx.model_key" => "0.17.0",
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require_relative "api"
4
5
  require_relative "context"
5
6
  require_relative "../hooks"
@@ -10,6 +11,11 @@ require_relative "../memory_bundle/manifest"
10
11
  require_relative "../memory_bundle/provenance"
11
12
 
12
13
  module Samagotchi
14
+ # Where installed bundles' plugin code is evaluated: one module per
15
+ # bundle (Plugin::Loader.namespace_for), so two bundles defining the same
16
+ # class name can't collide on a top-level constant.
17
+ module Bundles; end
18
+
13
19
  module Plugin
14
20
  # What a plugin registers into: an Engine's own registries.
15
21
  # +context_for+ is (bundle_name, settings, label) → the Plugin::Context
@@ -36,7 +42,7 @@ module Samagotchi
36
42
  #
37
43
  # For each bundle, by name: the file must match the sha256 recorded at
38
44
  # install and chi must meet the bundle's requires_chi. The file is
39
- # module_eval'd into the bundle's namespace (as its hooks are), the
45
+ # module_eval'd into the bundle's namespace (#namespace_for), the
40
46
  # class named like the file (plugin.rb → Plugin) is built with the
41
47
  # bundle's settings, and its #register gets a Plugin::Api. What it
42
48
  # registered takes effect only when #register returns: a plugin that
@@ -140,14 +146,45 @@ module Samagotchi
140
146
  return failure if failure
141
147
  return "the file is missing" unless file && File.file?(file)
142
148
 
143
- Hooks::BundleLoader.sha_mismatch(file, bundle.plugin.sha256, required: true)
149
+ sha_mismatch(file, bundle.plugin.sha256)
150
+ end
151
+
152
+ # Why +file+ doesn't match the sha256 recorded at install (+recorded+,
153
+ # "sha256:<hex>" or bare hex), or nil. None recorded is a mismatch;
154
+ # Provenance.unchecked? (a dev install) is none.
155
+ def sha_mismatch(file, recorded)
156
+ return nil if MemoryBundle::Provenance.unchecked?(recorded)
157
+
158
+ expected = MemoryBundle::Provenance.recorded_sha(recorded)
159
+ return "no sha256 recorded for it (reinstall the bundle)" if expected.empty?
160
+
161
+ actual = MemoryBundle::Provenance.file_sha(file)
162
+ return nil if actual == expected
163
+
164
+ "its sha256 #{actual[0, 12]}… differs from the installed #{expected[0, 12]}… (edited after install? reinstall the bundle)"
165
+ end
166
+
167
+ # The (lazily created) namespace module for a bundle, under
168
+ # Samagotchi::Bundles. The name is sanitized and made injective by a
169
+ # short digest, so `my-bundle` and `my_bundle` don't collide.
170
+ def namespace_for(bundle_name)
171
+ raw = bundle_name.to_s
172
+ base = raw.gsub(/[^A-Za-z0-9_]/, "_")
173
+ base = "B_#{base}" unless base.match?(/\A[A-Z]/)
174
+ ns_name = :"#{base}_#{Digest::MD5.hexdigest(raw)[0, 4]}"
175
+ if Samagotchi::Bundles.const_defined?(ns_name, false)
176
+ Samagotchi::Bundles.const_get(ns_name)
177
+ else
178
+ Samagotchi::Bundles.const_set(ns_name, Module.new)
179
+ end
144
180
  end
145
181
 
146
182
  # module_eval the file into a fresh module in the bundle's namespace
147
183
  # (Samagotchi::Bundles::<bundle>::PluginLoad<n>: each load gets new
148
184
  # classes, not the last load's reopened) and build its class
149
- # (plugin.rb → Plugin; my_plugin.rb → MyPlugin) as hooks are built:
150
- # an initialize that takes an argument gets the settings.
185
+ # (plugin.rb → Plugin; my_plugin.rb → MyPlugin) as config hooks are
186
+ # built (Hooks.build_plugin): an initialize that takes an argument
187
+ # gets the settings.
151
188
  def instantiate(bundle_name, file, settings, namespace: nil)
152
189
  namespace ||= fresh_namespace(bundle_name)
153
190
  namespace.module_eval(File.read(file), file, 1)
@@ -163,7 +200,7 @@ module Samagotchi
163
200
  def fresh_namespace(bundle_name)
164
201
  LOADS.synchronize do
165
202
  @loads = (@loads || 0) + 1
166
- Hooks::BundleLoader.namespace_for(bundle_name).const_set(:"PluginLoad#{@loads}", Module.new)
203
+ namespace_for(bundle_name).const_set(:"PluginLoad#{@loads}", Module.new)
167
204
  end
168
205
  end
169
206
 
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli/command"
4
+ require_relative "cli/exit"
5
+ require_relative "cli/flags"
6
+ require_relative "guardrails/path_resolve"
7
+ require_relative "memory_paths"
8
+ require_relative "paths"
9
+ require_relative "project_init"
10
+
11
+ module Samagotchi
12
+ # `chi project`: which project this folder is in (MemoryPaths.lookup: a
13
+ # git repository, a folder marked with `.chi/project.yml`, or none) and
14
+ # its project memories folder; `chi project init [DIR]` marks a folder in
15
+ # no git repository as a project. Git wins: a repository is already the
16
+ # project, so init writes nothing there. Machine-local
17
+ # (SessionAccess::Contract::MACHINE).
18
+ class ProjectCommand
19
+ include CLI::Command
20
+
21
+ USAGE = <<~TEXT
22
+ Usage: chi project [init [DIR]]
23
+ (none) this folder's project: how it was found (git, marker or none), its root, its
24
+ top and its project memories folder
25
+ init [DIR] make DIR (default: this folder; created when missing) a chi project: writes
26
+ DIR/#{MemoryPaths::MARKER}, so DIR and every subfolder share one project memories
27
+ folder and group their sessions. Not in a git repository (it already is the
28
+ project), nor $HOME or a folder above it
29
+ TEXT
30
+
31
+ SUBCOMMANDS = %w[init].freeze
32
+ FLAGS = {
33
+ nil => CLI::Flags.new(help: HELP_WORDS, args: false),
34
+ "init" => CLI::Flags.new(help: HELP_WORDS)
35
+ }.freeze
36
+
37
+ # @param cwd [String] the folder asked about (Dir.pwd)
38
+ # @param home [String, nil] $HOME (markers at or above it are ignored)
39
+ # @param sessions [#call, nil] () → this machine's SessionRows (for
40
+ # init's restart note; SessionManager.session_rows by default)
41
+ def initialize(argv, stdout: $stdout, stderr: $stderr, cwd: Dir.pwd, env: ENV, home: MemoryPaths.default_home,
42
+ sessions: nil)
43
+ @argv = argv.dup
44
+ @stdout = stdout
45
+ @stderr = stderr
46
+ @cwd = cwd
47
+ @env = env
48
+ @home = home
49
+ @sessions = sessions || lambda {
50
+ require_relative "session_manager"
51
+ SessionManager.session_rows
52
+ }
53
+ end
54
+
55
+ # @return [Integer] 0, 1 (a refusal), 2 usage
56
+ def run
57
+ @sub = SUBCOMMANDS.include?(@argv.first) ? @argv.first : nil
58
+ parsed = parse_flags(FLAGS.fetch(@sub), @sub ? @argv[1..] : @argv)
59
+ return parsed if parsed.is_a?(Integer)
60
+
61
+ return run_show unless @sub
62
+ return usage_error("init takes one folder") if parsed.args.size > 1
63
+
64
+ run_init(parsed.args.first || @cwd)
65
+ end
66
+
67
+ private
68
+
69
+ def command_name = @sub ? "chi project #{@sub}" : "chi project"
70
+ def usage_on_error = nil
71
+
72
+ def lookup(dir) = MemoryPaths.lookup(dir, home: @home)
73
+
74
+ def run_show
75
+ found = lookup(@cwd)
76
+ row("kind", found.kind.to_s)
77
+ if found.project?
78
+ row("root", found.root)
79
+ row("top", found.top)
80
+ end
81
+ row("memories", memories_line(found.root))
82
+ notes(found).each { |note| row("note", note) }
83
+ CLI::Exit::OK
84
+ end
85
+
86
+ def row(label, value) = @stdout.puts("#{label.ljust(9)}#{value}")
87
+
88
+ def memories_line(root)
89
+ dir = File.join(MemoryPaths.projects_dir(env: @env), MemoryPaths.key_for_root(root))
90
+ count = memory_count(dir)
91
+ "#{Paths.tilde(dir)}#{" (#{count} #{count == 1 ? "memory" : "memories"})" if count.positive?}"
92
+ end
93
+
94
+ def memory_count(dir)
95
+ return 0 unless File.directory?(dir)
96
+
97
+ Dir.children(dir).count { |name| name.end_with?(".md") && name != "index.md" }
98
+ end
99
+
100
+ def notes(found)
101
+ case found.kind
102
+ when :git
103
+ list = []
104
+ list << "#{Paths.tilde(found.top)} is $HOME, a git repository: every folder under it is in that repository" if home_repo?(found)
105
+ marker = MemoryPaths.nearest_marker(@cwd, home: @home)
106
+ list << "#{Paths.tilde(File.join(marker, MemoryPaths::MARKER))} is ignored: the git repository is the project" if marker
107
+ list
108
+ when :none then ["no git repository or chi project marker here: chi project init makes this folder a project"]
109
+ else []
110
+ end
111
+ end
112
+
113
+ def home_repo?(found)
114
+ !@home.to_s.empty? && Guardrails::PathResolve.real(found.top) == Guardrails::PathResolve.real(@home)
115
+ end
116
+
117
+ def run_init(dir)
118
+ dir = File.expand_path(dir, @cwd)
119
+ outcome = ProjectInit.call(dir, home: @home)
120
+ case outcome.kind
121
+ when :git
122
+ found = MemoryPaths::ProjectLookup.new(kind: :git, root: outcome.root, top: outcome.top)
123
+ note = home_repo?(found) ? " ($HOME is a git repository: every folder under it is in it)" : ""
124
+ @stdout.puts("#{outcome.message}#{note}")
125
+ CLI::Exit::OK
126
+ when :refused
127
+ error_line("#{command_name}: #{outcome.message}")
128
+ CLI::Exit::FAILED
129
+ when :already_marked
130
+ @stdout.puts(outcome.message)
131
+ CLI::Exit::OK
132
+ when :created
133
+ report_init(outcome.root, nested_in: outcome.nested_in)
134
+ CLI::Exit::OK
135
+ end
136
+ rescue SystemCallError => e
137
+ error_line("#{command_name}: couldn't write #{Paths.tilde(File.join(dir, MemoryPaths::MARKER))}: #{e.message}")
138
+ CLI::Exit::FAILED
139
+ end
140
+
141
+ def report_init(root, nested_in:)
142
+ nested = nested_in ? ", its own project nested in #{Paths.tilde(nested_in)}" : ""
143
+ @stdout.puts("#{Paths.tilde(root)} is now a chi project#{nested} (#{Paths.tilde(File.join(root, MemoryPaths::MARKER))})")
144
+ row("memories", memories_line(root))
145
+ sessions_note(root)
146
+ end
147
+
148
+ # Sessions in the new project (not one under DIR that a nested
149
+ # repository or marked folder keeps): a running one keeps its old project in its
150
+ # prompt (the memories folder line, the AGENTS.md chain) until it
151
+ # restarts; a running chi web groups the older ones after its restart
152
+ # (SessionHub looked their project up once).
153
+ def sessions_note(root)
154
+ rows = @sessions.call.select { |r| !r.cwd.to_s.empty? && lookup(r.cwd.to_s).root == root }
155
+ return if rows.empty?
156
+
157
+ live = rows.select(&:live)
158
+ unless live.empty?
159
+ @stdout.puts("running sessions in it keep the old project in their prompt until restarted " \
160
+ "(chi sessions restart ID): #{live.map { |r| r.id[0, 8] }.join(", ")}")
161
+ end
162
+ @stdout.puts("#{rows.size} saved session(s) in it join the project in a running chi web after its restart")
163
+ rescue StandardError => e
164
+ error_line("#{command_name}: couldn't list this machine's sessions (#{e.message})")
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "guardrails/path_resolve"
5
+ require_relative "memory_paths"
6
+ require_relative "paths"
7
+
8
+ module Samagotchi
9
+ # The shared decisions for `chi project init` and chi web's project picker.
10
+ module ProjectInit
11
+ Outcome = Data.define(:kind, :reason, :root, :top, :nested_in, :message)
12
+
13
+ MARKER_TEXT = "# chi project marker: this folder is a chi project (docs/memory.md)\n"
14
+
15
+ module_function
16
+
17
+ def call(dir, home:)
18
+ dir = File.expand_path(dir.to_s)
19
+ found = MemoryPaths.lookup(dir, home: home)
20
+ if found.git?
21
+ message = "#{Paths.tilde(dir)} is in the git repository #{Paths.tilde(found.top)}: the repository already is " \
22
+ "the project, nothing written"
23
+ return Outcome.new(kind: :git, reason: nil, root: found.root, top: found.top, nested_in: nil, message: message)
24
+ end
25
+ if MemoryPaths.above_home?(dir, home: home)
26
+ message = "#{Paths.tilde(dir)} is $HOME, a folder above it or /: it can't be a chi project " \
27
+ "(every folder under it would be one); mark a folder inside it"
28
+ return Outcome.new(kind: :refused, reason: :above_home, root: found.root, top: found.top, nested_in: nil,
29
+ message: message)
30
+ end
31
+
32
+ # The marked project this folder sits in, if any: the folder itself (already marked) or its parent project.
33
+ enclosing = found.marker? ? found.root : nil
34
+ if enclosing == Guardrails::PathResolve.real(dir)
35
+ return Outcome.new(kind: :already_marked, reason: nil, root: enclosing, top: found.top, nested_in: nil,
36
+ message: "#{Paths.tilde(enclosing)} is already a chi project")
37
+ end
38
+
39
+ write_marker(dir)
40
+ root = File.realpath(dir)
41
+ # No message: each surface words its own success (the CLI's report, the web's navigation).
42
+ Outcome.new(kind: :created, reason: nil, root: root, top: root, nested_in: enclosing, message: nil)
43
+ rescue SystemCallError => e
44
+ marker = File.join(dir, MemoryPaths::MARKER)
45
+ write_failed("couldn't write #{Paths.tilde(marker)}: #{e.message}")
46
+ end
47
+
48
+ # chi web opens a folder in a git repository without marking it, so the folder has to exist.
49
+ # @return [Outcome, nil] a refused (:write_failed) outcome when it can't be made, else nil
50
+ def ensure_folder(dir)
51
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
52
+ nil
53
+ rescue SystemCallError => e
54
+ write_failed("couldn't create #{Paths.tilde(dir)}: #{e.message}")
55
+ end
56
+
57
+ def write_failed(message)
58
+ Outcome.new(kind: :refused, reason: :write_failed, root: nil, top: nil, nested_in: nil, message: message)
59
+ end
60
+ private_class_method :write_failed
61
+
62
+ def write_marker(dir)
63
+ marker = File.join(dir, MemoryPaths::MARKER)
64
+ FileUtils.mkdir_p(File.dirname(marker))
65
+ File.write(marker, MARKER_TEXT)
66
+ end
67
+ private_class_method :write_marker
68
+ end
69
+ end
@@ -4,9 +4,10 @@ require_relative "memory_paths"
4
4
 
5
5
  module Samagotchi
6
6
  # Which project a folder belongs to, for scoping session lists (web and
7
- # terminal alike): the project root memories use (MemoryPaths.project_root),
8
- # so every worktree and subfolder of one repository is one project. A
9
- # folder in no git repository has no project (nil): its scope is "all".
7
+ # terminal alike): the project root memories use (MemoryPaths.lookup), so
8
+ # every worktree and subfolder of one repository, or every subfolder of a
9
+ # folder marked by `chi project init`, is one project. A folder in neither
10
+ # has no project (nil): its scope is "all".
10
11
  module ProjectScope
11
12
  module_function
12
13
 
@@ -17,7 +18,8 @@ module Samagotchi
17
18
  return nil if dir.empty?
18
19
  return cache[dir] if cache&.key?(dir)
19
20
 
20
- root = MemoryPaths.in_repo?(dir) ? MemoryPaths.project_root(File.expand_path(dir)) : nil
21
+ found = MemoryPaths.lookup(File.expand_path(dir))
22
+ root = found.project? ? found.root : nil
21
23
  cache[dir] = root if cache
22
24
  root
23
25
  end