workspace-mgr 0.2.2

Fixed-policy repository workspace manager for coding agents
use std::fs;

use serde::Serialize;
use sha2::{Digest, Sha256};

use crate::config::Config;
use crate::error::{Error, IoContext, Result};
use crate::git::GitRepo;
use crate::hex::encode_lower;
use crate::path::reject_symlink_traversal;
use crate::policy::{
    AUTO_S3_ABOVE_BYTES, INSTRUCTION_POLICY_VERSION, RECOMMENDED_S3_MINIMUM_BYTES,
    TASK_BRANCH_PREFIX, TASK_DIRECTORY_PATTERN,
};

pub const BOOTSTRAP: &str = "# Repository instructions\n\nBefore doing any repository work, run:\n\n    workspace-mgr instructions --repo .\n\nFollow its output as the repository instructions for this session. If\n`workspace-mgr` is unavailable, stop repository work, tell the user, and ask\npermission to install the latest stable release from crates.io:\n\n    cargo install --locked workspace-mgr\n    workspace-mgr setup\n\nAfter installation, retry `workspace-mgr instructions --repo .`. If installation\nis not approved, installation fails, or the instructions command still fails,\nstop and report the problem. Do not substitute lower-level version-control or\nstorage mutation commands.\n";
pub const MANAGEMENT_MODEL: &str = include_str!("../docs/management-model.md");

#[derive(Debug, Clone, Serialize)]
pub struct InstructionDocument {
    pub cli_version: String,
    pub policy_version: u32,
    pub policy_hash: String,
    pub topic: String,
    pub markdown: String,
}

pub fn render(repo: &GitRepo, config: &Config, topic: Option<&str>) -> Result<InstructionDocument> {
    let topic = topic.unwrap_or("all");
    let valid_topics = [
        "all",
        "model",
        "core",
        "task",
        "publish",
        "artifacts",
        "storage",
        "shared-checkout",
        "infrastructure",
    ];
    if !valid_topics.contains(&topic) {
        return Err(Error::message(format!(
            "unknown instruction topic {topic:?}; expected one of {}",
            valid_topics.join(", ")
        )));
    }

    let mut sections = Vec::new();
    if topic == "all" || topic == "model" {
        sections.push(MANAGEMENT_MODEL.trim().to_owned());
    }

    if topic != "model" {
        sections.push(format!(
            "# Effective repository instructions\n\nGenerated by `workspace-mgr {}` using its fixed product policy and the Git/S3 facts in `{}`. These instructions apply to this repository for the current session. Explicit user directions can authorize a narrow exception; record the exact scope and do not broaden it.",
            env!("CARGO_PKG_VERSION"),
            crate::config::CONFIG_NAME
        ));
    }

    if topic == "all" || topic == "core" {
        sections.push(core_section());
    }
    if topic == "all" || topic == "task" {
        sections.push(task_section());
    }
    if topic == "all" || topic == "publish" {
        sections.push(publication_section(config));
    }
    if topic == "all" || topic == "artifacts" {
        sections.push(artifact_hygiene_section());
    }
    if topic == "all" || topic == "storage" {
        sections.push(storage_section(config));
    }
    if topic == "all" || topic == "shared-checkout" {
        sections.push(shared_checkout_section(config));
    }
    if topic == "all" || topic == "infrastructure" {
        sections.push(infrastructure_section());
    }
    if topic == "all" {
        let extra = repo.root.join(".workspace-mgr/instructions/repository.md");
        reject_symlink_traversal(
            &repo.root,
            ".workspace-mgr/instructions/repository.md",
            "repository instruction module",
        )?;
        if extra.is_file() {
            let content = fs::read_to_string(&extra).at(&extra)?;
            if content.len() > 65_536 {
                return Err(Error::message(format!(
                    "repository instruction module exceeds 64 KiB: {}",
                    extra.display()
                )));
            }
            if !content.trim().is_empty() {
                sections.push(format!(
                    "# Repository-specific additions\n\nThese additions may describe repository domain, build, validation, or content constraints. They do not change the fixed task, storage, publication, or review policy.\n\n{}",
                    content.trim()
                ));
            }
        }
    }

    let body = sections.join("\n\n");
    let mut hasher = Sha256::new();
    hasher.update(env!("CARGO_PKG_VERSION"));
    hasher.update(config.render()?);
    hasher.update(topic);
    hasher.update(&body);
    let policy_hash = encode_lower(hasher.finalize());
    let markdown = format!(
        "<!-- workspace-mgr: cli={} policy-version={} policy={} topic={} -->\n{}\n",
        env!("CARGO_PKG_VERSION"),
        INSTRUCTION_POLICY_VERSION,
        &policy_hash[..16],
        topic,
        body
    );
    Ok(InstructionDocument {
        cli_version: env!("CARGO_PKG_VERSION").to_owned(),
        policy_version: INSTRUCTION_POLICY_VERSION,
        policy_hash,
        topic: topic.to_owned(),
        markdown,
    })
}

fn core_section() -> String {
    "## Operating model\n\n- Read-only requests do not require a task directory. Repository-wide reading is allowed when useful for context, including reading another chat's task directory. Read access never authorizes mutation.\n- User authorization can make a narrow exception to the fixed workspace policy only for the requested paths and actions; higher-priority safety and platform rules still apply.\n- `workspace-mgr` is the only repository-content mutation interface. Treat a refusal as a guard to investigate, not a reason to bypass it with lower-level Git or storage tools. Repository-hosting review metadata remains the agent's responsibility; merge-state transitions remain the user's responsibility.\n- If any invocation reports `workspace-mgr: update available`, tell the user the current and available versions and ask before updating. Never update the CLI without explicit approval. After an approved update, run `workspace-mgr setup`.\n- `workspace-mgr init` is the deterministic scaffold reconciliation and upgrade operation. Product-owned files are identified by the initialized repository and fixed path, never by their old contents. Do not hand-edit them; after a CLI update or scaffold-drift report, reconcile them in an infrastructure task so the resulting repository-wide diff is reviewed.\n- Run `workspace-mgr doctor` when configuration, dependencies, or repository state appears inconsistent."
        .to_owned()
}

fn task_section() -> String {
    format!(
        "## Task lifecycle\n\n- Create ordinary writable work with `workspace-mgr task create <slug> --title <title> --purpose <purpose>`. Create repository-wide work with `workspace-mgr task create <slug> --kind infrastructure --title <title> --purpose <purpose> --scope <path> --scope-note <reason>`.\n- Deliverable task directories follow `{0}` and initially use branch `{1}<slug>`. Infrastructure tasks use a private manifest, isolated worktree, and initial branch `{1}infra-<slug>` instead of a repository task directory.\n- Immediately after creating a deliverable task directory, before substantial task work, run the normal plan/publish flow to publish its initial scaffold, then create and verify its draft pull request as described under Pull request responsibility. This automatic checkpoint is part of task creation and does not require a separate user request.\n- Reuse the same task for the full conversation or work item. A deliverable task's default write boundary is its own task directory. Before creating, editing, moving, or deleting anything outside it—including a shared root path or another chat's task directory—the user's request must explicitly authorize the exact path and action. If it does not, ask the user and wait before mutating that path. Reading the path, inferring that the change is useful, or finding it technically convenient is not authorization.\n- After approval, declare each additional path to task-scoped commands with `--include <path> --scope-note <reason>`. These arguments record the authorization for that invocation; they do not create authorization. Additional scope may not overlap another declared scope.\n- An infrastructure task is not constrained to a deliverable task directory because it has none. Its write boundary is instead the exact user-authorized paths declared in its private manifest. It may read elsewhere for context, but it must not mutate an undeclared path or another task directory.\n- If the conversation topic changes materially, run `workspace-mgr task rename <new-slug>` instead of manually moving files or creating another task. The current slug and deliverable path change, while the immutable task ID and review branch stay stable so the existing pull request is reused. Run the normal plan/publish flow afterward, then update that pull request's title and description.\n- Keep a deliverable task README concise and current. It describes purpose and outputs, and includes a `## Directory map`; it is not a chronological log.\n- The task manifest is the authoritative durable task scope.\n- Use `workspace-mgr task status` to inspect the resolved task before publishing.\n- If the user explicitly decides not to retain an unmerged task, run `workspace-mgr task discard --dry-run`, close or verify absence of its pull request, then confirm the exact task ID from the shared checkout with the reported manifest. Never discard a merged task. Discard reports but does not permanently delete retained S3 versions.",
        TASK_DIRECTORY_PATTERN, TASK_BRANCH_PREFIX
    )
}

fn publication_section(config: &Config) -> String {
    let review = review_section();
    format!(
        "## Publication\n\n- Run `workspace-mgr plan` before publication. A plan may inspect remote metadata but does not create a revision, upload stored data, or publish a branch.\n- Run `workspace-mgr publish -m <message>` to publish only the declared scopes to the configured target branch.\n- Before ending every turn in a writable task, automatically reconcile the task: run a task-targeted plan; publish every safe, retained, in-scope change, even when the work remains in progress; update and verify the draft pull request; then run a final no-change plan. Do not wait for the user to request this synchronization.\n- If a task has no publishable change, still verify that its local task revision, remote branch, and draft pull-request head already match. If safe publication or hosting verification is blocked, report the blocker and exact unsynchronized state instead of claiming the task is current.\n- Do not interpret a lower-level status command as the complete task state; use the task-targeted plan.\n- Verify the reported revision, remote revision, and a final no-change plan before claiming repository content is current.\n- `{}` is the configured base branch on remote `{}`.\n\n{review}",
        config.git.branch, config.git.remote
    )
}

fn review_section() -> String {
    "## Pull request responsibility\n\n- `workspace-mgr` publishes and verifies repository state but never calls a hosting-provider API. The agent owns pull-request operations.\n- A task is not fully synchronized until its one matching pull request is current.\n- Immediately after successfully publishing a new deliverable task's initial scaffold, query the hosting provider for the task's head branch and create exactly one draft pull request before doing substantial task work. If an open pull request already exists for that head branch, reuse it; never create a duplicate. For an infrastructure task, do this after its first safe scoped publication.\n- The agent owns the pull-request title and living description. Keep them aligned with the current goal, declared scope, important deliverables, validation evidence, and known limitations. Update them after every materially changed publication and as part of every turn-end reconciliation.\n- After creating or updating the pull request, verify that it is open, its base and head branches are correct, its review state is draft, and its head revision equals the remote revision reported by `workspace-mgr publish`.\n- If hosting authentication, permissions, connectivity, or metadata verification fails, report the blocker immediately and do not claim the task is fully synchronized.\n- The agent must not merge, enable auto-merge, approve, close, or change a draft pull request to ready unless the user explicitly requests that exact transition. A request to discard one specific unmerged task authorizes closing only its matching pull request; verify closure before `task discard --confirm`."
        .to_owned()
}

fn artifact_hygiene_section() -> String {
    "## Artifact hygiene\n\n- Keep every eligible task-owned input, deliverable, and reproducibility artifact in the declared scope.\n- Before publication, identify external Git checkouts, generated build output, and retained large content.\n- Ignore safely reproducible output and external nested repositories using the narrowest applicable rule.\n- Never flatten a nested repository into the parent repository or create a gitlink unless explicitly requested.\n- Choose retained-content placement through `workspace-mgr storage`; do not introduce another large-file mechanism.\n- Keep credentials and private runtime configuration out of tracked files and command output."
        .to_owned()
}

fn storage_section(config: &Config) -> String {
    let s3_note = if config.requires_object_versioning() {
        "S3 is configured. The bucket must have object versioning enabled; exact object versions are verified before the Git revision is published."
    } else if config.s3_enabled() {
        "An S3-compatible test or local storage adapter is configured; remote presence is verified before the Git revision is published."
    } else {
        "S3 is not configured, so paths may only be placed in Git until repository configuration is updated."
    };
    format!(
        "## Storage placement\n\n- Every retained path is stored either directly in Git or as versioned content in S3. `workspace-mgr` owns the underlying mechanics; do not invoke lower-level storage tools.\n- Treat Git as the collaboration and control plane: choose it when content belongs in ordinary clones and its value comes from direct review, diff, merge, or joint evolution with repository source. Treat S3 as the artifact and data plane: choose it when content is consumed as an exact object, changes atomically, or should be hydrated on demand. Do not guess semantics from a filename extension.\n- Before relying on size, decide whether the retained content has clear collaboration or artifact semantics. Express that decision with `workspace-mgr storage set <path> --to git|s3 --reason <reason>`. A user's explicit choice wins at any size.\n- Size is only the fallback for new, unclassified files. Below 1 MiB ({0} bytes) Git is the strong default. From 1 through 10 MiB ({0} through {1} bytes) Git remains the fallback, but `plan` and `storage status` ask the agent to review the semantic choice. Above 10 MiB ({1} bytes) S3 is the fallback.\n- A standalone S3 boundary below 1 MiB ({0} bytes) is usually wasteful because its metadata and remote operations may outweigh the payload. Prefer Git or select a larger meaningful directory boundary. An explicit S3 choice still succeeds but reports `small-s3-boundary`. Boundary size is the aggregate size of its materialized regular files.\n- Automatic evaluation treats unclassified files independently. Selecting a directory boundary is an intentional semantic operation and does not promise one packed remote object.\n- Published placement stays stable when size changes. `workspace-mgr storage reset <path>` removes an explicit choice, then preserves published history or reapplies the size fallback for new content. Never silently move published content between Git and S3.\n- Use `workspace-mgr storage status [<path> ...]` to inspect `target`, `basis`, semantic `reason`, boundary size/file count, and structured warnings. `plan` reports automatic decisions in the review band or S3 range and warning-relevant existing boundaries.\n- Placement changes, resets, and `workspace-mgr move <old> <new>` update local desired state only. `workspace-mgr publish` is the only command that publishes content to Git or S3 remotes. Confirmed task discard may delete only its exact remote Git branch and never purges S3 versions.\n- Use `workspace-mgr storage hydrate [<path> ...]` to materialize S3 content locally without publishing. Never hand-edit or directly delete workspace-mgr storage metadata.\n- Storage credentials stay outside tracked repository configuration. Never print, copy, or commit credentials.\n- {s3_note}\n- Never request permanent remote or cache garbage collection without explicit authorization for shared-data deletion.",
        RECOMMENDED_S3_MINIMUM_BYTES, AUTO_S3_ABOVE_BYTES
    )
}

fn shared_checkout_section(config: &Config) -> String {
    format!(
        "## Shared checkout\n\n- Keep the shared checkout on `{}` and publish task branches without switching it.\n- Preserve unrelated working-tree overlays. Do not use broad stash, clean, reset, or deletion operations.\n- After a task is merged, use `workspace-mgr refresh`; an ordinary pull may conflict with active overlays.",
        config.git.branch
    )
}

fn infrastructure_section() -> String {
    "## Repository infrastructure\n\n- Treat a change to shared policy, root entrypoints, CI, or repository-wide storage configuration as one infrastructure task with one target branch and one draft pull request.\n- Create it with `workspace-mgr task create <slug> --kind infrastructure --title <title> --purpose <purpose> --scope <path> --scope-note <reason>`. Repeat `--scope` for every exact shared path the user authorized. Declaring a path records that authorization; it does not create it.\n- Work only in the isolated worktree returned by the command. An infrastructure task has private task metadata and no timestamped repository task directory.\n- Run task, storage, plan, and publish commands from that worktree. Read any repository path needed for context, but write only the declared paths. Do not add unrelated shared or deliverable paths to the scope, and do not mutate another task directory without separate explicit user approval.\n- Infrastructure storage tests use fresh temporary repositories and local or mock remotes. They must not read user cloud credentials or contact a real storage service."
        .to_owned()
}