Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,5 @@ The repository is an implementation preview. Source-level contracts and fixture
- [Allstack delivery plan 1414ddc7-4b9e-424f-bf1e-e73ad57d091d](plans/allstack-1414ddc7-4b9e-424f-bf1e-e73ad57d091d.md)

- [Allstack delivery plan a4abab43-5ae5-4618-8efa-7d61f0e5f323](plans/allstack-a4abab43-5ae5-4618-8efa-7d61f0e5f323.md)

- [Longhand managed staff compatibility](reports/2026-10-27-longhand-managed-enrollment.md) — complete snapshots and existing tracker routing.
4 changes: 2 additions & 2 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ The reverse sequence starts with `--host claude-code` and hands off to `codex` f

In the active worktree reported by `status`, explicitly stage every new intended file with `git add -- <path>`. Then `checkpoint <run> --epoch <epoch>` retains the workspace. Untracked files block snapshot creation; Allstack never silently includes them. Checkpointing or handing off invalidates previous verification.

Snapshots retain tracked contents and executable modes, including staged new files and tracked deletions. They reject symlinks, path escapes, recognized sensitive filenames, files above 4 MiB, totals above 64 MiB, and manifests above 10,000 files. Filename exclusions are a limited guardrail; review the candidate for secrets independently.
Snapshots retain tracked contents and executable modes, including staged new files and tracked deletions. They reject symlinks, path escapes and changes to recognized sensitive filenames. Each copied file is limited to 4 MiB. Default budgets remain 64 MiB total and 10,000 files. Larger repositories can explicitly set `max_files` and `max_total_bytes` in tracked `workflows/snapshot-limits.toml`, bounded by 100,000 files and 1 GiB. The budget file is retained with each checkpoint; it does not omit source or authorize execution. Linked Git worktrees reuse the primary checkout’s existing Chronis store, with no new tracker initialized. Filename exclusions are a limited guardrail; review the candidate for secrets independently.

Old worktrees remain separate after handoff. Native edits there do not change the incoming snapshot. An old epoch cannot drive Allstack-managed mutation. Local users and native host tools remain capable of operating outside Allstack; no filesystem sandbox or authenticated per-host identity is claimed by the epoch number.

Expand Down Expand Up @@ -142,6 +142,6 @@ Verification records the prepared commit so a changed checkpoint can be committe

Retain tool versions, run IDs, exact commits, actual test counts and outputs, interruption points, remote/PR readbacks, and private artifact digests when validating the release. Publish only deliberately reviewed evidence in indexed repository documents. Fixture definitions and source tests do not establish that authenticated Codex and Claude Code completed the real journey.

Initialization and doctor permit Chronis’s tracked `.chronis/sync/events.jsonl` Git sync export. Other tracked files under `.chronis/`, `.prime/`, and `.allstack/` remain rejected as private state. Existing exports are preserved.
Initialization and doctor permit Chronis’s tracked `.chronis/sync/events.jsonl` Git sync export, `.chronis/.gitignore`, and bounded embedded-only `config.toml` metadata. Remote or secret-bearing configuration remains private. Other tracked files under `.chronis/`, `.prime/`, and `.allstack/` remain rejected as private state. Existing exports are preserved.

Publishing changes under `.github/workflows/` requires GitHub workflow-write authority. A repository-scoped OAuth token can read the repository and create a PR while still failing the Git push for those files. Use an existing authorized SSH identity for the same repository, or a token with the required scope. Preserve the verified commit and reconcile the actual remote branch before retrying publication.
11 changes: 11 additions & 0 deletions docs/reports/2026-10-27-longhand-managed-enrollment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Longhand managed staff compatibility

## Scope

Longhand enrollment uses its complete tracked checkout and existing project Chronis history. Default snapshot bounds reject the assessed 11,246-file, 262,502,948-byte source tree. The repository can now declare a bounded snapshot budget in tracked workflows/snapshot-limits.toml; default bounds and per-file checks remain unchanged. Content-addressed artifacts retain every source file. No sparse checkout, excluded source subtree or independent tracker is substituted.

Linked worktrees resolve their existing project tracker from Git’s common directory. Initialization, doctor, task setup, claim transfer and completion use that owning project through cn. Run evidence remains private to the coordinator.

## Qualification

Thirteen bootstrap tests and six process-owned workspace cases passed; two optional real-store tests were skipped. The snapshot proof retained 10,013 entries above the original byte limit, restored complete source, reused primary tracker routing despite local setup metadata, and rejected independent worktree data. Actual Longhand init and doctor then passed with the primary project tracker readable and no duplicate tracker initialized. Real host execution remains separate. Process-owned regression cases exercise a checkout above both original bounds, restore added/edited/deleted content, preserve guarded base files and resolve the primary tracker without creating a worktree tracker. Invalid budgets remain rejected. No production or customer outcome follows from these toolkit checks.
35 changes: 14 additions & 21 deletions src/infrastructure/bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,18 +206,6 @@ fn call(scratch: &Scratch, repo: &Path, argv: &[&str]) -> Result<String> {
process::checked(&scratch.0, repo, argv)
}

fn tracked_private_state(scratch: &Scratch, repo: &Path) -> Result<bool> {
let tracked = call(
scratch,
repo,
&["git", "ls-files", "--", ".allstack", ".chronis", ".prime"],
)?;
// Chronis intentionally versions this portable export for Git-backed sync.
Ok(tracked
.lines()
.any(|path| path != ".chronis/sync/events.jsonl"))
}

fn repair_overridden_ignores(scratch: &Scratch, repo: &Path, files: &mut [Change]) -> Result<()> {
let outcome = process::run(process::Spec {
argv: [
Expand Down Expand Up @@ -339,11 +327,15 @@ pub fn initialize(repo: &Path, check: bool, profile: Option<&str>, policy: &str)
if call(&scratch, repo, &["cn", "--version", "--toon"]).is_err() {
missing.push("Chronis cn executable unavailable".into());
}
if tracked_private_state(&scratch, repo)? {
if super::chronis::tracked_private_state(&scratch.0, repo)? {
missing.push("private store files are tracked; resolve before initialization".into());
}
let chronis_exists = root.directory(".chronis")?;
if !check && chronis_exists && call(&scratch, repo, &["cn", "list", "--all", "--toon"]).is_err()
let chronis_project = super::chronis::project_root(&scratch.0, repo)?;
let chronis_repo = chronis_project.as_deref().unwrap_or(repo);
let chronis_exists = chronis_project.is_some();
if !check
&& chronis_exists
&& call(&scratch, chronis_repo, &["cn", "list", "--all", "--toon"]).is_err()
{
missing.push("existing Chronis store unreadable through cn".into());
}
Expand Down Expand Up @@ -396,10 +388,10 @@ pub fn initialize(repo: &Path, check: bool, profile: Option<&str>, policy: &str)
})?;
}
ensure!(
root.directory(".chronis")?,
super::chronis::project_root(&scratch.0, repo)?.is_some(),
"cn did not initialize the target repository; completed: {completed:?}"
);
call(&scratch, repo, &["cn", "list", "--all", "--toon"]).with_context(|| {
call(&scratch, chronis_repo, &["cn", "list", "--all", "--toon"]).with_context(|| {
format!("Chronis readback failed; completed: {completed:?}; inspect and rerun")
})?;
check_ignored(&scratch, repo).with_context(|| {
Expand Down Expand Up @@ -454,8 +446,9 @@ pub fn doctor(repo: &Path, policy: &str) -> Result<Value> {
missing.push(format!("{}: {}", f.path, f.status));
}
}
let chronis = if root.directory(".chronis")? {
match call(&scratch, repo, &["cn", "list", "--all", "--toon"]) {
let chronis_project = super::chronis::project_root(&scratch.0, repo)?;
let chronis = if let Some(project) = &chronis_project {
match call(&scratch, project, &["cn", "list", "--all", "--toon"]) {
Ok(_) => "readable",
Err(_) => {
missing.push("Chronis store unreadable through cn".into());
Expand All @@ -469,7 +462,7 @@ pub fn doctor(repo: &Path, policy: &str) -> Result<Value> {
if check_ignored(&scratch, repo).is_err() {
missing.push("private stores are not all ignored".into());
}
if tracked_private_state(&scratch, repo)? {
if super::chronis::tracked_private_state(&scratch.0, repo)? {
missing.push("private store files are tracked".into());
}
let tools: Vec<_> = ["codex", "claude", "cn", "git", "gh", "cargo", "rustc"].iter().map(|name| {
Expand All @@ -487,7 +480,7 @@ pub fn doctor(repo: &Path, policy: &str) -> Result<Value> {
};
Ok(
json!({"repository":repo,"store":repo.join(".allstack"),"setup_ready":missing.is_empty(),
"executables_ready":available,"authentication":"not-probed","missing":missing,"files":files,"chronis":chronis,
"executables_ready":available,"authentication":"not-probed","missing":missing,"files":files,"chronis":chronis,"chronis_repository":chronis_project,
"catalog":catalog,"stores_opened":false,"allsource":"embedded; fsync-per-event when run storage opens",
"prime":"embedded graph; lexical recall; local-only; not opened by doctor",
"coordinator":"exclusive-kernel-lock-per-run-command","host_scope":"sequential-same-machine","tools":tools}),
Expand Down
90 changes: 88 additions & 2 deletions src/infrastructure/chronis.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,91 @@
use super::process::{self, Spec};
use crate::domain::Run;
use anyhow::{Context, Result, ensure};
use std::{collections::BTreeMap, path::Path};
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};

pub fn project_root(state: &Path, repo: &Path) -> Result<Option<PathBuf>> {
ensure!(
!repo.join(".chronis").is_symlink(),
"Chronis store cannot be a symlink"
);
let common = super::git::call(
state,
repo,
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
)?;
let common = PathBuf::from(common).canonicalize()?;
if common.file_name().is_some_and(|name| name == ".git") {
let root = common
.parent()
.context("Git common directory has no parent")?;
if root.join(".chronis").is_dir() {
ensure!(
!root.join(".chronis").is_symlink(),
"Chronis store cannot be a symlink"
);
if root != repo && repo.join(".chronis").is_dir() {
for entry in std::fs::read_dir(repo.join(".chronis"))? {
let name = entry?.file_name();
ensure!(
name == "config.toml" || name == ".gitignore" || name == "sync",
"worktree has independent Chronis data; reconcile before enrollment"
);
}
}
return Ok(Some(root.to_path_buf()));
}
}
if repo.join(".chronis").is_dir() {
ensure!(
!repo.join(".chronis").is_symlink(),
"Chronis store cannot be a symlink"
);
return Ok(Some(repo.to_path_buf()));
}
Ok(None)
}

pub fn tracked_private_state(state: &Path, repo: &Path) -> Result<bool> {
let tracked = super::git::call(
state,
repo,
&["ls-files", "--", ".allstack", ".chronis", ".prime"],
)?;
for path in tracked.lines() {
if path == ".chronis/sync/events.jsonl" || path == ".chronis/.gitignore" {
continue;
}
if path == ".chronis/config.toml" {
let root = super::bootstrap_fs::Root::new(repo)?;
let bytes = root.read(path)?.context("tracked Chronis config absent")?;
ensure!(
bytes.len() <= 4096,
"Chronis config exceeds safe metadata bound"
);
let config: toml::Value = toml::from_str(std::str::from_utf8(&bytes)?)?;
if let Some(table) = config.as_table() {
let safe = table
.keys()
.all(|key| key == "mode" || key == "instance_id")
&& table
.get("mode")
.is_none_or(|mode| mode.as_str() == Some("embedded"))
&& table.get("instance_id").is_none_or(|id| {
id.as_str()
.is_some_and(|id| uuid::Uuid::parse_str(id).is_ok())
});
if safe {
continue;
}
}
}
return Ok(true);
}
Ok(false)
}

pub struct Tasks<'a> {
pub state: &'a Path,
Expand All @@ -10,6 +94,8 @@ pub struct Tasks<'a> {

impl Tasks<'_> {
fn call(&self, args: &[&str], actor: Option<&str>) -> Result<String> {
let project =
project_root(self.state, self.repo)?.context("Chronis project store absent")?;
let mut argv = vec!["cn".into()];
argv.extend(args.iter().map(|s| s.to_string()));
argv.push("--toon".into());
Expand All @@ -19,7 +105,7 @@ impl Tasks<'_> {
}
process::run(Spec {
argv,
cwd: self.repo.into(),
cwd: project,
seconds: 30,
environment,
input: None,
Expand Down
1 change: 1 addition & 0 deletions src/infrastructure/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub mod memory;
pub mod process;
pub mod runtime;
pub mod skills;
pub mod snapshot_limits;
pub mod spec;
pub mod store;
pub mod workspace;
Expand Down
2 changes: 1 addition & 1 deletion src/infrastructure/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ impl Platform for Runtime {
git::clean(&self.state, &self.repo)?;
}
ensure!(
self.repo.join(".chronis").is_dir(),
super::chronis::project_root(&self.state, &self.repo)?.is_some(),
"initialize project Chronis with cn init --toon first"
);
let branch = git::call(&self.state, &self.repo, &["branch", "--show-current"])?;
Expand Down
33 changes: 33 additions & 0 deletions src/infrastructure/snapshot_limits.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
use super::workspace::contained;
use anyhow::{Result, ensure};
use serde::Deserialize;
use std::{fs, path::Path};

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Limits {
pub max_files: usize,
pub max_total_bytes: u64,
}

pub fn read(root: &Path) -> Result<Limits> {
let name = "workflows/snapshot-limits.toml";
let path = contained(root, name)?;
if !path.exists() {
return Ok(Limits {
max_files: 10_000,
max_total_bytes: 64 * 1024 * 1024,
});
}
ensure!(
fs::metadata(&path)?.len() <= 4096,
"snapshot budget exceeds 4096 bytes"
);
let limits: Limits = toml::from_str(&fs::read_to_string(path)?)?;
ensure!(
(1..=100_000).contains(&limits.max_files)
&& (1..=1024 * 1024 * 1024).contains(&limits.max_total_bytes),
"snapshot budget must be within 100000 files and 1 GiB"
);
Ok(limits)
}
5 changes: 3 additions & 2 deletions src/infrastructure/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,13 @@ fn manifest(state: &Path, cwd: &Path) -> Result<Manifest> {
others.is_empty(),
"untracked files require explicit git add before checkpoint; never snapshot them implicitly"
);
let limits = super::snapshot_limits::read(cwd)?;
let mut result = BTreeMap::new();
let mut preserved = BTreeMap::new();
let mut total = 0;
for name in tracked.split('\0').filter(|s| !s.is_empty()) {
ensure!(
result.len() + preserved.len() < 10000,
result.len() + preserved.len() < limits.max_files,
"snapshot exceeds supported bounds"
);
if guarded_path(name) {
Expand All @@ -106,7 +107,7 @@ fn manifest(state: &Path, cwd: &Path) -> Result<Manifest> {
);
total += meta.len();
ensure!(
total <= 64 * 1024 * 1024,
total <= limits.max_total_bytes,
"snapshot exceeds supported bounds"
);
result.insert(
Expand Down
32 changes: 32 additions & 0 deletions tests/bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,38 @@ fn check_mode_never_opens_existing_chronis_engine() {
assert!(!f.repo.join("workflows").exists());
}

#[test]
fn tracked_embedded_configuration_is_metadata_but_remote_secrets_are_private() {
for (contents, ready) in [
("", true),
("mode = \"embedded\"\n", true),
(
"mode = \"remote\"\n[sync]\napi_key = \"fictional\"\n",
false,
),
] {
let f = Fixture::new(false);
fs::create_dir(f.repo.join(".chronis")).unwrap();
fs::write(f.repo.join(".chronis/config.toml"), contents).unwrap();
fs::write(f.repo.join(".chronis/.gitignore"), "wal/\nstorage/\n").unwrap();
let mut stage = Command::new("git");
stage.current_dir(&f.repo).args([
"add",
"-f",
"--",
".chronis/config.toml",
".chronis/.gitignore",
]);
assert!(bounded(stage).0);
let before = f.fingerprint();
assert_eq!(
f.json(&["init", "--check", "--profile", "rust"])["ready"],
ready
);
assert_eq!(f.fingerprint(), before);
}
}

#[test]
fn tracked_chronis_sync_export_survives_initialization_and_doctor() {
let f = Fixture::new(false);
Expand Down
Loading
Loading