# Goal-Tracker I1 Refactor — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Move goal-tracker's per-child-archetype reconciliation rules from the augmentation prompt into a Rust kernel (`goal_aggregation` module), integrate via gather-time injection (variant b), and land follow-up structural fixes per amendment decisions D1–D11.

**Architecture:** New `src/librarian/tools/goal_aggregation.rs` module owns deterministic predicates per child archetype. New `GatherSource::GoalChildren` variant in `gather.rs` fetches each child's augmentation params and runs predicates, surfacing results as `deterministic_child_statuses` in the refresh context. The augmentation prompt's rule 1 collapses to "copy verbatim". Rule 4 (auto-close gate), rule 5 (scope growth cap), rule 6 (NEVER list) move from prompt prose to Rust types and functions across follow-up phases.

**Tech Stack:** Rust (existing). `serde_json::Value` for params. `assert_cmd` + inline fixtures for tests. MiniJinja for render templates (existing). No new dependencies.

**Source-of-truth for decisions:** `docs/superpowers/specs/2026-05-17-goal-tracker-amendment.md` (decisions D1–D11). Each task below cites the relevant D-N.

---

## File map

**New files:**
- `src/librarian/tools/goal_aggregation.rs` — predicate module (Task 2).
- `tests/goal_aggregation.rs` — integration tests if the inline module tests outgrow the file (lazy split; only if needed).

**Modified files:**
- `src/librarian/tools/tracker_design.rs` — `archetype_goal` prompt updates (Tasks 1, 4, 9, 10, 11), `archetype_audit_issues` schema widening (Task 13).
- `src/librarian/tools/gather.rs` — `GatherSource::GoalChildren` variant + dispatch (Task 3).
- `src/librarian/tools/refresh.rs` — wire `GoalChildren` into the gather list when augmentation prompt is goal-archetype (Task 3).
- `src/librarian/tools/mod.rs` — register `goal_aggregation` (Task 2).
- `src/prompts/source.md` — discovery paragraph adjustments (Task 4).
- `tests/fixtures/prompt_surfaces/server_instructions.md` — snapshot regenerate (Task 4 / Task 9 / Task 10 / Task 11).
- `claude-plugins/codescout-companion/hooks/goal-stop-hook.sh` — `last_refreshed_at` in reason (Task 14).

---

## Phase 1 — I1 Core Landing (Tasks 1–5)

The minimum-viable I1: rule 1 of the augmentation prompt is enforced in Rust for the 5 clean archetypes, with gather-time injection. `metric_baseline` and `deployment_state` stay in prompt; they land in Phase 2.

### Task 1: Extract rule-1 prose into Rust const strings

**Files:**
- Modify: `src/librarian/tools/tracker_design.rs` (the `archetype_goal` function, lines 265–340)

**Purpose:** Currently rule 1 is a multi-line clause inside a heredoc JSON string inside `archetype_goal()`. Pull each archetype's mapping line into a `const &str` constant alongside the function. The constants are concatenated into the prompt by `archetype_goal()`. This prepares Task 2 by giving Rust code a single point of edit for the prose and lets Task 5 cross-check the prompt against `child_status_pure`'s coverage.

- [x] **Step 1: Write the failing test**

```rust
// In src/librarian/tools/tracker_design.rs — INSERT INSIDE the `tests`
// module (anchor with edit_code(symbol="tests", action="insert") so the
// new `#[test]` fn lands as a child of `mod tests`, not as a sibling at
// the parent module's scope). See F-5 in docs/trackers/archive/i1-session-friction.md.
#[test]
fn archetype_goal_prompt_concatenates_per_archetype_clauses() {
    let v = super::archetype_goal();
    let prompt = v.get("prompt_template").and_then(|p| p.as_str()).unwrap();
    // The current free-form table must contain every archetype name
    // that goal will reconcile in Phase 1.
    for arch in ["failure_table", "task_list", "metric_baseline", "audit_issues", "reflective"] {
        assert!(
            prompt.contains(arch),
            "prompt missing per-archetype clause for {arch}"
        );
    }
    // After refactor: rule 1 references the constants by name in the rendered text.
    assert!(prompt.contains("[RULE_1_FAILURE_TABLE]") == false, "prep refactor: clauses are inlined as text, not placeholders");
}
```

- [x] **Step 2: Run test to verify it fails**

Run: `cargo test -p codescout --lib librarian::tools::tracker_design::tests::archetype_goal_prompt_concatenates_per_archetype_clauses`

Expected: PASS (current state) — confirms baseline. This test is *current-state-pinning*, not a new behavior. If it fails, the prompt is already mid-edit.

- [x] **Step 3: Extract clauses into constants**

Above `archetype_goal()`, add:

```rust
// Phase 1 — per-archetype reconciliation clauses. These are the strings the LLM
// reads from rule 1 of the augmentation prompt. After Phase 1 lands they are
// also the strings `goal_aggregation::child_status_pure` is unit-tested against,
// keeping prompt and code in sync.
//
// Edit here, NOT inline in `archetype_goal()`'s prompt JSON.

pub(super) const RULE_1_FAILURE_TABLE: &str =
    "   - failure_table child → \"done\" if all entries are status `pass` or `wontfix`; \"active\" if any are `fail` or `flaky`; \"pending\" if `failures` is empty";
pub(super) const RULE_1_TASK_LIST: &str =
    "   - task_list child → \"done\" if `tasks` is non-empty AND all `status == \"done\"`; \"pending\" if `tasks` is empty; \"in-progress\" otherwise";
pub(super) const RULE_1_METRIC_BASELINE: &str =
    "   - metric_baseline child → status derives from acceptance_signals[kind=metric_threshold] citing this child (see rule 2). Not archetype-local.";
pub(super) const RULE_1_AUDIT_ISSUES: &str =
    "   - audit_issues child → \"done\" if `issues` has zero entries with `status == \"open\"`; \"pending\" if `issues` is empty; \"active\" otherwise";
pub(super) const RULE_1_REFLECTIVE: &str =
    "   - reflective child → \"done\" if `status ∈ {\"decided\",\"archived\"}`; \"blocked\" if `\"deferred\"`; \"active\" otherwise";
pub(super) const RULE_1_NESTED_GOAL: &str =
    "   - nested goal child → \"done\" if `status == \"done\"`; \"blocked\" if ∈ {\"blocked\",\"abandoned\"}; \"pending\" if `\"scoping\"`; \"in-progress\" if `\"pending-confirmation\"`; \"active\" otherwise";
pub(super) const RULE_1_DEPLOYMENT_STATE: &str =
    "   - deployment_state child → \"done\" if all `envs[*].enabled == true`; \"pending\" if `envs` is empty; \"in-progress\" otherwise";
pub(super) const RULE_1_UNREACHABLE: &str =
    "   - Child artifact unreachable → set status: \"orphan\"; DO NOT delete the row";
pub(super) const RULE_1_UNKNOWN_ARCHETYPE: &str =
    "   - Child whose archetype is not in this list → set status: \"unknown\"; note in progress_log";
```

Then inside `archetype_goal()`, replace the literal rule-1 lines in `prompt_template` with `format!()` interpolations using the constants. The output JSON shape is unchanged — only the source representation moves.

```rust
"prompt_template": format!(
    "Maintain a goal-tracker. Your job is **aggregation**, not evaluation: read children via artifact(action=\"get\") and reconcile their state into the goal's params. Do not recompute children's evidence — trust the child's own params.\n\nINPUTS (gather):\n- This goal's current params.\n- For each `children[].artifact_id`, the child's params via artifact(action=\"get\").\n- Optional: commit log scoped to paths the criterion names (gather_from: git_log).\n\nUPDATE RULES:\n\n1. Reconcile each `children[].status` from the child's actual status, normalizing into our enum (pending|active|in-progress|done|blocked|orphan|unknown):\n{rule_1_failure_table}\n{rule_1_task_list}\n{rule_1_metric_baseline}\n{rule_1_audit_issues}\n{rule_1_reflective}\n{rule_1_nested_goal}\n{rule_1_deployment_state}\n{rule_1_unreachable}\n{rule_1_unknown_archetype}\n\n[... rest of prompt unchanged ...]",
    rule_1_failure_table = RULE_1_FAILURE_TABLE,
    rule_1_task_list = RULE_1_TASK_LIST,
    // ... etc.
)
```

(In practice the implementer may prefer a private helper `fn rule_1_block() -> String` that concatenates the constants with `\n` separators. Either shape is fine; the requirement is "edit clauses in named constants, not inside the prompt string.")

- [x] **Step 4: Add the cross-check assertion**

Replace the test from Step 1 with:

```rust
#[test]
fn archetype_goal_prompt_contains_all_rule_1_constants() {
    let v = super::archetype_goal();
    let prompt = v.get("prompt_template").and_then(|p| p.as_str()).unwrap();
    for clause in [
        super::RULE_1_FAILURE_TABLE,
        super::RULE_1_TASK_LIST,
        super::RULE_1_METRIC_BASELINE,
        super::RULE_1_AUDIT_ISSUES,
        super::RULE_1_REFLECTIVE,
        super::RULE_1_NESTED_GOAL,
        super::RULE_1_DEPLOYMENT_STATE,
        super::RULE_1_UNREACHABLE,
        super::RULE_1_UNKNOWN_ARCHETYPE,
    ] {
        assert!(prompt.contains(clause), "prompt missing clause: {clause:.40}…");
    }
}
```

- [x] **Step 5: Run tests + regenerate snapshot if it bumped**

Run: `cargo test -p codescout --lib librarian::tools::tracker_design`
Expected: all archetype tests pass, new constant-cross-check passes.

Run: `cargo test -p codescout --lib prompts::tests::prompt_surfaces`
If this snapshot test fails because the prompt **text** changed (the new clauses include `deployment_state` and `unknown archetype` lines that may have been absent or differently phrased): `UPDATE_PROMPT_SNAPSHOTS=1 cargo test -p codescout --lib prompts::tests::prompt_surfaces` then re-run without the env var to confirm.

Run: `cargo fmt && cargo clippy -- -D warnings`

- [x] **Step 6: Commit**

```bash
git add src/librarian/tools/tracker_design.rs tests/fixtures/prompt_surfaces/
git commit -m "refactor(goal): extract rule-1 clauses into named Rust constants

Prep for I1 step 2: pulling per-archetype reconciliation prose out of
the augmentation prompt string and into pub(super) const &str items
named RULE_1_<ARCHETYPE>. The prompt JSON is unchanged — only the
source representation moves.

Adds two missing clauses surfaced by the goal-tracker amendment:
RULE_1_DEPLOYMENT_STATE (D3) and RULE_1_UNKNOWN_ARCHETYPE (H-5).
RULE_1_TASK_LIST tightens the empty-tasks case (D1)."
```

---

### Task 2: Create `goal_aggregation` module with `child_status_pure`

**Files:**
- Create: `src/librarian/tools/goal_aggregation.rs`
- Modify: `src/librarian/tools/mod.rs` (register the module)

**Purpose:** Land the Rust kernel for the 5 archetype-local predicates. Implements amendments D1, D2, D3, plus H-1/H-4/H-5 prompt-content findings as structural enforcement.

- [x] **Step 1: Write the failing test (module skeleton + first predicate)**

Create `src/librarian/tools/goal_aggregation.rs`:

```rust
//! Deterministic predicates for goal-tracker child status derivation.
//!
//! Implements amendment D1–D3 from
//! `docs/superpowers/specs/2026-05-17-goal-tracker-amendment.md`.
//!
//! `child_status_pure` is a **pure function of (archetype, child_params)**.
//! Archetypes whose status depends on parent context (`metric_baseline`)
//! return `ChildStatus::Unknown` here — those go through
//! `goal_aggregation::contextual::*` in a later phase.

use serde::Serialize;
use serde_json::Value;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ChildStatus {
    Pending,
    Active,
    InProgress,
    Done,
    Blocked,
    Orphan,
    Unknown,
}

impl ChildStatus {
    pub fn as_str(self) -> &'static str {
        match self {
            ChildStatus::Pending => "pending",
            ChildStatus::Active => "active",
            ChildStatus::InProgress => "in-progress",
            ChildStatus::Done => "done",
            ChildStatus::Blocked => "blocked",
            ChildStatus::Orphan => "orphan",
            ChildStatus::Unknown => "unknown",
        }
    }
}

/// Map a child's archetype + params to a normalized `ChildStatus`.
/// Returns `ChildStatus::Unknown` for archetypes that need parent context
/// (currently `metric_baseline`) or for unrecognized archetypes (H-5).
pub fn child_status_pure(archetype: &str, child_params: &Value) -> ChildStatus {
    match archetype {
        "failure_table" => failure_table_status(child_params),
        "task_list" => task_list_status(child_params),
        "audit_issues" => audit_issues_status(child_params),
        "reflective" => reflective_status(child_params),
        "goal" => nested_goal_status(child_params),
        "deployment_state" => deployment_state_status(child_params),
        // metric_baseline lives in `contextual` — needs parent signals.
        "metric_baseline" => ChildStatus::Unknown,
        _ => ChildStatus::Unknown, // H-5: unrecognized archetype
    }
}

// --- per-archetype predicates ---

fn failure_table_status(p: &Value) -> ChildStatus {
    match p.get("failures").and_then(|v| v.as_array()) {
        None => ChildStatus::Unknown,
        Some(f) if f.is_empty() => ChildStatus::Pending, // D1-style empty handling
        Some(f) if f.iter().all(|e| {
            matches!(
                e.get("status").and_then(|s| s.as_str()),
                Some("pass") | Some("wontfix")
            )
        }) => ChildStatus::Done, // D2: pass|wontfix → done
        Some(_) => ChildStatus::Active, // any fail or flaky blocks (D2)
    }
}

fn task_list_status(p: &Value) -> ChildStatus {
    match p.get("tasks").and_then(|v| v.as_array()) {
        None => ChildStatus::Unknown,
        Some(t) if t.is_empty() => ChildStatus::Pending, // D1
        Some(t) if t.iter().all(|task| {
            task.get("status").and_then(|s| s.as_str()) == Some("done")
        }) => ChildStatus::Done,
        Some(_) => ChildStatus::InProgress,
    }
}

fn audit_issues_status(p: &Value) -> ChildStatus {
    match p.get("issues").and_then(|v| v.as_array()) {
        None => ChildStatus::Unknown,
        Some(i) if i.is_empty() => ChildStatus::Pending,
        Some(i) if i.iter().all(|e| {
            e.get("status").and_then(|s| s.as_str()) != Some("open")
        }) => ChildStatus::Done,
        Some(_) => ChildStatus::Active,
    }
}

fn reflective_status(p: &Value) -> ChildStatus {
    match p.get("status").and_then(|v| v.as_str()) {
        Some("decided") | Some("archived") => ChildStatus::Done,
        Some("deferred") => ChildStatus::Blocked,
        Some(_) => ChildStatus::Active,
        None => ChildStatus::Active, // schema-permissive default
    }
}

fn nested_goal_status(p: &Value) -> ChildStatus {
    match p.get("status").and_then(|v| v.as_str()) {
        Some("done") => ChildStatus::Done,
        Some("blocked") | Some("abandoned") => ChildStatus::Blocked,
        Some("scoping") => ChildStatus::Pending,
        Some("pending-confirmation") => ChildStatus::InProgress,
        Some(_) | None => ChildStatus::Active,
    }
}

fn deployment_state_status(p: &Value) -> ChildStatus {
    match p.get("envs").and_then(|v| v.as_object()) {
        None => ChildStatus::Unknown,
        Some(e) if e.is_empty() => ChildStatus::Pending, // D3
        Some(e) if e.values().all(|env| {
            env.get("enabled").and_then(|b| b.as_bool()) == Some(true)
        }) => ChildStatus::Done,
        Some(_) => ChildStatus::InProgress,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    // --- failure_table D2 ---

    #[test]
    fn failure_table_done_when_all_pass() {
        let p = json!({"failures":[
            {"id":"F-1","status":"pass"},
            {"id":"F-2","status":"pass"}
        ]});
        assert_eq!(child_status_pure("failure_table", &p), ChildStatus::Done);
    }

    #[test]
    fn failure_table_done_when_pass_plus_wontfix() {
        let p = json!({"failures":[
            {"id":"F-1","status":"pass"},
            {"id":"F-2","status":"wontfix"}
        ]});
        assert_eq!(child_status_pure("failure_table", &p), ChildStatus::Done);
    }

    #[test]
    fn failure_table_active_when_any_flaky() {
        let p = json!({"failures":[
            {"id":"F-1","status":"pass"},
            {"id":"F-2","status":"flaky"}
        ]});
        assert_eq!(child_status_pure("failure_table", &p), ChildStatus::Active);
    }

    #[test]
    fn failure_table_active_when_any_fail() {
        let p = json!({"failures":[
            {"id":"F-1","status":"fail"}
        ]});
        assert_eq!(child_status_pure("failure_table", &p), ChildStatus::Active);
    }

    #[test]
    fn failure_table_pending_when_empty() {
        let p = json!({"failures":[]});
        assert_eq!(child_status_pure("failure_table", &p), ChildStatus::Pending);
    }

    #[test]
    fn failure_table_unknown_when_no_failures_key() {
        let p = json!({});
        assert_eq!(child_status_pure("failure_table", &p), ChildStatus::Unknown);
    }

    // --- task_list D1 ---

    #[test]
    fn task_list_pending_when_empty() {
        let p = json!({"tasks":[]});
        assert_eq!(child_status_pure("task_list", &p), ChildStatus::Pending);
    }

    #[test]
    fn task_list_done_when_all_done() {
        let p = json!({"tasks":[{"id":"T-1","status":"done"}]});
        assert_eq!(child_status_pure("task_list", &p), ChildStatus::Done);
    }

    #[test]
    fn task_list_in_progress_when_mixed() {
        let p = json!({"tasks":[
            {"id":"T-1","status":"done"},
            {"id":"T-2","status":"pending"}
        ]});
        assert_eq!(child_status_pure("task_list", &p), ChildStatus::InProgress);
    }

    // --- audit_issues ---

    #[test]
    fn audit_issues_done_when_no_open() {
        let p = json!({"issues":[
            {"n":1,"status":"fixed"},
            {"n":2,"status":"wontfix"}
        ]});
        assert_eq!(child_status_pure("audit_issues", &p), ChildStatus::Done);
    }

    #[test]
    fn audit_issues_active_when_any_open() {
        let p = json!({"issues":[
            {"n":1,"status":"open"}
        ]});
        assert_eq!(child_status_pure("audit_issues", &p), ChildStatus::Active);
    }

    // --- reflective ---

    #[test]
    fn reflective_done_when_decided_or_archived() {
        assert_eq!(child_status_pure("reflective", &json!({"status":"decided"})), ChildStatus::Done);
        assert_eq!(child_status_pure("reflective", &json!({"status":"archived"})), ChildStatus::Done);
    }

    #[test]
    fn reflective_blocked_when_deferred() {
        assert_eq!(child_status_pure("reflective", &json!({"status":"deferred"})), ChildStatus::Blocked);
    }

    // --- nested goal ---

    #[test]
    fn nested_goal_status_round_trip() {
        for (s, expected) in [
            ("done", ChildStatus::Done),
            ("blocked", ChildStatus::Blocked),
            ("abandoned", ChildStatus::Blocked),
            ("scoping", ChildStatus::Pending),
            ("pending-confirmation", ChildStatus::InProgress),
            ("active", ChildStatus::Active),
        ] {
            assert_eq!(
                child_status_pure("goal", &json!({"status": s})),
                expected,
                "nested goal status={s}"
            );
        }
    }

    // --- deployment_state D3 ---

    #[test]
    fn deployment_state_done_all_enabled() {
        let p = json!({"envs":{
            "dev":  {"enabled": true},
            "prod": {"enabled": true}
        }});
        assert_eq!(child_status_pure("deployment_state", &p), ChildStatus::Done);
    }

    #[test]
    fn deployment_state_in_progress_partial() {
        let p = json!({"envs":{
            "dev":  {"enabled": true},
            "prod": {"enabled": false}
        }});
        assert_eq!(child_status_pure("deployment_state", &p), ChildStatus::InProgress);
    }

    #[test]
    fn deployment_state_pending_when_empty_envs() {
        let p = json!({"envs":{}});
        assert_eq!(child_status_pure("deployment_state", &p), ChildStatus::Pending);
    }

    // --- metric_baseline → Unknown (Phase 2 handles in context) ---

    #[test]
    fn metric_baseline_returns_unknown() {
        let p = json!({"baseline":{"P@5":0.18},"current":{"P@5":0.19}});
        assert_eq!(child_status_pure("metric_baseline", &p), ChildStatus::Unknown);
    }

    // --- unknown archetype (H-5) ---

    #[test]
    fn unknown_archetype_returns_unknown() {
        let p = json!({"anything":42});
        assert_eq!(child_status_pure("link_rot_crawler", &p), ChildStatus::Unknown);
    }

    // --- idempotency property ---

    #[test]
    fn idempotent_pure_function() {
        let cases = [
            ("failure_table", json!({"failures":[{"id":"F-1","status":"pass"}]})),
            ("task_list", json!({"tasks":[{"id":"T-1","status":"done"}]})),
            ("audit_issues", json!({"issues":[]})),
            ("reflective", json!({"status":"decided"})),
            ("goal", json!({"status":"done"})),
            ("deployment_state", json!({"envs":{"prod":{"enabled":true}}})),
        ];
        for (arch, params) in &cases {
            let r1 = child_status_pure(arch, params);
            let r2 = child_status_pure(arch, params);
            assert_eq!(r1, r2, "non-idempotent: {arch}");
        }
    }
}
```

- [x] **Step 2: Register the module**

In `src/librarian/tools/mod.rs`, find the existing module list (alphabetical or sectioned). Add:

```rust
pub mod goal_aggregation;
```

- [x] **Step 3: Run tests**

Run: `cargo test -p codescout --lib librarian::tools::goal_aggregation`

Expected: all tests in Step 1's fixture pass (fixture currently has 20; see F-6).

Run: `cargo fmt && cargo clippy -- -D warnings`

Expected: clean.

- [x] **Step 4: Commit**

```bash
git add src/librarian/tools/goal_aggregation.rs src/librarian/tools/mod.rs
git commit -m "feat(goal): goal_aggregation::child_status_pure for 5 archetypes

Implements amendment decisions D1 (empty task_list→pending),
D2 (failure_table flaky blocks, wontfix passes), D3 (deployment_state
done iff all envs enabled), and Hamsa H-5 (unknown archetype → unknown,
not fabricated).

Pure function (archetype, params) → ChildStatus. metric_baseline
returns Unknown here; it needs parent context (acceptance_signals),
landed in Phase 2 via goal_aggregation::contextual.

18 unit tests including an idempotency property test mirroring the
audit-doc-refs merger's discipline."
```

---

### Task 3: `gather_goal_children` helper + refresh dispatch

**Files:**
- Modify: `src/librarian/tools/gather.rs` (lines 13–46 enum; lines 67–160 `gather_all`)
- Modify: `src/librarian/tools/refresh.rs` (lines 26–89 `call`)

**Purpose:** Refresh-time machinery that fetches each child's augmentation params and runs `child_status_pure`, surfacing results in the gather context as `deterministic_child_statuses`. This is Yak's variant (b) integration.

- [x] **Step 1: Write the failing test**

Add to `src/librarian/tools/gather.rs` tests module:

```rust
#[test]
fn gather_goal_children_returns_status_per_child() {
    use crate::librarian::catalog::{ArtifactRow, Catalog};
    use serde_json::json;

    let tmp = TempDir::new().unwrap();
    let mut ctx = mk_ctx(&tmp);
    // Build a parent goal with 2 children.
    let cat = ctx.catalog.lock();
    // Children rows + their augmentation params:
    insert_test_artifact(
        &cat,
        "child-a",
        "tracker",
        "failure_table",
        Some(json!({"failures":[{"id":"F-1","status":"pass"}]})),
    );
    insert_test_artifact(
        &cat,
        "child-b",
        "tracker",
        "task_list",
        Some(json!({"tasks":[]})),
    );
    drop(cat);

    let result = gather_goal_children(&ctx, &[
        ("C-1", "child-a"),
        ("C-2", "child-b"),
    ]).unwrap();

    let arr = result.as_array().unwrap();
    assert_eq!(arr.len(), 2);
    assert_eq!(arr[0]["child_id"], "C-1");
    assert_eq!(arr[0]["status"], "done");
    assert_eq!(arr[1]["child_id"], "C-2");
    assert_eq!(arr[1]["status"], "pending");
}
```

The helper `insert_test_artifact` is a test-only writer; implement it inline if not already present (mirror the pattern in `tracker_design.rs::tests::lists_existing_trackers_only`).

- [x] **Step 2: Run test to verify it fails**

Run: `cargo test -p codescout --lib librarian::tools::gather::tests::gather_goal_children_returns_status_per_child`

Expected: FAIL — `gather_goal_children` is undefined.

- [x] **Step 3: Implement `gather_goal_children`**

Add to `gather.rs`:

> **F-8 corrected (2026-05-17 second session):** The original plan snippet
> here proposed `cat.get(id).augmentation.archetype` — three field accesses
> that don't compile against the actual `ArtifactRow` / `AugmentationRow`
> structs (see F-8 in `docs/trackers/archive/i1-session-friction.md` for the full
> three-error breakdown). The fix landed inline during T-3 implementation;
> Phase 2 Task 7 later added a `parent_signals: &[AcceptanceSignal]`
> parameter for `metric_baseline` support. Read the live function via:
>
> `symbols(name="gather_goal_children", include_body=true, path="src/librarian/tools/gather.rs")`
>
> Key shape differences from the original snippet:
> - Signature: `fn(ctx, &[(child_id, artifact_id, archetype)], &[AcceptanceSignal]) -> Result<Value>`.
>   Archetype flows in from the parent goal's `params.children[].archetype`,
>   NOT from a `.augmentation.archetype` field on the row (no such field).
> - Uses `augmentation::get(&cat, artifact_id)` (sibling module call), not
>   `cat.get(id).augmentation` (no `.augmentation` field on `ArtifactRow`).
> - Calls `child_status_in_context(...)` (Phase 2 wired the
>   metric_threshold lookup), not the original `child_status_pure(...)`.

**Important — verify field names on `ArtifactRow`**: the `Augmentation` struct may not have an `archetype` field today. If it doesn't, the archetype must be derived elsewhere — either from a tag (`tags` containing `archetype:foo`), from a frontmatter field, or from the augmentation prompt's metadata. The subagent implementing this task must verify the actual struct via `symbols(name="ArtifactRow", include_body=true)` and `symbols(name="Augmentation", include_body=true)` before writing the body. If `archetype` is not on `ArtifactRow`, infer from the goal's own `children[].archetype` field (the goal stores its children's archetypes per-row in its params — see `archetype_goal()`'s `params_shape_example`).

- [x] **Step 4: Wire into refresh path**

In `refresh.rs::call` (lines 26–89), after the existing gather phase, when the augmentation prompt indicates this is a goal-archetype refresh:

```rust
// After existing gather_all call returns `(gathered, warnings)`:
let is_goal_tracker = augmentation
    .as_ref()
    .and_then(|a| a.params.as_object())
    .map(|p| p.contains_key("acceptance_signals") && p.contains_key("children"))
    .unwrap_or(false);

if is_goal_tracker {
    let children_refs: Vec<(&str, &str)> = augmentation
        .as_ref()
        .and_then(|a| a.params.get("children"))
        .and_then(|c| c.as_array())
        .map(|arr| arr.iter()
            .filter_map(|c| {
                let id = c.get("id")?.as_str()?;
                let aid = c.get("artifact_id")?.as_str()?;
                Some((id, aid))
            })
            .collect())
        .unwrap_or_default();

    if !children_refs.is_empty() {
        let det = crate::librarian::tools::gather::gather_goal_children(ctx, &children_refs)?;
        gathered.push(GatherResult {
            source_key: "deterministic_child_statuses".to_string(),
            data: det,
        });
    }
}
```

The is-goal-tracker detection is structural (params shape) rather than archetype-name based to stay agnostic of how the archetype name is stored.

- [x] **Step 5: Add an integration test**

In `refresh.rs` tests module, add a test that creates a goal artifact with two children and asserts the refresh response's `gathered` field contains a `deterministic_child_statuses` entry with both children resolved. Use the same `mk_ctx` pattern as the existing tests.

- [x] **Step 6: Run tests**

Run: `cargo test -p codescout --lib librarian::tools::gather`
Run: `cargo test -p codescout --lib librarian::tools::refresh`

Expected: all green.

Run: `cargo fmt && cargo clippy -- -D warnings`

- [x] **Step 7: Commit**

```bash
git add src/librarian/tools/gather.rs src/librarian/tools/refresh.rs
git commit -m "feat(goal): gather_goal_children — Yak variant (b) integration

Refresh-time machinery that fetches each child's augmentation params and
runs goal_aggregation::child_status_pure, surfacing results in the
gather context as deterministic_child_statuses.

Detection is structural (params shape carries acceptance_signals AND
children) rather than archetype-name based, to stay agnostic of how
the archetype name is stored on the artifact row.

Resolves the Yak refactor S4 seam: 'augmentation pipeline does not
call Rust mid-prompt today'. Now Rust runs at gather time; the LLM
reads ground truth from context."
```

---

### Task 4: Update `archetype_goal` prompt to consume `deterministic_child_statuses`

**Files:**
- Modify: `src/librarian/tools/tracker_design.rs` (the `archetype_goal` prompt template)
- Modify: `src/prompts/source.md` (the `### Goal-trackers` discovery subsection — minor adjustment per Hamsa D-1/D-3)
- Modify: `tests/fixtures/prompt_surfaces/server_instructions.md` (regenerate snapshot)

**Purpose:** The prompt now references a `deterministic_child_statuses` block in context. Rule 1 collapses to "copy verbatim" for archetypes Rust handles, with the original prose retained only for `metric_baseline` (still LLM-evaluated in Phase 1). Rule 4a tightens to `len(children) >= 2` per D9. Hamsa H-2 phrasing fix on rule 2.

- [x] **Step 1: Update the prompt body**

In `archetype_goal()`, edit `prompt_template` so the **rule 1 section** reads:

```
1. Reconcile each `children[].status` from the child's actual status:

   a. **First, copy ground truth from context.** The refresh has gathered
      a `deterministic_child_statuses` array — for every entry whose
      `basis == "deterministic"`, copy `status` into `children[id].status`
      verbatim. Do not reinterpret. These archetypes are handled by Rust
      and the verdict is authoritative.

   b. **For entries whose `basis != "deterministic"`** (currently
      `metric_baseline` only, plus any future archetype not yet in the
      Rust kernel), apply these rules manually:
      - metric_baseline child → "done" if `current` meets the related
        acceptance_signal's threshold; "in-progress" otherwise.
        See rule 2 for the threshold cross-reference.

   c. **For entries with `basis == "child unreachable"`** → set
      `children[id].status = "orphan"`. Do NOT delete the row.
```

Replace rule 2's first sentence from `"Re-evaluate each acceptance_signals[].met from the children's evidence"` with (Hamsa H-2 fix):

```
2. For each `acceptance_signals[i]`, set `.met` by looking up the cited
   child's params and copying the relevant value forward. Do not
   re-derive the underlying metric — read it from the child's params
   verbatim and compare against the signal's description.
```

Tighten rule 4 (the auto-close gate) — per D9:

```
4. AUTO-CLOSE GATE (ALL conditions required):
   a. len(children) >= 2 (per amendment D9 — single-child or empty goals
      should use the underlying archetype directly)
   b. All `children[].status == "done"`.
   c. Every `acceptance_signals[].met` is true.
```

- [x] **Step 2: Tighten `when_to_use`**

Update `archetype_goal()`'s `when_to_use` field, appending (D9):

```
... Not for: open-ended research (use `reflective`), single-metric
tracking (use `metric_baseline`), bare task lists with no completion
semantics (use `task_list`), goals with fewer than 2 child sub-trackers
(the container archetype's job is aggregation — without 2+ children to
aggregate, use the underlying archetype directly).
```

- [x] **Step 3: Update the discovery paragraph**

In `src/prompts/source.md`, find the `### Goal-trackers` subsection. Apply Hamsa D-1, D-2, D-3:

- Add a sentence after the existing first-paragraph: `"Each project should have at most one goal with status=active at a time — if multiple goals are simultaneously active, the Stop hook fails open (deferring) and the librarian context surfaces them in created_at order."`
- Append to "When starting work…": `"Not for open-ended research (use reflective), bare task lists, or anything without a definable 'done' line. The goal archetype requires 2+ child sub-trackers — for a single criterion checked directly, use the underlying archetype."`
- Adjust the child-archetype list to include nested goals: `"(failure_table, task_list, metric_baseline, audit_issues, reflective, or a nested goal for multi-level objectives)"`.

- [x] **Step 4: Regenerate snapshot, run tests**

Run: `UPDATE_PROMPT_SNAPSHOTS=1 cargo test -p codescout --lib prompts::tests::prompt_surfaces`
Then: `cargo test -p codescout --lib prompts::tests::prompt_surfaces` — should pass.

Run: `cargo test -p codescout --lib librarian::tools::tracker_design`

Expected: all archetype tests + new rule-1-cross-check pass.

Run: `cargo fmt && cargo clippy -- -D warnings`

- [x] **Step 5: Commit**

```bash
git add src/librarian/tools/tracker_design.rs src/prompts/source.md tests/fixtures/prompt_surfaces/
git commit -m "feat(goal): prompt consumes deterministic_child_statuses

Rule 1 of the augmentation prompt now reads ground truth from the
gather context (Yak variant b). Six archetypes are Rust-handled;
metric_baseline remains LLM-evaluated until Phase 2 D4/D8 land.

Rule 2 phrasing tightened to forbid re-derivation of child evidence
(Hamsa H-2). Rule 4a strengthened to len(children) >= 2 (D9).
when_to_use field carves out single-child + decomposition-less cases.

Discovery paragraph in source.md adds multi-goal consequence text
(Hamsa D-1) and not-for guidance (Hamsa D-2), and lists nested goal
as a legal child archetype (Hamsa D-3)."
```

---

### Task 5: Cross-check test — prompt and Rust kernel agree

**Files:**
- Modify: `src/librarian/tools/tracker_design.rs` (tests module)

**Purpose:** Prevent silent drift between the augmentation prompt's "Rust-handled" list and `child_status_pure`'s actual coverage. The test enumerates archetypes that `child_status_pure` returns non-`Unknown` for, and asserts the prompt's rule 1a-b correctly delegates to Rust for exactly those.

- [x] **Step 1: Write the failing test**

In `tracker_design.rs` tests:

```rust
#[test]
fn prompt_rule_1_matches_rust_kernel_coverage() {
    use crate::librarian::tools::goal_aggregation::{child_status_pure, ChildStatus};
    use serde_json::json;

    // Each archetype × a canonical "any non-trivial value" params.
    let probes = [
        ("failure_table",    json!({"failures":[{"id":"F-1","status":"pass"}]})),
        ("task_list",        json!({"tasks":[{"id":"T-1","status":"done"}]})),
        ("audit_issues",     json!({"issues":[{"n":1,"status":"fixed"}]})),
        ("reflective",       json!({"status":"decided"})),
        ("goal",             json!({"status":"done"})),
        ("deployment_state", json!({"envs":{"prod":{"enabled":true}}})),
        ("metric_baseline",  json!({"baseline":{"P@5":0.18},"current":{"P@5":0.20}})),
    ];

    let v = super::archetype_goal();
    let prompt = v.get("prompt_template").and_then(|p| p.as_str()).unwrap();
    let rule_2_block = prompt.split("2. For each `acceptance_signals[i]").next().unwrap();
    // rule_2_block now contains rules 1 and the framing intro.

    for (arch, params) in &probes {
        let status = child_status_pure(arch, params);
        if status == ChildStatus::Unknown {
            // Archetype is NOT Rust-handled in this phase. Prompt MUST
            // list it under "1b. For entries whose basis != deterministic".
            assert!(
                rule_2_block.contains(arch),
                "{arch} returns Unknown in kernel — prompt rule 1b must mention it for LLM fallback"
            );
        } else {
            // Archetype IS Rust-handled. Prompt rule 1a's "copy verbatim"
            // clause governs it. Rust kernel verdict is authoritative.
            // Implicit assertion: the prompt's free-form rules below
            // do NOT contradict the kernel. We can't easily verify the
            // negation in text; this assertion documents the invariant
            // and serves as a tripwire if a future edit accidentally
            // re-introduces per-archetype rules for `arch`.
            //
            // Spot-check: archetype name appears in "deterministic"
            // context, not in "manually" context.
            assert!(
                rule_2_block.contains("deterministic_child_statuses"),
                "rule 1 must reference deterministic_child_statuses"
            );
        }
    }
}
```

- [x] **Step 2: Run test**

Run: `cargo test -p codescout --lib librarian::tools::tracker_design::tests::prompt_rule_1_matches_rust_kernel_coverage`

Expected: PASS.

- [x] **Step 3: Run full suite**

Run: `cargo test -p codescout`

Expected: all green.

Run: `cargo fmt && cargo clippy -- -D warnings && cargo build --release`

- [x] **Step 4: Commit**

```bash
git add src/librarian/tools/tracker_design.rs
git commit -m "test(goal): cross-check prompt rule 1 vs Rust kernel coverage

Tripwire test that prevents silent drift between the augmentation
prompt's 'Rust-handled' list and child_status_pure's actual coverage.

If a future edit adds an archetype to child_status_pure but forgets to
collapse its prompt clause, this test fails. If a future edit removes
an archetype from the kernel but leaves the 'copy verbatim' framing,
this test fails.

Closes I1 Phase 1: rule 1 lives in Rust, prompt is the consumer."
```

---

### Phase 1 verification

Before declaring Phase 1 done:

- [x] `cargo test -p codescout` — all tests green.
- [x] `cargo build --release` — clean.
- [x] Manual MCP smoke: create a small goal-tracker with 2 children, refresh, verify `deterministic_child_statuses` appears in the gather log, verify the prompt-rendered context contains it.
- [x] Restart `/mcp` if testing live — the MCP server runs the release binary.

---

## Phase 2 — Structured Signals (Tasks 6–8)

Lifts `metric_baseline` aggregation out of the prompt's freeform NL pass into Rust per D4 + D8. Adds `deployment_state` context-aware aggregation if D3-Option-C is later adopted.

### Task 6: Structured `acceptance_signals` schema with `kind` discriminant

**Files:**
- Modify: `src/librarian/tools/tracker_design.rs` (archetype_goal's params_schema_example + render_template_example)
- Create: `src/librarian/tools/goal_aggregation/signal.rs` (Rust types for `AcceptanceSignal` enum) — or keep in `goal_aggregation.rs` if it stays small.

**Purpose:** D4. Add optional `kind` discriminant + kind-specific fields. Backward compat: missing `kind` → `freeform`.

- [x] **Step 1: Sketch the Rust types**

In `goal_aggregation.rs` (or a new sub-module if it grows beyond 100 lines):

```rust
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AcceptanceSignalKind {
    Freeform,
    AuditIssuesOpenCount {
        evidence_child_id: String,
        #[serde(default)]
        max_open: u64,
    },
    FailureTableClean {
        evidence_child_id: String,
    },
    TaskListComplete {
        evidence_child_id: String,
    },
    MetricThreshold {
        evidence_child_id: String,
        metric_key: String,
        op: ThresholdOp,
        threshold: f64,
    },
    ReflectiveDecided {
        evidence_child_id: String,
    },
    DeploymentEnvsEnabled {
        evidence_child_id: String,
        #[serde(default)]
        envs: Option<Vec<String>>,
    },
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum ThresholdOp {
    #[serde(rename = ">=")] Gte,
    #[serde(rename = ">")] Gt,
    #[serde(rename = "<=")] Lte,
    #[serde(rename = "<")] Lt,
    #[serde(rename = "==")] Eq,
}

#[derive(Debug, Clone, Deserialize)]
pub struct AcceptanceSignal {
    pub description: String,
    #[serde(default)]
    pub met: bool,
    #[serde(default)]
    pub evidence: String,
    #[serde(default = "default_freeform")]
    #[serde(flatten)]
    pub kind: AcceptanceSignalKind,
}

fn default_freeform() -> AcceptanceSignalKind {
    AcceptanceSignalKind::Freeform
}
```

- [x] **Step 2: Write per-kind evaluator**

```rust
pub fn evaluate_signal(
    signal: &AcceptanceSignal,
    children: &[(String, String, Value)], // (child_id, archetype, params)
) -> EvalResult {
    // ... implementation: lookup child by evidence_child_id, run predicate.
    // Returns { met: bool, evidence: String, error: Option<String> }
}

pub struct EvalResult {
    pub met: bool,
    pub evidence: String,
    pub error: Option<String>,
}
```

Concrete per-kind logic:

```rust
match &signal.kind {
    AcceptanceSignalKind::Freeform => EvalResult {
        met: signal.met,
        evidence: signal.evidence.clone(),
        error: None,
    },
    AcceptanceSignalKind::AuditIssuesOpenCount { evidence_child_id, max_open } => {
        let child = children.iter().find(|(id, _, _)| id == evidence_child_id);
        let Some((_, _, params)) = child else {
            return EvalResult {
                met: false,
                evidence: format!("child {evidence_child_id} not found"),
                error: Some(format!("unresolvable evidence_child_id: {evidence_child_id}")),
            };
        };
        let open_count = params
            .get("issues")
            .and_then(|v| v.as_array())
            .map(|a| a.iter().filter(|i| i.get("status").and_then(|s| s.as_str()) == Some("open")).count())
            .unwrap_or(0) as u64;
        EvalResult {
            met: open_count <= *max_open,
            evidence: format!("audit_issues: {evidence_child_id} open={open_count} max={max_open}"),
            error: None,
        }
    },
    // ... etc for the other 5 kinds
}
```

- [x] **Step 3: Tests per kind**

Add 6 tests covering each `AcceptanceSignalKind` variant + a "child not found" error case + a "child params missing the required field" defensive case.

- [x] **Step 4: Update `archetype_goal()` `params_schema_example` to document the new fields**

Add `kind`, `evidence_child_id`, `metric_key`, `threshold`, `op`, `max_open`, `envs` as optional properties on `acceptance_signals[].items.properties`. Update the `params_shape_example` to include one signal per kind to seed the LLM's understanding.

- [x] **Step 5: Run + commit**

```bash
cargo test -p codescout --lib librarian::tools::goal_aggregation
cargo test -p codescout --lib librarian::tools::tracker_design
cargo fmt && cargo clippy -- -D warnings
git commit -m "feat(goal): structured acceptance_signals with kind discriminant (D4)"
```

---

### Task 7: Wire `metric_baseline` aggregation per D8

**Files:**
- Modify: `src/librarian/tools/goal_aggregation.rs`
- Modify: `src/librarian/tools/gather.rs::gather_goal_children`

**Purpose:** Lift `metric_baseline` out of LLM lane. With D4's structured signals in place, `metric_baseline` status derives from the set of `metric_threshold` signals citing it.

- [x] **Step 1: Implement `child_status_in_context`**

```rust
pub fn child_status_in_context(
    archetype: &str,
    child_id: &str,
    child_params: &Value,
    parent_signals: &[AcceptanceSignal],
    children: &[(String, String, Value)],
) -> ChildStatus {
    if archetype != "metric_baseline" {
        return child_status_pure(archetype, child_params);
    }

    let citing: Vec<&AcceptanceSignal> = parent_signals.iter()
        .filter(|s| matches!(&s.kind, AcceptanceSignalKind::MetricThreshold { evidence_child_id, .. } if evidence_child_id == child_id))
        .collect();

    if citing.is_empty() {
        return ChildStatus::Active;
    }

    let evaluated: Vec<bool> = citing.iter()
        .map(|s| evaluate_signal(s, children).met)
        .collect();

    if evaluated.iter().all(|&m| m) { ChildStatus::Done }
    else if evaluated.iter().any(|&m| m) { ChildStatus::InProgress }
    else { ChildStatus::Active }
}
```

- [x] **Step 2: Update `gather_goal_children` to pass parent context**

Change signature:

```rust
pub fn gather_goal_children(
    ctx: &ToolContext,
    children: &[(&str, &str, &str)], // (child_id, artifact_id, archetype)
    parent_signals: &[AcceptanceSignal],
) -> Result<Value>
```

Inside the loop, call `child_status_in_context` instead of `child_status_pure`.

- [x] **Step 3: Update `refresh.rs` call site**

Parse parent's `acceptance_signals` into `Vec<AcceptanceSignal>` before calling `gather_goal_children`. The children's archetypes are read from `parent.children[].archetype`.

- [x] **Step 4: Tests**

Add a metric_baseline integration test: parent goal has a `metric_threshold` signal citing child C-1, child has `current.P@5 = 0.21`, threshold 0.20 → child's status comes out `done`.

- [x] **Step 5: Run + commit**

```bash
git commit -m "feat(goal): metric_baseline aggregates via acceptance_signals context (D8)"
```

---

### Task 8: Update prompt rule 1b — `metric_baseline` moves to deterministic list

**Files:**
- Modify: `src/librarian/tools/tracker_design.rs`

After Task 7, `metric_baseline` is no longer LLM-evaluated. Update the prompt:

- Remove the metric_baseline clause from rule 1b's "for entries whose basis != deterministic".
- Update the cross-check test in Task 5 to assert `metric_baseline` now produces non-`Unknown` from `child_status_in_context` and is no longer listed in rule 1b.
- Regenerate snapshot.

```bash
git commit -m "refactor(goal): metric_baseline now Rust-handled via D4 signals"
```

---

## Phase 3 — Refresh Discipline (Tasks 9–12)

Implements D5 (refresh_meta split), D6 (NEVER list → Rust), D10 (scope-growth cap), D11 (gate-check event).

### Task 9: Split `progress_log` into `refresh_meta` + `progress_log[].note` (D5)

**Files:**
- Modify: `src/librarian/tools/tracker_design.rs` (params schema, render template, prompt rule 3)
- Modify: `src/librarian/tools/refresh.rs` (compute `refresh_meta` deterministically)
- Modify: `src/librarian/tools/goal_aggregation.rs` (provide `compute_refresh_meta` helper)

**Sketch:**

```rust
// In goal_aggregation.rs
#[derive(Debug, Serialize, Deserialize)]
pub struct RefreshMeta {
    pub last_refresh_at: String,
    pub last_refresh_commit: Option<String>,
    pub children_status_delta: Vec<StatusDelta>,
    pub commit_count_since_last: u64,
    pub unchanged_refreshes: u64,
    pub degraded: bool,
    pub orphan_children: Vec<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct StatusDelta {
    pub child_id: String,
    pub from: String,
    pub to: String,
}

pub fn compute_refresh_meta(
    prior: Option<&RefreshMeta>,
    fresh_children: &[ChildSnapshot],
    now: chrono::DateTime<chrono::Utc>,
    head_commit: Option<&str>,
    commits_since_last: u64,
) -> RefreshMeta { ... }
```

- Update prompt rule 3 to: *"You do not write refresh_meta — Rust owns it. You may append at most one entry to `progress_log` describing what changed in human-readable prose. Append only if `refresh_meta.children_status_delta` is non-empty OR `refresh_meta.commit_count_since_last > 0`. Skip the append on a no-change refresh — Rust will increment `unchanged_refreshes`."*

- Migration: existing trackers without `refresh_meta` get it backfilled from `progress_log[-1].date` on first post-upgrade refresh.

- Tests: idempotency on no-change refresh (refresh_meta byte-identical modulo `last_refresh_at` which can be stubbed via a clock injection).

- Commit:

```bash
git commit -m "feat(goal): split refresh_meta (Rust) + progress_log.note (LLM) per D5"
```

---

### Task 10: Convert rule 6 NEVER list to Rust guarantees (D6)

**Files:**
- Modify: `src/librarian/tools/goal_aggregation.rs` (add `evaluate_gate`)
- Modify: `src/librarian/tools/tracker_design.rs` (remove rule 6 from prompt)
- Modify: `src/librarian/tools/augment.rs` (pre-write hook for goal-tracker)

**Sketch:**

```rust
pub enum GateOutcome { Block(GateBlockReason), AutoClose }

pub enum GateBlockReason {
    TooFewChildren { count: usize },
    ChildrenIncomplete { incomplete: Vec<String> },
    SignalsUnmet { unmet: Vec<String> },
}

pub fn evaluate_gate(params: &GoalParams) -> GateOutcome {
    if params.children.len() < 2 {
        return GateOutcome::Block(GateBlockReason::TooFewChildren { count: params.children.len() });
    }
    let incomplete: Vec<String> = params.children.iter()
        .filter(|c| c.status != "done")
        .map(|c| c.id.clone())
        .collect();
    if !incomplete.is_empty() {
        return GateOutcome::Block(GateBlockReason::ChildrenIncomplete { incomplete });
    }
    let unmet: Vec<String> = params.acceptance_signals.iter()
        .filter(|s| !s.met)
        .map(|s| s.description.clone())
        .collect();
    if !unmet.is_empty() {
        return GateOutcome::Block(GateBlockReason::SignalsUnmet { unmet });
    }
    GateOutcome::AutoClose
}
```

In `augment.rs::ArtifactAugment::call` with `merge=true`, when the artifact is a goal-tracker AND the submitted patch flips `status: "done"`:

- Run `evaluate_gate` on the **post-merge** params.
- If `GateOutcome::Block`, reject the merge with `RecoverableError` citing the block reason.
- If `GateOutcome::AutoClose`, allow.

This is the variant (a)-flavored hook from Yak's S4 analysis, **scoped narrowly to status-flip-to-done** so the broader trust-direction concern doesn't apply (we're refusing a forbidden transition, not silently rewriting params).

- Tests: 3 tests — gate accepts done when conditions met; gate rejects with TooFewChildren; gate rejects with SignalsUnmet.

- Remove rule 6 from prompt entirely. Update rule 4 to: *"Auto-close is enforced in Rust. Setting status=done is rejected unless the gate passes. You may still propose status=done — Rust will accept or reject."*

- Commit:

```bash
git commit -m "feat(goal): auto-close gate enforced in Rust, rule 6 NEVER list removed (D6)"
```

---

### Task 11: Scope-growth cap (D10)

**Files:**
- Modify: `src/librarian/tools/goal_aggregation.rs` — add `validate_scope_growth`
- Modify: `src/librarian/tools/refresh.rs` — invoke validator on submitted children list

**Sketch:**

```rust
pub fn validate_scope_growth(
    prior_children: &[ChildRef],
    submitted_children: &[ChildRef],
) -> Result<ScopeGrowthVerdict, ScopeGrowthError> {
    let prior_ids: HashSet<&str> = prior_children.iter().map(|c| c.id.as_str()).collect();
    let new: Vec<&ChildRef> = submitted_children.iter()
        .filter(|c| !prior_ids.contains(c.id.as_str()))
        .collect();
    if new.len() > 1 {
        return Err(ScopeGrowthError::TooManyNewChildren {
            count: new.len(),
            ids: new.iter().map(|c| c.id.clone()).collect(),
        });
    }
    Ok(ScopeGrowthVerdict { added_count: new.len() })
}
```

- Invoked from the augment pre-write hook (same site as Task 10).
- Update prompt rule 5 to mention the cap explicitly and tell the LLM to put deferred decompositions in `refresh_meta.deferred_decompositions`.

- Commit:

```bash
git commit -m "feat(goal): scope-growth cap 1 child/refresh (D10)"
```

---

### Task 12: Gate-check audit via `note` event (D11)

**Files:**
- Modify: `src/librarian/tools/augment.rs` or `src/librarian/tools/refresh.rs` — emit event after gate evaluation
- New tests in the same file

**Sketch:**

After `evaluate_gate` runs on a goal-tracker refresh's submitted params:

```rust
let gate_outcome = evaluate_gate(&new_params);
artifact_event::create(
    ctx,
    &goal_id,
    EventKind::Note,
    json!({
        "tag": "gate_check",
        "gate_passed": matches!(gate_outcome, GateOutcome::AutoClose),
        "evidence": {
            "children_count": new_params.children.len(),
            "children_done": new_params.children.iter().filter(|c| c.status == "done").count(),
            "signal_count_total": new_params.acceptance_signals.len(),
            "signal_count_met": new_params.acceptance_signals.iter().filter(|s| s.met).count(),
        },
        "refresh_at": now.to_rfc3339(),
    }),
).await?;
```

- Test: refresh a goal, verify `artifact_event::list(goal_id, kinds=["note"])` returns one event with `payload.tag == "gate_check"`.

- Commit:

```bash
git commit -m "feat(goal): gate_check audit emitted as note event (D11)"
```

---

## Phase 4 — Schema + Hook Cleanup (Tasks 13–14)

### Task 13: Widen `audit_issues` archetype schema (D7)

**Files:**
- Modify: `src/librarian/tools/tracker_design.rs` (the `archetype_audit_issues` function)

**Steps:**

- [x] In `archetype_audit_issues()`, expand the `params_schema_example` to add optional fields:
  - `severity_reason: { "type": "string" }`
  - `ref_kind: { "type": "string" }`
  - `md_file: { "type": "string" }`
  - `md_line: { "type": "integer" }`
  - `raw_ref: { "type": "string" }`
  - `first_seen_commit: { "type": "string" }`
  - `first_seen_at: { "type": "string", "format": "date-time" }`
  - `last_verified_at: { "type": "string", "format": "date-time" }`

- [x] Update `params_shape_example` to include one row with the richer fields populated, so the LLM understands the optional extension.

- [x] Update the `each_archetype_template_renders_against_example_params` test to confirm the richer params still render via the existing template (the existing template uses only the 5 required fields — should still pass).

- [x] Commit:

```bash
git commit -m "feat(audit_issues): widen schema to fold audit-doc-refs runtime fields (D7)"
```

---

### Task 14: Hamsa S-1 fix — `last_refreshed_at` in Stop hook reason

**Files:**
- Modify: `claude-plugins/codescout-companion/hooks/goal-stop-hook.sh`
- Modify: `claude-plugins/codescout-companion/hooks/goal-stop-hook.matrix.test.sh` (new branch coverage)

**Note:** This task lives in a different repo (`claude-plugins/codescout-companion`). It can ship independently of Phases 1–3.

**Steps:**

- [x] In the bash hook, after `GET_OUT=$(...)` succeeds:

```bash
LAST_REFRESHED=$(echo "$GET_OUT" | jq -r '.augmentation.last_refreshed_at // "never"' 2>/dev/null || echo "never")
```

- [x] Modify each `case "$STATUS"` branch's `reason` text to append ` (last refreshed: $LAST_REFRESHED)`:

```bash
done)
    jq -nc --arg c "$CRITERION" --arg lr "$LAST_REFRESHED" \
      '{continue: false, reason: ("goal done: " + $c + " (last refreshed: " + $lr + ")")}'
    ;;
# ... same pattern for blocked, abandoned, active branches
```

- [x] Hamsa S-2 fix at the same touch: split `unknown` from the `*` glob, emit a distinct reason:

```bash
unknown|"")
    jq -nc --arg s "$STATUS" --arg lr "$LAST_REFRESHED" \
      '{continue: true, reason: ("goal params malformed (status=" + $s + ") — fail-open; please refresh (last refreshed: " + $lr + ")")}'
    ;;
```

- [x] Add a matrix test branch for `unknown` status — assert the reason text contains "malformed".

- [x] Run: `./hooks/goal-stop-hook.matrix.test.sh` — all branches pass.

- [x] Commit (in the companion repo):

```bash
git commit -m "feat(goal-stop-hook): surface last_refreshed_at + distinguish unknown status

Hamsa S-1: reason text now carries 'last refreshed: <ts>' so the
agent reading the Stop hook output can tell stale state from fresh.
Closes the silent-stale-wrong-answer failure mode.

Hamsa S-2: unknown/empty status no longer collapses into the active
branch — emits 'goal params malformed' as a distinct signal.

Matrix test extended to 8 branches."
```

---

## Cross-cutting checks before declaring complete

- [x] `cargo test -p codescout` — full green.
- [x] `cargo clippy -- -D warnings` — clean.
- [x] `cargo build --release` — clean.
- [x] `cargo test -p codescout --lib prompts::tests::prompt_surfaces` — snapshot in sync.
- [x] **Bump `ONBOARDING_VERSION` only if** `src/prompts/onboarding_prompt.md` or `src/prompts/builders.rs::build_system_prompt_draft` changed. Otherwise NO bump (`source.md` is live-loaded). Phases 1-4 should not touch onboarding surfaces — verify and only bump if they did.
- [x] Manual MCP smoke: `cargo build --release`, restart `/mcp`, create a goal with 2 children of different archetypes, refresh, verify:
  - `deterministic_child_statuses` appears in gather context.
  - `refresh_meta` populated.
  - Stop hook output includes `last refreshed: <ts>`.
  - Submitting `status: done` with unmet signals is rejected with `RecoverableError`.

---

## Out of scope (deferred)

- **`TrackerMerger` trait extraction (I8)** — explicitly rejected per amendment. Revisit when a second concrete merger half-exists. Document the rejection in `docs/architecture/decisions/` (a brief ADR note).
- **Stop hook → Rust CLI subcommand (I9)** — endorsed by Lion Q4 but separate refactor. Add to a follow-up doc; not Phase 1-4 scope.
- **`ensure_goal` action (I4)** — UX/discoverability fix; needs its own design pass before implementation.
- **CLAUDE.md rule for goal-tracker creation (Hamsa F1)** — convention adoption, not code.

---

## References

- Amendment doc (source of truth for decisions): `docs/superpowers/specs/2026-05-17-goal-tracker-amendment.md`
- Original goal-tracker design: `docs/superpowers/specs/2026-05-16-goal-tracker-design.md`
- Sibling feature design (audit-doc-refs): `docs/superpowers/specs/2026-05-16-audit-doc-refs-design.md`
- Yak's refactor analysis (this session) — see git log for the conversation transcript.


## Post-execution notes

Plan executed 2026-05-17 across two sessions on `experiments` branch.
T-1 through T-13 landed in 13 commits; T-14 shipped pre-session as `0b75991`
in the codescout-companion repo. 461 librarian tests pass, clippy clean,
release binary verified live via `/mcp` reload.

Live verification (commit `2005d9fa`):
- **T-3** `deterministic_child_statuses` populated in gather context with
  `basis: deterministic` for all 3 children of the L1 dogfood goal-tracker.
- **T-9** `refresh_meta` populated with real `children_status_delta`
  (`C-3 in-progress → done`) — kernel detected staleness vs stored params.
- **T-10** auto-close gate: BLOCK path returned
  `goal auto-close gate blocked: children not all done: C-1, C-3`;
  PASS path flipped status to "done" without error.
- **T-12** gate_check note event emitted with full payload (`tag/gate_passed/
  text/evidence{4 keys}/refresh_at`) matching amendment D11 byte-for-byte.

Friction observations from execution — see `docs/trackers/archive/i1-session-friction.md`
for full F-N + W-N entries (these are the corrections; the plan body above is
preserved as history):

- **F-3, F-4** *(wontfix-false-alarm)*: My predictions about plan errors
  (`-p codescout` flag, `src/librarian/...` paths) were wrong — plan was
  correct. No plan change.
- **F-5** *(subagent placement)*: T-1 subagent placed the cross-check test
  at module scope, not inside `mod tests`. Functionally OK (Rust accepts
  `#[test]` anywhere). Lesson absorbed into T-2 subagent prompt (W-2).
- **F-6** *(prose drift)*: T-2 Step 3 said "all 18 tests pass" but Step 1
  fixture had 20 tests. Implementation landed with 20+. Plan prose drift only.
- **F-7** *(plan title vs body)*: T-3 title says "`GatherSource::GoalChildren`
  variant" but body adds a standalone `gather_goal_children` function. Landed
  as standalone fn — variant name was aspirational.
- **F-8** *(plan code uncompilable)*: T-3 code snippet used
  `cat.get(id).augmentation.archetype` — three field accesses that don't
  compile against actual structs. Replaced with caller-provided tuple from
  the goal's own `params.children[].archetype` — archetype flows in from the
  parent, no field chain through catalog rows.
- **F-9** *(architectural — runtime characteristic)*: Augmentation prompt
  is stored per-artifact at creation. `archetype_goal().prompt_template`
  edits do NOT propagate to existing trackers; manual re-augment required.
  Demonstrated this session for L1 (commit `2005d9fa`). Not a plan fix —
  a runtime characteristic to document.
- **F-10** *(serde quirk, high)*: Serde `flatten + default` doesn't handle
  missing internally-tagged discriminator on legacy payloads. T-6 required
  a custom `Deserialize` impl for `AcceptanceSignal` that probes for `kind`
  and falls back to `AcceptanceSignalSpec::Freeform`.
- **F-11** *(codescout-tool, promoted)*: `grep` on `@tool_*` buffer
  false-negatives. Promoted to bug-tracker.
- **F-12** *(silent failure trap, mitigated)*: T-12 plan payload sketch
  omitted required `note.text` field; `event_create` rejects silently if
  `let _ = ...await` swallows the error. Caught by integration test (W-6).
- **F-13** *(concurrent-work, mitigated)*: `git reset --soft HEAD~1` on
  stale HEAD blew away T-13 commit in a parallel session; recovered via
  `git reflog -10` + SHA-quoting (W-7). New rule landed in `CLAUDE.md`:
  *never `git reset` to a relative ref during concurrent work*.
- **F-15** *(tool-schema drift, high, mitigated)*: `artifact_augment`'s JSON
  schema description claimed `params` was "gather config"; field is actually
  the data params payload. Calling `merge=false, params={}` silently wiped
  L1's `acceptance_signals/children/criterion/progress_log/status`. Recovered
  from in-scrollback snapshot. Schema description fixed in commit `2005d9fa`.
