opencrabs 0.5.1

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Recommended: the 40MB prebuilt binary for macOS, Linux and Windows: https://github.com/adolfousier/opencrabs/releases
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
//! Tests for the shared session plan file store (`utils::plan_files`):
//! lifecycle state derivation, the durable pre-init Editing flag, the
//! legacy seven-status load map (with silent Completed archive and
//! Cancelled delete), canonical status strings on write, the design `.md`
//! scaffold, and the Editing markdown-to-description mirror.

use crate::config::profile::{home_for_profile, with_profile_home_async};
use crate::tui::plan::{PlanDocument, PlanStatus, PlanTask, TaskType};
use crate::utils::plan_files::{
    PlanModeState, archive_dir, create_design_md, discard_plan, is_plan_autonomy,
    is_pre_init_editing, load_plan, plan_json_path, plan_md_path, plan_mode_state,
    pre_init_marker_path, save_plan, set_plan_autonomy, set_pre_init_editing, sync_md_to_json,
    template_section_warnings,
};
use uuid::Uuid;

/// Run `f` under a throwaway profile home so nothing touches the real
/// `~/.opencrabs/agents/session/`, then clean the profile dir up.
async fn in_temp_home<F, T>(f: F) -> T
where
    F: std::future::Future<Output = T>,
{
    let profile = format!("plan-files-test-{}", Uuid::new_v4());
    let out = with_profile_home_async(Some(&profile), f).await;
    let home = home_for_profile(Some(&profile));
    let _ = std::fs::remove_dir_all(&home);
    out
}

fn task(order: usize, title: &str) -> PlanTask {
    PlanTask::new(order, title.to_string(), "desc".to_string(), TaskType::Edit)
}

async fn write_raw_plan(session_id: Uuid, status: &str, task_count: usize) {
    let mut plan = PlanDocument::new(session_id, "Legacy plan".to_string());
    for i in 0..task_count {
        plan.add_task(task(i + 1, &format!("t{}", i + 1)));
    }
    save_plan(&plan).await.unwrap();
    // Rewrite the status string raw, bypassing the canonical serializer.
    let path = plan_json_path(session_id).await;
    let mut v: serde_json::Value =
        serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
    v["status"] = serde_json::Value::String(status.to_string());
    std::fs::write(&path, serde_json::to_string_pretty(&v).unwrap()).unwrap();
}

#[tokio::test]
async fn no_plan_state_when_no_file() {
    in_temp_home(async {
        let sid = Uuid::new_v4();
        assert_eq!(plan_mode_state(sid).await, PlanModeState::NoPlan);
        assert!(!is_pre_init_editing(sid).await);
        assert!(load_plan(sid).await.is_none());
    })
    .await;
}

#[tokio::test]
async fn pre_init_flag_is_durable_and_gates_state() {
    in_temp_home(async {
        let sid = Uuid::new_v4();
        set_pre_init_editing(sid).await.unwrap();

        // The flag is a durable marker file, not process state: a fresh read
        // of disk (what a restart does) still sees it. It is NOT a fake plan
        // JSON (#569): no plan document, no approvable `.md`.
        assert!(pre_init_marker_path(sid).await.exists());
        assert!(!plan_json_path(sid).await.exists());
        assert!(!plan_md_path(sid).await.exists());
        assert!(load_plan(sid).await.is_none());

        assert!(is_pre_init_editing(sid).await);
        assert_eq!(plan_mode_state(sid).await, PlanModeState::PreInitEditing);

        // Setting it again is idempotent.
        set_pre_init_editing(sid).await.unwrap();
        assert!(is_pre_init_editing(sid).await);

        // Discard clears the marker and returns to NoPlan.
        discard_plan(sid).await;
        assert_eq!(plan_mode_state(sid).await, PlanModeState::NoPlan);
        assert!(!pre_init_marker_path(sid).await.exists());
    })
    .await;
}

#[tokio::test]
async fn init_clears_pre_init_marker_and_archive_does_not_resurrect_it() {
    in_temp_home(async {
        let sid = Uuid::new_v4();
        set_pre_init_editing(sid).await.unwrap();
        assert!(pre_init_marker_path(sid).await.exists());

        // `plan init` writes the first real plan JSON via save_plan, which must
        // clear the marker so a later archive can't leave the session looking
        // pre-init (#569).
        let mut plan = PlanDocument::new(sid, "Design".to_string());
        save_plan(&plan).await.unwrap();
        assert!(!pre_init_marker_path(sid).await.exists());

        // Take it Active, then archive: back to NoPlan, never PreInitEditing.
        plan.add_task(task(1, "t1"));
        plan.status = PlanStatus::Active;
        save_plan(&plan).await.unwrap();
        crate::utils::plan_files::archive_plan(sid).await.unwrap();
        assert_eq!(plan_mode_state(sid).await, PlanModeState::NoPlan);
    })
    .await;
}

#[tokio::test]
async fn legacy_stub_pre_init_json_still_resolves() {
    // Back-compat: sessions that already have the old stub plan JSON on disk
    // (pre_init_editing=true, empty tasks, no .md) must still resolve to
    // PreInitEditing even though new pre-init uses a marker file (#569).
    in_temp_home(async {
        let sid = Uuid::new_v4();
        let mut stub = PlanDocument::new(sid, String::new());
        stub.pre_init_editing = true;
        stub.status = PlanStatus::Editing;
        save_plan(&stub).await.unwrap();

        assert_eq!(plan_mode_state(sid).await, PlanModeState::PreInitEditing);
        assert!(is_pre_init_editing(sid).await);
    })
    .await;
}

#[tokio::test]
async fn pre_init_refused_when_plan_is_live() {
    in_temp_home(async {
        let sid = Uuid::new_v4();
        let mut plan = PlanDocument::new(sid, "Live".to_string());
        plan.add_task(task(1, "t1"));
        plan.status = PlanStatus::Active;
        save_plan(&plan).await.unwrap();

        assert!(set_pre_init_editing(sid).await.is_err());
        assert_eq!(plan_mode_state(sid).await, PlanModeState::Active);
    })
    .await;
}

#[tokio::test]
async fn post_init_editing_requires_md() {
    in_temp_home(async {
        let sid = Uuid::new_v4();
        let plan = PlanDocument::new(sid, "Design".to_string());
        save_plan(&plan).await.unwrap();
        create_design_md(sid, "Design").await.unwrap();

        assert_eq!(plan_mode_state(sid).await, PlanModeState::PostInitEditing);
        assert!(!is_pre_init_editing(sid).await);
    })
    .await;
}

#[tokio::test]
async fn save_writes_canonical_status_strings() {
    in_temp_home(async {
        let sid = Uuid::new_v4();
        let mut plan = PlanDocument::new(sid, "Canonical".to_string());
        save_plan(&plan).await.unwrap();
        let raw = std::fs::read_to_string(plan_json_path(sid).await).unwrap();
        assert!(raw.contains("\"Editing\""), "got: {raw}");

        plan.status = PlanStatus::Active;
        save_plan(&plan).await.unwrap();
        let raw = std::fs::read_to_string(plan_json_path(sid).await).unwrap();
        assert!(raw.contains("\"Active\""), "got: {raw}");
    })
    .await;
}

#[tokio::test]
async fn legacy_draft_checklist_normalizes_to_active() {
    in_temp_home(async {
        let sid = Uuid::new_v4();
        // Old-world Draft with tasks: executable then, must stay executable
        // now (there is no .md to approve, so Editing would trap it).
        write_raw_plan(sid, "Draft", 2).await;
        let plan = load_plan(sid).await.unwrap();
        assert_eq!(plan.status, PlanStatus::Active);
        assert_eq!(plan_mode_state(sid).await, PlanModeState::Active);
    })
    .await;
}

#[tokio::test]
async fn legacy_approved_and_in_progress_map_to_active() {
    in_temp_home(async {
        for legacy in ["Approved", "InProgress"] {
            let sid = Uuid::new_v4();
            write_raw_plan(sid, legacy, 1).await;
            let plan = load_plan(sid).await.unwrap();
            assert_eq!(plan.status, PlanStatus::Active, "legacy {legacy}");
            assert_eq!(plan_mode_state(sid).await, PlanModeState::Active);
        }
    })
    .await;
}

#[tokio::test]
async fn legacy_completed_archives_silently() {
    in_temp_home(async {
        let sid = Uuid::new_v4();
        write_raw_plan(sid, "Completed", 1).await;

        assert!(
            load_plan(sid).await.is_none(),
            "completed plan must resolve to NoPlan"
        );
        assert!(
            !plan_json_path(sid).await.exists(),
            "live JSON must be gone"
        );
        assert_eq!(plan_mode_state(sid).await, PlanModeState::NoPlan);

        // The plan retired into the archive dir instead of being lost.
        let archived: Vec<_> = std::fs::read_dir(archive_dir(sid).await)
            .unwrap()
            .filter_map(|e| e.ok())
            .map(|e| e.file_name().to_string_lossy().to_string())
            .filter(|n| n.contains(&sid.to_string()) && n.ends_with(".json"))
            .collect();
        assert_eq!(
            archived.len(),
            1,
            "expected one archived JSON, got {archived:?}"
        );
    })
    .await;
}

#[tokio::test]
async fn legacy_cancelled_deletes() {
    in_temp_home(async {
        let sid = Uuid::new_v4();
        write_raw_plan(sid, "Cancelled", 1).await;
        assert!(load_plan(sid).await.is_none());
        assert!(!plan_json_path(sid).await.exists());
        assert_eq!(plan_mode_state(sid).await, PlanModeState::NoPlan);
    })
    .await;
}

#[tokio::test]
async fn idle_active_plan_with_empty_tasks_survives_load() {
    in_temp_home(async {
        // A seed-failed Active plan (no tasks yet) must stay intact so the
        // idle retry path can pick it up: loading is never destructive for
        // live statuses.
        let sid = Uuid::new_v4();
        let mut plan = PlanDocument::new(sid, "Seed failed".to_string());
        plan.status = PlanStatus::Active;
        save_plan(&plan).await.unwrap();

        let loaded = load_plan(sid).await.unwrap();
        assert_eq!(loaded.status, PlanStatus::Active);
        assert!(loaded.tasks.is_empty());
        assert!(plan_json_path(sid).await.exists());
        assert_eq!(plan_mode_state(sid).await, PlanModeState::Active);
    })
    .await;
}

#[tokio::test]
async fn design_md_scaffold_and_mirror() {
    in_temp_home(async {
        let sid = Uuid::new_v4();
        let plan = PlanDocument::new(sid, "Design doc".to_string());
        save_plan(&plan).await.unwrap();
        let md_path = create_design_md(sid, "Design doc").await.unwrap();
        let scaffold = std::fs::read_to_string(&md_path).unwrap();
        assert!(scaffold.starts_with("# Design doc"));
        assert!(scaffold.contains("## Context"));
        assert!(scaffold.contains("## Implementation steps"));
        assert!(
            scaffold.contains("1. \n   - Done when: "),
            "scaffold must model the per-step Done when convention"
        );

        // Edit the .md, then mirror: the JSON description follows the body
        // and tasks stay empty (Editing cannot persist a checklist).
        let body = "# Design doc\n\n## Context\n- **Problem:** X is broken\n\
                    - **Target state:** X works\n- **Intent:** user asked\n\n\
                    ## Implementation steps\n1. Fix X in module Y\n";
        std::fs::write(&md_path, body).unwrap();
        sync_md_to_json(sid).await.unwrap();

        let mirrored = load_plan(sid).await.unwrap();
        assert_eq!(mirrored.description, body);
        assert!(mirrored.tasks.is_empty());
        assert_eq!(mirrored.status, PlanStatus::Editing);
    })
    .await;
}

#[tokio::test]
async fn sync_refuses_malformed_body_and_restores_previous_mirror() {
    in_temp_home(async {
        let sid = Uuid::new_v4();
        let plan = PlanDocument::new(sid, "Guarded design".to_string());
        save_plan(&plan).await.unwrap();
        let md_path = create_design_md(sid, "Guarded design").await.unwrap();
        let valid = "# Guarded design\n\n## Context\n- **Problem:** old\n- **Target state:** fixed\n- **Intent:** test\n\n## Implementation steps\n1. Keep old\n";
        std::fs::write(&md_path, valid).unwrap();
        sync_md_to_json(sid).await.unwrap();

        let malformed = "# Guarded design\n\n## Context\n- **Problem:**\n  text moved to the next line\n- **Target state:** fixed\n- **Intent:** test\n\n## Implementation steps\n1. Keep old\n";
        std::fs::write(&md_path, malformed).unwrap();
        let error = sync_md_to_json(sid).await.unwrap_err();
        assert!(error.contains("`**Problem:**` needs non-empty text after the label"));
        assert!(error.contains("each `**Label:**` must be a single line: label + space + text"));
        assert_eq!(std::fs::read_to_string(&md_path).unwrap(), valid);
        assert_eq!(load_plan(sid).await.unwrap().description, valid);

        let empty_label = "# Guarded design\n\n## Context\n- **Problem:** \n- **Target state:** fixed\n- **Intent:** test\n\n## Implementation steps\n1. Keep old\n";
        std::fs::write(&md_path, empty_label).unwrap();
        let error = sync_md_to_json(sid).await.unwrap_err();
        assert!(error.contains("`**Problem:**` needs non-empty text after the label"));
        assert_eq!(std::fs::read_to_string(&md_path).unwrap(), valid);
        assert_eq!(load_plan(sid).await.unwrap().description, valid);
    })
    .await;
}

#[tokio::test]
async fn sync_refused_first_write_restores_scaffold_not_empty() {
    in_temp_home(async {
        // A fresh plan has never mirrored a body: description is empty. A
        // refused first draft must restore the scaffold, not wipe the file.
        let sid = Uuid::new_v4();
        let plan = PlanDocument::new(sid, "Fresh design".to_string());
        save_plan(&plan).await.unwrap();
        let md_path = create_design_md(sid, "Fresh design").await.unwrap();
        let draft = "# Fresh design\n\n## Context\n- **Problem:**\n  pushed to the next line\n";
        std::fs::write(&md_path, draft).unwrap();
        let error = sync_md_to_json(sid).await.unwrap_err();
        assert!(error.contains("PLAN TEMPLATE WRITE REFUSED"));
        let restored = std::fs::read_to_string(&md_path).unwrap();
        assert!(restored.contains("## Context"));
        assert!(restored.contains("## Implementation steps"));
        assert!(restored.contains("1. \n   - Done when: "));
        assert!(!restored.contains("pushed to the next line"));
        assert_eq!(load_plan(sid).await.unwrap().description, "");

        // The writer can fix forward: a valid rewrite syncs normally.
        let good = "# Fresh design\n\n## Context\n- **Problem:** real\n- **Target state:** fixed\n- **Intent:** test\n\n## Implementation steps\n1. Do it\n";
        std::fs::write(&md_path, good).unwrap();
        sync_md_to_json(sid).await.unwrap();
        assert_eq!(load_plan(sid).await.unwrap().description, good);
    })
    .await;
}

#[test]
fn template_warnings_flag_missing_sections() {
    let empty = template_section_warnings("just prose, no structure");
    assert!(empty.iter().any(|w| w.contains("## Context")));
    assert!(empty.iter().any(|w| w.contains("## Implementation steps")));

    // Labels present but unfilled still warn.
    let unfilled = "## Context\n- **Problem:** \n- **Target state:** \n- **Intent:** \n\
                    \n## Implementation steps\n1. \n";
    let w = template_section_warnings(unfilled);
    assert!(w.iter().any(|x| x.contains("**Problem:**")));
    assert!(w.iter().any(|x| x.contains("numbered step")));

    // A filled template is quiet.
    let filled = "## Context\n- **Problem:** broken\n- **Target state:** fixed\n\
                  - **Intent:** asked\n\n## Implementation steps\n1. do the thing\n";
    assert!(template_section_warnings(filled).is_empty());
}

#[tokio::test]
async fn plan_autonomy_is_a_durable_session_policy() {
    // Self-approval autonomy is granted/revoked per session, survives across
    // plans (not cleared by discard), and defaults off (#581).
    in_temp_home(async {
        let sid = Uuid::new_v4();
        assert!(!is_plan_autonomy(sid).await, "default off");

        set_plan_autonomy(sid, true).await.unwrap();
        assert!(is_plan_autonomy(sid).await);

        // A plan lifecycle does not touch the session policy.
        set_pre_init_editing(sid).await.unwrap();
        discard_plan(sid).await;
        assert!(
            is_plan_autonomy(sid).await,
            "autonomy is a session policy, not cleared by plan discard"
        );

        set_plan_autonomy(sid, false).await.unwrap();
        assert!(!is_plan_autonomy(sid).await);
    })
    .await;
}

#[tokio::test]
async fn archive_roundtrip_latest_archived_plan_finds_writer_output() {
    // #16 round 2 regression: `archive_plan_files` trims the leading dot
    // off `.opencrabs_plan_<sid>` when renaming into `archive/`, and
    // `latest_archived_plan_from_path` must prefix-match exactly those
    // dot-less names via the shared `plan_archive_stem`. Between c57de25c
    // and this fix the reader computed its own prefix WITH the dot,
    // matched nothing on disk, and every completed-plan card finalize hit
    // its no-doc branch — zero completion cards posted.
    in_temp_home(async {
        let sid = Uuid::new_v4();
        let mut plan = PlanDocument::new(sid, "Round trip".to_string());
        plan.add_task(task(1, "t1"));
        save_plan(&plan).await.unwrap();

        // The live file is dotted…
        let live = plan_json_path(sid).await;
        assert!(
            live.file_name()
                .and_then(|n| n.to_str())
                .is_some_and(|n| n.starts_with('.')),
            "live plan file must be dotted, got {}",
            live.display()
        );

        // …the archive name is dot-less…
        crate::utils::plan_files::archive_plan(sid).await.unwrap();
        let names: Vec<String> = std::fs::read_dir(archive_dir(sid).await)
            .unwrap()
            .filter_map(|e| e.ok())
            .map(|e| e.file_name().to_string_lossy().to_string())
            .collect();
        assert!(
            names
                .iter()
                .any(|n| n.starts_with("opencrabs_plan_") && n.ends_with(".json")),
            "archive writer must produce dot-less names, got {names:?}"
        );

        // …and the reader finds it again — the round trip itself.
        let doc = crate::utils::plan_files::latest_archived_plan(sid)
            .await
            .expect(
                "latest_archived_plan must match the writer's dot-less \
                 archive names (#16 round 2)",
            );
        assert_eq!(doc.title, "Round trip");
        assert_eq!(doc.tasks.len(), 1);
    })
    .await;
}