merge 67bb67fe-77d1-448c-9f08-735f092c388e

dadfcd2gl0w

all commits

Message

merge 67bb67fe-77d1-448c-9f08-735f092c388e

Diff

.forge/issues/45ef3063-6814-4ea1-ac2d-aead62e3af77/issue.md+12 −0
diff --git a/.forge/issues/45ef3063-6814-4ea1-ac2d-aead62e3af77/issue.md b/.forge/issues/45ef3063-6814-4ea1-ac2d-aead62e3af77/issue.mdnew file mode 100644index 0000000..94a239c--- /dev/null+++ b/.forge/issues/45ef3063-6814-4ea1-ac2d-aead62e3af77/issue.md@@ -0,0 +1,12 @@1+---2+id: 45ef3063-6814-4ea1-ac2d-aead62e3af773+title: 'spec: wiki/release/init procedures + json orders undocumented'4+state: open5+labels:6+- dogfood7+assignees: []8+milestone: null9+created: 2026-09-08T04:17:08Z10+updated: null11+---12+spec/schema.md defines the release entity and wiki index line but not: wiki put/get/list flags + path-safety + 256KiB cap + list --json order (path,title,size); release create --notes file|- + build/out gitignored + ordering + create-upsert + list/view --json orders; init skeleton dirs/files + gitignore lines + idempotency. Add terse subsections; no format change.
.forge/issues/49d724b4-cf00-4ac9-9bac-1b22c71f3ad8/issue.md+12 −0
diff --git a/.forge/issues/49d724b4-cf00-4ac9-9bac-1b22c71f3ad8/issue.md b/.forge/issues/49d724b4-cf00-4ac9-9bac-1b22c71f3ad8/issue.mdnew file mode 100644index 0000000..cfc23a7--- /dev/null+++ b/.forge/issues/49d724b4-cf00-4ac9-9bac-1b22c71f3ad8/issue.md@@ -0,0 +1,12 @@1+---2+id: 49d724b4-cf00-4ac9-9bac-1b22c71f3ad83+title: m5 run_in_stdin races early-exit child (EPIPE flake)4+state: open5+labels:6+- dogfood7+assignees: []8+milestone: null9+created: 2026-09-08T04:20:17Z10+updated: null11+---12+m5.rs run_in_stdin does write_all(input).unwrap() on child stdin. When the child exits early (e.g. wiki put ../evil usage check before stdin read), the write races child exit -> BrokenPipe panic (~1/10 under parallel load). Fix in harness: ignore EPIPE on stdin write; exit-code/output assertions still verify behavior.
.forge/issues/a92913a1-7a05-4636-93be-e6bb4d7fa9a6/issue.md+12 −0
diff --git a/.forge/issues/a92913a1-7a05-4636-93be-e6bb4d7fa9a6/issue.md b/.forge/issues/a92913a1-7a05-4636-93be-e6bb4d7fa9a6/issue.mdnew file mode 100644index 0000000..3114bad--- /dev/null+++ b/.forge/issues/a92913a1-7a05-4636-93be-e6bb4d7fa9a6/issue.md@@ -0,0 +1,12 @@1+---2+id: a92913a1-7a05-4636-93be-e6bb4d7fa9a63+title: wiki get on directory pages misleads4+state: open5+labels:6+- dogfood7+assignees: []8+milestone: null9+created: 2026-09-08T04:16:52Z10+updated: null11+---12+Repro: f0rge wiki get docs/ -> 'docs/: escapes wiki' (exit 1); f0rge wiki get docs -> 'docs: io error' (exit 1). Both one-line but wrong/vague: trailing slash is not an escape, and a directory read is not a generic io error. Expect: one-line error naming the page, e.g. 'is a directory'. Scope: cmd/wiki.rs check_rel/get.
.forge/issues/d9ecf8cb-79bb-47fa-b4e9-25e6ece16bc6/issue.md+12 −0
diff --git a/.forge/issues/d9ecf8cb-79bb-47fa-b4e9-25e6ece16bc6/issue.md b/.forge/issues/d9ecf8cb-79bb-47fa-b4e9-25e6ece16bc6/issue.mdnew file mode 100644index 0000000..cbad607--- /dev/null+++ b/.forge/issues/d9ecf8cb-79bb-47fa-b4e9-25e6ece16bc6/issue.md@@ -0,0 +1,12 @@1+---2+id: d9ecf8cb-79bb-47fa-b4e9-25e6ece16bc63+title: release view/list drop unknown-field warnings4+state: open5+labels:6+- dogfood7+assignees: []8+milestone: null9+created: 2026-09-08T04:16:48Z10+updated: null11+---12+Repro: write .forge/releases/vW1.md with frontmatter field zzz: 1, run f0rge release view vW1 / release list. No warning on stderr. issue.rs warn() emits per-field warnings plus schema note; release.rs only calls warn_schema. Expect: same warning shape as issue (one line per unknown field on stderr, exit 0). Scope: cmd/release.rs (append_hashes, load_all, view).
.forge/releases/v0.1.0.md+12 −0
diff --git a/.forge/releases/v0.1.0.md b/.forge/releases/v0.1.0.mdnew file mode 100644index 0000000..c09b564--- /dev/null+++ b/.forge/releases/v0.1.0.md@@ -0,0 +1,12 @@1+---2+version: v0.1.03+title: null4+created: 2026-09-08T04:19:13Z5+---6+## v0.1.0 — first dogfood release7+8+Schema v1 frozen; core storage + derived indexes; CLI skeleton with9+token budgets; entity commands (issues, prs, tasks, labels,10+milestones); local CI gating merges; wiki + releases dogfooded.11+12+Shipped via `f0rge release create` on the dog-data worktree.
.forge/releases/v0.1.1.md+10 −0
diff --git a/.forge/releases/v0.1.1.md b/.forge/releases/v0.1.1.mdnew file mode 100644index 0000000..5c96e6b--- /dev/null+++ b/.forge/releases/v0.1.1.md@@ -0,0 +1,10 @@1+---2+version: v0.1.13+title: null4+created: 2026-09-08T04:19:13Z5+---6+## v0.1.1 — dog-data fixes7+8+- release view/list emit unknown-field warnings like issue (#10)9+- wiki get/put on directory pages report `is a directory` (#11)10+- spec documents wiki/release/init procedures + json orders (#12)
crates/cli/src/cmd/release.rs+11 −3
diff --git a/crates/cli/src/cmd/release.rs b/crates/cli/src/cmd/release.rsindex 3b7f8e6..2ad135f 100644--- a/crates/cli/src/cmd/release.rs+++ b/crates/cli/src/cmd/release.rs@@ -87,6 +87,14 @@ fn base_of(p: &str) -> String {8787         .unwrap_or_else(|| clean(p))8888 }8989 90+/// Unknown-field warnings + schema note, one line each on stderr.91+fn warn(file: &str, w: &[String], note: &Option<String>) {92+    for x in w {93+        eprintln!("warning: {file}: {x}");94+    }95+    output::warn_schema(note);96+}97+9098 #[derive(Debug, Clone, Serialize)]9199 struct ReleaseJson {92100     version: String,@@ -256,7 +264,7 @@ fn append_hashes(fg: &Forge, ver: &str) -> Result<(), CliError> {256264     rows.sort();257265     let rel = fg.release_file(ver);258266     let p: f0rge_core::Parsed<Release> = fg.load_entity(&rel).map_err(CliError::from)?;259-    output::warn_schema(&p.schema_note);267+    warn(&format!("{ver}.md"), &p.warnings, &p.schema_note);260268     let mut body = p.body;261269     if !body.is_empty() && !body.ends_with('\n') {262270         body.push('\n');@@ -361,7 +369,7 @@ fn load_all(fg: &Forge) -> Vec<Row> {361369         let rel = Path::new("releases").join(&name);362370         match fg.load_entity::<Release>(&rel) {363371             Ok(p) => {364-                output::warn_schema(&p.schema_note);372+                warn(&name, &p.warnings, &p.schema_note);365373                 out.push(Row { version: ver.to_string(), title: p.value.title, created: p.value.created })366374             }367375             Err(_) => continue,@@ -412,7 +420,7 @@ fn view(fs: &args::FlagSet, fg: &Forge, raw: &str) -> Result<(), CliError> {412420             ForgeError::NotFound { .. } => CliError::Runtime(format!("{ver}: not found")),413421             _ => CliError::from(e),414422         })?;415-    output::warn_schema(&p.schema_note);423+    warn(&format!("{ver}.md"), &p.warnings, &p.schema_note);416424     if fs.is("json") {417425         output::emit_json(418426             &ReleaseJson {
crates/cli/src/cmd/wiki.rs+10 −1
diff --git a/crates/cli/src/cmd/wiki.rs b/crates/cli/src/cmd/wiki.rsindex 117c931..0a02e7d 100644--- a/crates/cli/src/cmd/wiki.rs+++ b/crates/cli/src/cmd/wiki.rs@@ -26,7 +26,10 @@ fn clean(s: &str) -> String {2626 2727 /// Wiki-relative path; anything escaping wiki/ -> Runtime (exit 1).2828 fn check_rel(raw: &str) -> Result<String, CliError> {29-    let p = clean(raw);29+    let mut p = clean(raw);30+    if p.len() > 1 && p.ends_with('/') {31+        p.pop(); // `docs/` names the page `docs`; dirs fail later as such32+    }3033     if p.is_empty() || p.starts_with('/') || p == "." || p.ends_with('/') {3134         return Err(CliError::Runtime(format!("{p}: escapes wiki")));3235     }@@ -91,6 +94,9 @@ struct ListJson {9194 9295 fn get(fs: &args::FlagSet, fg: &Forge, raw: &str) -> Result<(), CliError> {9396     let rel = check_rel(raw)?;97+    if fg.root().join(fg.wiki_file(&rel)).is_dir() {98+        return Err(CliError::Runtime(format!("{rel}: is a directory")));99+    }94100     let bytes = fg.read_capped(&fg.wiki_file(&rel)).map_err(|e| match e {95101         ForgeError::NotFound { .. } => CliError::Runtime(format!("{rel}: not found")),96102         ForgeError::Escape { .. } => CliError::Runtime(format!("{rel}: escapes wiki")),@@ -113,6 +119,9 @@ fn put(fs: &args::FlagSet, fg: &Forge, raw: &str, src: &str) -> Result<(), CliEr113119     if src != "-" {114120         return Err(usage("f0rge wiki put <path> -"));115121     }122+    if fg.root().join(fg.wiki_file(&rel)).is_dir() {123+        return Err(CliError::Runtime(format!("{rel}: is a directory")));124+    }116125     let bytes = read_stdin_capped()?;117126     fg.atomic_write(&fg.wiki_file(&rel), &bytes).map_err(|e| match e {118127         ForgeError::Escape { .. } => CliError::Runtime(format!("{rel}: escapes wiki")),
crates/cli/tests/m5.rs+43 −1
diff --git a/crates/cli/tests/m5.rs b/crates/cli/tests/m5.rsindex ecb86fd..2d20ca8 100644--- a/crates/cli/tests/m5.rs+++ b/crates/cli/tests/m5.rs@@ -22,7 +22,10 @@ fn run_in_stdin(dir: &Path, args: &[&str], input: &[u8]) -> (String, String, i322222     let mut child =2323         bin().current_dir(dir).args(args).stdin(Stdio::piped()).stdout(Stdio::piped())2424             .stderr(Stdio::piped()).spawn().expect("spawn f0rge");25-    child.stdin.take().unwrap().write_all(input).unwrap();25+    // Early-exit children (usage/path checks run before stdin is read)26+    // may close the pipe first; EPIPE here is fine, the exit-code and27+    // output assertions below still verify behavior.28+    let _ = child.stdin.take().unwrap().write_all(input);2629     let out = child.wait_with_output().expect("wait f0rge");2730     (2831         String::from_utf8_lossy(&out.stdout).into_owned(),@@ -212,3 +215,42 @@ fn release_notes_truth_and_build() {212215 213216     let _ = std::fs::remove_dir_all(&dir);214217 }218+219+#[test]220+fn wiki_directory_pages_clean_error() {221+    let dir = tmp("wikidir");222+    assert_eq!(run_in(&dir, &["init"]).2, 0);223+    for args in [&["wiki", "get", "docs"] as &[&str], &["wiki", "get", "docs/"]] {224+        let (o, err, code) = run_in(&dir, args);225+        assert_eq!(code, 1, "args {args:?}");226+        assert_eq!(err.lines().count(), 1, "stderr:\n{err}");227+        assert!(err.contains("is a directory"), "stderr:\n{err}");228+        assert_eq!(o, "");229+    }230+    let (o, err, code) = run_in_stdin(&dir, &["wiki", "put", "docs", "-"], b"x");231+    assert_eq!(code, 1);232+    assert_eq!(err.lines().count(), 1, "stderr:\n{err}");233+    assert!(err.contains("is a directory"), "stderr:\n{err}");234+    assert_eq!(o, "");235+    let _ = std::fs::remove_dir_all(&dir);236+}237+238+#[test]239+fn release_unknown_field_warns() {240+    let dir = tmp("relwarn");241+    assert_eq!(run_in(&dir, &["init"]).2, 0);242+    std::fs::write(243+        dir.join(".forge/releases/v0.0.1.md"),244+        "---\nversion: v0.0.1\ncreated: 2025-09-08T06:00:00Z\nzzz: 1\n---\nnotes\n",245+    )246+    .unwrap();247+    let (o, err, code) = run_in(&dir, &["release", "view", "v0.0.1"]);248+    assert_eq!(code, 0);249+    assert_eq!(o, "notes\n");250+    assert_eq!(err.lines().count(), 1, "stderr:\n{err}");251+    assert!(err.contains("v0.0.1.md") && err.contains("zzz"), "stderr:\n{err}");252+    let (_, err, code) = run_in(&dir, &["release", "list", "--json"]);253+    assert_eq!(code, 0);254+    assert!(err.lines().any(|l| l.contains("zzz")), "stderr:\n{err}");255+    let _ = std::fs::remove_dir_all(&dir);256+}
spec/schema.md+38 −1
diff --git a/spec/schema.md b/spec/schema.mdindex 9f3b500..b5d08f6 100644--- a/spec/schema.md+++ b/spec/schema.md@@ -108,4 +108,41 @@ Golden: `spec/fixtures/ci-run.md`.108108   issue `number,title,state,labels,assignees,milestone,body,createdAt,comments`109109   (comments = count); pr `number,title,state,base,head,draft,110110   databaseId,status,conclusion,createdAt`111-  (`databaseId` = uuid, `status`/`conclusion` from `ci`).111+  (`databaseId` = uuid, `status`/`conclusion` from `ci`);112+  wiki list `path,title,size`; release create/view113+  `version,title,created,body,status`, release list114+  `version,title,created,status` (`status`: `built|none`, derived from115+  `out/<ver>/`).116+117+## 4. Procedures (wiki, release, init)118+119+### wiki — files are the API; index derived, never stored120+- `wiki get <path>`, `wiki put <path> -` (stdin only), `wiki list`.121+- Index: walk tree (symlinks skipped, files > 256 KiB skipped),122+  title = first `# H1` else filename stem; sorted by path.123+- Paths are wiki-relative: absolute, `.`, `..` rejected124+  (`escapes wiki`, exit 1); missing page (`not found`, exit 1);125+  directories are not pages (`is a directory`, exit 1).126+- Put/get are exact bytes; stdin capped at 256 KiB.127+128+### release — notes are truth, build is a side effect129+- `release create <ver> --notes <file|-> [--title <t>]` writes130+  `releases/<ver>.md` (upsert: last write wins), then runs the build.131+- `<ver>` becomes `<ver>.md`; separators/escapes rejected132+  (`escapes releases`, exit 1); missing `--notes` is usage (exit 2).133+- Build: executable scripts in `releases/build/` (filename order,134+  argv-only, `VERSION`/`OUT` env) run at the `<ver>` tag in a temp135+  worktree, else against the current tree; outputs land in136+  `releases/out/<ver>/` (gitignored); sha256 of artifacts is appended137+  to the notes body. Empty build dir = no-op.138+- `release list` order: `(created, version)`; `release view <ver>`139+  prints the notes body. No delete verb (nothing is deleted).140+141+### init — bootstrap skeleton, idempotent142+- `init [dir]` (default cwd) creates dirs `issues prs tasks143+  wiki/docs wiki/research wiki/experiments ci/steps releases/build144+  merges`, files `labels.md` (`[]`), `milestones.md` (`[]`),145+  `ci/ci.env` (`runner: local`), and appends any missing `.gitignore`146+  lines (`.forge/ci/runs/`, `.forge/releases/out/`, `*.tmp`).147+- Idempotent: existing files kept (never clobbered), gitignore lines148+  never duplicated. No git requirement (works in any dir).