samagotchi 0.157.0 → 0.158.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -1
  3. data/README.md +1 -1
  4. data/bin/chi +3 -1
  5. data/docs/architecture.md +7 -6
  6. data/docs/bundle-authoring.md +5 -5
  7. data/docs/bundles.md +2 -1
  8. data/docs/cli.md +21 -17
  9. data/docs/configuration.md +7 -5
  10. data/docs/context.md +5 -3
  11. data/docs/guardrails.md +8 -7
  12. data/docs/hooks.md +44 -80
  13. data/docs/internals/bridge.md +1 -1
  14. data/docs/internals/bundles.md +47 -34
  15. data/docs/internals/engine.md +7 -5
  16. data/docs/memory.md +39 -9
  17. data/docs/plugins.md +13 -9
  18. data/docs/releasing.md +6 -5
  19. data/docs/sessions.md +5 -5
  20. data/docs/testing.md +4 -4
  21. data/lib/samagotchi/bridge/card_places.rb +5 -13
  22. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  23. data/lib/samagotchi/bridge/turn_drawings.rb +7 -1
  24. data/lib/samagotchi/bundle_command/check.rb +23 -10
  25. data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
  26. data/lib/samagotchi/bundle_command/try/run.rb +4 -1
  27. data/lib/samagotchi/bundle_command.rb +15 -43
  28. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/memory_guide.md +6 -6
  31. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  32. data/lib/samagotchi/context_command.rb +7 -5
  33. data/lib/samagotchi/context_sources.rb +4 -2
  34. data/lib/samagotchi/engine.rb +1 -6
  35. data/lib/samagotchi/extension_load.rb +20 -41
  36. data/lib/samagotchi/guardrails/load_failures.rb +3 -2
  37. data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
  38. data/lib/samagotchi/guardrails/review_paths.rb +1 -5
  39. data/lib/samagotchi/hook_routes.rb +2 -2
  40. data/lib/samagotchi/hooks.rb +2 -3
  41. data/lib/samagotchi/memory_bundle/asset_installer.rb +39 -119
  42. data/lib/samagotchi/memory_bundle/builder.rb +10 -34
  43. data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
  44. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  45. data/lib/samagotchi/memory_bundle/installed_copy.rb +11 -18
  46. data/lib/samagotchi/memory_bundle/installed_manifest.rb +2 -16
  47. data/lib/samagotchi/memory_bundle/installer.rb +32 -14
  48. data/lib/samagotchi/memory_bundle/manifest.rb +37 -54
  49. data/lib/samagotchi/memory_bundle/provenance.rb +11 -51
  50. data/lib/samagotchi/memory_bundle/remerge.rb +7 -3
  51. data/lib/samagotchi/memory_bundle/shas.rb +5 -8
  52. data/lib/samagotchi/memory_bundle/shipped_update.rb +30 -35
  53. data/lib/samagotchi/memory_bundle/status.rb +12 -10
  54. data/lib/samagotchi/memory_bundle/system_bundle.rb +16 -5
  55. data/lib/samagotchi/memory_bundle/trash.rb +7 -12
  56. data/lib/samagotchi/memory_orphans.rb +4 -4
  57. data/lib/samagotchi/memory_paths.rb +102 -13
  58. data/lib/samagotchi/plugin/api.rb +1 -4
  59. data/lib/samagotchi/plugin/loader.rb +42 -5
  60. data/lib/samagotchi/project_command.rb +167 -0
  61. data/lib/samagotchi/project_init.rb +69 -0
  62. data/lib/samagotchi/project_scope.rb +6 -4
  63. data/lib/samagotchi/prompt_files.rb +10 -7
  64. data/lib/samagotchi/recap_place.rb +3 -4
  65. data/lib/samagotchi/run_options.rb +1 -0
  66. data/lib/samagotchi/session.rb +44 -18
  67. data/lib/samagotchi/session_access/contract.rb +1 -1
  68. data/lib/samagotchi/session_field.rb +5 -4
  69. data/lib/samagotchi/steer.rb +10 -0
  70. data/lib/samagotchi/system_prompt.rb +20 -11
  71. data/lib/samagotchi/terminal_ui/join_replay.rb +6 -1
  72. data/lib/samagotchi/testing/bundle.rb +5 -12
  73. data/lib/samagotchi/testing/install.rb +5 -5
  74. data/lib/samagotchi/testing/recorder.rb +1 -1
  75. data/lib/samagotchi/testing.rb +1 -1
  76. data/lib/samagotchi/tool_declarations.rb +1 -1
  77. data/lib/samagotchi/toolkit_binding.rb +1 -4
  78. data/lib/samagotchi/toolkit_command.rb +1 -4
  79. data/lib/samagotchi/tools/delegate.rb +12 -8
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +35 -0
  82. data/lib/samagotchi/web/project_list.rb +3 -2
  83. data/lib/samagotchi/web/public/index.html +6 -1
  84. data/lib/samagotchi/web/public/project_picker.js +86 -4
  85. metadata +3 -2
  86. data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
@@ -25,7 +25,7 @@ require_relative "recording_api"
25
25
 
26
26
  module Samagotchi
27
27
  module Testing
28
- # A hook (a plugin's chi.on block or a hook file) raised while the kit
28
+ # A hook (a plugin's chi.on block) raised while the kit
29
29
  # fired it: chi would log it and go on, a test fails with it.
30
30
  class HookFailed < StandardError
31
31
  # @return [Hooks::Failure]
@@ -53,8 +53,7 @@ module Samagotchi
53
53
 
54
54
  # One bundle loaded for a test through chi's own seams: its plugin
55
55
  # through Plugin::Loader.register_file (Plugin::Api, a real
56
- # Plugin::Context) and its hook files through
57
- # Hooks::BundleLoader.load_hook_file, into real registries (commands
56
+ # Plugin::Context), into real registries (commands
58
57
  # and tools with chi's built-ins in them, so a name clash fails as in
59
58
  # chi). Only the edges are recorded: the Host and hook Runtime
60
59
  # (Recorder), ctx.sessions (Testing::Sessions), chi.init tasks and
@@ -130,8 +129,6 @@ module Samagotchi
130
129
  # @return [Module, nil] the module the plugin was evaluated in
131
130
  # (bundle.namespace::ThinkingWatch)
132
131
  attr_reader :namespace
133
- # @return [Module] the module the hook files were evaluated in
134
- attr_reader :hooks_namespace
135
132
  # @return [Array<Init>]
136
133
  attr_reader :inits
137
134
  # @return [Array<StagedTools>]
@@ -152,6 +149,9 @@ module Samagotchi
152
149
  @session_moves = 1
153
150
  own = session_settings.nil? || session_settings.empty? ? {} : { setup: setup_with(session_settings, host[:setup]) }
154
151
  @own_sandbox = sandbox.nil? && Sandbox.current.nil?
152
+ # With no active sandbox, this one saves ENV at construction and
153
+ # restores that copy at shutdown; don't lazily build it inside a block
154
+ # that sets ENV for one example (your own helper, or ClimateControl).
155
155
  @sandbox = sandbox || Sandbox.current || Sandbox.enter
156
156
  build(host, own)
157
157
  rescue Exception # rubocop:disable Lint/RescueException -- leave the sandbox it entered, whatever failed
@@ -184,13 +184,6 @@ module Samagotchi
184
184
  @plugin = built
185
185
  end
186
186
  end
187
- @hooks_namespace = Module.new
188
- @manifest.hooks.each do |basename, hook|
189
- next if hook.events.empty?
190
-
191
- Hooks::BundleLoader.load_hook_file(@name, basename, File.join(@dir, "hooks", basename), hook, hooks,
192
- settings: @settings, namespace: @hooks_namespace)
193
- end
194
187
  self
195
188
  rescue Exception # rubocop:disable Lint/RescueException -- leave the sandbox it entered, whatever failed
196
189
  shutdown
@@ -15,7 +15,7 @@ module Samagotchi
15
15
  # A bundle as chi installs and loads it, unlike Bundle (its source as it
16
16
  # is): MemoryBundle::Installer copies it into the sandbox's state (sha256
17
17
  # and requires_chi checked), then chi's own loaders load what is
18
- # installed (ExtensionLoad: the bundle hooks, then the plugins). What
18
+ # installed (ExtensionLoad: config hooks, then the plugins). What
19
19
  # failed names the bundle's source files, not the installed copies.
20
20
  #
21
21
  # result = Samagotchi::Testing::Install.run("path/to/bundle", settings: { "every" => 3 })
@@ -23,8 +23,8 @@ module Samagotchi
23
23
  module Install
24
24
  # warnings: the Installer's; plugin_failures / hook_failures:
25
25
  # [Guardrails::LoadFailures::Failure] of this bundle, each location
26
- # in the source dir (hook_failures: the guardrails', a required
27
- # plugin's among them); registered: what it registered (#registered).
26
+ # in the source dir (hook_failures: the guardrails': a required
27
+ # plugin's load failure, a config hook's); registered: what it registered (#registered).
28
28
  Result = Data.define(:name, :warnings, :plugin_failures, :hook_failures, :registered) do
29
29
  def failures = plugin_failures + hook_failures
30
30
  end
@@ -35,7 +35,7 @@ module Samagotchi
35
35
  Registered = Data.define(:commands, :tools, :hooks, :inits, :toolkits, :tool_sources, :peer_sources, :prompt_sections)
36
36
  # A command: its mid_turn policy and its selection-bar labels (chi.annotation).
37
37
  Command = Data.define(:name, :mid_turn, :labels)
38
- # A hook on an event: the bundle, the hook (its file, or the plugin's) and its priority.
38
+ # A hook on an event: the bundle, the hook (the plugin's label) and its priority.
39
39
  Hook = Data.define(:bundle, :hook, :priority)
40
40
 
41
41
  module_function
@@ -119,7 +119,7 @@ module Samagotchi
119
119
  # installed copy to the source dir.
120
120
  def own(failures, name, installed, dir)
121
121
  failures.list.select { |f| f.what.end_with?("(bundle #{name})") }.map do |failure|
122
- # The plugin is installed into plugin/, the hooks into hooks/.
122
+ # The plugin is installed into plugin/.
123
123
  location = failure.location&.sub(%r{\A#{Regexp.escape(installed)}/(?:plugin/)?}, "#{dir}/")
124
124
  failure.dup.tap { |copy| copy.location = location }
125
125
  end
@@ -9,7 +9,7 @@ require_relative "judgments"
9
9
  module Samagotchi
10
10
  module Testing
11
11
  # A line to the user (ctx.notify, event[:notify]); +hook+ is the
12
- # plugin's or hook file's label ("plugin.rb (bundle x)").
12
+ # plugin's label ("plugin.rb (bundle x)").
13
13
  Notice = Data.define(:text, :level, :hook, :fallback_for)
14
14
  # Text put into the running turn (ctx.steer, event[:steer]).
15
15
  Steer = Data.define(:text, :hook)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # The bundle test kit (docs/plugins.md, "Testing a bundle"): load a bundle's
4
- # plugin and hook files through chi's own loaders, registries, Context and
4
+ # plugin through chi's own loaders, registries, Context and
5
5
  # guardrail Gate, with only the edges recorded (the session's Host, the hook
6
6
  # runtime, ctx.sessions, init tasks), in a sandboxed environment.
7
7
  #
@@ -375,7 +375,7 @@ module Samagotchi
375
375
  },
376
376
  cwd: {
377
377
  type: "string",
378
- description: "The new child's working folder: a worktree or subfolder of this session's repository (create a worktree with execute `git worktree add ../<repo>-<name> -b <branch>` first). Default: this session's folder"
378
+ description: "The new child's working folder: a worktree or subfolder of this session's project (a repository, or a `chi project init` folder; create a worktree with execute `git worktree add ../<repo>-<name> -b <branch>` first). Default: this session's folder"
379
379
  },
380
380
  toolkit: {
381
381
  type: "string",
@@ -139,10 +139,7 @@ module Samagotchi
139
139
 
140
140
  toolkit = Toolkit.new(name: name)
141
141
  if session.toolkit == toolkit
142
- if session.toolkit_origin != "chosen"
143
- session.toolkit_origin = "chosen"
144
- session.save(state_dir: @state_dir.call)
145
- end
142
+ session.save(state_dir: @state_dir.call) if session.choose_toolkit_origin!
146
143
  return true
147
144
  end
148
145
 
@@ -47,10 +47,7 @@ module Samagotchi
47
47
  # Nothing the prompt carries changes (full +delegate on full): only
48
48
  # the origin is saved, so a no-op choice still counts as user-selected.
49
49
  if after.prompt_key == before.prompt_key
50
- if session.toolkit_origin != "chosen"
51
- session.toolkit_origin = "chosen"
52
- @save.call(session)
53
- end
50
+ @save.call(session) if session.choose_toolkit_origin!
54
51
  return [listing(before) + "\n(no change to what the next turn declares)", false, nil]
55
52
  end
56
53
 
@@ -281,9 +281,10 @@ module Samagotchi
281
281
  private_class_method :unknown_toolkit
282
282
 
283
283
  # The new child's folder: the parent's, or +cwd+ (relative to the
284
- # parent's) when it is a folder of the parent's repository, in any of
285
- # its worktrees (one project_root, from the common git dir). Outside
286
- # git only a subfolder of the parent's folder. Nothing is created
284
+ # parent's) when it is a folder of the parent's project: in any of its
285
+ # repository's worktrees (one project_root, from the common git dir), or
286
+ # in its marked folder. Outside both only a subfolder of the parent's
287
+ # folder. Nothing is created
287
288
  # here: the model makes a worktree with execute, where guardrails see it.
288
289
  # @return [String] an absolute folder, or an Error: line
289
290
  def self.child_cwd(cwd, parent)
@@ -294,8 +295,8 @@ module Samagotchi
294
295
  return "Error: cwd #{folder} is not a folder (create the worktree first)" unless File.directory?(folder)
295
296
 
296
297
  folder = File.realpath(folder)
297
- unless same_repository?(folder, base)
298
- return "Error: cwd must be a folder of this session's repository (a worktree or subfolder of it), not #{folder}"
298
+ unless same_project?(folder, base)
299
+ return "Error: cwd must be a folder of this session's project (a worktree or subfolder of it), not #{folder}"
299
300
  end
300
301
  if MemoryPaths.in_repo?(base) && !work_tree?(folder)
301
302
  return "Error: cwd #{folder} is not in a work tree of this session's repository (a git dir, or a bare " \
@@ -327,8 +328,11 @@ module Samagotchi
327
328
  def self.within?(path, dir) = path == dir || path.start_with?("#{dir}/")
328
329
  private_class_method :within?
329
330
 
330
- def self.same_repository?(folder, base)
331
- if MemoryPaths.in_repo?(base)
331
+ # One project (a git repository or a marked folder): the same project
332
+ # root, so a nested repository or marked project is another one. A base
333
+ # in no project: a subfolder of it.
334
+ def self.same_project?(folder, base)
335
+ if MemoryPaths.in_project?(base)
332
336
  MemoryPaths.project_root(folder) == MemoryPaths.project_root(base)
333
337
  else
334
338
  within?(folder, File.realpath(base))
@@ -336,7 +340,7 @@ module Samagotchi
336
340
  rescue SystemCallError
337
341
  false
338
342
  end
339
- private_class_method :same_repository?
343
+ private_class_method :same_project?
340
344
 
341
345
  # @return [String] the child's id, or an Error: line
342
346
  def self.follow_up(task, session:, parent:, state_dir:)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.157.0"
4
+ VERSION = "0.158.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -17,6 +17,7 @@ require_relative "../delivery"
17
17
  require_relative "../answer_tail"
18
18
  require_relative "../answer_refs"
19
19
  require_relative "../question_asked"
20
+ require_relative "../project_init"
20
21
  require_relative "../bridge_client"
21
22
  require_relative "../worker_link"
22
23
  require_relative "../bridge/bounded_queue"
@@ -268,6 +269,7 @@ module Samagotchi
268
269
  ["GET", %r{\A/(?:index\.html)?\z}, :serve_index, :participant],
269
270
  ["GET", %r{\A/api/sessions\z}, :handle_list, :owner],
270
271
  ["GET", %r{\A/api/projects\z}, :handle_projects, :owner],
272
+ ["POST", %r{\A/api/projects\z}, :handle_project_create, :owner],
271
273
  ["POST", %r{\A/api/sessions\z}, :handle_create, :owner],
272
274
  ["GET", %r{\A/api/sessions/resolve\z}, :handle_resolve, :participant],
273
275
  ["GET", %r{\A/api/sessions/summaries\z}, :handle_summaries, :owner],
@@ -1006,6 +1008,39 @@ module Samagotchi
1006
1008
  json_response(200, ProjectList.build(@hub.snapshot, start_dir: start_dir, current_root: current_root))
1007
1009
  end
1008
1010
 
1011
+ def handle_project_create(req)
1012
+ body = parse_json(request_body(req))
1013
+ return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
1014
+
1015
+ requested = body["dir"]
1016
+ requested = requested.strip if requested.is_a?(String)
1017
+ unless requested.is_a?(String) && !requested.empty? && !requested.include?("\0")
1018
+ return error_response(400, "invalid_dir", "dir must be an absolute folder")
1019
+ end
1020
+
1021
+ home = Dir.home
1022
+ dir = if requested == "~"
1023
+ home
1024
+ elsif requested.start_with?("~/")
1025
+ File.join(home, requested.delete_prefix("~/"))
1026
+ else
1027
+ requested
1028
+ end
1029
+ return error_response(400, "invalid_dir", "dir must be an absolute folder") unless dir.start_with?("/")
1030
+
1031
+ # One normal form for the decision, the mkdir and the realpath (a `..` after a symlink would differ).
1032
+ dir = File.expand_path(dir)
1033
+ outcome = ProjectInit.call(dir, home: home)
1034
+ return error_response(422, outcome.reason.to_s, outcome.message) if outcome.kind == :refused
1035
+
1036
+ if outcome.kind == :git
1037
+ failed = ProjectInit.ensure_folder(dir)
1038
+ return error_response(422, failed.reason.to_s, failed.message) if failed
1039
+ end
1040
+ dir = File.realpath(dir)
1041
+ json_response(200, outcome: outcome.kind.to_s, root: outcome.root, dir: dir)
1042
+ end
1043
+
1009
1044
  # A scope folder from the page: [dir, nil] (dir nil when none was
1010
1045
  # given), or [nil, a 400 response] when it isn't an existing absolute
1011
1046
  # folder (a bookmarked worktree deleted since, a typo).
@@ -6,8 +6,8 @@ require_relative "push/attention"
6
6
 
7
7
  module Samagotchi
8
8
  module Web
9
- # Projects represented by the session hub, plus the start project even
10
- # when it has no sessions.
9
+ # Projects represented by the session hub, plus the start and current
10
+ # projects even when they have no sessions.
11
11
  module ProjectList
12
12
  module_function
13
13
 
@@ -24,6 +24,7 @@ module Samagotchi
24
24
  (groups[root] ||= []) << summary
25
25
  end
26
26
  groups[default_root] ||= [] if valid_root?(default_root)
27
+ groups[current_root] ||= [] if valid_root?(current_root)
27
28
 
28
29
  rows = groups.map do |root, sessions|
29
30
  default = root == default_root
@@ -1488,6 +1488,11 @@ body.has-session #newBtn .brand-new{display:inline}
1488
1488
  .project-all::before{content:"";position:absolute;left:-1px;right:-1px;top:-9px;height:1px;background:var(--border);pointer-events:none}
1489
1489
  .project-note{padding:5px 2px;color:var(--text-muted);font-size:12px}
1490
1490
  .project-error{color:var(--warn-text)}
1491
+ .project-picker .project-new{width:100%;text-align:left;background:var(--bg-inset);color:var(--accent)}
1492
+ .project-create{display:grid;gap:8px;padding:8px 2px}
1493
+ .project-create label{color:var(--text-muted);font-size:12px}
1494
+ .project-create input{width:100%;padding:9px 10px;border:1px solid var(--border);border-radius:8px;background:var(--bg-inset);color:var(--text)}
1495
+ .project-create-actions{display:flex;justify-content:flex-end;gap:8px}
1491
1496
  body.has-session #scope .scope-where{display:none}
1492
1497
  /* A phone has no room for both; the hidden strip's pill (count, live) wins. */
1493
1498
  @media(max-width:600px){body.strip-hidden #scope .scope-where{display:none}}
@@ -1780,7 +1785,7 @@ a{color:var(--accent)}
1780
1785
  @media (max-width:600px),(pointer:coarse){
1781
1786
  #composer textarea,.question-freeform,.continue-reason,.participants-popover input,.participants-popover select,
1782
1787
  .rs-form textarea,.pp-link input,.llmctx-field select,.llmctx-field input,.ws-add-in,.rename-input,
1783
- #modelSearch{font-size:16px}
1788
+ #modelSearch,.project-create input{font-size:16px}
1784
1789
  }
1785
1790
  </style>
1786
1791
  </head>
@@ -24,10 +24,19 @@ function projectRow(row) {
24
24
  </a>`;
25
25
  }
26
26
 
27
+ function projectParent(rootLabel) {
28
+ if (typeof rootLabel !== "string" || !rootLabel) return "~/";
29
+ const slash = rootLabel.lastIndexOf("/");
30
+ if (slash < 0) return "~/";
31
+ const parent = rootLabel.slice(0, slash);
32
+ return parent ? `${parent.replace(/\/$/, "")}/` : "/";
33
+ }
34
+
27
35
  // +rows+ is the API's ordered list. When there is no project scope, the
28
36
  // all-projects row is current; a bad ?dir= has no current project to mark.
29
37
  export function pickerTemplate({ rows = [], loading = false, error = "", currentDir = null,
30
- allCurrent = false } = {}) {
38
+ allCurrent = false, createOpen = false, createPath = "~/", creating = false,
39
+ createError = "", onPathInput = () => {} } = {}) {
31
40
  const allHref = allScopeHref("", currentDir);
32
41
  return html`<div class="ctx-pop-head"><strong>Projects</strong><button type="button" class="ctx-pop-close"
33
42
  data-act="close" aria-label="Close">×</button></div>
@@ -39,7 +48,15 @@ export function pickerTemplate({ rows = [], loading = false, error = "", current
39
48
  <span class="project-main"><span class="project-name">All projects</span
40
49
  ><span class="project-hint">Every session</span></span>
41
50
  ${allCurrent ? html`<span class="project-check" aria-hidden="true">✓</span>` : nothing}
42
- </a>
51
+ </a>${createOpen ? html`<form class="project-create" aria-busy=${creating}>
52
+ <label for="projectCreatePath">Folder path</label>
53
+ <input id="projectCreatePath" name="dir" type="text" autocomplete="off" .value=${createPath}
54
+ aria-invalid=${createError ? "true" : "false"} aria-describedby=${createError ? "projectCreateError" : nothing}
55
+ @input=${onPathInput}>
56
+ ${createError ? html`<div class="project-note project-error" id="projectCreateError" role="alert">${createError}</div>` : nothing}
57
+ <div class="project-create-actions"><button type="submit">${creating ? "Creating…" : "Create"}</button>
58
+ <button type="button" class="ghost" data-act="cancel-new">Cancel</button></div>
59
+ </form>` : html`<button type="button" class="project-row project-new" data-act="new-project">New project…</button>`}
43
60
  </nav>`;
44
61
  }
45
62
 
@@ -51,6 +68,12 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api,
51
68
  let error = "";
52
69
  // Only the newest read is drawn; a close drops the one out (latest.js).
53
70
  const latest = createLatest();
71
+ // Creating a project must not invalidate an in-flight list read.
72
+ const createLatestAnswer = createLatest();
73
+ let createOpen = false;
74
+ let createPath = "~/";
75
+ let creating = false;
76
+ let createError = "";
54
77
 
55
78
  const currentDir = () => scopeError() ? null : scope.dir || null;
56
79
  const paint = (focus = false) => {
@@ -58,6 +81,8 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api,
58
81
  render(pickerTemplate({
59
82
  rows, loading, error, currentDir: currentDir(),
60
83
  allCurrent: !scope.dir && !failedScope,
84
+ createOpen, createPath, creating, createError,
85
+ onPathInput: (event) => { createPath = event.currentTarget.value; },
61
86
  }), shell.pop);
62
87
  if (focus) {
63
88
  const target = shell.pop.querySelector('.project-row[aria-current="page"]') ||
@@ -89,11 +114,53 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api,
89
114
  if (!fresh()) return;
90
115
  loading = false;
91
116
  if (shell.isOpen()) {
92
- paint(focus);
117
+ // An open form keeps the caret: a late list must not pull focus to a row.
118
+ paint(focus && !createOpen);
93
119
  shell.replace();
94
120
  }
95
121
  }
96
122
 
123
+ async function submitProject() {
124
+ if (creating) return;
125
+ const fresh = createLatestAnswer.begin();
126
+ creating = true;
127
+ createError = "";
128
+ paint();
129
+ shell.replace();
130
+ try {
131
+ const answer = await api("/api/projects", { method: "POST", body: JSON.stringify({ dir: createPath }) });
132
+ if (!fresh()) return;
133
+ win.location.assign(projectScopeHref(answer.dir, ""));
134
+ } catch (e) {
135
+ if (!fresh()) return;
136
+ createError = e?.body?.detail || e?.message || String(e);
137
+ creating = false;
138
+ paint();
139
+ shell.replace();
140
+ }
141
+ }
142
+
143
+ function openCreateForm() {
144
+ createOpen = true;
145
+ createPath = projectParent(rows.find((row) => row.default)?.root_label);
146
+ createError = "";
147
+ creating = false;
148
+ paint();
149
+ shell.replace();
150
+ const input = shell.pop.querySelector("#projectCreatePath");
151
+ input?.focus();
152
+ input?.setSelectionRange(input.value.length, input.value.length);
153
+ }
154
+
155
+ function closeCreateForm() {
156
+ createLatestAnswer.drop();
157
+ createOpen = false;
158
+ creating = false;
159
+ createError = "";
160
+ paint();
161
+ shell.replace();
162
+ }
163
+
97
164
  const shell = createChipPopover({
98
165
  id: "projectPicker", className: "ctx-popover project-picker", width: 380, el, anchor: "#scopePick",
99
166
  summary: () => ({}), sheet: true, label: "Projects", doc, win,
@@ -101,10 +168,25 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api,
101
168
  rows = [];
102
169
  loading = true;
103
170
  error = "";
171
+ createOpen = false;
172
+ createPath = "~/";
173
+ creating = false;
174
+ createError = "";
104
175
  paint();
105
176
  void load(true);
106
177
  },
107
- onClose: () => { latest.drop(); },
178
+ onSubmit: () => { void submitProject(); },
179
+ onAct: (name) => {
180
+ if (name === "new-project") openCreateForm();
181
+ else if (name === "cancel-new") closeCreateForm();
182
+ },
183
+ onClose: () => {
184
+ latest.drop();
185
+ createLatestAnswer.drop();
186
+ createOpen = false;
187
+ creating = false;
188
+ createError = "";
189
+ },
108
190
  });
109
191
 
110
192
  return { pop: shell.pop, isOpen: shell.isOpen, load };
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.157.0
4
+ version: 0.158.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -435,7 +435,6 @@ files:
435
435
  - lib/samagotchi/hook_question.rb
436
436
  - lib/samagotchi/hook_routes.rb
437
437
  - lib/samagotchi/hooks.rb
438
- - lib/samagotchi/hooks/bundle_loader.rb
439
438
  - lib/samagotchi/hooks/loader.rb
440
439
  - lib/samagotchi/hooks/registry.rb
441
440
  - lib/samagotchi/hooks/stream_watch.rb
@@ -592,7 +591,9 @@ files:
592
591
  - lib/samagotchi/plugin_tasks.rb
593
592
  - lib/samagotchi/private_files.rb
594
593
  - lib/samagotchi/process_group.rb
594
+ - lib/samagotchi/project_command.rb
595
595
  - lib/samagotchi/project_env.rb
596
+ - lib/samagotchi/project_init.rb
596
597
  - lib/samagotchi/project_scope.rb
597
598
  - lib/samagotchi/prompt.rb
598
599
  - lib/samagotchi/prompt_files.rb
@@ -1,191 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "registry"
4
- require "digest"
5
- require_relative "../log"
6
- require_relative "../guardrails/load_failures"
7
- require_relative "../memory_bundle/bundle_hook"
8
- require_relative "../memory_bundle/provenance"
9
-
10
- module Samagotchi
11
- # Namespace that wraps hook plugin classes per-bundle. Each bundle's hooks are
12
- # evaluated inside `Samagotchi::Bundles::<BundleName>` so that two bundles
13
- # shipping a file with the same basename (e.g. `guardrails.rb`) cannot collide
14
- # on a top-level constant.
15
- module Bundles; end
16
-
17
- module Hooks
18
- # Loads bundle-owned hook plugins into a Registry.
19
- #
20
- # Unlike +Hooks::Loader+ (which `require`s a file and resolves a top-level
21
- # constant), this loader `module_eval`s each plugin file inside a
22
- # per-bundle namespace module. This gives:
23
- # - zero class-name collisions across bundles
24
- # - a clean uninstall path (`Registry#unregister_bundle`)
25
- #
26
- # Each plugin must define a class whose PascalCase name matches the file
27
- # basename and whose instances respond to +call(event)+.
28
- #
29
- # A "fail_closed" before_tool_call hook is required: if it is missing,
30
- # fails to load, or its file's sha256 differs from the one recorded at
31
- # install, the failure is reported and the gate denies every tool call.
32
- # Any hook whose sha256 differs is not loaded (reinstall the bundle
33
- # after editing a hook by hand), nor any of a bundle whose requires_chi
34
- # this chi doesn't meet.
35
- #
36
- # The +on_error+ policy is applied per hook at fire time (Hooks.wrap):
37
- # - "fail_closed" (on :before_tool_call): a raising guardrail denies
38
- # the tool call (fail-closed).
39
- # - "log": warn to stderr and continue.
40
- # - "skip" (default): silently continue.
41
- # Whatever the policy, a hook file that raises is told to the user once
42
- # per hook (its +hook_failed+ callback, the Engine's
43
- # HookRoutes#plugin_hook_failed): :skip would otherwise leave no trace.
44
- module BundleLoader
45
- class << self
46
- # Load all hook plugins for a bundle into the given registry.
47
- #
48
- # @param bundle_name [String]
49
- # @param hooks_dir [String] directory containing the bundle's .rb hook files
50
- # @param metadata [Hash] basename => { event:, on_error:, priority:,
51
- # sha256: } (MemoryBundle::BundleHook.parse reads it)
52
- # @param registry [Samagotchi::Hooks::Registry]
53
- # @param failures [Guardrails::LoadFailures, nil] collects hooks that
54
- # failed to load
55
- # @param settings [Hash] the bundle's section of config.yml
56
- # `bundles:` (string keys); a hook class whose initialize takes an
57
- # argument gets it
58
- # @param manifest [MemoryBundle::InstalledManifest, nil] the installed
59
- # bundle's manifest; when this chi doesn't meet its requires_chi no
60
- # hook loads (each is reported, as the plugin loader does)
61
- # @param hook_failed [#call, nil] (label, "Class: message", effect:)
62
- # when a hook file raises at fire time — the Engine's HookRoutes;
63
- # nil when there is no UI to tell (the test kit)
64
- # @return [Integer] number of hooks successfully registered
65
- def load(bundle_name:, hooks_dir:, metadata:, registry:, failures: nil, settings: {}, manifest: nil, hook_failed: nil)
66
- return 0 unless metadata.is_a?(Hash)
67
-
68
- too_old = manifest&.requires_chi_failure
69
- loaded = 0
70
- metadata.each do |raw_basename, raw_meta|
71
- basename = raw_basename.to_s
72
- next if basename.empty?
73
-
74
- hook = MemoryBundle::BundleHook.parse(raw_meta)
75
- # A hook with no declared event cannot be auto-registered.
76
- next if hook.events.empty?
77
-
78
- fail_closed = hook.events.any? { |event| hook.fail_closed?(event) }
79
- what = "hook #{basename} (bundle #{bundle_name})"
80
- file = hooks_dir && File.join(hooks_dir, basename)
81
- unless file && File.exist?(file)
82
- failures&.add(what, "the file is missing", required: true) if fail_closed
83
- next
84
- end
85
- if (mismatch = too_old || sha_mismatch(file, hook.sha256, required: fail_closed))
86
- Log.warn(:hooks, "bundle_hook_mismatch", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' not loaded: #{mismatch}", bundle: bundle_name, hook: basename.to_s)
87
- failures&.add(what, mismatch, required: fail_closed)
88
- next
89
- end
90
-
91
- begin
92
- load_hook_file(bundle_name, basename, file, hook, registry, settings: settings, hook_failed: hook_failed)
93
- loaded += 1
94
- rescue Exception => e # rubocop:disable Lint/RescueException -- a hook's SyntaxError or exit must not stop chi
95
- raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
96
-
97
- Log.error(:hooks, "bundle_hook_load_failed", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' failed to load: #{e.class}: #{e.message}", bundle: bundle_name, hook: basename.to_s, error: e.class.name)
98
- failures&.add(what, "#{e.class}: #{e.message}", required: fail_closed,
99
- location: Guardrails::LoadFailures.location(e, File.dirname(hooks_dir)))
100
- end
101
- end
102
- loaded
103
- end
104
-
105
- # Build one hook file and register it in +registry+ under each of
106
- # its events (one instance for all: its state is shared), with its
107
- # priority and error policy (a fail_closed before_tool_call one
108
- # denies when it raises). No missing-file, sha or requires_chi
109
- # check: #load makes them; the test kit loads a bundle's source
110
- # this way.
111
- # @param hook [MemoryBundle::BundleHook] its manifest entry
112
- # @param namespace [Module, nil] the module the file is evaluated
113
- # in (default: the bundle's, #namespace_for)
114
- # @param hook_failed [#call, nil] (label, "Class: message", effect:)
115
- # on every raise at fire time, whatever the policy; nil gives no
116
- # notice (the log still has a :log hook's failures)
117
- # @raise [Exception] what loading or building the file raised;
118
- # nothing is registered then
119
- def load_hook_file(bundle_name, basename, file, hook, registry, settings: {}, namespace: nil, hook_failed: nil)
120
- plugin = instantiate(bundle_name, basename, file, settings: settings, namespace: namespace)
121
- hook.events.each do |event_name|
122
- event_sym = event_name.to_sym
123
- fail_closed = hook.fail_closed?(event_sym)
124
- policy = if fail_closed then :deny
125
- elsif hook.on_error == "log" then :log
126
- else :skip
127
- end
128
- label = "#{"guardrail " if fail_closed}#{basename} (bundle #{bundle_name})"
129
- # Every policy tells, once per label: a hook file under the
130
- # default :skip would raise with no trace anywhere. The label
131
- # names the file, the bundle and the event, so one label per
132
- # hook and a stream hook can't nag.
133
- on_failure = hook_failed && lambda { |hook_label, failed|
134
- hook_failed.call("#{hook_label} #{event_sym} hook", failed, effect: policy)
135
- }
136
- handler = Hooks.wrap(label: label, event: event_sym, policy: policy, log: [:hooks, "bundle_hook_failed"],
137
- fields: { bundle: bundle_name }, on_failure: on_failure) { |event| plugin.call(event) }
138
- registry.register_bundle(bundle_name, event_sym, hook_name: basename, priority: hook.priority, &handler)
139
- end
140
- nil
141
- end
142
-
143
- # Why the file doesn't match the sha256 recorded at install, or nil.
144
- # With none recorded, only a required hook counts as a mismatch.
145
- def sha_mismatch(file, recorded, required:)
146
- return nil if MemoryBundle::Provenance.unchecked?(recorded)
147
-
148
- expected = MemoryBundle::Provenance.recorded_sha(recorded)
149
- return (required ? "no sha256 recorded for it (reinstall the bundle)" : nil) if expected.empty?
150
-
151
- actual = MemoryBundle::Provenance.file_sha(file)
152
- return nil if actual == expected
153
-
154
- "its sha256 #{actual[0, 12]}… differs from the installed #{expected[0, 12]}… (edited after install? reinstall the bundle)"
155
- end
156
-
157
- # Evaluate a plugin file inside the bundle's namespace module (or
158
- # +namespace+) and
159
- # return an instance that responds to #call. A class whose
160
- # initialize takes an argument gets the settings (one Hash, string
161
- # keys); one that takes none is built bare.
162
- def instantiate(bundle_name, basename, file, settings: {}, namespace: nil)
163
- ns = namespace || namespace_for(bundle_name)
164
- content = File.read(file)
165
- ns.module_eval(content, file, 1)
166
- klass = Hooks.class_for(basename, ns)
167
- instance = Hooks.build_plugin(klass, settings)
168
- raise ArgumentError, "plugin #{klass.name.split("::").last} does not respond to #call" unless instance.respond_to?(:call)
169
-
170
- instance
171
- end
172
-
173
- # The (lazily created) namespace module for a bundle.
174
- # Sanitization is made injective by appending a short digest so that
175
- # `my-bundle` and `my_bundle` do not collide.
176
- def namespace_for(bundle_name)
177
- raw = bundle_name.to_s
178
- base = raw.gsub(/[^A-Za-z0-9_]/, "_")
179
- base = "B_#{base}" unless base.match?(/\A[A-Z]/)
180
- digest = Digest::MD5.hexdigest(raw)[0, 4]
181
- ns_name = "#{base}_#{digest}"
182
- if Samagotchi::Bundles.const_defined?(ns_name.to_sym, false)
183
- Samagotchi::Bundles.const_get(ns_name.to_sym)
184
- else
185
- Samagotchi::Bundles.const_set(ns_name.to_sym, Module.new)
186
- end
187
- end
188
- end
189
- end
190
- end
191
- end