diff --git a/README.md b/README.md index 57027c1..ced09aa 100644 --- a/README.md +++ b/README.md @@ -268,9 +268,10 @@ make validate ## Status -`v0.1.6` is the current version. Alignment, declarative Tree construction, -protected branch traversal, Recall, Project Map, and Replay form a usable -end-to-end loop. Project Map interaction design will continue to evolve. +`v0.1.7` is the current version. Alignment, declarative Tree construction, +hierarchy-aligned branch documents, protected branch traversal, Recall, Project +Map, and Replay form a usable end-to-end loop. Project Map interaction design +will continue to evolve. ## Privacy diff --git a/README.zh-CN.md b/README.zh-CN.md index b93263a..0317bd0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -246,9 +246,9 @@ make validate ## 当前状态 -`v0.1.6` 是当前版本。Alignment、声明式 Tree 构建、受保护的 branch 移动、 -Recall、Project Map 和 Replay 已经形成可用的端到端闭环。Project Map 的交互 -设计仍会持续演化。 +`v0.1.7` 是当前版本。Alignment、声明式 Tree 构建、与 Tree 层级一致的 branch +文档、受保护的 branch 移动、Recall、Project Map 和 Replay 已经形成可用的 +端到端闭环。Project Map 的交互设计仍会持续演化。 ## 隐私 diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 091a6d7..8a3c75a 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,5 +1,17 @@ # Release Notes +## v0.1.7 - Hierarchical Branch Artifacts + +- Projects branch documents onto the same parent-child hierarchy as the + accepted Tree, keeping each branch's Spec, Plan, Progress, Findings, + Verification, and auxiliary evidence together. +- Migrates the legacy flat layout on the first protected mutation while keeping + read-only Recall and Project Map compatible before migration. +- Moves parent branches and all descendants atomically during Tree Apply, with + journaled rollback and collision, cycle, escape, and symlink validation. +- Routes CLI lifecycle operations, managed worktrees, Recall, Project Map, MCP, + and release fixtures through one shared branch-artifact resolver. + ## v0.1.6 - Atomic macOS Upgrade Publication - Publishes the rebuilt CLI through a fresh sibling file and atomic rename diff --git a/docs/README.md b/docs/README.md index efb163f..593d881 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,6 +33,9 @@ These files live under - [Transaction and projection](architecture/transaction-projection.md) defines publication, events, checkpoints, coherent reads, watchers, and Replay reconstruction. +- [Hierarchical branch artifacts](architecture/hierarchical-branch-artifacts.md) + defines the Tree-derived document layout, legacy migration, subtree moves, + and rollback contract. ## Contributors diff --git a/docs/architecture/hierarchical-branch-artifacts.md b/docs/architecture/hierarchical-branch-artifacts.md new file mode 100644 index 0000000..0595003 --- /dev/null +++ b/docs/architecture/hierarchical-branch-artifacts.md @@ -0,0 +1,87 @@ +# Hierarchical Branch Artifacts + +TreeWork's semantic Tree and its branch-document filesystem must describe the +same project shape. In TreeWork 0.1.6, branch state is hierarchical while +branch documents are stored in a flat `.TreeWork/branches//` +directory. A custom Spec path can additionally split one branch's documents +between two locations. + +TreeWork 0.1.7 replaces that split model with a deterministic filesystem +projection of the accepted Tree. + +## Storage Contract + +Branch identity and branch location are intentionally separate: + +- the branch ID is the stable identity used by commands, events, dependencies, + Replay, and worktree bindings; +- the artifact directory is derived from the branch's accepted parent chain; +- the derived path is never persisted as a second topology source; +- root documents remain directly under `.TreeWork/`; +- every non-root branch owns `spec.md`, `task_plan.md`, `progress.md`, + `findings.md`, and `verification.md` in one directory. + +For example: + +```text +.TreeWork/ +├── spec.md +├── task_plan.md +├── progress.md +├── findings.md +└── branches/ + └── public-release-adoption/ + ├── spec.md + ├── task_plan.md + ├── progress.md + ├── findings.md + ├── verification.md + └── release-maintenance/ + ├── spec.md + ├── task_plan.md + ├── progress.md + ├── findings.md + └── verification.md +``` + +Each branch ID is encoded as one filesystem segment. Lowercase ASCII letters, +digits, `-`, and `_` remain literal. Every other allowed byte is percent +encoded, including `.` and `/`. This preserves a one-to-one mapping and stops +an ID from creating undeclared levels or colliding with managed filenames. + +## Layout Versions + +`state/project.json` records `artifact_layout_version`: + +- missing or `1` means the legacy flat layout; +- `2` means the hierarchy derived from accepted parent relationships. + +Read-only operations understand both layouts and never migrate implicitly. +The first locked mutation of a legacy project performs a protected one-time +migration. New projects start at layout version 2. + +Migration moves every branch-owned file, not just the five standard Markdown +documents. Custom Specs become the canonical `spec.md` for their branch. +Branch IDs, lifecycle state, verification, dependencies, event sequence, Tree +revision, and worktree bindings do not change. `.TreeWork/archive/` is outside +the live resolver and is never migrated. + +## Tree Apply + +Tree Apply compares the committed layout with the candidate layout. Moving a +branch under another parent also changes the path of every descendant. Apply +therefore stages affected directories deepest-first and publishes them +shallowest-first inside the existing publication transaction. + +No non-identical destination may be overwritten. Symlinks, paths escaping the +branches root, missing parents, cycles, and duplicate destinations fail closed. +Before the publication marker, any failure restores the exact previous paths +and bytes. After the durable marker, recovery only finishes the accepted state +forward. + +## Runtime Rule + +All consumers use the same resolver: scaffolding, lifecycle commands, Recall, +completion validation, managed worktrees, Project Map narratives and watchers, +MCP delegation, hooks, fixtures, and packaging tests. Production code must not +construct `.TreeWork/branches/` directly. diff --git a/docs/development.md b/docs/development.md index 4a243c4..3691fb9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -72,6 +72,9 @@ python3 /path/to/skill-creator/scripts/quick_validate.py \ - Edit Agent workflow guidance under the plugin Skill. - Edit developer contracts under `docs/`. - Do not manually edit `.TreeWork/state/` or managed progress blocks. +- Do not construct flat `.TreeWork/branches//` paths in runtime or + tests. Resolve branch artifacts from the accepted semantic Tree so nested + branches and legacy layout migration share one contract. Run `make test` for the normal suite and `make validate` for release-facing structure and packaging checks. diff --git a/plugins/treework/.codex-plugin/plugin.json b/plugins/treework/.codex-plugin/plugin.json index 0a5fbf8..2fcce74 100644 --- a/plugins/treework/.codex-plugin/plugin.json +++ b/plugins/treework/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "treework", - "version": "0.1.6", + "version": "0.1.7", "description": "State-native project memory for long-running coding agents.", "author": { "name": "Zhongxuan Song", diff --git a/plugins/treework/Cargo.lock b/plugins/treework/Cargo.lock index 7b2ac4a..f17f4ec 100644 --- a/plugins/treework/Cargo.lock +++ b/plugins/treework/Cargo.lock @@ -975,7 +975,7 @@ dependencies = [ [[package]] name = "treework-cli" -version = "0.1.6" +version = "0.1.7" dependencies = [ "axum", "clap", diff --git a/plugins/treework/crates/treework-cli/Cargo.toml b/plugins/treework/crates/treework-cli/Cargo.toml index 0073610..8b1904a 100644 --- a/plugins/treework/crates/treework-cli/Cargo.toml +++ b/plugins/treework/crates/treework-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "treework-cli" -version = "0.1.6" +version = "0.1.7" edition = "2021" authors = ["Zhongxuan Song "] description = "TreeWork state, transaction, and Project Map runtime" diff --git a/plugins/treework/crates/treework-cli/src/branch_artifacts.rs b/plugins/treework/crates/treework-cli/src/branch_artifacts.rs new file mode 100644 index 0000000..74cad2c --- /dev/null +++ b/plugins/treework/crates/treework-cli/src/branch_artifacts.rs @@ -0,0 +1,285 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fmt; +use std::path::{Path, PathBuf}; + +pub const LEGACY_FLAT_LAYOUT: u32 = 1; +pub const HIERARCHICAL_LAYOUT: u32 = 2; +const MAX_FILESYSTEM_SEGMENT_BYTES: usize = 255; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BranchArtifactNode { + pub id: String, + pub parent: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BranchArtifactLayout { + relative_dirs: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BranchArtifactError(String); + +impl BranchArtifactError { + fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl fmt::Display for BranchArtifactError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::error::Error for BranchArtifactError {} + +impl BranchArtifactLayout { + pub fn build( + version: u32, + nodes: impl IntoIterator, + ) -> Result { + if !matches!(version, LEGACY_FLAT_LAYOUT | HIERARCHICAL_LAYOUT) { + return Err(BranchArtifactError::new(format!( + "unsupported branch artifact layout version {version}" + ))); + } + + let mut parents = HashMap::new(); + for node in nodes { + if parents.insert(node.id.clone(), node.parent).is_some() { + return Err(BranchArtifactError::new(format!( + "duplicate branch id `{}` in artifact layout", + node.id + ))); + } + } + if parents.get("root").is_none_or(|parent| !parent.is_empty()) { + return Err(BranchArtifactError::new( + "artifact layout requires branch `root` with no parent", + )); + } + + let mut relative_dirs = BTreeMap::new(); + relative_dirs.insert("root".to_string(), PathBuf::new()); + let mut destinations = HashMap::::new(); + + let mut ids: Vec = parents.keys().cloned().collect(); + ids.sort(); + for id in ids.into_iter().filter(|id| id != "root") { + let relative = if version == LEGACY_FLAT_LAYOUT { + legacy_relative_dir(&id)? + } else { + hierarchical_relative_dir(&id, &parents)? + }; + if let Some(existing) = destinations.insert(relative.clone(), id.clone()) { + return Err(BranchArtifactError::new(format!( + "branches `{existing}` and `{id}` resolve to the same artifact directory `{}`", + relative.display() + ))); + } + relative_dirs.insert(id, relative); + } + + Ok(Self { relative_dirs }) + } + + pub fn relative_dir(&self, branch: &str) -> Result<&Path, BranchArtifactError> { + self.relative_dirs + .get(branch) + .map(PathBuf::as_path) + .ok_or_else(|| BranchArtifactError::new(format!("unknown branch `{branch}`"))) + } + + pub fn artifact_dir( + &self, + treework_dir: &Path, + branch: &str, + ) -> Result { + Ok(treework_dir.join(self.relative_dir(branch)?)) + } + + pub fn canonical_spec_path(&self, branch: &str) -> Result { + Ok(self.relative_dir(branch)?.join("spec.md")) + } + + pub fn branches(&self) -> impl Iterator { + self.relative_dirs + .iter() + .map(|(id, path)| (id.as_str(), path.as_path())) + } +} + +fn legacy_relative_dir(id: &str) -> Result { + if id.starts_with('/') + || id + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + { + return Err(BranchArtifactError::new(format!( + "legacy branch id `{id}` is not a contained relative path" + ))); + } + Ok(PathBuf::from("branches").join(id)) +} + +fn hierarchical_relative_dir( + id: &str, + parents: &HashMap, +) -> Result { + let mut chain = Vec::new(); + let mut cursor = id; + let mut seen = HashSet::new(); + + loop { + if !seen.insert(cursor.to_string()) { + let mut cycle: Vec = seen.into_iter().collect(); + cycle.sort(); + return Err(BranchArtifactError::new(format!( + "branch parent cycle while resolving `{id}`: {}", + cycle.join(", ") + ))); + } + if cursor == "root" { + break; + } + let segment = encode_segment(cursor); + if segment.len() > MAX_FILESYSTEM_SEGMENT_BYTES { + return Err(BranchArtifactError::new(format!( + "branch `{cursor}` encodes to a {}-byte filesystem segment; maximum is {} bytes", + segment.len(), + MAX_FILESYSTEM_SEGMENT_BYTES + ))); + } + chain.push(segment); + let parent = parents.get(cursor).ok_or_else(|| { + BranchArtifactError::new(format!( + "branch `{cursor}` referenced while resolving `{id}` does not exist" + )) + })?; + if parent.is_empty() { + return Err(BranchArtifactError::new(format!( + "branch `{cursor}` has no parent while resolving `{id}`" + ))); + } + cursor = parent; + } + + chain.reverse(); + let mut path = PathBuf::from("branches"); + for segment in chain { + path.push(segment); + } + Ok(path) +} + +pub fn encode_segment(id: &str) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + let mut encoded = String::with_capacity(id.len()); + for byte in id.bytes() { + if byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') { + encoded.push(char::from(byte)); + } else { + encoded.push('%'); + encoded.push(char::from(HEX[(byte >> 4) as usize])); + encoded.push(char::from(HEX[(byte & 0x0f) as usize])); + } + } + encoded +} + +#[cfg(test)] +mod tests { + use super::*; + + fn node(id: &str, parent: &str) -> BranchArtifactNode { + BranchArtifactNode { + id: id.to_string(), + parent: parent.to_string(), + } + } + + #[test] + fn hierarchical_layout_follows_parent_chain() { + let layout = BranchArtifactLayout::build( + HIERARCHICAL_LAYOUT, + [ + node("root", ""), + node("release", "root"), + node("fix", "release"), + ], + ) + .unwrap(); + + assert_eq!(layout.relative_dir("root").unwrap(), Path::new("")); + assert_eq!( + layout.relative_dir("fix").unwrap(), + Path::new("branches/release/fix") + ); + assert_eq!( + layout.canonical_spec_path("fix").unwrap(), + Path::new("branches/release/fix/spec.md") + ); + } + + #[test] + fn branch_id_is_one_injective_segment() { + assert_eq!(encode_segment("api/v2"), "api%2Fv2"); + assert_eq!(encode_segment("progress.md"), "progress%2Emd"); + assert_eq!(encode_segment("api_v2-fix"), "api_v2-fix"); + } + + #[test] + fn legacy_layout_preserves_existing_slash_behavior() { + let layout = BranchArtifactLayout::build( + LEGACY_FLAT_LAYOUT, + [node("root", ""), node("api/v2", "root")], + ) + .unwrap(); + assert_eq!( + layout.relative_dir("api/v2").unwrap(), + Path::new("branches/api/v2") + ); + } + + #[test] + fn rejects_missing_parent() { + let error = BranchArtifactLayout::build( + HIERARCHICAL_LAYOUT, + [node("root", ""), node("child", "missing")], + ) + .unwrap_err(); + assert!(error.to_string().contains("does not exist")); + } + + #[test] + fn rejects_parent_cycle() { + let error = BranchArtifactLayout::build( + HIERARCHICAL_LAYOUT, + [node("root", ""), node("a", "b"), node("b", "a")], + ) + .unwrap_err(); + assert!(error.to_string().contains("parent cycle")); + } + + #[test] + fn rejects_duplicate_ids() { + let error = BranchArtifactLayout::build( + HIERARCHICAL_LAYOUT, + [node("root", ""), node("a", "root"), node("a", "root")], + ) + .unwrap_err(); + assert!(error.to_string().contains("duplicate branch id")); + } + + #[test] + fn rejects_an_encoded_segment_over_the_portable_limit() { + let long_id = format!("a{}", ".".repeat(100)); + let error = BranchArtifactLayout::build( + HIERARCHICAL_LAYOUT, + [node("root", ""), node(&long_id, "root")], + ) + .unwrap_err(); + assert!(error.to_string().contains("filesystem segment")); + } +} diff --git a/plugins/treework/crates/treework-cli/src/checkpoint.rs b/plugins/treework/crates/treework-cli/src/checkpoint.rs index e9bf5fc..72f5031 100644 --- a/plugins/treework/crates/treework-cli/src/checkpoint.rs +++ b/plugins/treework/crates/treework-cli/src/checkpoint.rs @@ -338,6 +338,7 @@ mod tests { schema_version: "0.1".to_string(), stage: "alignment".to_string(), current_branch: "root".to_string(), + artifact_layout_version: crate::branch_artifacts::HIERARCHICAL_LAYOUT, last_event_seq: 1, tree_revision: 0, tree_editing: None::, diff --git a/plugins/treework/crates/treework-cli/src/main.rs b/plugins/treework/crates/treework-cli/src/main.rs index 2472436..b2523b8 100644 --- a/plugins/treework/crates/treework-cli/src/main.rs +++ b/plugins/treework/crates/treework-cli/src/main.rs @@ -13,6 +13,7 @@ use std::thread; use std::time::Duration; use std::time::{SystemTime, UNIX_EPOCH}; +mod branch_artifacts; mod checkpoint; mod event; mod project_map_read_model; @@ -25,6 +26,10 @@ mod tree_document; mod tree_migration; mod tree_transaction; +use branch_artifacts::{ + encode_segment, BranchArtifactLayout, BranchArtifactNode, HIERARCHICAL_LAYOUT, + LEGACY_FLAT_LAYOUT, +}; use checkpoint::{write_checkpoint, TreeCheckpoint}; use event::{ AlignmentData, BranchCompletedData, BranchEnteredData, BranchStatusData, EventData, @@ -36,7 +41,7 @@ use transaction::{recover_pending_transaction, PublicationTransaction, RecoveryO use tree_diff::{diff_tree, omitted_branch_ids}; use tree_document::{ accepted_nodes, parse_tree_document, serialize_tree_document, AcceptedTreeNode, - AcceptedTreeState, TreeDocument, + AcceptedTreeState, TreeDocument, TreeNode, }; use tree_migration::{document_from_legacy, LegacyTreeNode}; use tree_transaction::{TreeApplyJournal, TreeApplyPlan}; @@ -56,6 +61,8 @@ struct Project { stage: String, #[serde(default = "default_current_branch")] current_branch: String, + #[serde(default = "default_artifact_layout_version")] + artifact_layout_version: u32, #[serde(default)] last_event_seq: u64, #[serde(default)] @@ -430,6 +437,24 @@ impl TwCommand { } ) } + + fn mutates_project_state(&self) -> bool { + match self { + TwCommand::Init + | TwCommand::Check(_) + | TwCommand::Recall(_) + | TwCommand::Graph { .. } + | TwCommand::Version => false, + TwCommand::Enter(args) => !args.dry_run, + TwCommand::Sync + | TwCommand::Align { .. } + | TwCommand::Tree { .. } + | TwCommand::Pause(_) + | TwCommand::Abort(_) + | TwCommand::Verify(_) + | TwCommand::Complete(_) => true, + } + } } #[derive(Debug, Args)] @@ -561,6 +586,9 @@ fn run() -> AppResult<()> { if needs_lock && existing_tw(&root).is_some() { recover_pending_transaction(&root)?; rollback_pending_tree_apply(&root)?; + if command.mutates_project_state() { + ensure_hierarchical_artifact_layout(&root)?; + } } match command { TwCommand::Init => { @@ -610,6 +638,7 @@ fn cmd_init(root: &Path) -> AppResult<()> { schema_version: "0.1".to_string(), stage: "alignment".to_string(), current_branch: "root".to_string(), + artifact_layout_version: HIERARCHICAL_LAYOUT, last_event_seq: 1, tree_revision: 0, tree_editing: None, @@ -938,6 +967,7 @@ fn ensure_tree_document_draft(root: &Path, project: &mut Project) -> AppResult<( fn migrate_legacy_tree_state(root: &Path, project: &mut Project) -> AppResult<()> { let branches = load_branches(root)?; let edges = load_edges(root)?; + let layout = artifact_layout(project, &branches)?; archive_legacy_tree_state(root)?; let unsupported_relations: Vec<&str> = edges .iter() @@ -957,17 +987,17 @@ fn migrate_legacy_tree_state(root: &Path, project: &mut Project) -> AppResult<() .collect(); let legacy_nodes: Vec = branches .iter() - .map(|branch| { + .map(|branch| -> AppResult { let default_spec = if branch.path == "root" { tw_dir(root) .join("spec.md") .exists() .then(|| "spec.md".to_string()) } else { - let relative = format!("branches/{}/spec.md", branch.path); + let relative = canonical_spec_string(&layout, &branch.path)?; tw_dir(root).join(&relative).exists().then_some(relative) }; - LegacyTreeNode { + Ok(LegacyTreeNode { id: branch.path.clone(), parent: branch.parent.clone(), title: if branch.title.trim().is_empty() { @@ -989,9 +1019,9 @@ fn migrate_legacy_tree_state(root: &Path, project: &mut Project) -> AppResult<() .get(branch.path.as_str()) .cloned() .unwrap_or_default(), - } + }) }) - .collect(); + .collect::>>()?; let document = document_from_legacy(&legacy_nodes).map_err(|errors| { AppError(format!( "cannot migrate the accepted legacy tree:\n{}", @@ -1256,6 +1286,32 @@ fn build_declarative_tree_plan(root: &Path) -> AppResult { } } } + if project.artifact_layout_version == HIERARCHICAL_LAYOUT { + match BranchArtifactLayout::build( + HIERARCHICAL_LAYOUT, + nodes.iter().map(|node| BranchArtifactNode { + id: node.id.clone(), + parent: node.parent.clone(), + }), + ) { + Ok(layout) => { + for node in &nodes { + let Some(spec) = &node.spec else { + continue; + }; + match canonical_spec_string(&layout, &node.id) { + Ok(canonical) if spec != &canonical => errors.push(format!( + "branch `{}` Spec path must be canonical `{}` rather than `{}`", + node.id, canonical, spec + )), + Ok(_) => {} + Err(error) => errors.push(format!("branch `{}`: {}", node.id, error.0)), + } + } + } + Err(error) => errors.push(format!("invalid candidate artifact layout: {error}")), + } + } let operations = diff_tree(accepted_before.as_ref(), &nodes); Ok(TreeApplyPlan { @@ -1300,6 +1356,27 @@ fn apply_declarative_tree(root: &Path) -> AppResult<()> { fn apply_declarative_tree_inner(root: &Path, plan: TreeApplyPlan) -> AppResult<()> { let branches_dir = tw_dir(root).join("branches"); + let prior_project = load_project(root)?; + let prior_branches = load_branches(root)?; + let old_layout = artifact_layout(&prior_project, &prior_branches)?; + let candidate_layout = BranchArtifactLayout::build( + prior_project.artifact_layout_version, + plan.nodes.iter().map(|node| BranchArtifactNode { + id: node.id.clone(), + parent: node.parent.clone(), + }), + ) + .map_err(|error| AppError(format!("invalid candidate artifact layout: {error}")))?; + let affected_artifacts: HashSet = prior_branches + .iter() + .filter(|branch| branch.path != "root") + .filter_map(|branch| { + let old = old_layout.relative_dir(&branch.path).ok()?; + let new = candidate_layout.relative_dir(&branch.path).ok()?; + (old != new).then(|| branch.path.clone()) + }) + .collect(); + let managed_workspaces = managed_artifact_workspaces(root, &prior_branches); let mut tracked_paths = vec![ tw_dir(root).join("state/project.json"), tw_dir(root).join("state/branches.json"), @@ -1311,6 +1388,7 @@ fn apply_declarative_tree_inner(root: &Path, plan: TreeApplyPlan) -> AppResult<( tw_dir(root).join("progress.md"), tree_document_path(root), ]; + tracked_paths.extend(managed_workspaces.iter().map(|workspace| tw_dir(workspace))); for relative in plan.nodes.iter().filter_map(|node| node.spec.as_deref()) { let path = tw_dir(root).join(relative); if !path.starts_with(&branches_dir) { @@ -1334,6 +1412,27 @@ fn apply_declarative_tree_inner(root: &Path, plan: TreeApplyPlan) -> AppResult<( let mut project = load_project(root)?; let previous_branches = load_branches(root)?; let previous_edges = load_edges(root)?; + if !affected_artifacts.is_empty() { + relocate_artifact_tree( + root, + &old_layout, + &candidate_layout, + Some(&affected_artifacts), + &[], + )?; + for workspace in &managed_workspaces { + if tw_dir(workspace).exists() { + relocate_artifact_tree( + workspace, + &old_layout, + &candidate_layout, + Some(&affected_artifacts), + &[], + )?; + } + } + inject_transaction_failure("tree-apply-after-artifact-relocation", &[])?; + } let mut branches = Vec::with_capacity(plan.nodes.len()); let timestamp = now(); @@ -1350,13 +1449,19 @@ fn apply_declarative_tree_inner(root: &Path, plan: TreeApplyPlan) -> AppResult<( branch.purpose = node.purpose.clone(); branch.last_sync = timestamp.clone(); if node.id != "root" { - create_branch_docs(root, &node.id, &node.parent)?; + create_branch_docs(root, &candidate_layout, &node.id, &node.parent)?; if old_parent != node.parent { - rewrite_branch_doc_headers(root, &node.id, &node.parent)?; + rewrite_branch_doc_headers( + root, + &candidate_layout, + &node.id, + &node.parent, + )?; } if title_changed { sync_branch_plan_to_task_plan( root, + &candidate_layout, &branch, BranchPlanChanges { title: title_changed, @@ -1385,8 +1490,13 @@ fn apply_declarative_tree_inner(root: &Path, plan: TreeApplyPlan) -> AppResult<( last_sync: timestamp.clone(), }; if node.id != "root" { - create_branch_docs(root, &branch.path, &branch.parent)?; - sync_branch_plan_to_task_plan(root, &branch, BranchPlanChanges::all())?; + create_branch_docs(root, &candidate_layout, &branch.path, &branch.parent)?; + sync_branch_plan_to_task_plan( + root, + &candidate_layout, + &branch, + BranchPlanChanges::all(), + )?; } ensure_spec_document(root, node)?; branches.push(branch); @@ -1749,7 +1859,7 @@ fn cmd_enter(root: &Path, args: &EnterArgs) -> AppResult<()> { tw_dir(root).join("state/branches.json"), tw_dir(root).join("events.jsonl"), tw_dir(root).join("progress.md"), - branch_dir(root, branch_path).join("progress.md"), + branch_dir(root, branch_path)?.join("progress.md"), ]; let mut transaction = PublicationTransaction::begin(root, "branch.enter", &paths, false)?; let mut candidate = before_branch.clone(); @@ -2123,12 +2233,16 @@ fn cleanup_created_enter_isolation( Ok(()) } -fn branch_publication_paths(root: &Path, branch: &str, verification: bool) -> Vec { - let active_docs = docs_dir_for_branch(root, branch); +fn branch_publication_paths( + root: &Path, + branch: &str, + verification: bool, +) -> AppResult> { + let active_docs = docs_dir_for_branch(root, branch)?; let control_docs = if branch == "root" { tw_dir(root) } else { - branch_dir(root, branch) + branch_dir(root, branch)? }; let mut paths = vec![ tw_dir(root).join("state/project.json"), @@ -2142,7 +2256,7 @@ fn branch_publication_paths(root: &Path, branch: &str, verification: bool) -> Ve paths.push(active_docs.join("verification.md")); paths.push(control_docs.join("verification.md")); } - paths + Ok(paths) } fn cmd_pause(root: &Path, args: &PauseArgs) -> AppResult<()> { @@ -2166,7 +2280,7 @@ fn cmd_pause(root: &Path, args: &PauseArgs) -> AppResult<()> { println!("Branch `{}` is already paused.", current_branch); return Ok(()); } - let paths = branch_publication_paths(root, ¤t_branch, false); + let paths = branch_publication_paths(root, ¤t_branch, false)?; let transaction = PublicationTransaction::begin(root, "branch.pause", &paths, false)?; let timestamp = now(); let branch = &mut branches[branch_index]; @@ -2229,7 +2343,7 @@ fn cmd_abort(root: &Path, args: &AbortArgs) -> AppResult<()> { println!("Branch `{}` is already aborted.", current_branch); return Ok(()); } - let paths = branch_publication_paths(root, ¤t_branch, false); + let paths = branch_publication_paths(root, ¤t_branch, false)?; let transaction = PublicationTransaction::begin(root, "branch.abort", &paths, false)?; let timestamp = now(); let branch = &mut branches[branch_index]; @@ -2283,14 +2397,14 @@ fn cmd_verify(root: &Path, args: &VerifyArgs) -> AppResult<()> { .position(|branch| branch.path == target) .ok_or_else(|| AppError(format!("missing branch `{}`", target)))?; let before = branches[branch_index].clone(); - let verification_path = docs_dir_for_branch(root, &target).join("verification.md"); + let verification_path = docs_dir_for_branch(root, &target)?.join("verification.md"); if before.verification_status == status && verification_evidence_matches(&verification_path, &args.command, &args.result, &args.gap) { println!("Verification for `{}` is unchanged: {}.", target, status); return Ok(()); } - let paths = branch_publication_paths(root, &target, true); + let paths = branch_publication_paths(root, &target, true)?; let transaction = PublicationTransaction::begin(root, "verification.record", &paths, false)?; let timestamp = now(); let branch = &mut branches[branch_index]; @@ -2366,7 +2480,7 @@ fn cmd_complete(root: &Path, args: &CompleteArgs) -> AppResult<()> { let cleanup = prepare_completion_cleanup(root, &mut branches[branch_index], args.keep_worktree)?; let publish_control_docs = cleanup.plan.is_some(); - let paths = branch_publication_paths(root, &target, false); + let paths = branch_publication_paths(root, &target, false)?; let transaction = PublicationTransaction::begin(root, "branch.complete", &paths, false)?; let timestamp = now(); let branch = &mut branches[branch_index]; @@ -2689,8 +2803,13 @@ fn load_template(name: &str) -> AppResult { Ok(content.to_string()) } -fn create_branch_docs(root: &Path, path: &str, parent: &str) -> AppResult<()> { - let dir = branch_dir(root, path); +fn create_branch_docs( + root: &Path, + layout: &BranchArtifactLayout, + path: &str, + parent: &str, +) -> AppResult<()> { + let dir = branch_dir_from_layout(root, layout, path)?; fs::create_dir_all(&dir)?; let replacements = |template: &str| { template @@ -2718,10 +2837,11 @@ fn create_branch_docs(root: &Path, path: &str, parent: &str) -> AppResult<()> { fn sync_branch_plan_to_task_plan( root: &Path, + layout: &BranchArtifactLayout, branch: &Branch, changes: BranchPlanChanges, ) -> AppResult<()> { - let path = branch_dir(root, &branch.path).join("task_plan.md"); + let path = branch_dir_from_layout(root, layout, &branch.path)?.join("task_plan.md"); if !path.exists() { return Ok(()); } @@ -2805,7 +2925,7 @@ fn write_branch_verification_at( branch, command, result, gap, recorded_at ); write_atomic( - &docs_dir_for_branch(root, branch).join("verification.md"), + &docs_dir_for_branch(root, branch)?.join("verification.md"), &content, ) } @@ -2842,9 +2962,15 @@ fn sync_all_from_state_with_control_branch( control_branch: Option<&str>, ) -> AppResult<()> { sync_root_progress(root, project, branches)?; + let layout = artifact_layout(project, branches)?; for branch in branches { if branch.path != "root" { - sync_branch_progress(root, branch, control_branch == Some(branch.path.as_str()))?; + sync_branch_progress( + root, + &layout, + branch, + control_branch == Some(branch.path.as_str()), + )?; } } Ok(()) @@ -2887,12 +3013,18 @@ fn remove_managed_block(content: &str, key: &str) -> String { next } -fn sync_branch_progress(root: &Path, branch: &Branch, force_control: bool) -> AppResult<()> { - let active_branch = invocation().branch.clone(); - let dir = if !force_control && active_branch.as_deref() == Some(branch.path.as_str()) { - docs_dir_for_branch(root, &branch.path) +fn sync_branch_progress( + root: &Path, + layout: &BranchArtifactLayout, + branch: &Branch, + force_control: bool, +) -> AppResult<()> { + let use_active_workspace = + !force_control && invocation().branch.as_deref() == Some(branch.path.as_str()); + let dir = if use_active_workspace { + docs_dir_for_branch(root, &branch.path)? } else { - branch_dir(root, &branch.path) + branch_dir_from_layout(root, layout, &branch.path)? }; let path = dir.join("progress.md"); let mut content = read_to_string(&path) @@ -3579,7 +3711,7 @@ fn validate_completion(root: &Path, branch_path: &str) -> AppResult> if branch.status == "aborted" { findings.push(format!("branch is aborted: {}", branch.status_reason)); } - let task_plan = read_to_string(&docs_dir_for_branch(root, branch_path).join("task_plan.md")) + let task_plan = read_to_string(&docs_dir_for_branch(root, branch_path)?.join("task_plan.md")) .unwrap_or_default(); if !acceptance_complete(&task_plan) { findings.push("acceptance checklist is missing or incomplete".to_string()); @@ -4022,7 +4154,25 @@ pub(crate) fn validate_transaction_external_path( )) })?; let workspace = validate_managed_worktree(control_root, &binding.branch, workspace_path)?; - let branch_docs = tw_dir(&workspace).join("branches").join(&binding.branch); + let workspace_treework = tw_dir(&workspace); + let branches_root = workspace_treework.join("branches"); + if path == workspace_treework || path == branches_root { + let parent = path.parent().ok_or_else(|| { + AppError(format!( + "external transaction path `{}` has no TreeWork parent", + path.display() + )) + })?; + if canonical_existing(parent, "external TreeWork directory")? != parent { + return Err(AppError(format!( + "external transaction path `{}` crosses a symlinked directory", + path.display() + ))); + } + return Ok(()); + } + let layout = accepted_artifact_layout(control_root)?; + let branch_docs = branch_dir_from_layout(&workspace, &layout, &binding.branch)?; let allowed = [ branch_docs.join("progress.md"), branch_docs.join("verification.md"), @@ -4310,7 +4460,7 @@ fn build_branch_recall( .collect(); related_edges.sort_by(|a, b| a.id.cmp(&b.id)); - let docs = read_branch_docs(root, branch_path); + let docs = read_branch_docs(root, branch_path)?; let verification = branch_verification_summary(&branch, &docs); let (allowed_actions, blocked_actions) = branch_action_eligibility(root, project, &branch, &docs); @@ -4681,15 +4831,55 @@ fn extract_coverage_gap(markdown: &str) -> String { String::new() } -fn read_branch_docs(root: &Path, branch_path: &str) -> BranchDocs { - let dir = read_docs_dir_for_branch(root, branch_path); - BranchDocs { - spec: read_to_string(&dir.join("spec.md")).unwrap_or_default(), +fn read_branch_docs(root: &Path, branch_path: &str) -> AppResult { + let dir = read_docs_dir_for_branch(root, branch_path)?; + let spec_path = legacy_spec_path(root, branch_path)?.unwrap_or_else(|| dir.join("spec.md")); + Ok(BranchDocs { + spec: read_to_string(&spec_path).unwrap_or_default(), task_plan: read_to_string(&dir.join("task_plan.md")).unwrap_or_default(), progress: read_to_string(&dir.join("progress.md")).unwrap_or_default(), findings: read_to_string(&dir.join("findings.md")).unwrap_or_default(), verification: read_to_string(&dir.join("verification.md")).unwrap_or_default(), + }) +} + +fn legacy_spec_path(root: &Path, branch: &str) -> AppResult> { + let project = load_project(root)?; + if project.artifact_layout_version != LEGACY_FLAT_LAYOUT || !accepted_tree_path(root).exists() { + return Ok(None); + } + let accepted = load_accepted_tree(root)?; + let Some(relative) = accepted + .nodes + .iter() + .find(|node| node.id == branch) + .and_then(|node| node.spec.as_deref()) + else { + return Ok(None); + }; + let mut workspaces = Vec::new(); + if invocation().branch.as_deref() == Some(branch) { + workspaces.push(invocation().workspace_root.clone()); + } + if let Some(item) = load_branches(root)? + .into_iter() + .find(|item| item.path == branch && !item.isolation.workspace_path.trim().is_empty()) + { + let workspace = Path::new(&item.isolation.workspace_path); + if let Ok(workspace) = validate_managed_worktree(root, branch, workspace) { + workspaces.push(workspace); + } } + workspaces.push(root.to_path_buf()); + workspaces.dedup(); + for workspace in workspaces { + validate_spec_target(&workspace, relative)?; + let candidate = tw_dir(&workspace).join(relative); + if candidate.exists() { + return Ok(Some(candidate)); + } + } + Ok(None) } fn push_doc_section(content: &mut String, title: &str, body: &str) { @@ -4727,41 +4917,44 @@ fn branch_slug(branch: &str) -> String { } } -fn docs_dir_for_branch(root: &Path, branch: &str) -> PathBuf { +fn docs_dir_for_branch(root: &Path, branch: &str) -> AppResult { + let layout = accepted_artifact_layout(root)?; if branch == "root" { - return tw_dir(root); + return Ok(tw_dir(root)); } if let Some(bound_branch) = &invocation().branch { if bound_branch == branch { - let candidate = tw_dir(&invocation().workspace_root) - .join("branches") - .join(branch); + let candidate = branch_dir_from_layout(&invocation().workspace_root, &layout, branch)?; if candidate.exists() { - return candidate; + return Ok(candidate); } } } - branch_dir(root, branch) + branch_dir_from_layout(root, &layout, branch) } -fn read_docs_dir_for_branch(root: &Path, branch: &str) -> PathBuf { - let local = docs_dir_for_branch(root, branch); - if local != branch_dir(root, branch) { - return local; +fn read_docs_dir_for_branch(root: &Path, branch: &str) -> AppResult { + let layout = accepted_artifact_layout(root)?; + let local = docs_dir_for_branch(root, branch)?; + let control = branch_dir_from_layout(root, &layout, branch)?; + if local != control { + return Ok(local); } if let Ok(branches) = load_branches(root) { if let Some(item) = branches.iter().find(|item| item.path == branch) { if !item.isolation.workspace_path.trim().is_empty() { - let candidate = tw_dir(Path::new(&item.isolation.workspace_path)) - .join("branches") - .join(branch); + let candidate = branch_dir_from_layout( + Path::new(&item.isolation.workspace_path), + &layout, + branch, + )?; if candidate.exists() { - return candidate; + return Ok(candidate); } } } } - branch_dir(root, branch) + Ok(control) } fn replace_block(content: &str, key: &str, new_block: &str) -> String { @@ -4820,8 +5013,13 @@ fn repair_parent_edges(edges: &mut Vec, branches: &[Branch]) { edges.retain(|edge| edge.kind != "parent_of" || used_parent_edges.contains(&edge.id)); } -fn rewrite_branch_doc_headers(root: &Path, branch_path: &str, parent: &str) -> AppResult<()> { - let dir = branch_dir(root, branch_path); +fn rewrite_branch_doc_headers( + root: &Path, + layout: &BranchArtifactLayout, + branch_path: &str, + parent: &str, +) -> AppResult<()> { + let dir = branch_dir_from_layout(root, layout, branch_path)?; rewrite_doc_fields( &dir.join("task_plan.md"), &[("Branch", branch_path), ("Parent", parent)], @@ -4874,8 +5072,402 @@ fn tw_dir(root: &Path) -> PathBuf { root.join(TW_DIR) } -fn branch_dir(root: &Path, branch: &str) -> PathBuf { - tw_dir(root).join("branches").join(branch) +fn artifact_layout(project: &Project, branches: &[Branch]) -> AppResult { + BranchArtifactLayout::build( + project.artifact_layout_version, + branches.iter().map(|branch| BranchArtifactNode { + id: branch.path.clone(), + parent: branch.parent.clone(), + }), + ) + .map_err(|error| AppError(format!("invalid branch artifact layout: {error}"))) +} + +fn accepted_artifact_layout(root: &Path) -> AppResult { + let project = load_project(root)?; + let branches = load_branches(root)?; + artifact_layout(&project, &branches) +} + +fn ensure_hierarchical_artifact_layout(root: &Path) -> AppResult<()> { + let project = load_project(root)?; + match project.artifact_layout_version { + HIERARCHICAL_LAYOUT => Ok(()), + LEGACY_FLAT_LAYOUT => migrate_hierarchical_artifact_layout(root, project), + version => Err(AppError(format!( + "unsupported branch artifact layout version {version}" + ))), + } +} + +fn migrate_hierarchical_artifact_layout(root: &Path, mut project: Project) -> AppResult<()> { + if project.tree_editing.is_some() { + return Err(AppError( + "cannot migrate the legacy flat branch layout while a Tree Editing Session is open; finish that session with TreeWork 0.1.6 or restore the accepted Tree before upgrading" + .to_string(), + )); + } + + let branches = load_branches(root)?; + let old_layout = artifact_layout(&project, &branches)?; + let new_layout = BranchArtifactLayout::build( + HIERARCHICAL_LAYOUT, + branches.iter().map(|branch| BranchArtifactNode { + id: branch.path.clone(), + parent: branch.parent.clone(), + }), + ) + .map_err(|error| { + AppError(format!( + "cannot build hierarchical artifact layout: {error}" + )) + })?; + + let mut document = if tree_document_path(root).exists() { + let source = read_to_string(&tree_document_path(root))?; + Some(parse_tree_document(&source).map_err(|errors| { + AppError(format!( + "cannot migrate branch artifacts because `.TreeWork/tree.yaml` is invalid:\n{}", + errors + .iter() + .map(|error| format!("- {}", error.render(".TreeWork/tree.yaml"))) + .collect::>() + .join("\n") + )) + })?) + } else { + None + }; + + let managed_workspaces = managed_artifact_workspaces(root, &branches); + let tracked: Vec = managed_workspaces + .iter() + .map(|workspace| tw_dir(workspace)) + .collect(); + let transaction = + PublicationTransaction::begin(root, "artifact-layout.migrate", &tracked, false)?; + let result = (|| { + let spec_nodes = document.as_ref().map(accepted_nodes).unwrap_or_default(); + relocate_artifact_tree(root, &old_layout, &new_layout, None, &spec_nodes)?; + for workspace in &managed_workspaces { + if tw_dir(workspace).exists() { + relocate_artifact_tree(workspace, &old_layout, &new_layout, None, &spec_nodes)?; + } + } + inject_transaction_failure("artifact-migration-after-relocation", &[])?; + + if let Some(document) = &mut document { + rewrite_tree_document_spec_paths(document, &new_layout)?; + let source = serialize_tree_document(document).map_err(|error| { + AppError(format!("cannot serialize migrated Tree document: {error}")) + })?; + let source_hash = stable_hash_str(&source); + write_atomic(&tree_document_path(root), &source)?; + if accepted_tree_path(root).exists() { + let mut accepted = load_accepted_tree(root)?; + rewrite_accepted_spec_paths(&mut accepted, &new_layout)?; + accepted.source_hash = source_hash.clone(); + accepted.state_hash = accepted_tree_state_hash(&accepted)?; + write_json_pretty(&accepted_tree_path(root), &accepted)?; + project.tree_hash = source_hash; + } + } + + project.artifact_layout_version = HIERARCHICAL_LAYOUT; + for branch in branches.iter().filter(|branch| branch.path != "root") { + create_branch_docs(root, &new_layout, &branch.path, &branch.parent)?; + } + if let Some(document) = &document { + for node in accepted_nodes(document) { + ensure_spec_document(root, &node)?; + } + } + sync_root_progress(root, &project, &branches)?; + for branch in branches.iter().filter(|branch| branch.path != "root") { + sync_branch_progress(root, &new_layout, branch, true)?; + } + inject_transaction_failure("artifact-migration-after-state", &[])?; + + let mut transaction = transaction; + transaction.prepare_intent(&project, None)?; + transaction.sync_before_marker()?; + inject_transaction_failure("artifact-migration-after-durable-intent", &[])?; + save_project(root, &project)?; + transaction.sync_marker()?; + inject_transaction_failure("artifact-migration-after-project-marker", &[])?; + transaction.finish() + })(); + settle_transaction_result(root, result)?; + println!("Migrated branch documents to hierarchical artifact layout version 2."); + Ok(()) +} + +fn managed_artifact_workspaces(root: &Path, branches: &[Branch]) -> Vec { + let mut workspaces = HashSet::new(); + for branch in branches { + let isolation = &branch.isolation; + if isolation.mode != "git-worktree" + || !isolation.managed_by_treework + || isolation.workspace_path.trim().is_empty() + { + continue; + } + let workspace = Path::new(&isolation.workspace_path); + if let Ok(validated) = validate_managed_worktree(root, &branch.path, workspace) { + if validated != root { + workspaces.insert(validated); + } + } + } + let mut workspaces: Vec = workspaces.into_iter().collect(); + workspaces.sort(); + workspaces +} + +#[derive(Clone, Debug)] +struct StagedArtifactBranch { + id: String, + old_path: PathBuf, + staged_path: PathBuf, + new_path: PathBuf, +} + +fn relocate_artifact_tree( + workspace_root: &Path, + old_layout: &BranchArtifactLayout, + new_layout: &BranchArtifactLayout, + selected: Option<&HashSet>, + spec_nodes: &[AcceptedTreeNode], +) -> AppResult<()> { + let treework = tw_dir(workspace_root); + let branches_root = treework.join("branches"); + let staging = treework.join("state/.branch-artifact-staging"); + if staging.exists() { + return Err(AppError(format!( + "artifact migration staging path already exists: {}", + staging.display() + ))); + } + fs::create_dir_all(&staging)?; + + let mut branches: Vec = old_layout + .branches() + .filter(|(id, _)| *id != "root" && selected.is_none_or(|selected| selected.contains(*id))) + .map(|(id, old_relative)| { + let new_relative = new_layout.relative_dir(id).map_err(|error| { + AppError(format!("cannot resolve destination for `{id}`: {error}")) + })?; + Ok(StagedArtifactBranch { + id: id.to_string(), + old_path: treework.join(old_relative), + staged_path: staging.join(encode_segment(id)), + new_path: treework.join(new_relative), + }) + }) + .collect::>>()?; + branches.sort_by(|left, right| { + path_depth(&right.old_path) + .cmp(&path_depth(&left.old_path)) + .then_with(|| left.id.cmp(&right.id)) + }); + + for branch in &branches { + let metadata = match fs::symlink_metadata(&branch.old_path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => continue, + Err(error) => return Err(error.into()), + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(AppError(format!( + "branch artifact source must be a real directory: {}", + branch.old_path.display() + ))); + } + reject_symlinks_recursively(&branch.old_path)?; + fs::rename(&branch.old_path, &branch.staged_path)?; + remove_empty_parents(branch.old_path.parent(), &branches_root)?; + } + + for node in spec_nodes.iter().filter(|node| { + node.spec.is_some() + && (node.id == "root" || selected.is_none_or(|selected| selected.contains(&node.id))) + }) { + let source = treework.join(node.spec.as_deref().unwrap_or_default()); + let source = remap_staged_path(&source, &branches); + if !source.exists() { + continue; + } + let destination = if node.id == "root" { + treework.join("spec.md") + } else { + let branch = branches + .iter() + .find(|branch| branch.id == node.id) + .ok_or_else(|| AppError(format!("missing staged branch `{}`", node.id)))?; + if !branch.staged_path.exists() { + fs::create_dir_all(&branch.staged_path)?; + } + branch.staged_path.join("spec.md") + }; + move_file_without_overwrite(&source, &destination)?; + } + + branches.sort_by(|left, right| { + path_depth(&left.new_path) + .cmp(&path_depth(&right.new_path)) + .then_with(|| left.id.cmp(&right.id)) + }); + for branch in &branches { + if !branch.staged_path.exists() { + continue; + } + if branch.new_path.exists() { + if !branch.new_path.is_dir() || fs::read_dir(&branch.new_path)?.next().is_some() { + return Err(AppError(format!( + "artifact destination is not empty: {}", + branch.new_path.display() + ))); + } + fs::remove_dir(&branch.new_path)?; + } + if let Some(parent) = branch.new_path.parent() { + fs::create_dir_all(parent)?; + } + fs::rename(&branch.staged_path, &branch.new_path)?; + } + if staging.exists() { + fs::remove_dir_all(&staging)?; + } + remove_empty_parents(staging.parent(), &treework)?; + Ok(()) +} + +fn remap_staged_path(path: &Path, branches: &[StagedArtifactBranch]) -> PathBuf { + branches + .iter() + .filter_map(|branch| { + path.strip_prefix(&branch.old_path).ok().map(|relative| { + ( + path_depth(&branch.old_path), + branch.staged_path.join(relative), + ) + }) + }) + .max_by_key(|(depth, _)| *depth) + .map(|(_, staged)| staged) + .unwrap_or_else(|| path.to_path_buf()) +} + +fn move_file_without_overwrite(source: &Path, destination: &Path) -> AppResult<()> { + if source == destination { + return Ok(()); + } + let metadata = fs::symlink_metadata(source)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(AppError(format!( + "Spec source must be a regular file: {}", + source.display() + ))); + } + if destination.exists() { + let destination_metadata = fs::symlink_metadata(destination)?; + if destination_metadata.file_type().is_symlink() || !destination_metadata.is_file() { + return Err(AppError(format!( + "Spec destination is not a regular file: {}", + destination.display() + ))); + } + if fs::read(source)? != fs::read(destination)? { + return Err(AppError(format!( + "Spec destination contains different content: {}", + destination.display() + ))); + } + fs::remove_file(source)?; + return Ok(()); + } + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + fs::rename(source, destination)?; + Ok(()) +} + +fn reject_symlinks_recursively(path: &Path) -> AppResult<()> { + for entry in fs::read_dir(path)? { + let entry = entry?; + let metadata = fs::symlink_metadata(entry.path())?; + if metadata.file_type().is_symlink() { + return Err(AppError(format!( + "branch artifact migration refuses symlink {}", + entry.path().display() + ))); + } + if metadata.is_dir() { + reject_symlinks_recursively(&entry.path())?; + } else if !metadata.is_file() { + return Err(AppError(format!( + "branch artifact migration refuses non-regular path {}", + entry.path().display() + ))); + } + } + Ok(()) +} + +fn path_depth(path: &Path) -> usize { + path.components().count() +} + +fn rewrite_tree_document_spec_paths( + document: &mut TreeDocument, + layout: &BranchArtifactLayout, +) -> AppResult<()> { + fn rewrite(node: &mut TreeNode, layout: &BranchArtifactLayout) -> AppResult<()> { + if node.spec.is_some() { + node.spec = Some(canonical_spec_string(layout, &node.id)?); + } + for child in &mut node.children { + rewrite(child, layout)?; + } + Ok(()) + } + rewrite(&mut document.tree, layout) +} + +fn rewrite_accepted_spec_paths( + accepted: &mut AcceptedTreeState, + layout: &BranchArtifactLayout, +) -> AppResult<()> { + for node in &mut accepted.nodes { + if node.spec.is_some() { + node.spec = Some(canonical_spec_string(layout, &node.id)?); + } + } + Ok(()) +} + +fn canonical_spec_string(layout: &BranchArtifactLayout, branch: &str) -> AppResult { + layout + .canonical_spec_path(branch) + .map_err(|error| AppError(error.to_string()))? + .to_str() + .map(str::to_string) + .ok_or_else(|| AppError(format!("canonical Spec path for `{branch}` is not UTF-8"))) +} + +fn branch_dir_from_layout( + root: &Path, + layout: &BranchArtifactLayout, + branch: &str, +) -> AppResult { + layout + .artifact_dir(&tw_dir(root), branch) + .map_err(|error| AppError(error.to_string())) +} + +fn branch_dir(root: &Path, branch: &str) -> AppResult { + branch_dir_from_layout(root, &accepted_artifact_layout(root)?, branch) } fn lock_dir(root: &Path) -> PathBuf { @@ -5049,6 +5641,10 @@ fn default_binding_version() -> u32 { 1 } +fn default_artifact_layout_version() -> u32 { + LEGACY_FLAT_LAYOUT +} + fn default_schema_version() -> String { "0.1".to_string() } @@ -5183,3 +5779,163 @@ mod project_map_output_tests { assert_eq!(accepted_snapshot(&fixture.root), accepted_before); } } + +#[cfg(test)] +mod hierarchical_artifact_integration_tests { + use super::*; + use tempfile::TempDir; + + fn branch(id: &str, parent: &str, timestamp: &str) -> Branch { + Branch { + path: id.to_string(), + parent: parent.to_string(), + title: branch_title_from_id(id), + purpose: format!("Own {id} work."), + scope: BranchScope::default(), + intake_rationale: "test fixture".to_string(), + status: if id == "root" { + "in_progress".to_string() + } else { + "pending".to_string() + }, + verification_status: "unverified".to_string(), + sync_status: "clean".to_string(), + isolation: BranchIsolation::default(), + status_reason: String::new(), + last_sync: timestamp.to_string(), + } + } + + #[test] + fn migrates_flat_documents_and_custom_spec_without_changing_semantic_state() { + let temp = TempDir::new().expect("temp project"); + let root = temp.path(); + scaffold_treework(root).expect("scaffold"); + let timestamp = "unix:10"; + let branches = vec![ + branch("root", "", timestamp), + branch("platform", "root", timestamp), + branch("api/v2", "platform", timestamp), + ]; + let legacy_layout = BranchArtifactLayout::build( + LEGACY_FLAT_LAYOUT, + branches.iter().map(|branch| BranchArtifactNode { + id: branch.path.clone(), + parent: branch.parent.clone(), + }), + ) + .expect("legacy layout"); + for item in branches.iter().filter(|branch| branch.path != "root") { + create_branch_docs(root, &legacy_layout, &item.path, &item.parent) + .expect("legacy docs"); + } + fs::write( + branch_dir_from_layout(root, &legacy_layout, "platform") + .expect("platform dir") + .join("spec.md"), + "# Platform Spec\n", + ) + .expect("platform spec"); + fs::write( + branch_dir_from_layout(root, &legacy_layout, "api/v2") + .expect("api dir") + .join("evidence.txt"), + "preserve evidence\n", + ) + .expect("evidence"); + fs::create_dir_all(tw_dir(root).join("custom")).expect("custom dir"); + fs::write( + tw_dir(root).join("custom/api-design.md"), + "# API Design\n\nKeep this content.\n", + ) + .expect("custom spec"); + + let tree = TreeDocument { + version: 1, + tree: TreeNode { + id: "root".to_string(), + title: "Root".to_string(), + purpose: "Coordinate the test project.".to_string(), + spec: Some("spec.md".to_string()), + depends_on: Vec::new(), + children: vec![TreeNode { + id: "platform".to_string(), + title: "Platform".to_string(), + purpose: "Own platform work.".to_string(), + spec: Some("branches/platform/spec.md".to_string()), + depends_on: Vec::new(), + children: vec![TreeNode { + id: "api/v2".to_string(), + title: "API V2".to_string(), + purpose: "Own API work.".to_string(), + spec: Some("custom/api-design.md".to_string()), + depends_on: Vec::new(), + children: Vec::new(), + }], + }], + }, + }; + let source = serialize_tree_document(&tree).expect("tree source"); + let source_hash = stable_hash_str(&source); + write_atomic(&tree_document_path(root), &source).expect("tree document"); + let accepted = + accepted_tree_from_document(&tree, 3, &source_hash, timestamp).expect("accepted tree"); + write_json_pretty(&accepted_tree_path(root), &accepted).expect("accepted tree state"); + let project = Project { + schema_version: "0.1".to_string(), + stage: "work_tree".to_string(), + current_branch: "platform".to_string(), + artifact_layout_version: LEGACY_FLAT_LAYOUT, + last_event_seq: 0, + tree_revision: 3, + tree_editing: None, + tree_hash: source_hash, + last_sync: timestamp.to_string(), + }; + save_project(root, &project).expect("project"); + save_branches(root, &branches).expect("branches"); + save_edges(root, &[]).expect("edges"); + + migrate_hierarchical_artifact_layout(root, project).expect("migration"); + + let migrated_project = load_project(root).expect("migrated project"); + assert_eq!( + migrated_project.artifact_layout_version, + HIERARCHICAL_LAYOUT + ); + assert_eq!(migrated_project.last_event_seq, 0); + assert_eq!(migrated_project.tree_revision, 3); + assert_eq!(migrated_project.current_branch, "platform"); + let layout = artifact_layout(&migrated_project, &branches).expect("new layout"); + let api_dir = branch_dir_from_layout(root, &layout, "api/v2").expect("new api dir"); + assert_eq!( + api_dir + .strip_prefix(tw_dir(root)) + .expect("relative api dir"), + Path::new("branches/platform/api%2Fv2") + ); + assert_eq!( + fs::read_to_string(api_dir.join("spec.md")).expect("migrated spec"), + "# API Design\n\nKeep this content.\n" + ); + assert_eq!( + fs::read_to_string(api_dir.join("evidence.txt")).expect("migrated evidence"), + "preserve evidence\n" + ); + assert!(!tw_dir(root).join("custom/api-design.md").exists()); + assert!(!tw_dir(root).join("branches/api/v2").exists()); + + let migrated_tree = parse_tree_document( + &fs::read_to_string(tree_document_path(root)).expect("migrated tree source"), + ) + .expect("valid migrated tree"); + let api = accepted_nodes(&migrated_tree) + .into_iter() + .find(|node| node.id == "api/v2") + .expect("api node"); + assert_eq!( + api.spec.as_deref(), + Some("branches/platform/api%2Fv2/spec.md") + ); + } +} diff --git a/plugins/treework/crates/treework-cli/src/project_map_read_model.rs b/plugins/treework/crates/treework-cli/src/project_map_read_model.rs index f001498..626c84d 100644 --- a/plugins/treework/crates/treework-cli/src/project_map_read_model.rs +++ b/plugins/treework/crates/treework-cli/src/project_map_read_model.rs @@ -10,6 +10,10 @@ use std::sync::{Mutex, RwLock}; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +#[cfg(test)] +use crate::branch_artifacts::HIERARCHICAL_LAYOUT; +use crate::branch_artifacts::{BranchArtifactLayout, BranchArtifactNode, LEGACY_FLAT_LAYOUT}; + const MAX_STATE_BYTES: u64 = 16 * 1024 * 1024; const MAX_DOCUMENT_BYTES: u64 = 4 * 1024 * 1024; const MAX_EVENT_TAIL_BYTES: u64 = 1024 * 1024; @@ -213,6 +217,8 @@ struct StrictProject { schema_version: String, stage: String, current_branch: String, + #[serde(default = "default_artifact_layout_version")] + artifact_layout_version: u32, last_event_seq: u64, tree_revision: u64, tree_editing: Option, @@ -220,6 +226,10 @@ struct StrictProject { last_sync: String, } +fn default_artifact_layout_version() -> u32 { + LEGACY_FLAT_LAYOUT +} + #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] struct StrictTreeEditing { @@ -1324,6 +1334,14 @@ fn read_narratives( project: &StrictProject, branches: &[StrictBranch], ) -> ReadResult { + let layout = BranchArtifactLayout::build( + project.artifact_layout_version, + branches.iter().map(|branch| BranchArtifactNode { + id: branch.path.clone(), + parent: branch.parent.clone(), + }), + ) + .map_err(|error| ReadModelError(format!("invalid branch artifact layout: {error}")))?; let mut ordered: Vec<&StrictBranch> = branches.iter().collect(); if project.tree_revision == 0 { ordered.retain(|branch| branch.path == "root"); @@ -1334,7 +1352,7 @@ fn read_narratives( let mut managed_watch_roots = BTreeSet::new(); for branch in ordered { - let source = document_source(root, treework, branch); + let source = document_source(root, treework, &layout, branch)?; if source.managed { managed_watch_roots.insert(source.base.clone()); } @@ -1369,25 +1387,33 @@ struct DocumentSource { managed: bool, } -fn document_source(root: &Path, treework: &Path, branch: &StrictBranch) -> DocumentSource { +fn document_source( + root: &Path, + treework: &Path, + layout: &BranchArtifactLayout, + branch: &StrictBranch, +) -> ReadResult { if branch.path != "root" { - if let Some(source) = validated_managed_document_source(root, branch) { - return source; + if let Some(source) = validated_managed_document_source(root, layout, branch) { + return Ok(source); } } - let relative_dir = if branch.path == "root" { - PathBuf::new() - } else { - PathBuf::from("branches").join(&branch.path) - }; - DocumentSource { + let relative_dir = layout + .relative_dir(&branch.path) + .map_err(|error| ReadModelError(error.to_string()))? + .to_path_buf(); + Ok(DocumentSource { base: treework.to_path_buf(), relative_dir, managed: false, - } + }) } -fn validated_managed_document_source(root: &Path, branch: &StrictBranch) -> Option { +fn validated_managed_document_source( + root: &Path, + layout: &BranchArtifactLayout, + branch: &StrictBranch, +) -> Option { let isolation = branch.isolation.as_ref()?; if isolation.mode != "git-worktree" || !isolation.managed_by_treework @@ -1420,7 +1446,7 @@ fn validated_managed_document_source(root: &Path, branch: &StrictBranch) -> Opti let treework = fs::canonicalize(workspace.join(".TreeWork")).ok()?; Some(DocumentSource { base: treework, - relative_dir: PathBuf::from("branches").join(&branch.path), + relative_dir: layout.relative_dir(&branch.path).ok()?.to_path_buf(), managed: true, }) } @@ -2148,6 +2174,7 @@ pub(crate) mod test_support { mod tests { use super::test_support::{write_json, TestFixture}; use super::*; + use serde_json::Value; use std::process::Command; #[test] @@ -2183,6 +2210,81 @@ mod tests { assert_eq!(detail.verification.coverage_gap, "None."); } + #[test] + fn hierarchical_layout_reads_nested_branch_narratives() { + let fixture = TestFixture::accepted(); + let project_path = fixture.root.join(".TreeWork/state/project.json"); + let mut project: Value = + serde_json::from_str(&fs::read_to_string(&project_path).expect("project source")) + .expect("project JSON"); + project["artifact_layout_version"] = json!(HIERARCHICAL_LAYOUT); + write_json(&project_path, &project); + + let branches_path = fixture.root.join(".TreeWork/state/branches.json"); + let mut branches: Value = + serde_json::from_str(&fs::read_to_string(&branches_path).expect("branches source")) + .expect("branches JSON"); + let feature = branches["branches"] + .as_array_mut() + .expect("branch array") + .iter_mut() + .find(|branch| branch["path"] == "feature") + .expect("feature branch"); + feature["parent"] = json!("foundation"); + write_json(&branches_path, &branches); + + let tree_path = fixture.root.join(".TreeWork/state/tree.json"); + let mut tree: StrictAcceptedTree = read_strict_json(&tree_path).expect("accepted tree"); + let feature = tree + .nodes + .iter_mut() + .find(|node| node.id == "feature") + .expect("feature node"); + feature.parent = "foundation".to_string(); + feature.spec = Some("branches/foundation/feature/spec.md".to_string()); + tree.state_hash = strict_tree_state_hash(&tree).expect("tree hash"); + write_json( + &tree_path, + &serde_json::to_value(&tree).expect("tree value"), + ); + + let graph_path = fixture.root.join(".TreeWork/state/graph.json"); + let mut graph: Value = + serde_json::from_str(&fs::read_to_string(&graph_path).expect("graph source")) + .expect("graph JSON"); + let edge = graph["edges"] + .as_array_mut() + .expect("edge array") + .iter_mut() + .find(|edge| edge["kind"] == "parent_of" && edge["to"] == "feature") + .expect("feature parent edge"); + edge["from"] = json!("foundation"); + write_json(&graph_path, &graph); + + let old = fixture.root.join(".TreeWork/branches/feature"); + let nested = fixture.root.join(".TreeWork/branches/foundation/feature"); + fs::rename(&old, &nested).expect("nest feature documents"); + + let store = fixture.store(); + let _ = store.refresh(); + assert_eq!( + store + .projection() + .expect("hierarchical projection") + .health + .status, + "ok" + ); + assert_eq!( + store + .branch_detail("feature") + .expect("feature detail") + .progress + .current_reality, + "initial feature reality" + ); + } + #[test] fn revision_zero_projects_only_bootstrap_root() { let fixture = TestFixture::revision_zero(); diff --git a/plugins/treework/crates/treework-cli/src/project_map_replay.rs b/plugins/treework/crates/treework-cli/src/project_map_replay.rs index c3ccb48..07c00f0 100644 --- a/plugins/treework/crates/treework-cli/src/project_map_replay.rs +++ b/plugins/treework/crates/treework-cli/src/project_map_replay.rs @@ -90,6 +90,8 @@ struct ReplayMarker { schema_version: String, stage: String, current_branch: String, + #[serde(default = "default_artifact_layout_version")] + artifact_layout_version: u32, last_event_seq: u64, tree_revision: u64, tree_editing: Option, @@ -97,6 +99,10 @@ struct ReplayMarker { last_sync: String, } +fn default_artifact_layout_version() -> u32 { + crate::branch_artifacts::LEGACY_FLAT_LAYOUT +} + #[derive(Clone, Debug)] struct ReplayInputs { marker: ReplayMarker, @@ -1410,6 +1416,7 @@ mod tests { schema_version: "0.1".to_string(), stage: stage.to_string(), current_branch: current_branch.to_string(), + artifact_layout_version: crate::branch_artifacts::HIERARCHICAL_LAYOUT, last_event_seq, tree_revision, tree_editing: None, diff --git a/plugins/treework/mcp/treework_mcp.py b/plugins/treework/mcp/treework_mcp.py index 2ffac40..6ac2886 100755 --- a/plugins/treework/mcp/treework_mcp.py +++ b/plugins/treework/mcp/treework_mcp.py @@ -216,12 +216,6 @@ def pick_branch(workspace: Path, arguments: dict[str, Any] | None) -> str: return current if isinstance(current, str) and current else "root" -def branch_doc_dir(tw_dir: Path, branch: str) -> Path: - if branch == "root": - return tw_dir - return tw_dir / "branches" / branch - - def related_edges(edges: list[dict[str, Any]], branch: str) -> list[dict[str, Any]]: return [ edge diff --git a/plugins/treework/skills/treework/references/02-build-tree.md b/plugins/treework/skills/treework/references/02-build-tree.md index 1b5e01e..2595409 100644 --- a/plugins/treework/skills/treework/references/02-build-tree.md +++ b/plugins/treework/skills/treework/references/02-build-tree.md @@ -50,9 +50,18 @@ notes into the Tree document. Do not describe procedural operations such as `move`, `rename`, or `split`; move the stable node, edit its metadata, or add children so Apply can derive the semantic change. -Create or revise `.TreeWork/branches//spec.md` for implementation -branches with meaningful local design. A purely organizational branch needs a -Spec only when it adds shared technical direction for descendants. +Create or revise the branch's `spec.md` for implementation branches with +meaningful local design. Branch documents physically follow the accepted parent +chain. For example, branch `api` under `platform` owns +`.TreeWork/branches/platform/api/spec.md`, alongside its Plan, Progress, +Findings, and Verification. A purely organizational branch needs a Spec only +when it adds shared technical direction for descendants. + +The branch ID remains its stable command and dependency identity; its document +directory is a projection of Tree hierarchy. Do not invent or separately +maintain directory mappings. When a branch moves, edit its nesting and canonical +`spec` path in `tree.yaml`; Apply moves the branch's complete document subtree +as part of the same protected transaction. Read `tree-yaml.md` when the exact Agent-facing YAML fields and editing language matter. The corresponding machine-readable shape is @@ -64,9 +73,10 @@ After review, apply the complete candidate as one transaction. There is no normal preview step. Apply parses typed YAML, validates IDs, references, parent and dependency cycles, -Spec paths, protected history, and the accepted base revision. It computes the -semantic diff, scaffolds valid new branch documents, and commits state, -transaction event, managed blocks, and projection metadata atomically. +canonical Spec paths, artifact destinations, protected history, and the accepted +base revision. It computes the semantic diff, moves affected branch-document +subtrees, scaffolds valid new branch documents, and commits state, transaction +event, managed blocks, and projection metadata atomically. If validation fails, it reports the YAML path and location when available, changes no accepted state, and leaves the editing session open. Fix the diff --git a/plugins/treework/skills/treework/references/05-spec.md b/plugins/treework/skills/treework/references/05-spec.md index 6b9597a..eab421b 100644 --- a/plugins/treework/skills/treework/references/05-spec.md +++ b/plugins/treework/skills/treework/references/05-spec.md @@ -34,8 +34,10 @@ Use `.TreeWork/spec.md` for project-level development thinking. It explains the overall technical direction, important modules or phases, their relationships, and the intended development approach. -Use `.TreeWork/branches//spec.md` for the concrete development thinking -that belongs to one implementation branch. A branch Spec inherits the relevant +Use the branch directory's `spec.md` for the concrete development thinking that +belongs to one implementation branch. The directory follows the accepted Tree: +branch `api` under `platform` uses +`.TreeWork/branches/platform/api/spec.md`. A branch Spec inherits the relevant project direction without copying unrelated global or sibling detail. A purely organizational branch needs its own Spec only when it adds shared technical direction for descendants. @@ -59,8 +61,9 @@ relationship. This is normal. Spec-first means design before code, not a rigid `finish every Spec -> generate Tree` sequence. Keep Tree nodes concise. Do not copy branch Spec bodies into `purpose`; use the -node's `spec` path and standard branch directory to locate Spec, Plan, Progress, -Findings, and Verification. +node's canonical `spec` path and hierarchy-derived branch directory to locate +Spec, Plan, Progress, Findings, and Verification. The branch ID is stable even +when its parent changes; Tree Apply relocates the complete document subtree. Choose a tree shape that fits the actual project. Module-first and phase-then- module are common, but TreeWork does not impose a branch-granularity algorithm. diff --git a/plugins/treework/skills/treework/references/tree-yaml.md b/plugins/treework/skills/treework/references/tree-yaml.md index a931b7c..18d7f20 100644 --- a/plugins/treework/skills/treework/references/tree-yaml.md +++ b/plugins/treework/skills/treework/references/tree-yaml.md @@ -21,6 +21,12 @@ tree: purpose: Establish shared runtime and data contracts. spec: branches/foundation/spec.md + children: + - id: runtime + title: Runtime + purpose: Implement the shared execution core. + spec: branches/foundation/runtime/spec.md + - id: project-map title: Project Map purpose: Make accepted project structure visible to the user. @@ -36,8 +42,8 @@ hierarchy, and YAML order defines stable sibling order. - `version`: document schema version. Use `1`. - `tree`: the single root branch. -- `id`: stable, unique, path-safe branch identity. Do not change an ID to rename - a branch. +- `id`: stable, unique branch identity used by commands, dependencies, events, + Replay, and worktree bindings. Do not change an ID to rename a branch. - `title`: human-facing branch name. - `purpose`: one concise sentence explaining why the branch exists. - `spec`: optional path relative to `.TreeWork/` for the technical design owned @@ -49,13 +55,39 @@ hierarchy, and YAML order defines stable sibling order. Use nesting for hierarchy and `depends_on` only for execution prerequisites. Do not add vague `related_to`, `affects`, `blocks`, or layout edges. +## Canonical Artifact Paths + +Tree hierarchy also determines branch-document directories. Every non-root +branch contributes one encoded directory segment beneath its parent: + +```text +root / foundation / runtime + -> .TreeWork/branches/foundation/runtime/ +``` + +Its `spec` value must name the canonical `spec.md` in that directory, relative +to `.TreeWork/`. The other branch documents live beside it and are not listed +in YAML. Root documents remain directly under `.TreeWork/`. + +The branch ID remains stable when a branch moves; the artifact path does not. +Move the node and update its `spec` value in the same candidate. Apply derives +and atomically relocates the branch and every descendant. Do not move managed +branch directories by hand. + +IDs may contain the schema's supported punctuation, but each complete ID is one +filesystem segment. Lowercase ASCII letters, digits, `-`, and `_` remain +literal; other bytes are percent encoded. For example, branch ID `api/v2` under +`platform` owns `branches/platform/api%2Fv2/spec.md`; the slash does not create +another semantic level. + ## Desired-State Editing Edit the complete desired tree rather than writing procedural operations: - add a nested node to create a branch; - move the same `id` to change its parent; -- edit `title`, `purpose`, or `spec` to revise metadata; +- edit `title` or `purpose` to revise metadata; +- update `spec` when nesting changes so it remains the canonical derived path; - edit `depends_on` to revise prerequisites; - add children when a scope needs distinct owned work. diff --git a/plugins/treework/skills/treework/schemas/project.schema.json b/plugins/treework/skills/treework/schemas/project.schema.json index 88f8d87..7672ae0 100644 --- a/plugins/treework/skills/treework/schemas/project.schema.json +++ b/plugins/treework/skills/treework/schemas/project.schema.json @@ -2,11 +2,12 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "TreeWork Project State", "type": "object", - "required": ["schema_version", "stage", "current_branch", "last_event_seq", "tree_revision", "tree_editing", "tree_hash", "last_sync"], + "required": ["schema_version", "stage", "current_branch", "artifact_layout_version", "last_event_seq", "tree_revision", "tree_editing", "tree_hash", "last_sync"], "properties": { "schema_version": { "type": "string" }, "stage": { "enum": ["alignment", "build_tree", "work_tree"] }, "current_branch": { "type": "string" }, + "artifact_layout_version": { "const": 2 }, "last_event_seq": { "type": "integer", "minimum": 0 }, "tree_revision": { "type": "integer", "minimum": 0 }, "tree_editing": { diff --git a/project-map-ui/package-lock.json b/project-map-ui/package-lock.json index aa70de9..cfdacd7 100644 --- a/project-map-ui/package-lock.json +++ b/project-map-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@treework/project-map-ui", - "version": "0.1.6", + "version": "0.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@treework/project-map-ui", - "version": "0.1.6", + "version": "0.1.7", "license": "MIT", "dependencies": { "@fontsource/fraunces": "5.3.0", diff --git a/project-map-ui/package.json b/project-map-ui/package.json index 985e774..cf29cb8 100644 --- a/project-map-ui/package.json +++ b/project-map-ui/package.json @@ -1,6 +1,6 @@ { "name": "@treework/project-map-ui", - "version": "0.1.6", + "version": "0.1.7", "private": true, "type": "module", "license": "MIT", diff --git a/scripts/_paths.py b/scripts/_paths.py index 7f2cf81..3267855 100644 --- a/scripts/_paths.py +++ b/scripts/_paths.py @@ -1,4 +1,6 @@ -"""Shared repository paths for TreeWork development tooling.""" +"""Shared repository paths and artifact-layout helpers for TreeWork tooling.""" + +import json from pathlib import Path @@ -6,3 +8,39 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[1] PLUGIN_ROOT = REPOSITORY_ROOT / "plugins" / "treework" DIST_ROOT = REPOSITORY_ROOT / "dist" / "treework" + + +def _encode_branch_segment(branch_id: str) -> str: + safe = b"abcdefghijklmnopqrstuvwxyz0123456789-_" + return "".join( + chr(byte) if byte in safe else f"%{byte:02X}" + for byte in branch_id.encode("utf-8") + ) + + +def branch_artifact_dir(workspace: Path, branch_id: str) -> Path: + """Resolve one branch directory from the project's committed layout.""" + treework = workspace / ".TreeWork" + if branch_id == "root": + return treework + project = json.loads((treework / "state" / "project.json").read_text(encoding="utf-8")) + version = project.get("artifact_layout_version", 1) + if version == 1: + return treework / "branches" / branch_id + if version != 2: + raise ValueError(f"unsupported branch artifact layout version {version}") + + state = json.loads((treework / "state" / "branches.json").read_text(encoding="utf-8")) + parents = {item["path"]: item.get("parent", "") for item in state["branches"]} + chain: list[str] = [] + cursor = branch_id + seen: set[str] = set() + while cursor != "root": + if cursor in seen: + raise ValueError(f"branch parent cycle while resolving {branch_id}") + seen.add(cursor) + if cursor not in parents: + raise ValueError(f"unknown branch {cursor}") + chain.append(_encode_branch_segment(cursor)) + cursor = parents[cursor] + return treework / "branches" / Path(*reversed(chain)) diff --git a/scripts/check_cli_regression.py b/scripts/check_cli_regression.py index 82a4763..fbbd13a 100755 --- a/scripts/check_cli_regression.py +++ b/scripts/check_cli_regression.py @@ -12,7 +12,7 @@ import tempfile from pathlib import Path -from _paths import PLUGIN_ROOT +from _paths import PLUGIN_ROOT, branch_artifact_dir TW = PLUGIN_ROOT / "skills" / "treework" / "scripts" / "tw" @@ -185,7 +185,7 @@ def assert_full_init_scaffold(workspace: Path) -> None: def assert_branch_documents(workspace: Path, branch: str, custom_spec: str) -> None: - branch_dir = workspace / ".TreeWork" / "branches" / branch + branch_dir = branch_artifact_dir(workspace, branch) missing = [ name for name in [ @@ -209,7 +209,7 @@ def write_tree(workspace: Path, source: str) -> None: def complete_acceptance(workspace: Path, branch: str) -> None: - path = workspace / ".TreeWork" / "branches" / branch / "task_plan.md" + path = branch_artifact_dir(workspace, branch) / "task_plan.md" content = path.read_text(encoding="utf-8") if "- [ ]" not in content: fail(f"{branch} task plan has no acceptance checkbox") @@ -734,7 +734,7 @@ def check_primary_flow(temp_root: Path, build_dir: Path) -> None: - id: gamma title: Gamma purpose: Exercise atomic apply rollback. - spec: design/gamma.md + spec: branches/gamma/spec.md """, ) before_state = project_state(workspace) @@ -756,12 +756,12 @@ def check_primary_flow(temp_root: Path, build_dir: Path) -> None: fail("failed tree apply did not restore events") if (tw_dir / "state" / "tree.json").read_text(encoding="utf-8") != before_tree: fail("failed tree apply did not restore accepted Tree state") - if (tw_dir / "design" / "gamma.md").exists(): + if (tw_dir / "branches" / "gamma" / "spec.md").exists(): fail("failed tree apply left a newly scaffolded custom Spec behind") run(workspace, build_dir, "tree", "apply") if branch_state(workspace, "gamma")["parent"] != "root": fail("successful tree apply did not create gamma") - if not (tw_dir / "design" / "gamma.md").is_file(): + if not (tw_dir / "branches" / "gamma" / "spec.md").is_file(): fail("successful tree apply did not scaffold a custom Spec path") unchanged_revision = project_state(workspace)["tree_revision"] @@ -800,11 +800,11 @@ def check_primary_flow(temp_root: Path, build_dir: Path) -> None: - id: alpha title: Alpha purpose: Exercise completion and recovery. - spec: branches/alpha/spec.md + spec: branches/beta/alpha/spec.md - id: gamma title: Gamma purpose: Exercise atomic apply rollback. - spec: design/gamma.md + spec: branches/gamma/spec.md """, ) protected = run(workspace, build_dir, "tree", "apply", expect_ok=False) @@ -1132,6 +1132,185 @@ def durable_treework_snapshot(workspace: Path) -> dict[str, bytes]: return snapshot +def check_hierarchical_artifact_relocation(temp_root: Path, build_dir: Path) -> None: + workspace = temp_root / "hierarchical-artifact-relocation" + workspace.mkdir() + run(workspace, build_dir, "init") + run(workspace, build_dir, "align", "end") + run(workspace, build_dir, "tree", "start") + write_tree( + workspace, + """version: 1 +tree: + id: root + title: Hierarchical Artifacts + purpose: Verify semantic parent movement on disk. + spec: spec.md + children: + - id: platform + title: Platform + purpose: Own platform work. + spec: branches/platform/spec.md + children: + - id: api/v2 + title: API V2 + purpose: Own versioned API work. + spec: branches/platform/api%2Fv2/spec.md + - id: target + title: Target + purpose: Receive the moved subtree. +""", + ) + run(workspace, build_dir, "tree", "apply") + platform_before = branch_artifact_dir(workspace, "platform") + api_before = branch_artifact_dir(workspace, "api/v2") + (platform_before / "platform-evidence.txt").write_text("platform evidence\n", encoding="utf-8") + (api_before / "api-evidence.txt").write_text("api evidence\n", encoding="utf-8") + + run(workspace, build_dir, "tree", "update") + write_tree( + workspace, + """version: 1 +tree: + id: root + title: Hierarchical Artifacts + purpose: Verify semantic parent movement on disk. + spec: spec.md + children: + - id: target + title: Target + purpose: Receive the moved subtree. + children: + - id: platform + title: Platform + purpose: Own platform work. + spec: branches/target/platform/spec.md + children: + - id: api/v2 + title: API V2 + purpose: Own versioned API work. + spec: branches/target/platform/api%2Fv2/spec.md +""", + ) + before_failure = durable_treework_snapshot(workspace) + run( + workspace, + build_dir, + "tree", + "apply", + expect_ok=False, + extra_env={"TREEWORK_TEST_FAILPOINT": "tree-apply-after-artifact-relocation"}, + ) + if durable_treework_snapshot(workspace) != before_failure: + fail("artifact relocation failure did not restore exact paths and bytes") + if not platform_before.is_dir() or not api_before.is_dir(): + fail("artifact relocation rollback did not restore the old subtree") + assert_no_pending_transaction(workspace) + + previous_revision = project_state(workspace)["tree_revision"] + run(workspace, build_dir, "tree", "apply") + if project_state(workspace)["tree_revision"] != previous_revision + 1: + fail("artifact parent move did not advance the Tree revision") + platform_after = branch_artifact_dir(workspace, "platform") + api_after = branch_artifact_dir(workspace, "api/v2") + if platform_after != workspace / ".TreeWork/branches/target/platform": + fail(f"platform resolved to the wrong hierarchy: {platform_after}") + if api_after != workspace / ".TreeWork/branches/target/platform/api%2Fv2": + fail(f"API resolved to the wrong hierarchy: {api_after}") + if (platform_after / "platform-evidence.txt").read_text(encoding="utf-8") != "platform evidence\n": + fail("parent movement lost platform evidence") + if (api_after / "api-evidence.txt").read_text(encoding="utf-8") != "api evidence\n": + fail("parent movement lost descendant evidence") + if platform_before.exists() or api_before.exists(): + fail("parent movement left stale branch artifact directories") + + +def check_flat_artifact_layout_migration(temp_root: Path, build_dir: Path) -> None: + workspace = temp_root / "flat-artifact-layout-migration" + workspace.mkdir() + run(workspace, build_dir, "init") + run(workspace, build_dir, "align", "end") + run(workspace, build_dir, "tree", "start") + write_tree( + workspace, + """version: 1 +tree: + id: root + title: Flat Migration + purpose: Reproduce the TreeWork 0.1.6 split artifact layout. + spec: spec.md + children: + - id: platform + title: Platform + purpose: Own the parent branch. + spec: branches/platform/spec.md + children: + - id: child + title: Child + purpose: Own the nested branch. + spec: branches/platform/child/spec.md +""", + ) + run(workspace, build_dir, "tree", "apply") + nested = branch_artifact_dir(workspace, "child") + (nested / "evidence.txt").write_text("legacy evidence\n", encoding="utf-8") + flat = workspace / ".TreeWork/branches/child" + flat.mkdir() + for name in ["task_plan.md", "progress.md", "findings.md", "verification.md", "evidence.txt"]: + shutil.move(str(nested / name), flat / name) + if not (nested / "spec.md").is_file(): + fail("legacy split fixture lost its nested custom Spec") + with (flat / "progress.md").open("a", encoding="utf-8") as output: + output.write("\nLegacy flat narrative marker.\n") + with (nested / "spec.md").open("a", encoding="utf-8") as output: + output.write("\nLegacy custom Spec marker.\n") + + project_path = workspace / ".TreeWork/state/project.json" + legacy_project = load_json(project_path) + legacy_project.pop("artifact_layout_version", None) + project_path.write_text(json.dumps(legacy_project, indent=2) + "\n", encoding="utf-8") + legacy_recall = json.loads( + run(workspace, build_dir, "recall", "child", "--json").stdout + ) + if ( + "Legacy flat narrative marker." not in legacy_recall.get("docs", {}).get("progress", "") + or "Legacy custom Spec marker." not in legacy_recall.get("docs", {}).get("spec", "") + or load_json(project_path).get("artifact_layout_version") is not None + ): + fail("read-only Recall did not understand the legacy split artifact layout") + before = durable_treework_snapshot(workspace) + run( + workspace, + build_dir, + "sync", + expect_ok=False, + extra_env={"TREEWORK_TEST_FAILPOINT": "artifact-migration-after-relocation"}, + ) + if durable_treework_snapshot(workspace) != before: + fail("flat-layout migration failure did not restore exact legacy bytes and paths") + if not flat.is_dir() or not (nested / "spec.md").is_file(): + fail("flat-layout migration rollback did not restore the split legacy layout") + assert_no_pending_transaction(workspace) + + before_seq = legacy_project["last_event_seq"] + before_revision = legacy_project["tree_revision"] + run(workspace, build_dir, "sync") + migrated = project_state(workspace) + if ( + migrated.get("artifact_layout_version") != 2 + or migrated["last_event_seq"] != before_seq + or migrated["tree_revision"] != before_revision + ): + fail("flat-layout migration changed semantic project state") + migrated_child = branch_artifact_dir(workspace, "child") + if migrated_child != workspace / ".TreeWork/branches/platform/child": + fail(f"flat-layout migration published the wrong child path: {migrated_child}") + if (migrated_child / "evidence.txt").read_text(encoding="utf-8") != "legacy evidence\n": + fail("flat-layout migration lost branch-owned evidence") + if not (migrated_child / "spec.md").is_file() or flat.exists(): + fail("flat-layout migration did not reunify Spec and branch documents") + + def check_publication_recovery(temp_root: Path, build_dir: Path) -> None: for point in [ "transaction-after-checkpoint", @@ -1293,7 +1472,7 @@ def check_publication_recovery(temp_root: Path, build_dir: Path) -> None: - id: beta title: Beta purpose: Failure boundary candidate. - spec: design/beta.md + spec: branches/beta/spec.md """, ) before = durable_treework_snapshot(workspace) @@ -1315,7 +1494,7 @@ def check_publication_recovery(temp_root: Path, build_dir: Path) -> None: fail(f"Apply failure at {point} did not restore exact prior bytes") if ( (workspace / ".TreeWork" / "branches" / "beta").exists() - or (workspace / ".TreeWork" / "design" / "beta.md").exists() + or (workspace / ".TreeWork" / "branches" / "beta" / "spec.md").exists() ): fail(f"Apply failure at {point} left new branch or custom Spec documents") assert_no_pending_transaction(workspace) @@ -1337,7 +1516,7 @@ def check_publication_recovery(temp_root: Path, build_dir: Path) -> None: assert_typed_event(marker_event, "tree.applied") if not (workspace / ".TreeWork" / marker_event["data"]["snapshot_ref"]).is_file(): fail("post-marker forward recovery lost its checkpoint") - assert_branch_documents(workspace, "beta", "design/beta.md") + assert_branch_documents(workspace, "beta", "branches/beta/spec.md") assert_no_pending_transaction(workspace) run(workspace, build_dir, "tree", "update") @@ -1357,11 +1536,11 @@ def check_publication_recovery(temp_root: Path, build_dir: Path) -> None: - id: beta title: Beta purpose: Failure boundary candidate. - spec: design/beta.md + spec: branches/beta/spec.md - id: gamma title: Gamma purpose: Crash recovery candidate. - spec: design/gamma.md + spec: branches/gamma/spec.md """, ) before_crash = durable_treework_snapshot(workspace) @@ -1380,7 +1559,7 @@ def check_publication_recovery(temp_root: Path, build_dir: Path) -> None: fail("startup recovery did not exactly roll back a pre-marker crash") if ( (workspace / ".TreeWork" / "branches" / "gamma").exists() - or (workspace / ".TreeWork" / "design" / "gamma.md").exists() + or (workspace / ".TreeWork" / "branches" / "gamma" / "spec.md").exists() ): fail("pre-marker Apply crash left new branch or custom Spec documents") assert_no_pending_transaction(workspace) @@ -1405,7 +1584,7 @@ def check_publication_recovery(temp_root: Path, build_dir: Path) -> None: "branches/gamma/findings.md", "branches/gamma/verification.md", "branches/alpha/spec.md", - "design/gamma.md", + "branches/gamma/spec.md", "events.jsonl", "state/branches.json", "state/graph.json", @@ -1422,7 +1601,7 @@ def check_publication_recovery(temp_root: Path, build_dir: Path) -> None: fail("durable-intent crash did not exactly roll back before marker publication") if ( (workspace / ".TreeWork" / "branches" / "gamma").exists() - or (workspace / ".TreeWork" / "design" / "gamma.md").exists() + or (workspace / ".TreeWork" / "branches" / "gamma" / "spec.md").exists() ): fail("durable-intent rollback left new branch or custom Spec documents") assert_no_pending_transaction(workspace) @@ -1450,7 +1629,7 @@ def check_publication_recovery(temp_root: Path, build_dir: Path) -> None: ): fail("startup recovery did not finish a complete marker commit forward") assert_typed_event(event_records(workspace)[-1], "tree.applied") - assert_branch_documents(workspace, "gamma", "design/gamma.md") + assert_branch_documents(workspace, "gamma", "branches/gamma/spec.md") assert_no_pending_transaction(workspace) @@ -1531,27 +1710,38 @@ def check_worktree_binding(temp_root: Path, build_dir: Path) -> None: purpose: Exercise isolated workers. spec: spec.md children: - - id: worker-a - title: Worker A - purpose: Independent worker branch A. - - id: worker-b - title: Worker B - purpose: Independent worker branch B. - - id: cleanup-remove - title: Cleanup Remove - purpose: Verify recoverable worktree removal. - - id: cleanup-keep - title: Cleanup Keep - purpose: Verify recoverable binding removal. - - id: cleanup-warning-remove - title: Cleanup Warning Remove - purpose: Verify committed removal warnings. - - id: cleanup-warning-keep - title: Cleanup Warning Keep - purpose: Verify committed binding warnings. - - id: cleanup-guard - title: Cleanup Guard - purpose: Verify untrusted workspace paths are never removed. + - id: reorg + title: Reorganization Target + purpose: Receive the live workers subtree during Apply. + - id: workers + title: Workers + purpose: Group isolated worker branches. + children: + - id: worker-a + title: Worker A + purpose: Independent worker branch A. + - id: worker-b + title: Worker B + purpose: Independent worker branch B. + - id: cleanup + title: Cleanup + purpose: Group completion cleanup fixtures. + children: + - id: cleanup-remove + title: Cleanup Remove + purpose: Verify recoverable worktree removal. + - id: cleanup-keep + title: Cleanup Keep + purpose: Verify recoverable binding removal. + - id: cleanup-warning-remove + title: Cleanup Warning Remove + purpose: Verify committed removal warnings. + - id: cleanup-warning-keep + title: Cleanup Warning Keep + purpose: Verify committed binding warnings. + - id: cleanup-guard + title: Cleanup Guard + purpose: Verify untrusted workspace paths are never removed. """, ) run(workspace, build_dir, "tree", "apply") @@ -1607,6 +1797,67 @@ def check_worktree_binding(temp_root: Path, build_dir: Path) -> None: if not worker_a.is_dir() or not worker_b.is_dir(): fail("enter did not create independent managed worktrees") + worker_a_before = branch_artifact_dir(worker_a, "worker-a") + worker_b_before = branch_artifact_dir(worker_b, "worker-b") + (worker_a_before / "worker-evidence.txt").write_text("worker A\n", encoding="utf-8") + (worker_b_before / "worker-evidence.txt").write_text("worker B\n", encoding="utf-8") + run(workspace, build_dir, "tree", "update") + write_tree( + workspace, + """version: 1 +tree: + id: root + title: Worktree Test + purpose: Exercise isolated workers. + spec: spec.md + children: + - id: reorg + title: Reorganization Target + purpose: Receive the live workers subtree during Apply. + children: + - id: workers + title: Workers + purpose: Group isolated worker branches. + children: + - id: worker-a + title: Worker A + purpose: Independent worker branch A. + - id: worker-b + title: Worker B + purpose: Independent worker branch B. + - id: cleanup + title: Cleanup + purpose: Group completion cleanup fixtures. + children: + - id: cleanup-remove + title: Cleanup Remove + purpose: Verify recoverable worktree removal. + - id: cleanup-keep + title: Cleanup Keep + purpose: Verify recoverable binding removal. + - id: cleanup-warning-remove + title: Cleanup Warning Remove + purpose: Verify committed removal warnings. + - id: cleanup-warning-keep + title: Cleanup Warning Keep + purpose: Verify committed binding warnings. + - id: cleanup-guard + title: Cleanup Guard + purpose: Verify untrusted workspace paths are never removed. +""", + ) + run(workspace, build_dir, "tree", "apply") + worker_a_after = worker_a / ".TreeWork/branches/reorg/workers/worker-a" + worker_b_after = worker_b / ".TreeWork/branches/reorg/workers/worker-b" + if not worker_a_after.is_dir() or not worker_b_after.is_dir(): + fail("Tree Apply did not publish moved branch documents to managed worktrees") + if worker_a_before.exists() or worker_b_before.exists(): + fail("Tree Apply left stale branch documents in managed worktrees") + if (worker_a_after / "worker-evidence.txt").read_text(encoding="utf-8") != "worker A\n": + fail("Tree Apply lost worker A evidence while moving a live worktree") + if (worker_b_after / "worker-evidence.txt").read_text(encoding="utf-8") != "worker B\n": + fail("Tree Apply lost worker B evidence while moving a live worktree") + env = os.environ.copy() env["TREEWORK_PLUGIN_ROOT"] = str(PLUGIN_ROOT) env["TREEWORK_BUILD_DIR"] = str(build_dir) @@ -1690,9 +1941,7 @@ def check_worktree_binding(temp_root: Path, build_dir: Path) -> None: remove_worktree, remove_binding = prepare_completion_worktree( workspace, build_dir, "cleanup-remove" ) - remove_progress = ( - remove_worktree / ".TreeWork" / "branches" / "cleanup-remove" / "progress.md" - ) + remove_progress = branch_artifact_dir(remove_worktree, "cleanup-remove") / "progress.md" before_remove_failure = ( (workspace / ".TreeWork" / "state" / "project.json").read_bytes(), (workspace / ".TreeWork" / "state" / "branches.json").read_bytes(), @@ -1734,9 +1983,7 @@ def check_worktree_binding(temp_root: Path, build_dir: Path) -> None: keep_worktree, keep_binding = prepare_completion_worktree( workspace, build_dir, "cleanup-keep" ) - keep_progress = ( - keep_worktree / ".TreeWork" / "branches" / "cleanup-keep" / "progress.md" - ) + keep_progress = branch_artifact_dir(keep_worktree, "cleanup-keep") / "progress.md" before_keep_failure = ( (workspace / ".TreeWork" / "state" / "project.json").read_bytes(), (workspace / ".TreeWork" / "state" / "branches.json").read_bytes(), @@ -1887,6 +2134,8 @@ def main() -> None: check_legacy_state_read(temp_root, build_dir) check_project_index_state_migration(temp_root, build_dir) check_tree_apply_validation(temp_root, build_dir) + check_hierarchical_artifact_relocation(temp_root, build_dir) + check_flat_artifact_layout_migration(temp_root, build_dir) check_publication_recovery(temp_root, build_dir) check_worktree_binding(temp_root, build_dir) print("ok: TreeWork current CLI regression") diff --git a/scripts/check_mcp.py b/scripts/check_mcp.py index c8b9ad9..df87915 100755 --- a/scripts/check_mcp.py +++ b/scripts/check_mcp.py @@ -278,7 +278,7 @@ def write_tree(workspace: Path) -> None: - id: mcp-sample title: MCP Sample purpose: Branch used for MCP recall checks. - spec: branches/mcp-sample/spec.md + spec: branches/mcp-ready/mcp-sample/spec.md depends_on: - mcp-ready """, diff --git a/scripts/check_project_map_browser.py b/scripts/check_project_map_browser.py index ca7ddcb..b6b601d 100755 --- a/scripts/check_project_map_browser.py +++ b/scripts/check_project_map_browser.py @@ -11,7 +11,7 @@ import tempfile from pathlib import Path -from _paths import PLUGIN_ROOT, REPOSITORY_ROOT +from _paths import PLUGIN_ROOT, REPOSITORY_ROOT, branch_artifact_dir TW = PLUGIN_ROOT / "skills" / "treework" / "scripts" / "tw" BUNDLED_RUNTIME = ( @@ -72,7 +72,7 @@ def tree_source(*, include_late_branch: bool) -> str: - id: ui-late title: Late Topology Branch purpose: Prove topology invalidation and viewport anchoring. - spec: branches/ui-late/spec.md + spec: branches/ui-parent/ui-late/spec.md """ if include_late_branch else "" return f"""version: 1 tree: @@ -88,11 +88,11 @@ def tree_source(*, include_late_branch: bool) -> str: - id: done-leaf title: Accepted Foundation purpose: Represent settled and verified work. - spec: branches/done-leaf/spec.md + spec: branches/foundation/done-leaf/spec.md - id: shared-base title: Shared Accepted Base purpose: Prove minimum-distance fan-in deduplication. - spec: branches/shared-base/spec.md + spec: branches/foundation/shared-base/spec.md - id: paused-leaf title: Parked Foundation purpose: Represent paused work using pattern and type. @@ -107,7 +107,7 @@ def tree_source(*, include_late_branch: bool) -> str: - id: ui-source title: Strata Map View purpose: Render the current depth-column manuscript view. - spec: branches/ui-source/spec.md + spec: branches/ui-parent/ui-source/spec.md depends_on: - done-leaf children: @@ -130,7 +130,7 @@ def tree_source(*, include_late_branch: bool) -> str: - id: dependency-focus title: Focused Causal Manuscript purpose: Explain direct and transitive prerequisites and dependents. - spec: branches/dependency-focus/spec.md + spec: branches/ui-parent/dependency-focus/spec.md depends_on: - done-leaf - ui-source @@ -187,7 +187,7 @@ def complete_fixture_branch( branch_id: str, ) -> None: run_tw(workspace, build_dir, "enter", branch_id, "--no-isolate") - plan = workspace / ".TreeWork" / "branches" / branch_id / "task_plan.md" + plan = branch_artifact_dir(workspace, branch_id) / "task_plan.md" plan.write_text( plan.read_text(encoding="utf-8").replace("- [ ]", "- [x]"), encoding="utf-8", @@ -852,10 +852,7 @@ def main() -> None: const projectionBeforeNarrative = requests.filter((value) => value === '/api/project-map').length; const branchBeforeNarrative = requests.filter((value) => value.startsWith('/api/project-map/branch')).length; const progressPath = path.join( - process.env.TREEWORK_WORKSPACE, - '.TreeWork', - 'branches', - 'ui-source', + process.env.TREEWORK_UI_SOURCE_ARTIFACT_DIR, 'progress.md' ); const progress = fs.readFileSync(progressPath, 'utf8'); @@ -1956,6 +1953,9 @@ def main() -> None: { "TREEWORK_PROJECT_MAP_URL": url, "TREEWORK_WORKSPACE": str(workspace), + "TREEWORK_UI_SOURCE_ARTIFACT_DIR": str( + branch_artifact_dir(workspace, "ui-source") + ), "TREEWORK_TW": str(TW), "TREEWORK_UPDATED_TREE": str(updated_tree), "TREEWORK_EVIDENCE_DIR": str(EVIDENCE_DIR), diff --git a/scripts/check_project_map_installed.py b/scripts/check_project_map_installed.py index 0604760..4728e8f 100755 --- a/scripts/check_project_map_installed.py +++ b/scripts/check_project_map_installed.py @@ -16,7 +16,7 @@ from pathlib import Path from typing import Any -from _paths import PLUGIN_ROOT, REPOSITORY_ROOT +from _paths import PLUGIN_ROOT, REPOSITORY_ROOT, branch_artifact_dir from check_mcp import ( McpClient, accepted_state_snapshot, @@ -145,7 +145,7 @@ def fresh_tree() -> str: def complete_branch(workspace: Path, build_dir: Path, branch: str) -> None: run_tw(workspace, build_dir, "enter", branch, "--no-isolate") - plan = workspace / ".TreeWork" / "branches" / branch / "task_plan.md" + plan = branch_artifact_dir(workspace, branch) / "task_plan.md" plan.write_text( plan.read_text(encoding="utf-8").replace("- [ ]", "- [x]"), encoding="utf-8",