merge b5bf495e-680e-452d-bbee-82fc40cfb084
Message
merge b5bf495e-680e-452d-bbee-82fc40cfb084
Diff
.forge/wiki/docs/AGENTS.md+1 −0
--- a/.forge/wiki/docs/AGENTS.md+++ b/.forge/wiki/docs/AGENTS.md@@ -51,6 +51,7 @@ Canonical agent contract: `f0rge --skill` (single source of truth; skill.md body5151 Never hold state the files don't have; never duplicate it.5252 - **Nothing is deleted.** Close/archive only (`state: closed|archived` +5353 `.forge/archive/`). git is the undo log; history is the product.54+ Wiki pages are plain files in git: edit or remove with rm + git (git is the undo log).5455 - **CI is just scripts.** Steps are executable files in filename order;5556 first nonzero exit is the verdict. Posture (local/docker) is explicit,5657 never implicit.
crates/cli/src/cmd/init.rs+7 −0
--- a/crates/cli/src/cmd/init.rs+++ b/crates/cli/src/cmd/init.rs@@ -61,6 +61,13 @@ fn init_at(target: &std::path::Path) -> Result<(), std::io::Error> {6161 if !env.exists() {6262 write_new(&env, b"runner: local\n")?;6363 }64+ // starter spec: only if absent, never clobber (single source = embedded SPEC_TEXT).65+ let spec_dir = target.join("spec");66+ std::fs::create_dir_all(&spec_dir)?;67+ let schema = spec_dir.join("schema.md");68+ if !schema.exists() {69+ write_new(&schema, crate::cmd::spec::SPEC_TEXT.as_bytes())?;70+ }6471 ensure_gitignore(target)?;6572 Ok(())6673 }
crates/cli/src/cmd/mod.rs+6 −2
--- a/crates/cli/src/cmd/mod.rs+++ b/crates/cli/src/cmd/mod.rs@@ -12,6 +12,7 @@ pub mod milestone;1212 pub mod pr;1313 pub mod release;1414 pub mod skill;15+pub mod spec;1516 pub mod task;1617 pub mod wiki;1718 @@ -23,7 +24,7 @@ use crate::CliError;2324 pub const ROOT_HELP: &str = "f0rge: local forge\n\2425 usage: f0rge [--skill] <cmd> [--json|--jq E] [args]\n\2526 issue pr task label milestone ci wiki\n\26-release merge init skill\n\27+release merge init skill spec\n\2728 'cmd -h' for help\n\2829 contract: f0rge --skill\n";2930 @@ -51,6 +52,7 @@ pub fn dispatch(5152 "merge" => merge::run(args, fg),5253 "init" => init::run(args, fg),5354 "skill" => skill::run(args, fg),55+ "spec" => spec::run(args, fg),5456 _ => Err(CliError::Usage(format!("unknown command: {sub}"))),5557 }5658 }@@ -68,11 +70,12 @@ fn help_for(sub: &str) -> Option<&'static str> {6870 "merge" => merge::help(),6971 "init" => init::help(),7072 "skill" => skill::help(),73+ "spec" => spec::help(),7174 _ => return None,7275 })7376 }7477 75-/// All sub helps for budget tests (exactly 11).78+/// All sub helps for budget tests (exactly 12).7679 #[allow(dead_code)]7780 pub fn all_helps() -> Vec<(&'static str, &'static str)> {7881 vec![@@ -87,5 +90,6 @@ pub fn all_helps() -> Vec<(&'static str, &'static str)> {8790 ("merge", merge::help()),8891 ("init", init::help()),8992 ("skill", skill::help()),93+ ("spec", spec::help()),9094 ]9195 }
crates/cli/src/cmd/spec.rs+58 −0
--- /dev/null+++ b/crates/cli/src/cmd/spec.rs@@ -0,0 +1,58 @@1+#![forbid(unsafe_code)]2+//! f0rge spec: print/write the embedded canonical spec/schema.md.3+//! Single source of truth: include_str! of the repo spec, no scrubbed copy.4+use crate::CliError;5+6+/// Embedded canonical spec: single source, no drift.7+pub const SPEC_TEXT: &str = include_str!("../../../../spec/schema.md");8+9+pub fn help() -> &'static str {10+ "f0rge spec: schema\nusage: f0rge spec [--write]\nprints embedded spec/schema.md\ndocs: spec/schema.md §Spec\ncontract: f0rge --skill\n"11+}12+13+pub fn run(args: &[String], _fg: &f0rge_core::Forge) -> Result<(), CliError> {14+ let mut write = false;15+ for a in args {16+ if a == "-h" || a == "--help" {17+ crate::output::out(help());18+ return Ok(());19+ }20+ if a == "--write" {21+ write = true;22+ continue;23+ }24+ if a.starts_with('-') {25+ return Err(CliError::Usage(format!("unknown flag: {a}")));26+ }27+ return Err(CliError::Usage("spec [--write]".to_string()));28+ }29+ if write {30+ let cwd = std::env::current_dir().map_err(|_| CliError::Io("cwd: io error".to_string()))?;31+ write_spec(&cwd).map_err(|e| CliError::Io(e.to_string()))?;32+ return Ok(());33+ }34+ crate::output::out(SPEC_TEXT);35+ if !SPEC_TEXT.ends_with('\n') {36+ crate::output::out("\n");37+ }38+ Ok(())39+}40+41+fn io_err(file: &str) -> std::io::Error {42+ std::io::Error::other(file.to_string())43+}44+45+/// Overwrite spec/schema.md (atomic tmp+rename). Creates spec/ dir.46+fn write_spec(target: &std::path::Path) -> Result<(), std::io::Error> {47+ use std::io::Write;48+ let dir = target.join("spec");49+ std::fs::create_dir_all(&dir)?;50+ let path = dir.join("schema.md");51+ let tmp = path.with_extension("tmp");52+ let mut f = std::fs::File::create(&tmp).map_err(|_| io_err("schema.md"))?;53+ f.write_all(SPEC_TEXT.as_bytes()).map_err(|_| io_err("schema.md"))?;54+ f.sync_all().map_err(|_| io_err("schema.md"))?;55+ drop(f);56+ std::fs::rename(&tmp, &path).map_err(|_| io_err("schema.md"))?;57+ Ok(())58+}
crates/cli/src/main.rs+1 −1
--- a/crates/cli/src/main.rs+++ b/crates/cli/src/main.rs@@ -102,7 +102,7 @@ mod budget_tests {102102 #[test]103103 fn all_sub_helps_within_budget() {104104 let helps = crate::cmd::all_helps();105- assert_eq!(helps.len(), 11, "expected 11 sub helps");105+ assert_eq!(helps.len(), 12, "expected 12 sub helps");106106 for (name, h) in helps {107107 let (lines, bytes) = lines_and_bytes(h);108108 assert!(lines <= 8, "{name} -h {lines} lines > 8");
crates/cli/tests/cli.rs+35 −1
--- a/crates/cli/tests/cli.rs+++ b/crates/cli/tests/cli.rs@@ -22,7 +22,7 @@ fn lines_and_bytes(s: &str) -> (usize, usize) {2222 }2323 2424 const SUBS: &[&str] =25- &["issue", "pr", "task", "label", "milestone", "ci", "wiki", "release", "merge", "init", "skill"];25+ &["issue", "pr", "task", "label", "milestone", "ci", "wiki", "release", "merge", "init", "skill", "spec"];2626 2727 #[test]2828 fn root_help_within_budget() {@@ -192,3 +192,37 @@ fn sub_help_single_style_with_contract_pointer() {192192 }193193 }194194 195+196+#[test]197+fn spec_prints_embedded_schema() {198+ let (out, _, code) = run(&["spec"]);199+ assert_eq!(code, 0);200+ let canon = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../spec/schema.md"))201+ .expect("read spec/schema.md");202+ assert_eq!(out, canon, "f0rge spec != spec/schema.md");203+}204+205+#[test]206+fn spec_write_overwrites_and_init_never_clobbers() {207+ use std::process::Command;208+ let dir = std::env::temp_dir().join(format!("f0rge-cli-spec-{}-{}", std::process::id(), line!()));209+ let _ = std::fs::remove_dir_all(&dir);210+ std::fs::create_dir_all(&dir).unwrap();211+ let s = dir.to_string_lossy().into_owned();212+ let st = Command::new(env!("CARGO_BIN_EXE_f0rge")).args(["init", &s]).output().unwrap();213+ assert!(st.status.success(), "init: {st:?}");214+ let schema = dir.join("spec/schema.md");215+ assert!(schema.is_file(), "init must write spec/schema.md");216+ let canon = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../spec/schema.md")).unwrap();217+ assert_eq!(std::fs::read_to_string(&schema).unwrap(), canon);218+ // init never clobbers: hand-edit then re-init keeps edit.219+ std::fs::write(&schema, "custom\n").unwrap();220+ let st = Command::new(env!("CARGO_BIN_EXE_f0rge")).args(["init", &s]).output().unwrap();221+ assert!(st.status.success());222+ assert_eq!(std::fs::read_to_string(&schema).unwrap(), "custom\n");223+ // spec --write re-clobbers back to canonical.224+ let st = Command::new(env!("CARGO_BIN_EXE_f0rge")).arg("spec").arg("--write").current_dir(&dir).output().unwrap();225+ assert!(st.status.success(), "spec --write: {st:?}");226+ assert_eq!(std::fs::read_to_string(&schema).unwrap(), canon);227+ let _ = std::fs::remove_dir_all(&dir);228+}
spec/schema.md+2 −0
--- a/spec/schema.md+++ b/spec/schema.md@@ -25,6 +25,7 @@ without a schema-version bump in the contract (`f0rge --skill`).2525 pr `open|closed|merged` (+ `draft:` bool);2626 task `status: todo|doing|blocked|done`;2727 milestone `open|closed`.28+- address: issue <N> | task|pr <uuid> | milestone|label <name> | wiki <path>2829 2930 ## 1. Layout (OUTLINE §5)3031 @@ -127,6 +128,7 @@ Golden: `spec/fixtures/ci-run.md`.127128 (`escapes wiki`, exit 1); missing page (`not found`, exit 1);128129 directories are not pages (`is a directory`, exit 1).129130 - Put/get are exact bytes; stdin capped at 256 KiB.131+- wiki pages are plain files in git; edit or remove with rm + git (git is the undo log); 'never delete' governs entity history (issues/prs/tasks), not doc files.130132 131133 ### release — notes are truth, build is a side effect132134 - `release create <ver> --notes <file|-> [--title <t>]` writes