merge 67bb67fe-77d1-448c-9f08-735f092c388e
Message
merge 67bb67fe-77d1-448c-9f08-735f092c388e
Diff
.forge/issues/45ef3063-6814-4ea1-ac2d-aead62e3af77/issue.md+12 −0
--- /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
--- /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
--- /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
--- /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
--- /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
--- /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
--- 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
--- 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
--- 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
--- 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).