Skip to content

Commit a94a22a

Browse files
authored
Merge pull request #2 from saagar210/codex/export-manifest-hardening
feat(export): add deterministic manifest file metadata
2 parents 4012a41 + 0e55083 commit a94a22a

File tree

5 files changed

+146
-7
lines changed

5 files changed

+146
-7
lines changed

IMPLEMENTATION_MAP.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,3 +535,19 @@ A roadmap item is complete only when all conditions are true:
535535
## 9) Immediate Next Action
536536

537537
Start with **Planning Coverage UI** implementation using section 6.1 exactly as written, then progress sequentially by commit plan in section 7.
538+
539+
## 10) Next Roadmap Block (Post-Merge)
540+
541+
### Block A: Model-Agnostic Export Interop Hardening
542+
543+
Objective:
544+
- Ensure exported planning packs can be consumed and validated reliably by any coding model/tooling pipeline.
545+
546+
Scope:
547+
1. Expand `manifest.json` to include deterministic file metadata (`bytes`, `lines`, `sha256`) per document.
548+
2. Make manifest file ordering deterministic across exports.
549+
3. Add regression tests for ordering and checksum generation.
550+
4. Update docs so pack consumers understand validation fields.
551+
552+
Status:
553+
- Started 2026-02-07.

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,11 @@ my-project-plan/
219219
├── CLAUDE.md # Project context for Claude Code
220220
├── PROMPTS.md # Phased implementation prompts
221221
├── MODEL_HANDOFF.md # Target-specific model handoff
222-
└── CONVERSATION.md # Full planning transcript
222+
├── CONVERSATION.md # Full planning transcript
223+
└── manifest.json # Export metadata + file checksums
223224
```
224225

225-
Save to any folder via `Cmd+S` or the Save button. Folder names are sanitized to lowercase alphanumeric + hyphens (max 60 characters). AuraForge checks for existing folders (won't overwrite), verifies disk space (20 GB threshold), and handles permission errors with specific messages.
226+
Save to any folder via `Cmd+S` or the Save button. Folder names are sanitized to lowercase alphanumeric + hyphens (max 60 characters). AuraForge checks for existing folders (won't overwrite), verifies disk space (20 GB threshold), and handles permission errors with specific messages. `manifest.json` includes deterministic file metadata (`filename`, `bytes`, `lines`, `sha256`) to make handoff packs verifiable across coding models.
226227

227228
---
228229

src-tauri/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ dirs = "5"
4444
futures = "0.3"
4545
log = "0.4"
4646
libc = "0.2"
47+
sha2 = "0.10"
4748

4849
# Logging
4950
tauri-plugin-log = "2"

src-tauri/src/commands/mod.rs

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use serde::Serialize;
2+
use sha2::{Digest, Sha256};
23
use std::sync::atomic::AtomicBool;
34
use std::sync::Arc;
45
use tauri::{Emitter, State};
@@ -116,6 +117,16 @@ If the user seems stuck or unsure what to discuss next, suggest the next uncover
116117
- Propagate typos or unclear terms without clarifying
117118
- Rush to architecture before understanding the problem"##;
118119

120+
const EXPORT_FILE_ORDER: &[&str] = &[
121+
"START_HERE.md",
122+
"README.md",
123+
"SPEC.md",
124+
"CLAUDE.md",
125+
"PROMPTS.md",
126+
"MODEL_HANDOFF.md",
127+
"CONVERSATION.md",
128+
];
129+
119130
fn to_response<E: Into<AppError>>(err: E) -> ErrorResponse {
120131
err.into().to_response()
121132
}
@@ -1072,6 +1083,7 @@ pub async fn save_to_folder(
10721083
}
10731084

10741085
let manifest = ExportManifest {
1086+
schema_version: 2,
10751087
session_id: session_id_for_thread.clone(),
10761088
session_name: session_name_for_thread.clone(),
10771089
target: meta_for_thread
@@ -1096,10 +1108,7 @@ pub async fn save_to_folder(
10961108
.and_then(|m| m.confidence_json.as_ref())
10971109
.and_then(|q| serde_json::from_str::<ConfidenceReport>(q).ok()),
10981110
import_context: import_context_for_thread.clone(),
1099-
files: docs_for_thread
1100-
.iter()
1101-
.map(|doc| doc.filename.clone())
1102-
.collect(),
1111+
files: build_export_manifest_files(&docs_for_thread),
11031112
};
11041113
let manifest_json =
11051114
serde_json::to_string_pretty(&manifest).map_err(|e| AppError::FileSystem {
@@ -1229,6 +1238,7 @@ fn resolve_forge_target(
12291238

12301239
#[derive(Debug, Clone, Serialize)]
12311240
struct ExportManifest {
1241+
schema_version: u32,
12321242
session_id: String,
12331243
session_name: String,
12341244
target: String,
@@ -1238,11 +1248,59 @@ struct ExportManifest {
12381248
quality: Option<QualityReport>,
12391249
confidence: Option<ConfidenceReport>,
12401250
import_context: Option<CodebaseImportSummary>,
1241-
files: Vec<String>,
1251+
files: Vec<ExportManifestFile>,
1252+
}
1253+
1254+
#[derive(Debug, Clone, Serialize)]
1255+
struct ExportManifestFile {
1256+
filename: String,
1257+
bytes: usize,
1258+
lines: usize,
1259+
sha256: String,
12421260
}
12431261

12441262
// ============ HELPERS ============
12451263

1264+
fn build_export_manifest_files(docs: &[GeneratedDocument]) -> Vec<ExportManifestFile> {
1265+
let mut files: Vec<ExportManifestFile> = docs
1266+
.iter()
1267+
.map(|doc| ExportManifestFile {
1268+
filename: doc.filename.clone(),
1269+
bytes: doc.content.len(),
1270+
lines: if doc.content.is_empty() {
1271+
0
1272+
} else {
1273+
doc.content.lines().count()
1274+
},
1275+
sha256: sha256_hex(doc.content.as_bytes()),
1276+
})
1277+
.collect();
1278+
1279+
files.sort_by(|a, b| {
1280+
let rank_a = export_file_rank(&a.filename);
1281+
let rank_b = export_file_rank(&b.filename);
1282+
rank_a
1283+
.cmp(&rank_b)
1284+
.then_with(|| a.filename.cmp(&b.filename))
1285+
});
1286+
1287+
files
1288+
}
1289+
1290+
fn export_file_rank(filename: &str) -> usize {
1291+
EXPORT_FILE_ORDER
1292+
.iter()
1293+
.position(|known| known == &filename)
1294+
.unwrap_or(EXPORT_FILE_ORDER.len())
1295+
}
1296+
1297+
fn sha256_hex(bytes: &[u8]) -> String {
1298+
let mut hasher = Sha256::new();
1299+
hasher.update(bytes);
1300+
let digest = hasher.finalize();
1301+
digest.iter().map(|b| format!("{:02x}", b)).collect()
1302+
}
1303+
12461304
fn extract_import_summary_from_metadata(metadata: &str) -> Option<CodebaseImportSummary> {
12471305
let value = serde_json::from_str::<serde_json::Value>(metadata).ok()?;
12481306
serde_json::from_value::<CodebaseImportSummary>(value.get("import_summary")?.clone()).ok()
@@ -1271,3 +1329,65 @@ fn build_search_context(query: &str, results: &[SearchResult]) -> String {
12711329

12721330
context
12731331
}
1332+
1333+
#[cfg(test)]
1334+
mod tests {
1335+
use super::*;
1336+
1337+
fn doc(filename: &str, content: &str) -> GeneratedDocument {
1338+
GeneratedDocument {
1339+
id: "doc-id".to_string(),
1340+
session_id: "session-id".to_string(),
1341+
filename: filename.to_string(),
1342+
content: content.to_string(),
1343+
created_at: "2026-01-01 00:00:00".to_string(),
1344+
}
1345+
}
1346+
1347+
#[test]
1348+
fn build_export_manifest_files_orders_known_documents_first() {
1349+
let files = build_export_manifest_files(&[
1350+
doc("Z_NOTES.md", "notes"),
1351+
doc("README.md", "read me"),
1352+
doc("START_HERE.md", "start here"),
1353+
doc("A_CUSTOM.md", "custom"),
1354+
]);
1355+
1356+
let ordered_names: Vec<String> = files.into_iter().map(|f| f.filename).collect();
1357+
assert_eq!(
1358+
ordered_names,
1359+
vec![
1360+
"START_HERE.md".to_string(),
1361+
"README.md".to_string(),
1362+
"A_CUSTOM.md".to_string(),
1363+
"Z_NOTES.md".to_string(),
1364+
]
1365+
);
1366+
}
1367+
1368+
#[test]
1369+
fn build_export_manifest_files_includes_hash_bytes_and_lines() {
1370+
let files = build_export_manifest_files(&[doc("SPEC.md", "abc"), doc("EMPTY.md", "")]);
1371+
let spec = files
1372+
.iter()
1373+
.find(|f| f.filename == "SPEC.md")
1374+
.expect("SPEC.md entry missing");
1375+
assert_eq!(spec.bytes, 3);
1376+
assert_eq!(spec.lines, 1);
1377+
assert_eq!(
1378+
spec.sha256,
1379+
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
1380+
);
1381+
1382+
let empty = files
1383+
.iter()
1384+
.find(|f| f.filename == "EMPTY.md")
1385+
.expect("EMPTY.md entry missing");
1386+
assert_eq!(empty.bytes, 0);
1387+
assert_eq!(empty.lines, 0);
1388+
assert_eq!(
1389+
empty.sha256,
1390+
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1391+
);
1392+
}
1393+
}

0 commit comments

Comments
 (0)