merge b5bf495e-680e-452d-bbee-82fc40cfb084

99ed748gl0w

all commits

Message

merge b5bf495e-680e-452d-bbee-82fc40cfb084

Diff

.forge/wiki/docs/AGENTS.md+1 −0
diff --git a/.forge/wiki/docs/AGENTS.md b/.forge/wiki/docs/AGENTS.mdindex f42fc76..9e47af0 100644--- 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
diff --git a/crates/cli/src/cmd/init.rs b/crates/cli/src/cmd/init.rsindex dcb4a0d..a82a239 100644--- 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
diff --git a/crates/cli/src/cmd/mod.rs b/crates/cli/src/cmd/mod.rsindex 72f3f2c..a3eb9ff 100644--- 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
diff --git a/crates/cli/src/cmd/spec.rs b/crates/cli/src/cmd/spec.rsnew file mode 100644index 0000000..055b802--- /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
diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rsindex d0878f5..0f47ac3 100644--- 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
diff --git a/crates/cli/tests/cli.rs b/crates/cli/tests/cli.rsindex 76fcf5b..76ef8ca 100644--- 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
diff --git a/spec/schema.md b/spec/schema.mdindex 5398f7d..5d878b4 100644--- 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