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
data/docs/memory.md CHANGED
@@ -3,12 +3,41 @@
3
3
  Samagotchi stores memories in two scopes:
4
4
 
5
5
  - Project scope: `$XDG_CONFIG_HOME/samagotchi/memories/projects/<name>_<hash>/`, one folder per
6
- git repository (named and hashed by its root), shared by all its worktrees and
7
- subdirectories; outside a repository, one per working directory
6
+ project (named and hashed by its root): a git repository, shared by all its worktrees and
7
+ subdirectories, or a folder marked by `chi project init` and its subfolders; outside both, one
8
+ per working directory
8
9
  - System scope: `$XDG_CONFIG_HOME/samagotchi/memories`
9
10
 
10
11
  `$XDG_CONFIG_HOME` falls back to `~/.config` when unset.
11
12
 
13
+ ## Projects without git
14
+
15
+ A folder in no git repository becomes a project with `chi project init [DIR]` (see
16
+ [cli.md](cli.md#commands)): it writes `DIR/.chi/project.yml` (one comment line; the file, not a bare
17
+ `.chi/` dir, is the marker), and DIR and every subfolder share one project memories folder, group their
18
+ sessions (`chi sessions list`, the web's project picker) and take `chi context --project`. Nothing
19
+ chi-specific has to be committed anywhere: a repository never needs the file. It can also be created
20
+ from chi web's project picker (a folder inside a git repository opens that repository's project, and
21
+ the folder is left as is: it is created when missing, nothing is marked).
22
+
23
+ - **Git wins.** chi looks up a folder's `.git` first; only when there is none does it look for the
24
+ nearest marker above it, so a marker inside a repository is ignored (`chi project` says so) and a
25
+ marked folder inside another is a project of its own, as nested repositories are. A dotfiles repository
26
+ at `~` puts every folder under home in that repository, so markers there never apply.
27
+ - **Not at home.** A marker in `$HOME`, in a folder above it, or in `/` is ignored (`chi project init`
28
+ refuses them), so a marker in `~` never makes every folder under home one project.
29
+ - **The key.** The marked root is the folder's real path (as git's root is), so a folder chi already ran
30
+ in at its top keeps its memories after `init`, and a later `git init` in the same folder keeps the same
31
+ key. Memories saved from a *subfolder* before `init` stay in that subfolder's own folder: move them by
32
+ hand if you want them in the project's.
33
+ - **Restart what runs there.** A session running in the folder when it is marked keeps the old memories
34
+ folder and AGENTS.md chain in its prompt until `chi sessions restart ID` (init names them); a running
35
+ `chi web` groups the folder's older sessions under the project after its restart.
36
+ - **Git-only features stay off in a marked project**: guardrails' outside-the-repo rule, the participant
37
+ boundaries, a plugin's `ctx.repo_root` (and the bundles using it: nested AGENTS.md, slow-commands,
38
+ known-names), `chi home doctor`'s project checks and delegate's work-tree check measure from a git
39
+ checkout only.
40
+
12
41
  Tool behavior:
13
42
 
14
43
  - `memory_read`: `scope` is optional.
@@ -28,7 +57,7 @@ memories copied to a machine (or a path) where the repository lives elsewhere si
28
57
  longer reads. Rename the folder to the new key, or move memories with a bundle zip (see
29
58
  [What a bundle owns](#what-a-bundle-owns)).
30
59
 
31
- Inside a git repository, when this project's folder is missing or holds no `*.md` while the projects
60
+ Inside a project (a git repository or a marked folder), when this project's folder is missing or holds no `*.md` while the projects
32
61
  dir has a folder of the same basename with another hash, chi says so, naming both folders and the
33
62
  `mv` that fixes it (a name match can't prove it's the same project, so nothing is renamed for you):
34
63
 
@@ -430,14 +459,14 @@ memory still marked as behind an earlier upgrade keeps its mark either way.
430
459
  repo can have a `README.md` without it becoming a memory. A listed file is
431
460
  installed whatever its name (`chi bundle build` of your own `security.md`
432
461
  lists it). Since chi 0.75.0. Older chi installs every top-level `*.md`
433
- whatever `requires_chi` says (it gates hooks, rules and the plugin, not
462
+ whatever `requires_chi` says (it gates rules and the plugin, not
434
463
  memories), so a bundle for older chi too names its readme
435
464
  `README.markdown` (or puts docs in a subdirectory).
436
465
  `chi bundle upgrade` moves a README an older install made a memory to the
437
466
  trash (unless you edited it, like any file a bundle stops shipping).
438
467
  - **A symlink is never installed.** A memory that is a symlink is skipped
439
- with `notes.md is a symlink (not installed)`, and so is a hook or guardrail
440
- rule that is one or lives in a linked dir (`hooks/x.rb is a symlink (not
468
+ with `notes.md is a symlink (not installed)`, and so is a guardrail rule
469
+ that is one or lives in a linked dir (`guardrails/x.yml is a symlink (not
441
470
  installed)`); a plugin or script that is one fails the install. A tar, zip
442
471
  or git repo keeps links, and copying through one would install whatever
443
472
  file it points at (`~/.ssh/…`, an API key) as a memory the model reads.
@@ -497,7 +526,7 @@ memory still marked as behind an earlier upgrade keeps its mark either way.
497
526
  does an upgrade's removal of a file the new version no longer ships. A
498
527
  file you edited still needs `--force`: chi tells by comparing it with what
499
528
  the install recorded (the record's checksum, else its base copy; a file with
500
- neither counts as edited). A hook, rule, plugin or script you
529
+ neither counts as edited). A rule, plugin or script (or an older install's hook) you
501
530
  edited isn't asked about: it is copied to the same dir (`Copied to the trash
502
531
  first: scripts/x (…)`) and the bundle's own copy goes. `chi bundle trash` lists the trash
503
532
  (oldest first, with file count, size, and age); `--empty` deletes all
@@ -561,7 +590,12 @@ memory still marked as behind an earlier upgrade keeps its mark either way.
561
590
  - **`chi bundle status`** says per file what is `modified` (the uninstall's
562
591
  rule above: edited, or with nothing recorded to tell), `missing` or
563
592
  `unreadable`, and that a file stays behind and why. Its `issues=` counts files
564
- behind, missing or unreadable (and a plugin or hooks that won't load), not edits. A
593
+ behind, missing or unreadable (and a plugin that won't load, or class-file hooks an older chi installed, which no
594
+ longer load: `Hooks (N): not loaded`), not edits. `chi bundle status NAME`
595
+ lists the bundle's guardrail rules and scripts too (`Rules (N):`, `Scripts (N):`),
596
+ each `[ok]`, `[missing]` or `[modified]`: edited, so the next install or upgrade
597
+ replaces it, its copy kept in the trash. An edited or missing rule doesn't load
598
+ and counts in `issues=`, as a missing script does. A
565
599
  file an older chi marked, which no retry has looked at, says `may be behind`, and so
566
600
  do the counts in `chi bundle list`, `chi self` and `chi update`'s last line.
567
601
  `chi bundle status NAME` also has a `Manifest:` line: `[kept]` when chi reads
@@ -569,7 +603,7 @@ memory still marked as behind an earlier upgrade keeps its mark either way.
569
603
  what the install recorded, with why (installed by an older chi, the file edited
570
604
  or gone) and the command that fixes it (`chi update` keeps it where it can,
571
605
  [CLI: Updating](cli.md#updating)).
572
- - **A dev install** (`chi bundle install --dev PATH`) links the hooks, rules,
606
+ - **A dev install** (`chi bundle install --dev PATH`) links the rules,
573
607
  plugin and scripts to `PATH` but still copies the memories at install, so
574
608
  an edit to one of them in `PATH` isn't seen until the next install.
575
609
  - **The rest of the manifest** (`plugin:`, `requires_chi`, `session_settings:`,
data/docs/plugins.md CHANGED
@@ -17,7 +17,7 @@ loop, the event shapes, the kit, the pitfalls.
17
17
  my-bundle/
18
18
  manifest.yml
19
19
  plugin.rb
20
- identity.md # optional, like any bundle's memories, hooks/ and guardrails/
20
+ identity.md # optional, like any bundle's memories and guardrails/
21
21
  README.md # never a memory (nor CHANGELOG, LICENSE, ...): see memory.md#what-a-bundle-owns
22
22
  ```
23
23
 
@@ -30,7 +30,7 @@ plugin:
30
30
  sha256: sha256:6a1a7022… # shasum -a 256 plugin.rb
31
31
  # required: true # optional: a guardrail plugin; if it fails to load, every tool call is
32
32
  # denied (see chi.on's on_error: :fail_closed; chi 0.157.0 or newer)
33
- requires_chi: ">= 0.1.28" # optional: a gem-style requirement (">= 0.1.28, < 0.2"); the bundle's hooks/ check it too
33
+ requires_chi: ">= 0.1.28" # optional: a gem-style requirement (">= 0.1.28, < 0.2"); the bundle's rules check it too
34
34
  # every key below has a chi floor: check warns when requires_chi admits a chi that never read it
35
35
  # requires_chi_next: true # shipped bundles only, ignored by chi: rake release:bump raises requires_chi
36
36
  # to the chi being released (docs/releasing.md)
@@ -473,7 +473,8 @@ end
473
473
 
474
474
  ### `chi.on(event, priority: 100, on_error: :log) { |event, ctx| … }`
475
475
 
476
- This is a bundle hook, the same as a `hooks/*.rb` file. See
476
+ This is a bundle hook: the one way a bundle hooks an event (class-file
477
+ `hooks/*.rb` hooks are retired, [hooks.md](hooks.md#class-file-hooks-are-retired)). See
477
478
  [hooks.md](hooks.md#hook-events) for the events; on `:after_turn`,
478
479
  `event[:present]` sets how the answer is shown in the web
479
480
  ([Presenting the answer](hooks.md#presenting-the-answer-display-only)) and `event[:refs]`
@@ -1546,7 +1547,7 @@ bundle.shutdown
1546
1547
  ```
1547
1548
 
1548
1549
  - **Real seams.** The plugin loads through `Plugin::Loader.register_file` (the real `chi` API and a real `ctx`),
1549
- the hook files through `Hooks::BundleLoader.load_hook_file`, into registries that already hold chi's own
1550
+ into registries that already hold chi's own
1550
1551
  commands and tools: a name chi has fails the load as it would in chi. `tool_call` runs a real
1551
1552
  `Guardrails::Gate` (context and targets computed for the call; `verdict:` starts from one with a vote in it).
1552
1553
  Only the edges are recorded: the session (`Testing::Host`), the hook helpers (`Testing::Runtime`),
@@ -1600,7 +1601,10 @@ bundle.shutdown
1600
1601
  go on; `bundle.lenient { … }` collects them in `bundle.errors` instead.
1601
1602
  - **Sandbox.** A test runs with temp config, state, data and cache homes (config.yml, memories, `ctx.data_dir`, the
1602
1603
  debug log), `HOME`, `CLAUDE_CONFIG_DIR` and `CODEX_HOME`, no git config of yours, no Keychain and none of your
1603
- `SAMAGOTCHI_*` settings; `sandbox.config(hash)` writes its config.yml.
1604
+ `SAMAGOTCHI_*` settings; `sandbox.config(hash)` writes its config.yml. A `Testing::Bundle.new` or `Bundle.load` with
1605
+ no `sandbox:` and no current sandbox enters its own, snapshots `ENV` when it is built, and restores that copy at
1606
+ `bundle.shutdown`. Build it before changing `ENV` in a test, not lazily inside a block that sets `ENV` for one example (your own
1607
+ helper, or ClimateControl); or scope the sandbox to the example with the `:bundle` tag or `Sandbox.with { … }`.
1604
1608
  - **RSpec:** `require "samagotchi/testing/rspec"`; example groups tagged `:bundle` (a spec under a bundle's own
1605
1609
  `spec/` is tagged by itself) get `load_bundle(dir)`, `shipped_bundle(name)` and `sandbox`, each example in its
1606
1610
  own sandbox, its bundles shut down after it (chi's shipped bundles keep theirs in `<bundle>/spec/`,
@@ -1634,14 +1638,14 @@ When a session's worker exits (an idle exit, `/exit`, a crash, TERM), or a
1634
1638
  ## Loading, and when it fails
1635
1639
 
1636
1640
  Plugins load when a session starts (`Engine.new`, in a worker or a
1637
- `--non-interactive` one-shot), after the bundle hooks. The steps are:
1641
+ `--non-interactive` one-shot), after the config hooks. The steps are:
1638
1642
 
1639
1643
  1. The file must match the sha256 recorded at install (not checked for a bundle installed with `--dev`).
1640
1644
  2. chi must meet `requires_chi`. A bundle whose `requires_chi` this chi doesn't
1641
1645
  meet is refused at install, so this step is reached only by one installed
1642
1646
  with `--force` or by a chi that has since been downgraded.
1643
1647
  3. The file is `module_eval`'d into a new module in the bundle's namespace
1644
- (`Samagotchi::Bundles::<bundle>`).
1648
+ (`Samagotchi::Bundles::<bundle>`, `Plugin::Loader.namespace_for`).
1645
1649
  4. The class is built, and `register(chi)` runs.
1646
1650
 
1647
1651
  What `register` adds takes effect only when it returns. A plugin that raises
@@ -1663,8 +1667,8 @@ starts again (`chi sessions stop`, or the idle exit).
1663
1667
 
1664
1668
  ## Trust
1665
1669
 
1666
- A plugin is Ruby code that runs inside chi with your permissions, just like
1667
- a bundle's hooks. **Installing a bundle means trusting it**, as you would a
1670
+ A plugin is Ruby code that runs inside chi with your permissions.
1671
+ **Installing a bundle means trusting it**, as you would a
1668
1672
  gem.
1669
1673
 
1670
1674
  - The sha256 is an integrity check, not proof of who wrote the file. A file
data/docs/releasing.md CHANGED
@@ -31,10 +31,10 @@ can run the whole release; the user approves the notes before the tag and the
31
31
  profile's (next point);
32
32
  - a new `ctx.*` / `chi.*` call a plugin may make gets its release's version in `Plugin::Api::SINCE` once
33
33
  released (`chi bundle check`'s `requires-chi-floor` reads it); an unreleased one waits there as an `# Unreleased` comment, and
34
- `release:check` fails while that comment is left: move each call up with the new version;
34
+ `release:bump` moves each call it names up with the new version (`release:check` fails while a comment is left);
35
35
  - a new manifest key or shape gets its release's version in `Manifest::SINCE` the same way (check's
36
36
  `requires-chi-floor` reads it for the manifest too), waits as an `# Unreleased` comment while unreleased,
37
- and `release:check` fails while that comment is left;
37
+ and `release:bump` moves it up with the new version the same way;
38
38
  - a new shipped bundle joins a profile (`includes:` in `core` or `dev`; a
39
39
  spec checks every bundle is in exactly one), and that profile's version
40
40
  goes up: `chi update` then installs only the new bundle for those who
@@ -69,14 +69,14 @@ as the GitHub release body.
69
69
 
70
70
  | Task | What it does |
71
71
  | --- | --- |
72
- | `rake bundles:sha` | Recomputes the sha256 lines of every shipped bundle manifest (`files:`, `hooks:`, `plugin:`). |
72
+ | `rake bundles:sha` | Recomputes the sha256 lines of every shipped bundle manifest (`files:`, `plugin:`, `scripts:`). |
73
73
  | `rake bundles:check` | Sha lines match; every top-level `*.md` of a bundle is listed in its `files:` (an install takes only the listed ones); a bundle changed since the last `v*` tag has a higher version (skipped, with a note, before the first tag). Warns (doesn't fail) when such a bundle's `requires_chi` still admits the tagged chi: mark it `requires_chi_next` if the change uses anything new. Notes a bundle marked `requires_chi_next`, and a profile (`core`, `dev`) whose `requires_chi` is below a member's. The release workflow and `release:check` run it in full. |
74
74
  | `rake 'bundles:check[ci]'` | The CI workflow's (every push and PR): the same, except the version check leaves out the system bundle, which is changed and unbumped between releases (`release:bump` moves its version with VERSION). A task argument, not `CI=1`: GitHub sets `CI` in the release workflow too. |
75
75
  | `rake bundles:specs` | The shipped bundles' own specs (`lib/samagotchi/bundles/*/spec`) on the bundle test kit alone (no `spec_helper`), as an external bundle's run. CI runs it as its own step. |
76
76
  | `rake lint` / `rake lint:fix` | RuboCop over the repo (must report no offenses; CI runs it) / its safe autocorrect (`rubocop -a`). |
77
77
  | `rake gem:validate`, `gem:build`, `gem:install`, `gem:clean` | Check the gemspec; build the gem into `pkg/`; build and install it locally; remove the built gems. |
78
78
  | `rake release:draft_changelog` | A draft Unreleased section from the commit subjects since the last tag, grouped. Stdout only. |
79
- | `rake release:bump[X.Y.Z]` | VERSION, the system manifest, Gemfile.lock, CHANGELOG, and `requires_chi: ">= X.Y.Z"` for each bundle marked `requires_chi_next` (the marker dropped), then a profile left below a member's `requires_chi` raised to it, with a patch bump of its version (none when it moved since the last tag) and named in the CHANGELOG's "Update with `chi update`" line. No commit. |
79
+ | `rake release:bump[X.Y.Z]` | VERSION, the system manifest, Gemfile.lock, CHANGELOG, and `requires_chi: ">= X.Y.Z"` for each bundle marked `requires_chi_next` (the marker dropped), then a profile left below a member's `requires_chi` raised to it, with a patch bump of its version (none when it moved since the last tag) and named in the CHANGELOG's "Update with `chi update`" line, and each entry an `# Unreleased` comment in `Plugin::Api::SINCE` or `Manifest::SINCE` names moved into the table at X.Y.Z (the comment dropped). No commit. |
80
80
  | `rake release:check` | Clean tracked tree, VERSION == system bundle, a CHANGELOG section, no `requires_chi_next` left, no `# Unreleased` comment left in `Plugin::Api::SINCE` or `Manifest::SINCE`, `bundles:check`, `rake lint` (RuboCop) + `npm run lint` (the web frontend's ESLint), rspec (both roots: `spec` and the bundles' own `spec/`) + `rake bundles:specs` + npm test, `gem build`, then a clean install into a temp GEM_HOME that runs `chi --version`, `chi self` and `chi bundle list` with a temp HOME. Needs the network (gem dependencies). |
81
81
  | `rake release:notes[X.Y.Z]` | Prints the CHANGELOG section (default: the current VERSION). |
82
82
 
@@ -96,7 +96,8 @@ The agent does each step and stops where the user has to say yes.
96
96
  names the bundles whose `requires_chi` it raised (their `requires_chi_next`
97
97
  markers are gone) and the profiles it raised with them (a patch bump of
98
98
  each one's version, none for a profile already bumped since the last tag),
99
- which it names in the CHANGELOG's "Update with `chi update`" line.
99
+ which it names in the CHANGELOG's "Update with `chi update`" line, and the
100
+ SINCE entries it moved out of their `# Unreleased` comments.
100
101
  5. **Check.** Commit first (the check wants a clean tree), then run it:
101
102
  `git commit -am "Release X.Y.Z"` and `bundle exec rake release:check`.
102
103
  Fix anything it finds in new commits and run it again.
@@ -0,0 +1,228 @@
1
+ # Schedules (`chi schedule`)
2
+
3
+ A **schedule** is a prompt with a clock. It lives in one file on this machine, not in a
4
+ worker's memory, so it outlives every session, every restart and every crash:
5
+ `~/.local/state/samagotchi/schedules.json` (next to `sessions/`, written with
6
+ `AtomicFile`, every change a read-modify-write under `schedules.lock`, so cron and
7
+ chi web's hub can never lose an update to each other). A **tick** — cron, systemd,
8
+ launchd, `chi schedule tick`, or chi web's hub when `schedule.auto_tick` is on —
9
+ walks the entries whose `next_due` has passed and runs the ones that are not busy.
10
+
11
+ Each entry carries `id`, an optional `title`, `anchor` (the session it was made
12
+ against, never rewritten: the chain's root and the id you remember), `head` (the
13
+ chain's current link, re-anchored after every firing: the link a chain continue
14
+ made, or the session a `same` turn was queued into), `mode`,
15
+ `prompt`, `recurrence`, `catch_up`, `keep_links`, `max_failures`, `max_skips`,
16
+ `next_due` (UTC ISO), `last_fired`, `last_result`, `failures`, `skips` and
17
+ `enabled`.
18
+
19
+ <a id="reminder-vs-schedule"></a>## Reminder or schedule?
20
+
21
+ `register_reminder` and `chi schedule` are separate on purpose; which one you want
22
+ depends on whether the worker has to be running.
23
+
24
+ | | `register_reminder` | `chi schedule` |
25
+ |---|---|---|
26
+ | Where it lives | the worker's `Engine` (`ReminderStore`), in memory | `<state dir>/schedules.json`, durable |
27
+ | Who fires it | `IdleReminders`, inside a live idle worker | `chi schedule tick`, run by cron/systemd (or the hub when opted in) |
28
+ | Needs a worker | yes — and a reminder *holds* the worker up | no — it wakes one when none runs |
29
+ | Survives a stop, a crash, an archive | only the short asked-restart hand-off (`ReminderCarry`, 2 min) | yes, until cancelled |
30
+ | Longest interval | 1440 minutes | unlimited (`every`, `weekday`, `date`) |
31
+ | What it produces | a turn in the same session | a new chain link, or a queued turn in the same session |
32
+
33
+ A schedule turn carries `client_id: "system:schedule:<id>"`, so `ClientId.origin`
34
+ says `schedule` and the terminal and the web label it as such rather than as
35
+ something you typed. Like `register_reminder`, the schedule tools are owner-voiced:
36
+ a participant's review or pair turn is denied them ([guardrails.md](guardrails.md)).
37
+
38
+ ## The two modes
39
+
40
+ - **chain** (the default) runs the prompt as the **next link of the session's chain**:
41
+ the head is archived, a new link is created with the carry note
42
+ ([sessions.md, Session chains](sessions.md#session-chains)), and `head` moves to it.
43
+ This is the "same routine, fresh context" case: a daily coordinator that starts each
44
+ day with the recap of the last one instead of a 40-link transcript.
45
+ - **same** queues the prompt as a turn **in the session itself**
46
+ (`SessionManager.deliver_turn`, `Delivery::QUEUE`), waking a worker when none runs.
47
+ It prunes nothing, and it never archives anything. Like chain mode, it follows
48
+ `head` through the chain: once the session is continued — by a chain schedule or
49
+ by a `chi send --continues` — the turn goes to the new link, not to the archived
50
+ anchor the schedule was made against.
51
+
52
+ A `scratch` session is refused at `add`: it is deleted when its terminal leaves.
53
+ A prompt is checked at `add` too — non-empty and at most 16 KiB
54
+ (`SessionInbox::NOTE_MAX_BYTES`); a blank one would archive a link and start an
55
+ idle session for nothing.
56
+
57
+ ## Recurrence
58
+
59
+ One kind per schedule:
60
+
61
+ | Kind | Written as | How the next due is computed |
62
+ |---|---|---|
63
+ | `every` | `--every 45m` / `2h` / `1d` | interval arithmetic from the firing — `next_due = fired_at + interval` |
64
+ | `weekday` | `--weekday 1 --time 09:00` (1=Mon … 7=Sun) | the next such wall-clock moment in the machine's local zone, strictly after now (today counts if the time is still ahead), stored as UTC |
65
+ | `date` | `--date 2026-11-20` or `--date 2026-11-20T09:00` | one local moment; after it fires the entry is disabled |
66
+
67
+ **The DST caveat.** `every` is interval math in UTC, so an `every 1d` schedule
68
+ drifts by an hour across a DST fold: fired Monday 09:00, its next due is Tuesday
69
+ 09:00 UTC, which is a different hour on the wall clock after the change. For a
70
+ schedule at a *time of day*, use `--weekday` (or `--date`): those build the moment
71
+ with `Time.local` for the candidate date, so `Mon 09:00` stays 09:00 local across
72
+ the fold. If a local moment does not exist (a spring-forward gap), it fires at the
73
+ next valid minute. Schedules use the machine's local clock; there are no named
74
+ time zones.
75
+
76
+ `catch_up` says what an overdue entry does when the tick arrives after its moment:
77
+
78
+ - `once` (default): fire once now, then reschedule normally.
79
+ - `none`: fire only when the tick is near the moment. "Missed" — `last_result:
80
+ "missed"` and the entry moves to its next occurrence — is a **whole cycle**, not
81
+ a late tick: it happens only when the occurrence *after* the one it missed is
82
+ already past, which is what the file looks like when the machine was asleep
83
+ across the interval. A `Mon 09:00` schedule ticked at 09:20 fires; the same
84
+ entry first seen the following Monday is missed and moves to the Monday after.
85
+ A `date` one-off has no next occurrence to compare with, so it is never missed:
86
+ it fires late, once.
87
+
88
+ ## The busy check
89
+
90
+ A cron tick cannot read a worker's memory, so the tick has two paths.
91
+
92
+ **No live worker** (the usual case for a weekly schedule): the files say what can
93
+ be known — the saved status is not `running` (and not inside the starting grace),
94
+ `input/` is empty, no step-limit question is open. Nothing else can be busy.
95
+
96
+ **A live worker**: ask it, over the new Bridge route
97
+ `GET /session/:id/activity` (`Worker#activity_snapshot`, the same signals
98
+ `WorkerIdleExit#hold` reads): `{turn_running, input_pending, awaiting_continue,
99
+ open_streams, reminders, tasks_holding}`. It is in `Bridge::FEATURES`, so a worker
100
+ from an older chi has no such route, and any non-200 from it is read as "older":
101
+ the tick says to restart that worker.
102
+
103
+ - `turn_running`, `input_pending` or `awaiting_continue` ⇒ busy, in both modes.
104
+ - `open_streams` (a terminal attached, or a web tab open on the session) counts
105
+ **only for chain mode**: a chain firing archives the session and stops its
106
+ worker, which would cut off the person looking at it. A queued turn into an open
107
+ session is fine, so `same` mode ignores streams.
108
+ - **An older worker is itself a busy signal for chain mode.** Its 404 must not fall
109
+ back to the file checks: those cannot see an open tab, so the firing would
110
+ archive under someone's terminal. The tick skips it with "its worker is too old
111
+ to ask: `chi sessions restart ID`" — chi's usual rule for an older worker:
112
+ refuse with a restart, never silently degrade. `same` mode, which only queues a
113
+ turn, may use the file checks.
114
+
115
+ A `chi send --wait` or a parent's `delegate` wait is **not** busy: `ReplyWait::Poll`
116
+ reads the session files and `GET /session/:id/state`, never `open_stream`, so a
117
+ coordinator waiting on its own child does not block that child's chain.
118
+
119
+ Schedules never hold a worker up: unlike reminders, they are not in
120
+ `WorkerIdleExit#hold` (FR14) — the whole point is that the worker may be gone and
121
+ the tick wakes it.
122
+
123
+ ## Skip, not failure
124
+
125
+ A firing that cannot run now is a **skip**: nothing happens, `skips += 1`,
126
+ `last_result: "skipped"`, and `next_due` goes back to the occurrence this tick tried
127
+ (the tick advances the clock before acting and keeps that moment as `due_was`, so the
128
+ next tick tries it again — a weekly schedule skipped on its Monday is retried at that
129
+ Monday, not a week later). A failure restores it the same way. A `date` entry is the
130
+ exception: advancing it spends it, so it comes back at that same moment.
131
+ Skips
132
+ are the refusals that mean "not this moment": `ArchiveRefused` with `busy`, `queued`
133
+ or `offer`, or `busy_child`/`queued_child`/`offer_child` (a chain's own running
134
+ delegate refuses the archive, since `archivable!` walks the whole descendant tree),
135
+ `SessionContinue::Refused(:continued)` (a human continued it while we were
136
+ resolving) and `:open_children`.
137
+
138
+ Anything else is a **failure** (`failures += 1`): a gone or archived target,
139
+ `SessionContinue::Refused(:folder_gone)`, `SessionManager::DeleteRefused`,
140
+ `ModelProfile::MissingModel`, `OwnedByTUI`, `Session::NotFound`, and anything a
141
+ spawn throws (`Errno::ENOSPC` included — `run_locked` re-raises it after undoing
142
+ the move, so it is never mistaken for a skip). A `DeleteRefused(:worker_running)`
143
+ hit while pruning stops the pruning for that firing, is logged, and is not a
144
+ schedule failure.
145
+
146
+ `skips >= max_skips` (default 12) or `failures >= max_failures` (default 3)
147
+ disables the entry and `chi schedule list` says why. **Any successful firing resets
148
+ both counters**, so twelve skips spread over months do not kill a healthy weekly
149
+ schedule. `list` prints the skip budget as a time window — 12 skips at a 5 minute
150
+ tick is about an hour of patience, which is what the count really measures.
151
+
152
+ A chain holds at most `schedule.max_per_session` enabled schedules (default 10) and
153
+ the machine `schedule.max_total` (default 50); `schedule_task` and `chi schedule add`
154
+ refuse past them, naming the key. A schedule the chain already has — the same prompt
155
+ and recurrence, or the same title — is refused too, naming its id, so a routine that
156
+ tells each of its links to "make sure the Monday audit is scheduled" cannot multiply
157
+ itself. `schedule_task`, `list_schedules` and `cancel_schedule` see only schedules of
158
+ the calling session's chain (`all: true` steps outside it); `chi schedule list` is
159
+ always the machine-wide view. An id, prefix or title that matches more than one entry
160
+ is refused with exit 2, naming the matches.
161
+
162
+ The tick's lock covers the file only, never the action: the clock advances before
163
+ the work starts — for `every` and `weekday` to the next occurrence, for a `date`
164
+ one-off to none — so a tick killed mid-continue cannot fire the same entry twice.
165
+ `firing_since` is what `chi schedule show` prints; the advanced clock is the guard.
166
+ And a `chi schedule add` from a terminal never waits on an archive or a recap wait. Chain mode targets `last:<head>`, so `SessionContinue.run`'s
167
+ own retry loop follows a continue a human made between our resolve and its lock
168
+ instead of racing it.
169
+
170
+ ## Keeping the chain short
171
+
172
+ `keep_links` (default `schedule.default_keep_links`, `0` keeps all) is how many
173
+ chain links stay behind the head. After a successful chain firing — and after the
174
+ result is recorded under the lock, so a prune that raises midway cannot lose the
175
+ new `head` — the walk goes backwards from `head` and every link older than the
176
+ window is deleted **together with its delegated children** (`descendant_ids`): the
177
+ whole subtree only when every session in it is archived and no worker owns any of
178
+ it, deepest child first and the link last, oldest link first. A subtree with a live
179
+ worker or an unarchived descendant is left whole and retried at the next firing. Pruning a link alone would leave its finished
180
+ delegates archived forever with a dangling `parent_id`: archiving a session archives
181
+ its descendants, and archived sessions are outside the retention sweep
182
+ ([sessions.md](sessions.md)), so the delegate pile is the bulk of the archived
183
+ count. The `anchor` is never pruned: it is the chain's root, the id the user and
184
+ the coordinator remember, and `last:<anchor>` must keep resolving.
185
+
186
+ The oldest kept link still has `continues` naming a link that is gone. The walks
187
+ follow `continues` forward, so nothing breaks; the web's `↩ #N of M` chip counts
188
+ from the surviving root (pruning renumbers the links) and says "earlier links
189
+ pruned" instead of linking to a deleted session.
190
+
191
+ If a link to prune still has a worker (`DeleteRefused(:worker_running)`), pruning
192
+ stops there for this firing and is retried at the next one.
193
+
194
+ ## Running the tick
195
+
196
+ `chi schedule tick` is quiet and safe to run often; it is this machine only
197
+ (`chi --remote studio schedule list` exits 2), so point cron at the chi of this
198
+ machine (cron runs with a thin PATH, so name the binary rather than a shell
199
+ function):
200
+
201
+ ```
202
+ # crontab -e on the machine
203
+ */5 * * * * /Users/you/.local/share/mise/installs/ruby/global/bin/chi schedule tick >> /Users/you/.local/state/samagotchi/schedule-tick.log 2>&1
204
+ ```
205
+
206
+ Run it from systemd or launchd instead if you prefer a timer: the command is the
207
+ same, `chi schedule tick`, and the lock makes overlapping runs harmless — the
208
+ second one exits 0 with "no schedules due (another tick holds the lock)".
209
+ `--dry-run` prints what would fire and changes nothing; `--format json` is the seam
210
+ for scripts and for a future web UI.
211
+
212
+ A tick fires **one entry per chain**: of the due entries of one chain, the one due
213
+ earliest (`next_due`, then id) fires and the rest are skipped — "the chain just
214
+ fired; retry at the next tick" — with `skips += 1` and `next_due` left at the
215
+ occurrence they tried, so the chain's entries take turns instead of the first one
216
+ added winning every tick. A second chain
217
+ firing in the same pass would continue the link the first one just made and archive
218
+ it before its prompt ever ran: that link has no worker lock yet and the tick's
219
+ listing predates it, so the busy check cannot see it. `same` entries are unaffected:
220
+ they queue a turn, they do not archive.
221
+
222
+ `schedule.auto_tick: true` makes chi web's hub tick on
223
+ `schedule.tick_interval_minutes` (default 5) as well, sharing the same lock, so
224
+ cron and the hub can coexist without firing twice. It is off by default: cron or
225
+ systemd is the documented driver.
226
+
227
+ See [cli.md](cli.md) for the subcommands and flags, and
228
+ [configuration.md](configuration.md) for the `schedule.*` settings.