From e016361ee0253cd2764470fb09798d96c63fbe17 Mon Sep 17 00:00:00 2001 From: Owain Lewis Date: Thu, 30 Jul 2026 17:56:47 +0100 Subject: [PATCH 1/2] chore(release): prepare v0.10.0 --- .github/workflows/release.yml | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- RELEASE_NOTES.md | 51 ++++++++++++++++++++++++----------- tests/release-version.sh | 10 +++++-- 5 files changed, 47 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b41a91..88b0b72 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,5 +110,5 @@ jobs: gh release create "${TAG}" dist/* \ --title "${TAG}" \ --verify-tag \ - --generate-notes + --notes-file RELEASE_NOTES.md fi diff --git a/Cargo.lock b/Cargo.lock index c151b5f..9022494 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -797,7 +797,7 @@ dependencies = [ [[package]] name = "push" -version = "0.9.0" +version = "0.10.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 4229a79..8f1d1ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "push" -version = "0.9.0" +version = "0.10.0" edition = "2021" license = "MIT" description = "A tiny messaging gateway that turns coding agents into a personal assistant you text." diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3fb54bd..74c8b0b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,15 +1,36 @@ -# Push v0.7.0 - -- Render Markdown as Telegram HTML for chat and scheduled-job output, with safe - plain-text fallback when Telegram rejects formatting. -- Add reusable agent evaluations for scheduled jobs and persist evaluation, - execution, and delivery state separately. -- Add `help`, `reload`, `restart`, and `version` commands, including `--help` - and `--version` flags that work without loading configuration. -- Recover stalled message queues, support `/stop`, and harden scheduled delivery - with durable chunk progress, bounded retries, and impossible-cron validation. -- Add configurable voice credentials and voices. Removed runtime settings now - fail with migration guidance, backend commands resolve from `PATH`, and - Telegram environment credentials use `TELEGRAM_BOT_TOKEN`. - -**Full changelog:** https://github.com/owainlewis/push/compare/v0.6.0...v0.7.0 +# Push v0.10.0 + +- Derive private runtime storage from one `PUSH_HOME`, while keeping the + Git-versioned assistant repository and its jobs outside that boundary. +- Add a stable, redacted JSON CLI contract for paths, status, diagnostics, + job inspection, validation, runs, and schedule reviews. +- Compose backend instructions from explicit policy, identity, workspace, + request, and history sections. +- Install one versioned Push capability skill for Claude Code, Codex, and Pi. +- Move channel cursors and backend session mappings into the canonical SQLite + database with crash-safe, repeatable migration from `state.json`. +- Require durable owner review before a new or changed enabled schedule can + activate, including fail-closed queued-run checks and replayable audit events. + +## Upgrade notes + +- Existing `assistant_root` configurations continue to load jobs from + `/jobs`. Default runtime paths remain under `PUSH_HOME`. +- Explicit compatibility overrides such as `state_path`, `database_path`, + `audit_log_path`, and `jobs_run_dir` remain authoritative. Back up and + restore those paths separately when they point outside `PUSH_HOME`. +- If an older `$PUSH_HOME/jobs` directory remains after adopting + `assistant_root`, its files are not active. Move any jobs you still want into + `/jobs`, then archive the legacy directory. +- Push imports legacy cursor and session state from `state.json` once and keeps + the JSON file as a recovery copy. +- Existing valid enabled schedules receive a one-time migration baseline only + when the first config-aware command has a valid primary delivery destination. + If it does not, migration closes without grandfathering those schedules and + they require review. Later schedule changes require approval of the exact + revision. +- Rerun `push init ` to install or update the managed Push skill. +- Set an absolute `PUSH_HOME` in managed service definitions so the service and + interactive CLI always use the same runtime database and lock directory. + +**Full changelog:** https://github.com/owainlewis/push/compare/v0.9.0...v0.10.0 diff --git a/tests/release-version.sh b/tests/release-version.sh index ddbf7a7..03963f4 100755 --- a/tests/release-version.sh +++ b/tests/release-version.sh @@ -3,10 +3,11 @@ set -euo pipefail repo_root="$(cd "$(dirname "$0")/.." && pwd)" check="$repo_root/scripts/check-release-version.sh" +release_workflow="$repo_root/.github/workflows/release.yml" -"$check" v0.9.0 +"$check" v0.10.0 -for invalid_tag in 0.9.0 v0.8.2 v0.9.0-rc.1 refs/tags/v0.9.0; do +for invalid_tag in 0.10.0 v0.9.0 v0.10.0-rc.1 refs/tags/v0.10.0; do if "$check" "$invalid_tag" >/dev/null 2>&1; then echo "release version check accepted invalid tag: $invalid_tag" >&2 exit 1 @@ -17,3 +18,8 @@ if "$check" >/dev/null 2>&1; then echo "release version check accepted a missing tag" >&2 exit 1 fi + +if ! grep -F -- "--notes-file RELEASE_NOTES.md" "$release_workflow" >/dev/null; then + echo "release workflow does not publish RELEASE_NOTES.md" >&2 + exit 1 +fi From 2d4df4d49cd2e0f323cc202a367cf2e2f08cb8b4 Mon Sep 17 00:00:00 2001 From: Owain Lewis Date: Thu, 30 Jul 2026 18:18:33 +0100 Subject: [PATCH 2/2] fix(init): complete configured assistant upgrades --- RELEASE_NOTES.md | 26 +++++-- assistant/skills/push/SKILL.md | 17 ++++- docs/designing-an-assistant.md | 13 ++-- docs/getting-started.md | 7 +- docs/jobs.md | 12 +-- docs/reference/cli.md | 19 +++-- docs/services.md | 8 +- src/assistant.rs | 130 +++++++++++++++++++++++++++++++-- tests/docs.rs | 8 ++ tests/init_cli.rs | 6 +- 10 files changed, 202 insertions(+), 44 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 74c8b0b..4a46c2a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -14,6 +14,19 @@ ## Upgrade notes +Upgrade in this order: + +1. Stop the Push service. Back up `PUSH_HOME`, the assistant repository, and + any explicit runtime paths that live outside `PUSH_HOME`. +2. Before the first v0.10 command that opens job state, such as `push doctor`, + confirm `primary_delivery` names an enabled, allowlisted destination if + existing enabled schedules should receive the one-time migration baseline. +3. Install v0.10.0 and set one absolute `PUSH_HOME` in the service definition. +4. Run `push init ` to complete an older configured assistant + layout and install or update the managed Push skill. +5. Run `push doctor` and `push job validate`, then restart the service and + confirm `push status`. + - Existing `assistant_root` configurations continue to load jobs from `/jobs`. Default runtime paths remain under `PUSH_HOME`. - Explicit compatibility overrides such as `state_path`, `database_path`, @@ -25,12 +38,11 @@ - Push imports legacy cursor and session state from `state.json` once and keeps the JSON file as a recovery copy. - Existing valid enabled schedules receive a one-time migration baseline only - when the first config-aware command has a valid primary delivery destination. - If it does not, migration closes without grandfathering those schedules and - they require review. Later schedule changes require approval of the exact - revision. -- Rerun `push init ` to install or update the managed Push skill. -- Set an absolute `PUSH_HOME` in managed service definitions so the service and - interactive CLI always use the same runtime database and lock directory. + when the first v0.10 command that opens job state has a valid primary delivery + destination. If it does not, migration closes without grandfathering those + schedules and they require review. Later schedule changes require approval + of the exact revision. +- The service and interactive CLI must use the same `PUSH_HOME` so they share + the runtime database and lock directory. **Full changelog:** https://github.com/owainlewis/push/compare/v0.9.0...v0.10.0 diff --git a/assistant/skills/push/SKILL.md b/assistant/skills/push/SKILL.md index b9b37b7..98d60b2 100644 --- a/assistant/skills/push/SKILL.md +++ b/assistant/skills/push/SKILL.md @@ -4,7 +4,7 @@ description: Operate a Push personal assistant, inspect its health and jobs, and license: MIT compatibility: Requires the Push CLI and an initialized assistant repository. metadata: - push-managed-version: "2" + push-managed-version: "3" --- # Push @@ -37,6 +37,8 @@ are: - `push init [path]` - `push` - `push doctor` +- `push status` +- `push paths` - `push reload` or `push restart` - `push job validate` - `push job list` @@ -45,9 +47,16 @@ are: - `push job runs []` - `push job reviews []` -All commands accept `--config `. Do not assume machine-readable output -unless `push help` documents it in the installed version. Never expose tokens, -message content, or sensitive runtime state in diagnostics or replies. +All commands accept `--config `, although service status and restart use +the installed service definition. Inspection commands support `--json` where +documented by `push help`: help, version, doctor, status, paths, job validation, +job inspection, runs, and reviews. Commands that start the gateway, change +service state, scaffold files, or run a job reject JSON mode. The first upgraded +inspection that loads configuration and job state records the one-time schedule +migration baseline; follow the release upgrade notes before running it. Prefer +JSON for automation, but still treat paths, handles, and operational metadata +as sensitive. Never expose tokens, message content, or sensitive runtime state +in diagnostics or replies. ## Author jobs safely diff --git a/docs/designing-an-assistant.md b/docs/designing-an-assistant.md index 7292f50..2c33970 100644 --- a/docs/designing-an-assistant.md +++ b/docs/designing-an-assistant.md @@ -198,12 +198,13 @@ jobs/morning-brief.md scheduled request with timeout and delivery ``` Keep job bodies self-contained because every job starts a fresh backend -session without conversation history. Chat turns start in `assistant_root`, but -jobs start in the job's configured work directory, which must stay outside the -assistant repository. A job therefore does not automatically discover skills -linked under the assistant root. Keep required procedures in the job body, or -make the skill available through the backend's global skill location or the -job work directory. +session without conversation history. Chat turns start in `assistant_root`, and +jobs use `assistant_root` as their default work directory. Jobs can therefore +discover project instructions and skills linked there. An explicit alternate +work directory changes that discovery context and must not overlap Push-owned +runtime paths. Keep every required procedure in the job body, or make the +needed skill available through the backend's global skill location or the +selected work directory. Put stable preferences in `SOUL.md` or `context/`, and put the schedule, work directory, constraints, required procedure, and requested output in the job. diff --git a/docs/getting-started.md b/docs/getting-started.md index b680293..14a721c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -156,9 +156,10 @@ have a configured model provider or authenticated account for the service user. If you replace the config file created by `push init`, keep its `assistant_root` setting. Running the same init command again is safe for a -complete assistant repository. It preserves user-owned files and refreshes the -Push-managed skill only when the installed checksum proves that the managed -copy is unmodified. +complete assistant repository. It can also complete an older partial layout +when the selected config already names that exact root. It preserves user-owned +files and refreshes the Push-managed skill only when the installed checksum +proves that the managed copy is unmodified. ## 5. Validate and run diff --git a/docs/jobs.md b/docs/jobs.md index 80e5bd9..46965a3 100644 --- a/docs/jobs.md +++ b/docs/jobs.md @@ -227,12 +227,12 @@ updating the repository keeps its checked-in guidance accurate. Pending draft-install approvals from older Push versions are cancelled during database migration. Replying to one explains that the job must be requested again. On upgrade to schedule activation review, each valid enabled schedule -whose exact revision exists at the first config-aware Push command is captured -as the migration baseline. Those revisions are recorded as approved and -activated once when a valid primary destination exists. If that first command -has no valid primary destination, Push records an empty baseline and closes the -migration without grandfathering any schedules. Disabled and invalid jobs are -not grandfathered. +whose exact revision exists when the first upgraded command opens job state is +captured as the migration baseline. Those revisions are recorded as approved +and activated once when a valid primary destination exists. If that first +command has no valid primary destination, Push records an empty baseline and +closes the migration without grandfathering any schedules. Disabled and invalid +jobs are not grandfathered. !!! warning diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 709ba64..cdc1b80 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -61,9 +61,11 @@ ignores `--config`. Run `push doctor` separately when you want to validate those settings from the current shell. `push init` accepts an empty target, the selected config by itself, or a -complete existing assistant layout. It refuses unrelated and partial non-empty -directories, preserves user-owned `SOUL.md` and `AGENTS.md`, persists one -canonical `assistant_root`, and initializes Git when needed. +complete existing assistant layout. It can also safely complete a partial +layout when the selected config already names that exact `assistant_root`. +It refuses unrelated partial non-empty directories, preserves user-owned +`SOUL.md` and `AGENTS.md`, persists one canonical `assistant_root`, and +initializes Git when needed. Initialization also installs the versioned Push capability skill at `skills/push/` and exposes that one directory through relative links under @@ -83,10 +85,13 @@ available for: - `doctor`, `status`, and `paths` - `job validate`, `job list`, `job show`, `job runs`, and `job reviews` -Commands that start or mutate runtime state reject `--json`. This includes the -gateway, `init`, `reload`, `restart`, and `job run`. In particular, Push does -not claim that an interrupted mutation is safe to retry when its outcome is -unknown. +Commands that start the gateway, change service state, scaffold files, or run a +job reject `--json`. This includes the gateway, `init`, `reload`, `restart`, and +`job run`. Config-loading inspection commands can still migrate the database +schema and capture the one-time upgraded schedule baseline. They do not start a +job or decide a schedule review, but they are not filesystem-state-free. Push +does not claim that an interrupted mutation is safe to retry when its outcome +is unknown. A successful command writes exactly one JSON document and a trailing newline to stdout. It writes nothing to stderr: diff --git a/docs/services.md b/docs/services.md index 090b6ce..5bd6a20 100644 --- a/docs/services.md +++ b/docs/services.md @@ -55,10 +55,12 @@ databases, logs, locks, and service credentials outside it. ## macOS launchd -Create the log directory: +Create private service logs: ```sh mkdir -p ~/Library/Logs +touch ~/Library/Logs/push.err.log ~/Library/Logs/push.out.log +chmod 600 ~/Library/Logs/push.err.log ~/Library/Logs/push.out.log ``` Create `~/Library/LaunchAgents/com.owainlewis.push.plist`. You can start from @@ -133,8 +135,8 @@ override when service-level secret injection is preferred. ## Linux systemd -Use this for Telegram-only deployments. The iMessage channel still requires -macOS. +Use this for Telegram or Slack deployments. The iMessage channel still +requires macOS. Create the service directories: diff --git a/src/assistant.rs b/src/assistant.rs index e72f92e..0f88700 100644 --- a/src/assistant.rs +++ b/src/assistant.rs @@ -61,7 +61,7 @@ Good examples include preferences, active projects, people, recurring processes, "#; const PUSH_SKILL: &str = include_str!("../assistant/skills/push/SKILL.md"); -const PUSH_SKILL_VERSION: u32 = 2; +const PUSH_SKILL_VERSION: u32 = 3; const PUSH_SKILL_LINK: &str = "../../skills/push"; const PUSH_SKILL_MANIFEST: &str = ".push-managed.json"; const PUSH_SKILL_PROVIDERS: [&str; 2] = [".agents", ".claude"]; @@ -98,7 +98,7 @@ pub fn init(requested_path: &str, config_path: &str) -> Result { let config_path = absolute_path(Path::new(&expanded_config)).context("resolve config path")?; let existing_config = inspect_config(&config_path, &target, &paths)?; - prepare_target(&target, &config_path)?; + prepare_target(&target, &config_path, existing_config)?; let root = fs::canonicalize(&target) .with_context(|| format!("resolve assistant root {}", target.display()))?; scaffold(&root)?; @@ -334,7 +334,7 @@ fn resolve_existing_or_lexical(path: &Path) -> Result { Ok(resolved) } -fn prepare_target(target: &Path, config_path: &Path) -> Result<()> { +fn prepare_target(target: &Path, config_path: &Path, config_state: ConfigState) -> Result<()> { if target.exists() { if !target.is_dir() { bail!("assistant target {} is not a directory", target.display()); @@ -343,6 +343,19 @@ fn prepare_target(target: &Path, config_path: &Path) -> Result<()> { .with_context(|| format!("inspect assistant target {}", target.display()))? .collect::>>()?; let resolved_config = resolve_existing_or_lexical(config_path)?; + match fs::symlink_metadata(target.join(".git")) { + Ok(_) => { + let resolved_target = fs::canonicalize(target) + .with_context(|| format!("resolve assistant target {}", target.display()))?; + verify_git_root(&resolved_target) + .context("validate existing Git metadata before init")?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error) + .with_context(|| format!("inspect Git metadata under {}", target.display())) + } + } if entries.is_empty() || entries.iter().all(|entry| { entry.file_name() == ".git" @@ -350,12 +363,9 @@ fn prepare_target(target: &Path, config_path: &Path) -> Result<()> { .is_ok_and(|path| path == resolved_config) }) { - if target.join(".git").exists() { - verify_git_root(target).context("validate existing Git metadata before init")?; - } return Ok(()); } - if !valid_assistant_structure(target) { + if !valid_assistant_structure(target) && config_state != ConfigState::MatchingRoot { bail!( "assistant target {} is non-empty but is not a complete assistant repository. Choose an empty directory or a valid assistant containing SOUL.md, AGENTS.md, README.md, context/README.md, and jobs/.", target.display() @@ -815,6 +825,43 @@ mod tests { let _ = fs::remove_dir_all(parent); } + #[cfg(unix)] + #[test] + fn configured_partial_assistant_with_broken_git_link_is_not_scaffolded() { + use std::os::unix::fs::symlink; + + let parent = temp_dir("assistant-configured-partial-broken-git-link"); + let target = parent.join("assistant"); + let missing_git = parent.join("missing-git"); + let config = parent.join("push.toml"); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("SOUL.md"), "Existing identity\n").unwrap(); + symlink(&missing_git, target.join(".git")).unwrap(); + fs::write( + &config, + format!( + "assistant_root = {}\n", + toml::Value::String(target.to_string_lossy().to_string()) + ), + ) + .unwrap(); + + let error = init(target.to_str().unwrap(), config.to_str().unwrap()).unwrap_err(); + + assert!(error.to_string().contains("validate existing Git metadata")); + assert_eq!( + fs::read_to_string(target.join("SOUL.md")).unwrap(), + "Existing identity\n" + ); + assert!(!target.join("AGENTS.md").exists()); + assert!(!target.join("README.md").exists()); + assert!(!target.join("context").exists()); + assert!(!target.join("jobs").exists()); + assert!(!target.join("skills").exists()); + assert!(!missing_git.exists()); + let _ = fs::remove_dir_all(parent); + } + #[test] fn repeat_initialization_preserves_user_files_and_configuration() { let parent = temp_dir("assistant-reinit"); @@ -1030,6 +1077,75 @@ mod tests { let _ = fs::remove_dir_all(parent); } + #[test] + fn completes_a_partial_configured_assistant_without_overwriting_user_files() { + let parent = temp_dir("assistant-configured-partial"); + let target = parent.join("assistant"); + let config = parent.join("push.toml"); + fs::create_dir_all(target.join("context")).unwrap(); + fs::create_dir(target.join("jobs")).unwrap(); + fs::write(target.join("SOUL.md"), "Existing identity\n").unwrap(); + fs::write(target.join("context/private.md"), "Existing context\n").unwrap(); + fs::write( + &config, + format!( + "assistant_root = {}\n", + toml::Value::String(target.to_string_lossy().to_string()) + ), + ) + .unwrap(); + + let result = init(target.to_str().unwrap(), config.to_str().unwrap()).unwrap(); + + assert_eq!(result.root, fs::canonicalize(&target).unwrap()); + assert_eq!( + fs::read_to_string(target.join("SOUL.md")).unwrap(), + "Existing identity\n" + ); + assert_eq!( + fs::read_to_string(target.join("context/private.md")).unwrap(), + "Existing context\n" + ); + assert!(target.join("AGENTS.md").is_file()); + assert!(target.join("README.md").is_file()); + assert!(target.join("context/README.md").is_file()); + assert!(target.join("evals").is_dir()); + assert!(target.join("skills/push/SKILL.md").is_file()); + assert_push_skill_links(&target); + let _ = fs::remove_dir_all(parent); + } + + #[test] + fn configured_partial_assistant_with_invalid_git_is_not_scaffolded() { + let parent = temp_dir("assistant-configured-partial-invalid-git"); + let target = parent.join("assistant"); + let config = parent.join("push.toml"); + fs::create_dir_all(target.join(".git")).unwrap(); + fs::write(target.join("SOUL.md"), "Existing identity\n").unwrap(); + fs::write( + &config, + format!( + "assistant_root = {}\n", + toml::Value::String(target.to_string_lossy().to_string()) + ), + ) + .unwrap(); + + let error = init(target.to_str().unwrap(), config.to_str().unwrap()).unwrap_err(); + + assert!(error.to_string().contains("validate existing Git metadata")); + assert_eq!( + fs::read_to_string(target.join("SOUL.md")).unwrap(), + "Existing identity\n" + ); + assert!(!target.join("AGENTS.md").exists()); + assert!(!target.join("README.md").exists()); + assert!(!target.join("context").exists()); + assert!(!target.join("jobs").exists()); + assert!(!target.join("skills").exists()); + let _ = fs::remove_dir_all(parent); + } + #[test] fn persists_root_at_top_level_when_config_ends_with_a_table() { let parent = temp_dir("assistant-table-config"); diff --git a/tests/docs.rs b/tests/docs.rs index 08f3b26..bad9669 100644 --- a/tests/docs.rs +++ b/tests/docs.rs @@ -81,6 +81,14 @@ fn service_examples_select_one_push_home() { ); } +#[test] +fn service_guide_protects_launchd_logs() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let services = fs::read_to_string(root.join("docs/services.md")).unwrap(); + + assert!(services.contains("chmod 600 ~/Library/Logs/push.err.log ~/Library/Logs/push.out.log")); +} + fn heading_anchors(markdown: &str) -> Vec { let mut anchors = Vec::new(); let mut heading = None; diff --git a/tests/init_cli.rs b/tests/init_cli.rs index b096b5d..68eae1a 100644 --- a/tests/init_cli.rs +++ b/tests/init_cli.rs @@ -93,7 +93,11 @@ fn init_without_path_creates_assistant_in_current_directory() { let canonical_skill = assistant.join("skills/push/SKILL.md"); let skill = std::fs::read_to_string(&canonical_skill).unwrap(); assert!(skill.contains("name: push")); - assert!(skill.contains("push-managed-version: \"2\"")); + assert!(skill.contains("push-managed-version: \"3\"")); + assert!(skill.contains("- `push status`")); + assert!(skill.contains("- `push paths`")); + assert!(skill.contains("Inspection commands support `--json`")); + assert!(skill.contains("records the one-time")); assert!(skill.contains("push job reviews []")); assert!(skill.contains("separate owner review")); assert!(!skill.contains(&root.to_string_lossy().to_string()));